diff --git a/.agents/skills/vitest/SKILL.md b/.agents/skills/vitest/SKILL.md index 0578bdcf3a8..69776b6edad 100644 --- a/.agents/skills/vitest/SKILL.md +++ b/.agents/skills/vitest/SKILL.md @@ -1,15 +1,21 @@ --- name: vitest -description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures. +description: >- + Vitest API and config reference (Jest-compatible) — mocking with vi.*, spies, + fake timers, coverage configuration, fixtures, snapshots, and test filtering. + Use for Vitest API and configuration questions anywhere in the monorepo; for + Studio-specific test strategy and component-test setup, start with + studio-testing and studio-mock-api-tests. metadata: author: Anthony Fu - version: "2026.1.28" + version: '2026.1.28' source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills --- Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app. **Key Features:** + - Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates - Jest-compatible: Drop-in replacement for most Jest test suites - Smart watch mode: Only reruns affected tests based on module graph @@ -22,31 +28,31 @@ Vitest is a next-generation testing framework powered by Vite. It provides a Jes ## Core -| Topic | Description | Reference | -|-------|-------------|-----------| -| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) | -| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) | -| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) | -| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) | -| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) | -| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) | +| Topic | Description | Reference | +| ------------- | --------------------------------------------------------------- | -------------------------------------------- | +| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) | +| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) | +| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) | +| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) | +| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) | +| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) | ## Features -| Topic | Description | Reference | -|-------|-------------|-----------| -| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) | -| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) | -| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) | -| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) | -| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) | -| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) | +| Topic | Description | Reference | +| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------- | +| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) | +| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) | +| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) | +| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) | +| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) | +| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) | ## Advanced -| Topic | Description | Reference | -|-------|-------------|-----------| -| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) | -| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) | -| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) | -| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) | +| Topic | Description | Reference | +| ------------ | ------------------------------------------------------- | ------------------------------------------------------------ | +| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) | +| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) | +| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) | +| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) | diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ca29fe71fba..8f48264e86e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,42 +1,70 @@ # Supabase Monorepo -pnpm 10 + Turborepo monorepo. Requires Node >= 22. +pnpm 11 + Turborepo monorepo. Requires Node >= 22.13. ## Structure -| Directory | Purpose | -| ----------------- | ------------------------------------------------------------ | -| `apps/studio` | Supabase Studio/Dashboard — Next.js (pages router), React 19 | -| `apps/docs` | Documentation site | -| `apps/www` | Marketing website | -| `packages/ui` | Shared UI components (shadcn/ui based) | -| `packages/common` | Shared utilities and telemetry constants | -| `e2e/studio` | Playwright E2E tests for Studio | +| Directory | Purpose | +| ------------------------ | --------------------------------------------------------------------------- | +| `apps/studio` | Supabase Studio/Dashboard — has its own `apps/studio/CLAUDE.md` (see below) | +| `apps/docs` | Documentation site — Next.js app router, MDX (port 3001) | +| `apps/www` | Marketing website — Next.js, app + pages (port 3000) | +| `apps/design-system` | Component demos — source of truth for Studio UI patterns (port 3003) | +| `apps/ui-library` | shadcn-style registry site for Supabase UI blocks (port 3004) | +| `apps/lite-studio` | Lightweight Studio — different stack: React Router 7 + Vite + Tailwind v4 | +| `packages/ui` | Shared UI components (shadcn/ui based) — `import { Button } from 'ui'` | +| `packages/ui-patterns` | Composite components — subpath imports, e.g. `ui-patterns/AssistantChat` | +| `packages/common` | Shared utils, telemetry constants, feature flags | +| `packages/api-types` | Generated platform Management API types | +| `packages/pg-meta` | SQL builders for Postgres introspection (`SafeSqlFragment`) | +| `packages/shared-data` | Static data: pricing, plans, regions, error codes | +| `e2e/studio`, `e2e/docs` | Playwright E2E tests | +| `supabase/` | Local Supabase project: edge functions, migrations, config.toml | ## Common Commands ```bash -pnpm install # install dependencies -pnpm dev:studio # run Studio dev server -pnpm test:studio # run Studio unit tests (vitest) -pnpm --prefix e2e/studio run e2e # run Studio E2E tests (playwright) -pnpm build --filter=studio # build Studio -pnpm lint --filter=studio # lint Studio -pnpm typecheck # typecheck all packages +pnpm dev:studio # run Studio dev server → http://localhost:8082 +pnpm dev:docs # run docs dev server +pnpm dev:www # run www dev server +pnpm test:studio # Studio unit tests (vitest) +pnpm e2e # Studio E2E tests (playwright) +pnpm build --filter=studio # build Studio +pnpm lint --filter=studio # lint Studio +pnpm typecheck # typecheck all packages +pnpm format # Prettier write (check: pnpm test:prettier) +pnpm generate:types # local DB types → supabase/functions/common/database-types.ts +pnpm api:codegen # platform Management API types → packages/api-types ``` +## CI + +Every PR must pass typecheck + lint (one workflow), Prettier, and a typos check. Other checks are path-filtered: Studio unit tests/build and the lint ratchet (ESLint warning count must not increase) run on `apps/studio/**` changes; app-specific test suites run on their own paths. + +Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`. + ## Conventions -**UI** — import from `'ui'`, use `_Shadcn_` suffixed variants for form primitives. Check `packages/ui/index.tsx` before creating new primitives. +**UI** — import from `'ui'`; primitives are shadcn/ui-based and exported unsuffixed (`Input`, `Select`, `Form`, …). Use `Button` — the in-house component and the standard everywhere (a raw shadcn `Button_Shadcn_` also exists but is rarely the right choice). Check `packages/ui/index.tsx` before creating new primitives. Higher-level patterns live in `packages/ui-patterns`. **Styling** — Tailwind only, semantic tokens (`bg-muted`, `text-foreground-light`), no hardcoded colors. +**Exports** — named exports only; default exports are allowed only where a framework requires them (`pages/**`, `app/**`, config files — the eslint preset has the exact carve-out list). Lint-enforced across all apps via `eslint-config-supabase` (severity `warn` everywhere; hard-enforced in Studio by the lint ratchet). + **Language** — Use U.S. English everywhere. -**Studio shortcuts** — when adding or changing repeated Studio UI actions, use the shared shortcut registry and primitives in `apps/studio/state/shortcuts/` and `apps/studio/components/ui/Shortcut*.tsx`. Prefer registered, discoverable shortcuts over one-off keyboard listeners; keep `G then ...` chords for navigation. +## Skills + +The skills in `.claude/skills/` are the source of truth for conventions — load the relevant ones before working, don't guess: + +- `copywriting` — any user-facing text, anywhere in the monorepo +- `docs-content` — anything under `apps/docs` +- `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts` +- `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` +- `safe-sql-execution` — any code that builds or executes SQL against user databases +- `react-hook-form` — writing or modifying any form code, anywhere in the monorepo +- `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references ## Studio -Pages router. Co-locate sub-components with parent. Avoid barrel re-export files. - -See studio-\* skills for detailed studio conventions. +Before working on anything in `apps/studio`, read `apps/studio/CLAUDE.md` if it isn't already in context — it maps Studio tasks to required skills and covers the TanStack Start migration rules. diff --git a/.claude/settings.json b/.claude/settings.json index 3ffd1f88913..66fdfd3e7e5 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,14 @@ { + "permissions": { + "deny": [ + "Edit(packages/api-types/types/**)", + "Edit(**/routeTree.gen.ts)", + "Edit(**/__generated__/**)", + "Edit(apps/docs/features/docs/generated/**)", + "Edit(apps/www/.generated/**)", + "Edit(supabase/functions/common/database-types.ts)" + ] + }, "hooks": { "SessionStart": [ { diff --git a/.claude/skills/copywriting/SKILL.md b/.claude/skills/copywriting/SKILL.md index 1a6994d8020..f69a7e0ed90 100644 --- a/.claude/skills/copywriting/SKILL.md +++ b/.claude/skills/copywriting/SKILL.md @@ -1,6 +1,6 @@ --- name: copywriting -description: Write or audit UI copy (buttons, labels, empty states, error messages, tooltips, form text) anywhere in the monorepo. Always check this before shipping or reviewing user-facing text. +description: Write or audit UI copy (buttons, labels, empty states, error messages, tooltips, form text) anywhere in the monorepo. Load it before shipping or reviewing any user-facing text — including when copy is incidental to the task, like a new feature that adds buttons, toasts, dialogs, or validation messages. --- # Copywriting diff --git a/.claude/skills/dev-toolbar-review/SKILL.md b/.claude/skills/dev-toolbar-review/SKILL.md index 8ead0b413f4..ae6b81d10ab 100644 --- a/.claude/skills/dev-toolbar-review/SKILL.md +++ b/.claude/skills/dev-toolbar-review/SKILL.md @@ -1,6 +1,7 @@ --- name: dev-toolbar-review -description: Use when reviewing PRs that touch packages/dev-tools/, packages/common/posthog-client.ts, +description: Safety rules for the dev toolbar, PostHog client, and feature flags. Use + when writing or reviewing any change to packages/dev-tools/, packages/common/posthog-client.ts, or packages/common/feature-flags.tsx. Covers environment guards, flag override cookies, telemetry event subscription, and SSE stream safety. --- @@ -30,10 +31,12 @@ so PRs touching only those files won't auto-request review. Watch for these in t **Files:** `packages/dev-tools/index.ts`, `DevToolbar.tsx`, `DevToolbarTrigger.tsx`, `DevToolbarContext.tsx` The toolbar uses two layers of protection: + - **Build-time tree-shaking** in `index.ts`: `process.env.NODE_ENV !== 'development'` ternaries that replace components with noops/stubs so the implementation is eliminated from production bundles. - **Runtime guards** in components: `IS_LOCAL_DEV` checks — `DevToolbar` and `DevToolbarTrigger` return `null` to hide themselves, while `DevToolbarProvider` passes children through (`<>{children}`) to preserve the component tree. **Check for:** + - Guards being removed or broadened. The toolbar is expanding to staging and preview deploys but must remain invisible in production. - Tree-shaking ternaries in `index.ts` staying intact — these are the primary production safety mechanism. - New components or exports that bypass the existing guard pattern. @@ -43,14 +46,17 @@ The toolbar uses two layers of protection: **Files:** `packages/dev-tools/DevToolbar.tsx`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` The toolbar writes two cookies that override feature flags locally: + - `x-ph-flag-overrides` — PostHog flag overrides - `x-cc-flag-overrides` — ConfigCat flag overrides These are read by: + - `posthog-client.ts:getFeatureFlag()` — checks the PostHog override cookie before querying the SDK - `feature-flags.tsx` — merges both override cookies into the flag store during initialization **Check for:** + - Cookie name changes (must stay in sync across writer and all readers) - Changes to the merge/precedence logic in `feature-flags.tsx` (currently: `vercel-flag-overrides` first, then `x-cc-flag-overrides` takes precedence in local dev) - Override cookies being read outside the `IS_LOCAL_DEV` / `isLocalDev` guard — overrides must never affect production flag evaluation @@ -66,6 +72,7 @@ and `identify`. Note: `captureExperimentExposure` calls `posthog.capture()` dire without emitting to dev listeners — experiment exposure events are invisible in the toolbar. **Check for:** + - Changes to `emitToDevListeners` or `subscribeToEvents` that could introduce side effects on the actual capture path (e.g., throwing errors, blocking, mutating event data) - The listener set (`devListeners`) being iterated synchronously in a way that could delay event dispatch - New PostHog client methods that capture events but don't call `emitToDevListeners` (gap in toolbar visibility) @@ -78,6 +85,7 @@ The toolbar connects to `${apiUrl}/telemetry/stream` via Server-Sent Events to d server-side telemetry. Uses exponential backoff on connection errors. **Check for:** + - Changes to the SSE endpoint URL or `session_id` cookie handling - Reconnection logic changes that could cause excessive retries or connection leaks - Note: the stream endpoint lives in the platform repo — cross-repo changes need coordinated review @@ -85,16 +93,19 @@ server-side telemetry. Uses exponential backoff on connection errors. ### 5. App-Level Mounting **Provider + toolbar panel** (`DevToolbarProvider`, `DevToolbar`): + - `apps/studio/pages/_app.tsx` - `apps/www/pages/_app.tsx`, `apps/www/app/providers.tsx` - `apps/docs/features/app.providers.tsx` **Trigger button** (`DevToolbarTrigger`) — rendered separately in nav/header components: + - `apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx` - `apps/www/components/Nav/index.tsx` - `apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx` **Check for:** + - Provider being added or removed from an app - `apiUrl` prop changes (must point to the correct platform API) - Rendering order changes that could affect the toolbar's access to PostHog context diff --git a/.claude/skills/docs-content/SKILL.md b/.claude/skills/docs-content/SKILL.md new file mode 100644 index 00000000000..a24486efb64 --- /dev/null +++ b/.claude/skills/docs-content/SKILL.md @@ -0,0 +1,97 @@ +--- +name: docs-content +description: Write, edit, organize, and review Supabase content anywhere in apps/docs — guides, explainers, tutorials, troubleshooting entries, reference docs, and partials. Use for MDX/TOML authoring, frontmatter, navigation, terminology, links, code samples, content listings, and docs validation. +--- + +# Supabase docs authoring + +## Sources of truth + +Before changing docs content: + +1. Read `apps/docs/CONTRIBUTING.md` for content types, structure, components, and + style. +2. Read `apps/docs/WORD_LIST.md` for preferred terminology, spelling, and + capitalization. +3. Inspect nearby content of the same type and the relevant navigation section + before deciding on file placement or structure. Guides, explainers, and + tutorials live under `apps/docs/content/guides`. Troubleshooting entries live + under `apps/docs/content/troubleshooting` and use TOML frontmatter — follow + `_template.mdx` in that directory rather than a guide's YAML frontmatter. + Reference docs are generated from `apps/docs/spec` and library source, so + look for the spec file or repo definition instead of editing rendered output + directly. + +When guidance conflicts, follow `apps/docs/CONTRIBUTING.md`. Match literal code, +API names, UI labels, and third-party product names even when they differ from the +word list. + +## Writing workflow + +1. Identify the document type: explainer, tutorial, guide, or reference, per + `apps/docs/CONTRIBUTING.md`. A guide is a concise procedure for a targeted + task; a tutorial covers a larger goal and includes more explanatory context; + an explainer is conceptual and prose-based; reference content is factual, + like a dictionary entry. Troubleshooting entries follow their own TOML + structure rather than these four types. +2. Define the reader's goal and prerequisites before drafting. +3. Classify substantial sections as contextual, procedural, or reference content. + In a mixed page, group sections by information type so that context doesn't + interrupt the procedural path. +4. For a long or mixed page, add a short introduction that links to its major + section groups and tells readers when to use each one. Skip this navigation + when a short page is already easy to scan. +5. Connect contextual sections to their corresponding procedures when useful. + Add introductions to section groups, transitions between information types, + and outcomes after procedures. Don't link every adjacent section. +6. Use second person, present tense, short paragraphs, and ordered steps for + sequential actions. +7. Search `apps/docs/WORD_LIST.md` when introducing or reviewing technical terms, + UI actions, abbreviations, and potentially ambiguous language. +8. Keep code samples executable in their stated context and consistent with + repository formatting. Clearly mark intentionally omitted code. Use lowercase + SQL keywords. +9. Reuse repeated content through `apps/docs/content/_partials` instead of copying + it. +10. Add new guide, explainer, and tutorial pages to + `apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts`. + File placement alone doesn't add a page to navigation. Troubleshooting + entries are indexed automatically and don't need a navigation entry. +11. Use `/docs/...` paths for pages in Supabase docs and site-root paths such as + `/dashboard` for pages outside docs. Use descriptive link text and sparse + admonitions with the appropriate severity. + +## Validation + +From `apps/docs`, run: + +```bash +pnpm lint:mdx +pnpm build:guides-markdown +``` + +`pnpm lint:mdx` covers all content under `apps/docs/content`, including +troubleshooting entries. `pnpm build:guides-markdown` only applies to guides, +explainers, and tutorials. + +From the repository root, run `pnpm format` to apply Prettier to any changed +MDX (and other) files. This enforces repo-wide formatting rules, including +lowercase SQL keyword casing in code samples. + +Run broader type checking or tests when the change affects MDX components, +content listings, navigation code, or generated output. + +For a mixed page, verify that context and procedures are grouped, introductory +navigation links resolve to the intended sections, related context and procedures +are cross-referenced where useful, and transitions make the reading path clear. + +Treat lint replacements as suggestions when context matters. Rewrite the sentence +instead of applying a replacement that changes its technical meaning. + +Anchor IDs are generated from heading text at render time, and nothing in CI +checks that `#anchor` links still resolve. Before renaming, removing, or +substantially rewording a heading, run +`grep -rn "#" apps/docs/content` to find in-page and +cross-file links that target it, and update every match. If a heading needs a +stable anchor independent of its wording, pin it with a custom anchor, for +example `## Some heading [#some-heading]`. diff --git a/.claude/skills/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md new file mode 100644 index 00000000000..c08c5037a2b --- /dev/null +++ b/.claude/skills/react-hook-form/SKILL.md @@ -0,0 +1,284 @@ +--- +name: react-hook-form +description: Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions, + reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE + writing or modifying ANY form code, adding a field to an existing form, touching + watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or + sheet, or building a submit/cancel footer — even when the change looks trivial. + The codebase contains widespread RHF anti-patterns; without this skill you will + copy them. For form layout and which components to use, also load + studio-ui-patterns. +--- + +# React Hook Form + +How to write forms that stay correct as they grow. The existing codebase is **not** +a safe reference: `form.watch()` off prop-drilled form objects, subscription-only +watches, unguarded `valueAsNumber`, and `?? undefined` controlled values are all +common in older code and all wrong. Follow this skill, not the neighboring file. + +**Policy — fix what you touch.** New code must follow these rules. When you modify +existing form code, upgrade the specific fields/hooks/components you're editing to +match (e.g. a component you touch that calls `form.watch` gets converted to +`useWatch`). Leave untouched code alone, but tell the user about anti-patterns you +noticed and didn't fix. Never add new violations: `react-hook-form/no-use-watch` +is ratcheted in Studio CI — any increase in the warning count fails the build. + +## Mental model: subscriptions decide who re-renders + +RHF is uncontrolled at heart. Values live in refs; nothing re-renders unless a +subscription says so. Every read API is a subscription decision: + +| API | Subscribes | Re-renders | Use for | +| ----------------------------- | ---------- | -------------------------- | ---------------------------------------------- | +| `useWatch({ control, name })` | yes | only the calling component | reactive value reads, anywhere | +| `useFormState({ control })` | yes | only the calling component | `isDirty`/`errors`/etc. outside the form owner | +| `formState` (destructured) | yes | the `useForm` owner | form state **in the owner component only** | +| `form.watch(name)` | yes | the **entire form tree** | avoid — lint-flagged, see below | +| `getValues()` | no | never | event handlers and `onSubmit` only | +| `subscribe()` | callback | none | side effects outside render | + +Two facts explain most of the bugs we've shipped: + +1. **`form.watch()` and `form.formState` hoist their subscription to the `useForm` + owner**, no matter which component calls them. A child that reads + `form.watch('x')` off a prop works today only because the whole tree re-renders + on every change — it silently goes stale the moment anyone adds `React.memo` + between owner and child, and until then it re-renders every sibling on every + keystroke. A no-arg `form.watch()` sets `watchAll` and re-renders the tree on + every field change for the life of the form. +2. **`formState` is a Proxy** — reading a property is what arms the subscription. + Destructure it (`const { isDirty } = form.formState`), never pass the object + around or read it conditionally (`a && formState.isValid` may never subscribe). + Enforced by `react-hook-form/destructuring-formstate` (error). + +### Reading values, by location + +- **In the component that owns `useForm`:** destructure `formState`; prefer + `useWatch` over `form.watch` even here (the `no-use-watch` rule flags every + `watch`, and `useWatch` scopes the re-render if the JSX is later extracted). +- **In any child component or custom hook:** accept `control` (not the whole + `form`) and use `useWatch({ control, name })` / `useFormState({ control })`. + Inside `
` (which _is_ `FormProvider`), `useFormContext()` + + `useWatch({ name })` also works and avoids prop-drilling entirely. +- **Consume the return value.** Never call a watch for its subscription side + effect and then read via `getValues()` — the watch list and the read list will + drift apart (it has already happened; fields silently lost reactivity). The + value you render must _be_ the value you subscribed to. +- **One read path per value per render.** Mixing `useWatch('x')` on one line and + `getValues('x')` a few lines later lets the two disagree within a single render. +- **Name what you watch.** `useWatch({ control })` with no `name` re-renders on + every keystroke in every field. Subscribe to the specific names you use. +- `watch(callback)` is deprecated — use `subscribe()` for render-free listeners, + and always return its cleanup from `useEffect`. + +```tsx +// ❌ common in the codebase — all three subscriptions hoist to the form owner +function Fields({ form }: { form: UseFormReturn }) { + form.watch(['storageType', 'totalSize']) // return value discarded + const { errors } = form.formState // prop-form formState + const size = form.getValues('totalSize') // non-reactive read in render + ... +} + +// ✅ child subscribes for itself and consumes what it watches +function Fields({ control }: { control: Control }) { + const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] }) + const { errors } = useFormState({ control }) + ... +} +``` + +## The canonical form + +zod schema → `z.infer` type → `useForm` with `zodResolver` and **complete** +`defaultValues` → `` → `FormField` render-prop per field → +`FormItemLayout` → `FormControl` → primitive from `ui`. Layout/container choices +(Card vs Sheet, `layout=` variants) are covered by the `studio-ui-patterns` skill +and the demos in `apps/design-system/registry/default/example/` +(`form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`) — check them +before inventing structure. + +```tsx +// Module level — static references, not recreated on every render +const FORM_ID = 'pool-config-form' + +const FormSchema = z.object({ + name: z.string().min(1, 'Name is required'), + maxConnections: z + .union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')]) + .refine((v) => v !== '', 'Max connections is required'), +}) +type FormValues = z.infer + +const defaultValues: FormValues = { name: '', maxConnections: '' } + +// Inside the component +const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues, +}) + + + + ( + + + + + + )} + /> + + +``` + +Define the schema, `type`, static `defaultValues`, and the form's id at module +level, outside the component. Rebuilding them per render is wasted work and +unstable references — RHF reads `defaultValues` only on the first render, but +anything else comparing against these objects sees a fresh identity each time. +When they genuinely depend on runtime data, build the schema with `useMemo` and +feed server-driven defaults through the `values` option (next section) instead +of hoisting. + +Submit buttons living outside the `
` (sheet/dialog footers) use the same +module-level `FORM_ID` via `form={FORM_ID}` on the button. A module-level id is +only safe for singleton forms — if the component can mount more than once at a +time, duplicate ids make external buttons submit the first matching form, so +mint a per-instance id with `useId()` and share it between the `` and its +buttons. + +## defaultValues, server data, and reset + +- **Provide a complete `defaultValues` object — every field, no `undefined`.** + `isDirty`, `dirtyFields`, and Cancel-reset all compare against it; a missing or + `undefined` default breaks all three, and `undefined` also makes React treat the + input as uncontrolled (see below). +- **Form populated from an API? Use the `values` option, not a hand-rolled + effect.** `values` reacts to the query resolving and resets the form for you; + computing `defaultValues` from a query that may not have loaded freezes whatever + happened to be in cache at mount. Add + `resetOptions: { keepDirtyValues: true }` when a background refetch must not + clobber the user's in-progress edits. (Good examples: + `components/interfaces/Settings/Database/ConnectionLogging.tsx`, + `components/interfaces/Storage/EditBucketModal.tsx`.) +- **After a successful mutation, re-baseline the form** in `onSuccess` so the + saved state becomes the new baseline (`isDirty` returns to false, Cancel now + reverts to the saved values). Prefer what the server actually persisted: if the + form uses `values` and the mutation invalidates the query, the refetch handles + this for you; if the mutation returns the updated resource, `reset(response)`. + `reset(submittedValues)` is the fallback for APIs that store exactly what was + sent — if the server normalizes or fills values, it baselines the form to data + that was never saved. A bare `reset()` reverts to the _previous_ defaults — + wrong after a save. +- Cancel buttons call `form.reset()`. This only visually restores fields whose + values round-trip through defined, controlled values — which is why the null + rules below matter. + +## Controlled inputs: never let `value` flip to `undefined` + +React decides controlled vs uncontrolled per render from whether `value` is +defined. A field whose value can be `undefined` (or becomes `undefined` on reset) +flips modes: console warnings, and — worse — `reset()` stops clearing the visible +text because React abandoned the DOM value. `value={field.value ?? undefined}` is +a bug, not a fix. + +- Text fields: default to `''`, never `null`/`undefined`. +- **Normalize `null` from the API at the form boundary** (`growthPercent ?? ''` + when building defaults) and convert back on submit (`'' → null`). Do not paper + over a `null` default with a `placeholder` that looks like a value: the user + sees "50", the form holds `null`, and every downstream comparison + (`defaultValues.growthPercent !== watched` → `null !== 50`) reports a permanent + phantom change while Cancel silently fails to reset the field. +- Selects/radios: default to `''` or a real option value; checkboxes/switches to + `false`. + +## Number inputs + +The blessed pattern keeps `''` as the "empty" sentinel so the input stays +controlled, and lets zod coerce on validation (see `maxConnections` above): +`z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…')` +with a plain ``. + +If you instead wire `onChange` through `e.target.valueAsNumber` (or +`valueAsNumber: true`), an empty or partially-typed input produces `NaN`, which +lands in form state and propagates into every calculation, price preview, and +`value` attribute downstream. Guard it with the **same empty sentinel the +field's schema declares** — with the `''`-union schema above: +`field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)`. +Never let `NaN` into form state. + +A nullable API field (`null` = "unset", e.g. a platform default applies) +doesn't change the in-form sentinel — keep `''` inside the form and convert at +the boundaries: + +```tsx +// inbound: null → '' when building defaults/values +values: { growthPercent: data.growth_percent ?? '' }, +// schema: '' stays the in-form sentinel, zod coerces real input +growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]), +// outbound: '' → null in onSubmit +mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent }) +``` + +If `null` does end up in form state (some existing forms hold it), keep it out +of both the input and the coercion: render via `value={field.value ?? ''}`, and +don't pass the value through `z.coerce.number()` — `Number(null)` is `0`, so a +nullable field fed into the coercing union silently validates empty as `0`. +Either way it's one sentinel per field, used consistently across defaults, +schema, `onChange`, rendering, and the submit mapping. + +## Dirty state and change detection + +- Gate Save on `isDirty`; show Cancel only when dirty. In the owner, destructure + from `form.formState`; anywhere else, `useFormState({ control })`. +- When the form lives in a Sheet or Dialog, also wire dirty dismissal: + `useConfirmOnClose` + `DiscardChangesConfirmationDialog`. Route Cancel, + Escape, and backdrop through the guard; call the raw `onClose` on successful + submit so you do not prompt after save. Details: + `apps/design-system/content/docs/ui-patterns/modality.mdx` (Dirty form + dismissal) and the studio-ui-patterns skill Sheets section. +- To show _which_ fields changed (review/summary dialogs), read `dirtyFields` + from the same subscription instead of hand-comparing + `defaultValues.x !== watchedX`. RHF already does that comparison correctly; + hand-rolled versions break on the null-vs-placeholder mismatch and must be + kept in sync with the watch list by hand. +- `setValue` outside user input needs explicit flags: + `setValue('x', v, { shouldDirty: true, shouldValidate: true })` — otherwise the + change is invisible to `isDirty` and validation. + +## Disabling and gating + +If a field must not be edited (plan tier, permissions, cooldown), disable the +field itself — a notice next to an editable input gates nothing. Wire the same +condition into both the notice and the control. Permission checks come from +`useAsyncCheckPermissions`; disabled buttons that need an explanation use +`ButtonTooltip`. + +Caution: `register`/`useController` `disabled: true` removes the field's value +from submission data. For "visible but locked" fields whose value must survive +submit, use the input's own `disabled`/`readOnly` prop (as `FormField` + +primitive props do) rather than RHF-level disabling, or the form-level +`disabled` option to freeze everything during async work. + +## Submit and mutations + +`onSubmit` receives validated, typed data — trust it; don't re-read via +`getValues()`. Mutations follow Studio conventions: `onSuccess` → `toast.success` + +- `reset(values)` (or query invalidation when using `values:`), `onError` → + `toast.error`; pass the mutation's `isPending` to the button's `loading` prop. + Default validation `mode: 'onSubmit'` is right for most forms — pick another mode + deliberately, not by copying. + +## Lint rules in force (Studio) + +| Rule | Level | Meaning | +| ------------------------------------------- | ---------------- | ------------------------------------------------ | +| `react-hook-form/destructuring-formstate` | error | destructure `formState`, never hold the object | +| `react-hook-form/no-access-control` | error | don't reach into `control` internals | +| `react-hook-form/no-nested-object-setvalue` | error | `setValue('a.b', v)`, not `setValue('a', {b:v})` | +| `react-hook-form/no-use-watch` | warn (ratcheted) | use `useWatch`, not `watch` | diff --git a/.claude/skills/safe-sql-execution/SKILL.md b/.claude/skills/safe-sql-execution/SKILL.md index c0f43cb8f8f..37674ce1114 100644 --- a/.claude/skills/safe-sql-execution/SKILL.md +++ b/.claude/skills/safe-sql-execution/SKILL.md @@ -1,6 +1,20 @@ --- name: safe-sql-execution -description: Safely execute SQL queries against a user database without risking SQL injection or other security vulnerabilities. +description: >- + Use whenever code will build, return, fetch, or execute SQL that runs against + a user's real Postgres database — even when the request reads like an ordinary + feature or bug fix and never says "security," "injection," or + "SafeSqlFragment." This covers: writing or editing any pg-meta function, query + builder, or endpoint that builds/returns SQL for database objects (tables, + views, functions, DB triggers, indexes, RLS policies); interpolating a + schema/table/column/search/route-param value into SQL text; storing, fetching, + or re-running SQL that round-trips from the database (a policy's definition, a + function/view definition, a snippet's saved content); and any + "Run"/"Apply"/"Execute" action that sends SQL to a project's database (SQL + editor run-selection, policy editor apply, snippet runner). Load this BEFORE + writing such code, not only when reviewing a finished diff. Skip only for + changes that never touch SQL text or execution — styling, unrelated data + hooks, non-SQL form validation, or UI layout work. --- # Safe SQL execution diff --git a/.claude/skills/studio-best-practices/SKILL.md b/.claude/skills/studio-best-practices/SKILL.md deleted file mode 100644 index 62eca4c6d1d..00000000000 --- a/.claude/skills/studio-best-practices/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: studio-best-practices -description: React and TypeScript best practices for Supabase Studio. Use when writing - or reviewing Studio components — covers boolean naming, component structure, loading/error - states, state management, custom hooks, event handlers, conditional rendering, - performance, and TypeScript conventions. ---- - -# Studio Best Practices - -Applies to `apps/studio/**/*.{ts,tsx}`. - -## Boolean Naming - -Use descriptive prefixes — derive from existing state rather than storing separately: - -- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord` -- `has` — possession: `hasPermission`, `hasData` -- `can` — capability: `canUpdateColumns`, `canDelete` -- `should` — conditional behavior: `shouldFetch`, `shouldRender` - -Extract complex conditions into named variables: - -```tsx -// ❌ inline multi-condition -{ - !isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && - - @@ -52,19 +53,26 @@ const SortRow = ({ index, columnName, sort, onDelete, onToggle }: SortRowProps)
- + ascending: onToggle(columnName, e)} + aria-label={`Sort ${column.name} in ascending order`} />
- - - - - -
- {sortActions(selectedResource.actions).map((action) => ( - - ))} + { + const fieldValue = field.value || [] + + return ( +
+
+
+
+
+ + {selectedResource?.title} + +
- - - )} -
-
- {index < permissionRows.length - 1 &&
} -
+
+
+ {selectedResource && ( + + + + + + + +
+ {sortActions(selectedResource.actions).map((action) => ( + + ))} +
+
+
+ )} +
+
+
+ +
+ {index < permissionRows.length - 1 && ( +
+ )} +
+ ) + }} + /> ) })} @@ -160,6 +192,11 @@ export const Permissions = + {errors.permissionRows?.message || errors.permissionRows?.root?.message ? ( +

+ {errors.permissionRows?.message || errors.permissionRows?.root?.message} +

+ ) : null} ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts index ffb26fe487a..8b3c4a5d439 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/Permissions/Permissions.types.ts @@ -1,4 +1,6 @@ -import { FieldValues, UseFormSetValue, UseFormWatch } from 'react-hook-form' +import { Control, FieldValues } from 'react-hook-form' + +import { TokenFormValues } from '../../../AccessToken.schemas' export interface PermissionResource { resource: string @@ -15,19 +17,16 @@ export interface PermissionsFormValues extends FieldValues { permissionRows?: PermissionRow[] } -export interface PermissionsProps< - TFormValues extends PermissionsFormValues = PermissionsFormValues, -> { - setValue: UseFormSetValue - watch: UseFormWatch +export interface PermissionsProps { + control: Control resourceSearchOpen: boolean setResourceSearchOpen: (open: boolean) => void } -export interface PermissionResourceSelectorProps { +export interface PermissionResourceSelectorProps { open: boolean onOpenChange: (open: boolean) => void permissionRows: PermissionRow[] - setValue: UseFormSetValue + onResourceToggled: (resource: PermissionResource) => void align?: 'center' | 'end' | 'start' } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 21cd176dbcd..f6e775cc08a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -3,7 +3,7 @@ import dayjs from 'dayjs' import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useCallback, useState } from 'react' -import { useForm, type SubmitHandler } from 'react-hook-form' +import { useForm, useWatch, type SubmitHandler } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -17,7 +17,7 @@ import { SheetHeader, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CUSTOM_EXPIRY_VALUE, @@ -66,12 +66,13 @@ export const NewScopedTokenSheet = ({ }, mode: 'onChange', }) + const track = useTrack() const { mutate: createAccessToken, isPending } = useAccessTokenCreateMutation() - const resourceAccess = form.watch('resourceAccess') - const expiresAt = form.watch('expiresAt') - const permissionRows = form.watch('permissionRows') || [] + const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' }) + const expiresAt = useWatch({ control: form.control, name: 'expiresAt' }) + const permissionRows = useWatch({ control: form.control, name: 'permissionRows' }) || [] const onSubmit: SubmitHandler = async (values) => { if (!permissionRows || permissionRows.length === 0) { @@ -303,8 +304,7 @@ export const NewScopedTokenSheet = ({ /> diff --git a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx index d421f5ffc2d..c7c8d4d5d79 100644 --- a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { SupportCategories } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -63,7 +63,7 @@ export const DeleteAccountButton = () => { resolver: zodResolver(FormSchema), defaultValues: { account: '' }, }) - const { account } = form.watch() + const account = useWatch({ control: form.control, name: 'account' }) const { mutate: submitSupportTicket, isPending } = useSendSupportTicketMutation({ onSuccess: () => { diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx index 63c89019637..8b6ce19fb05 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx @@ -59,25 +59,28 @@ interface FirstStepProps { onClose: () => void } +const ENROLL_FORM_ID = 'add-totp-factor-form' + +const EnrollFormSchema = z.object({ + name: z.string().trim().min(1, 'Name is required'), +}) +type EnrollFormValues = z.infer + +const enrollFormDefaultValues: EnrollFormValues = { name: '' } + const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => { - const FormSchema = z.object({ - name: z.string().min(1, 'Please provide a name to identify this app'), - }) - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { name: '' }, + const form = useForm({ + resolver: zodResolver(EnrollFormSchema), + defaultValues: enrollFormDefaultValues, mode: 'onChange', }) - const onSubmit: SubmitHandler> = async (values) => { + const onSubmit: SubmitHandler = async (values) => { enroll({ factorType: 'totp', friendlyName: values.name }) } useEffect(() => { - if (!visible) { - // Generate a name with a number between 0 and 1000 - form.reset({ name: `App ${Math.floor(Math.random() * 1000)}` }) - } + if (visible) form.reset(enrollFormDefaultValues) }, [form, visible]) return ( @@ -93,7 +96,7 @@ const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => > @@ -104,11 +107,11 @@ const FirstStep = ({ visible, isEnrolling, enroll, onClose }: FirstStepProps) => render={({ field }) => ( - + )} diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx index ea9a8a4df60..61c79ac6bec 100644 --- a/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx +++ b/apps/studio/components/interfaces/Account/TOTPFactors/index.tsx @@ -18,7 +18,7 @@ export const TOTPFactors = () => { <>

- Use an authenticator app (like 1Password or Authy) to verify your identity at sign-in. + Use an authenticator app (like Google Authenticator or 1Password) to protect your account.

{isLoading && } @@ -37,7 +37,7 @@ export const TOTPFactors = () => {

- Added on {dayjs(factor.updated_at).format(DATETIME_FORMAT)} + Added on {dayjs(factor.created_at).format(DATETIME_FORMAT)}

- {redirectUrl && ( + + {!actionError && redirectUrl && (

- Authorizing will redirect you to {redirectUrl} + Authorizing will redirect you to{' '} + + {redirectUrl} +

)} diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx index 66c2f0c7c51..5759641e559 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Invalid.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx index 507b06fa481..6d1ab488c80 100644 --- a/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx +++ b/apps/studio/components/interfaces/ApiAuthorization/ApiAuthorization.Valid.tsx @@ -108,7 +108,6 @@ function usePrefillFormOnOrganizationsSuccess( if (organizationsState._tag === 'success') { prefillForm() } - // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) }, [organizationsState._tag]) } @@ -143,38 +142,57 @@ export function ApiAuthorizationValidScreen({ } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null - const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ + const { + mutate: approveRequest, + error: approveError, + reset: resetApproveError, + } = useApiAuthorizationApproveMutation({ onSuccess: (res) => { window.location.href = res.url }, + onError: () => { + setApprovalState('indeterminate') + }, }) - const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ + const { + mutate: declineRequest, + error: declineError, + reset: resetDeclineError, + } = useApiAuthorizationDeclineMutation({ onSuccess: () => { toast.success('Declined API authorization request') navigate('/organizations') }, + onError: () => { + setApprovalState('indeterminate') + }, }) + const actionError = approveError + ? `Failed to authorize request: ${approveError.message}` + : declineError + ? `Failed to cancel authorization request: ${declineError.message}` + : undefined + const resetActionError = () => { + resetApproveError() + resetDeclineError() + } const onApproveRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('approving') - approveRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + approveRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) const onDeclineRequest = form.handleSubmit((values) => { if (approvalState !== 'indeterminate') { return } + resetActionError() setApprovalState('declining') - declineRequest( - { id: auth_id, slug: values.selectedOrgSlug }, - { onError: () => setApprovalState('indeterminate') } - ) + declineRequest({ id: auth_id, slug: values.selectedOrgSlug }) }) if (isLoading) { @@ -225,6 +243,8 @@ export function ApiAuthorizationValidScreen({ requester={effectiveRequester} requestedOrganizationSlug={effectiveOrganizationSlug} organizations={effectiveOrganizationsState} + actionError={actionError} + onOrganizationChange={resetActionError} onApprove={onApproveRequest} onDecline={onDeclineRequest} /> diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 54026ead90b..832f2769a75 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -9,7 +9,7 @@ import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUp import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -const TOSUpdateExpiry = new Date('2026-07-04T00:00:00Z') +const TOSUpdateExpiry = new Date('2026-08-29T00:00:00Z') export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index ce32634141e..63541a53ed4 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -66,7 +66,6 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = // flag-derived defaults (e.g. default opt-in) are reflected in `flags`. if (hasLoaded) setIsInitialized(true) } - // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) }, [hasLoaded]) const value = { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 7a03d13ea76..29d8272bbcf 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -94,13 +94,17 @@ export const FeaturePreviewModal = () => { return } - toggleFeaturePreviewModal(false) if (hasRoute) { + // Navigating away drops the `featurePreviewModal` query param, which is + // what closes the modal. Don't also close it via + // toggleFeaturePreviewModal — its queued nuqs URL update races the push + // and can navigate back to the current page, swallowing the redirect. router.push(selectedFeatureRoute) toast.success(`${selectedFeature.name} enabled`, { description: "We've taken you to where you can try it out.", }) } else { + toggleFeaturePreviewModal(false) toast.success(`${selectedFeature.name} enabled`, { description: "It's now active across the dashboard.", }) @@ -251,6 +255,7 @@ const FeaturePreviewItem = ({ return ( - )} - - - - - - - - @@ -411,6 +368,157 @@ export const SessionsAuthSettingsForm = () => { + + + + + Access Tokens + + + +
+ + + + ( + + + + + + seconds + + + + + )} + /> + + + {accessTokenForm.formState.isDirty && ( + + )} + + + +
+ +
+
+ + + + + Refresh Tokens + + + +
+ + + + ( + + + + + + )} + /> + + + ( + + + + + + seconds + + + + + )} + /> + + + {refreshTokenForm.formState.isDirty && ( + + )} + + + +
+ +
+
) } diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index b2570e70367..b3827866156 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -19,7 +19,7 @@ import { InputGroupText, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' @@ -100,13 +100,17 @@ type SmtpFormValues = z.infer export const SmtpForm = () => { const { ref: projectRef } = useParams() - const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef }) + const { + data: authConfig, + error: authConfigError, + isError, + isSuccess, + } = useAuthConfigQuery({ projectRef }) const { data: selectedProject } = useSelectedProjectQuery() const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() const { mutateAsync: resetAuthTemplate } = useAuthTemplateResetMutation() - const [enableSmtp, setEnableSmtp] = useState(false) const [showDisableConfirmation, setShowDisableConfirmation] = useState(false) const [pendingValues, setPendingValues] = useState(null) @@ -150,6 +154,8 @@ export const SmtpForm = () => { }) const { isDirty } = form.formState + const smtpHost = useWatch({ control: form.control, name: 'SMTP_HOST' }) + const enableSmtp = useWatch({ control: form.control, name: 'ENABLE_SMTP' }) const doUpdate = ({ values, @@ -235,27 +241,16 @@ export const SmtpForm = () => { }) } - // Update form values when auth config is loaded useEffect(() => { - if (authConfig) { + if (isSuccess) { const formValues = generateFormValues(authConfig) form.reset({ ...formValues, ENABLE_SMTP: isSmtpEnabled(authConfig), } as SmtpFormValues) - setEnableSmtp(isSmtpEnabled(authConfig)) } - }, [authConfig, form]) - - // Update enableSmtp state when the form field changes - useEffect(() => { - const subscription = form.watch((value, { name }) => { - if (name === 'ENABLE_SMTP') { - setEnableSmtp(value.ENABLE_SMTP as boolean) - } - }) - return () => subscription.unsubscribe() - }, [form]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccess, form]) if (isError) { return ( @@ -305,6 +300,7 @@ export const SmtpForm = () => { > { )} /> - {form.watch('SMTP_HOST')?.endsWith('.gmail.com') && ( + {smtpHost?.endsWith('.gmail.com') && ( { variant="default" onClick={() => { form.reset() - setEnableSmtp(isSmtpEnabled(authConfig)) }} > Cancel diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx index d8b184185af..47fb516c310 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { Trash } from 'lucide-react' import { useEffect } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -90,7 +90,7 @@ export const CreateAwsCognitoAuthIntegrationDialog = ({ }) } - const awsRegion = form.watch('awsRegion') + const awsRegion = useWatch({ control: form.control, name: 'awsRegion' }) return ( onClose()}> diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index cc928dff45a..ddc0b42d28a 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import dayjs from 'dayjs' import { useEffect } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -61,7 +61,7 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { defaultValues, }) - const { value, unit } = form.watch() + const [value, unit] = useWatch({ control: form.control, name: ['value', 'unit'] }) const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)') const onSubmit = (data: FormType) => { diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index beaa3fcd4fb..574f355de8e 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useQueryState } from 'nuqs' import { useEffect } from 'react' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 2b7808c3da2..1a4da14e9e0 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { ComponentProps, ReactNode, useEffect, useState } from 'react' import { toast } from 'sonner' import { Button, cn, Separator } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx new file mode 100644 index 00000000000..23b4caea738 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.test.tsx @@ -0,0 +1,91 @@ +import { screen } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { ResizablePanelGroup } from 'ui' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { UserPanel } from './UserPanel' +import type { User } from '@/data/auth/users-infinite-query' +import { BASE_PATH } from '@/lib/constants' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' +import { createMockProfileContext } from '@/tests/lib/profile-helpers' + +const { mockUser } = vi.hoisted(() => ({ + mockUser: { + id: '11111111-1111-1111-1111-111111111111', + email: 'user@example.com', + providers: ['email'], + } as unknown as User, +})) + +// Project resolution is pure scaffolding here (it only supplies `ref` to the +// user query); mocking the whole project query chain over the network would add +// noise without testing anything this spec cares about. +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: vi + .fn() + .mockReturnValue({ data: { ref: 'project-ref', connectionString: 'postgres://' } }), +})) + +// Heavy tab bodies — the sanctioned use of vi.mock. This spec only asserts tab +// visibility, not what the tabs render. +vi.mock('./UserOverview', () => ({ + UserOverview: () =>
, +})) +vi.mock('./UserLogs', () => ({ + UserLogs: () =>
, +})) + +const renderPanel = (disabledFeatures: string[] = []) => { + // The user query flows through pg-meta SQL; mock it at the network boundary. + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json([mockUser] as any), + }) + + // `/api/enabled-features-overrides` is a Next.js route, not an OpenAPI path, + // so addAPIMock can't type it — register a raw handler. Feature state is + // driven by the profile's disabled_features below. + mswServer.use( + http.get(`${BASE_PATH}/api/enabled-features-overrides`, () => + HttpResponse.json({ disabled_features: [] }) + ) + ) + + return customRender( + + + , + { + nuqs: { searchParams: `?show=${mockUser.id}` }, + profileContext: createMockProfileContext({ + profile: { + disabled_features: disabledFeatures as any, + } as any, + }), + } + ) +} + +describe('UserPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the Logs tab when logs:all is enabled', async () => { + renderPanel([]) + + expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Logs' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeInTheDocument() + }) + + it('hides the Logs tab when logs:all is disabled', async () => { + renderPanel(['logs:all']) + + expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'Logs' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx index 5e9723437f0..f9d7342f6ed 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx @@ -20,10 +20,12 @@ import { UserOverview } from './UserOverview' import { PANEL_PADDING } from './Users.constants' import { useUserQuery } from '@/data/auth/user-query' import { User } from '@/data/auth/users-infinite-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' export const UserPanel = () => { const { data: project } = useSelectedProjectQuery() + const showLogs = useIsFeatureEnabled('logs:all') const [selectedId, setSelectedId] = useQueryState( 'show', @@ -85,12 +87,14 @@ export const UserPanel = () => { > Overview - - Logs - + {showLogs && ( + + Logs + + )} { setSelectedId(null)} /> )} - - {selectedUser && } - + {showLogs && ( + + {selectedUser && } + + )} void onSelectDeleteUser: (user: User) => void onSelectImpersonateUser: (user: User, destination: 'sql' | 'table-editor') => Promise + onSelectViewLogs: (user: User) => void }) => { const columnOrder = config.map((c) => c.id) ?? columns.map((c) => c.id) @@ -464,6 +466,17 @@ export const formatUserColumns = ({ Run SQL as user + e.stopPropagation()} + onSelect={() => { + if (user) onSelectViewLogs(user) + }} + > + + View user logs + + { 'show', parseAsString.withOptions({ history: 'push', clearOnDefault: true }) ) - const [improvedSearchDismissed, setImprovedSearchDismissed] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.AUTH_USERS_IMPROVED_SEARCH_DISMISSED(projectRef ?? ''), false @@ -373,6 +373,12 @@ export const UsersV2 = () => { } } + const onSelectViewLogs = (user: User) => { + const identifier = user.id || user.email + if (!projectRef || !identifier) return + router.push(buildUnifiedLogsUrl({ projectRef, user: identifier })) + } + const handleScroll = (event: UIEvent) => { const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -482,6 +488,7 @@ export const UsersV2 = () => { setSortByValue: updateSortByValue, onSelectDeleteUser: setSelectedUserToDelete, onSelectImpersonateUser, + onSelectViewLogs, }) setColumns(columns) if (columns.length < userTableColumns.length) { @@ -716,6 +723,7 @@ export const UsersV2 = () => { setSortByValue: updateSortByValue, onSelectDeleteUser: setSelectedUserToDelete, onSelectImpersonateUser, + onSelectViewLogs, }) setSelectedColumns(value) diff --git a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx index 1686bdf5b0f..471917a2ca2 100644 --- a/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx +++ b/apps/studio/components/interfaces/Billing/NoProjectsOnPaidOrgInfo.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx index 5d70043695b..d8d83b3a8ec 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/DeletePaymentMethodModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useOrganizationPaymentMethodDeleteMutation } from '@/data/organizations/organization-payment-method-delete-mutation' import type { OrganizationPaymentMethod } from '@/data/organizations/organization-payment-methods-query' diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx index cd4037b16a5..c2b2e72f3c3 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx @@ -14,7 +14,7 @@ import { import { Form } from '@ui/components/shadcn/ui/form' import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react' import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -140,7 +140,10 @@ export const NewPaymentMethodElement = forwardRef( form.setValue('tax_id_name', name) } - const { tax_id_name, tax_id_value: rawTaxIdValue } = form.watch() + const [tax_id_name, rawTaxIdValue] = useWatch({ + control: form.control, + name: ['tax_id_name', 'tax_id_value'], + }) const taxIdValue = rawTaxIdValue?.trim() ?? '' const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.test.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.test.tsx index fecbfd6e02f..29dbe25a73c 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.test.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.test.tsx @@ -1,10 +1,13 @@ import { screen } from '@testing-library/react' +import { mockIntersectionObserver } from 'jsdom-testing-mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' import PaymentMethods from './PaymentMethods' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { createMockOrganization, render } from '@/tests/helpers' +mockIntersectionObserver() + const { mockSelectedOrganization, mockPaymentMethodsQuery, mockSubscription } = vi.hoisted(() => ({ mockSelectedOrganization: vi.fn(), mockPaymentMethodsQuery: vi.fn(), diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx index 85b92a6c18a..09ef0267878 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx @@ -2,9 +2,10 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/ import { useParams } from 'common' import { CreditCardIcon, ExternalLink, Plus } from 'lucide-react' import { useState } from 'react' +import { useInView } from 'react-intersection-observer' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import ChangePaymentMethodModal from './ChangePaymentMethodModal' @@ -37,14 +38,15 @@ const PaymentMethods = () => { const [selectedMethodToDelete, setSelectedMethodToDelete] = useState() const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(false) - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }) + const { ref, inView } = useInView({ triggerOnce: true }) + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: inView }) const { data: paymentMethods, error, isPending: isLoading, isError, isSuccess, - } = useOrganizationPaymentMethodsQuery({ slug }) + } = useOrganizationPaymentMethodsQuery({ slug }, { enabled: inView }) const { can: canReadPaymentMethods, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, @@ -61,7 +63,7 @@ const PaymentMethods = () => { selectedOrganization?.managed_by === MANAGED_BY.STRIPE_PROJECTS return ( <> - +

Payment Methods

diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection.tsx index a980ff2eb60..e4eb1b951f5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/StripePaymentConnection.tsx @@ -1,6 +1,6 @@ import { ExternalLink } from 'lucide-react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import PartnerIcon from '@/components/ui/PartnerIcon' import { MANAGED_BY } from '@/lib/constants/infrastructure' diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx index 76fd9d3bbdb..21a1d5428c3 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' // [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth interface DiskIOBandwidthWarningsProps { diff --git a/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx b/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx new file mode 100644 index 00000000000..d4256ee9e34 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/ConnectToGitHub.tsx @@ -0,0 +1,56 @@ +import { useParams } from 'common' +import { Github } from 'lucide-react' +import { useRouter } from 'next/router' +import { Button } from 'ui' + +import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' +import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { openInstallGitHubIntegrationWindow } from '@/lib/github' +import { useAppStateSnapshot } from '@/state/app-state' + +export const ConnectToGitHub = () => { + const router = useRouter() + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const { data: selectedOrg } = useSelectedOrganizationQuery() + const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot() + + const isBranch = project?.parent_project_ref !== undefined + const projectRef = + project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined + + const { data: gitHubAuthorization } = useGitHubAuthorizationQuery() + + const { data: connections } = useGitHubConnectionsQuery( + { organizationId: selectedOrg?.id }, + { enabled: showCreateBranchModal } + ) + const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) + + const showAuthorizeCta = githubConnection && !gitHubAuthorization + + const onClick = () => { + if (showAuthorizeCta) { + openInstallGitHubIntegrationWindow('authorize') + } else { + if (showCreateBranchModal) setShowCreateBranchModal(false) + router.push(`/project/${projectRef}/settings/integrations`) + } + } + + return ( +
+
+ Sync with a GitHub branch +

+ Keep this preview branch in sync with a chosen GitHub branch +

+
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 8bd01d65a4d..d282a1e5eeb 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -3,12 +3,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { useFlag, useParams } from 'common' -import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react' +import { Check, DatabaseZap, DollarSign, GitMerge, Loader2 } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { useCallback, useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Badge, @@ -25,7 +25,6 @@ import { FormControl, FormField, Input, - Label, Switch, Tooltip, TooltipContent, @@ -40,7 +39,9 @@ import { estimateDiskCost, estimateRestoreTime, } from './BranchManagement.utils' +import { ConnectToGitHub } from './ConnectToGitHub' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice' import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -49,6 +50,7 @@ import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation' import { useBranchesQuery } from '@/data/branches/branches-query' import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query' +import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { projectKeys } from '@/data/projects/keys' @@ -111,9 +113,20 @@ export const CreateBranchModal = () => { defaultValues: { branchName: '', gitBranchName: '', withData: false }, }) - const { withData, gitBranchName } = form.watch() + const [withData, gitBranchName] = useWatch({ + control: form.control, + name: ['withData', 'gitBranchName'], + }) const debouncedGitBranchName = useDebounce(gitBranchName, 500) + const { + data: githubAuthorization, + error: authorizationError, + isPending: isLoadingAuthorization, + isSuccess: isSuccessAuthorization, + isError: isErrorAuthorization, + } = useGitHubAuthorizationQuery() + const { data: connections, error: connectionsError, @@ -125,6 +138,11 @@ export const CreateBranchModal = () => { { enabled: showCreateBranchModal } ) + const isLoading = isLoadingAuthorization || isLoadingConnections + const isSuccess = isSuccessAuthorization && isSuccessConnections + const isError = isErrorAuthorization || isErrorConnections + const error = authorizationError || connectionsError + const { data: branches } = useBranchesQuery({ projectRef }) const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery( { projectRef }, @@ -256,11 +274,6 @@ export const CreateBranchModal = () => { }) } - const handleGitHubClick = () => { - setShowCreateBranchModal(false) - router.push(`/project/${projectRef}/settings/integrations`) - } - useEffect(() => { if (showCreateBranchModal) form.reset() }, [form, showCreateBranchModal]) @@ -319,22 +332,24 @@ export const CreateBranchModal = () => { )} /> - {isLoadingConnections && ( + {isLoading && (
)} - {isErrorConnections && ( + {isError && ( )} - {isSuccessConnections && - (githubConnection ? ( + {isSuccess && + (!githubAuthorization || !githubConnection ? ( + + ) : ( {
} labelOptional="Optional" - description="Automatically deploy changes on every commit" + description={ + githubAuthorization + ? 'Automatically deploy changes on every commit' + : undefined + } >
@@ -390,18 +409,6 @@ export const CreateBranchModal = () => { )} /> - ) : ( -
-
- -

- Keep this preview branch in sync with a chosen GitHub branch -

-
- -
))} {allowDataBranching && ( @@ -412,7 +419,7 @@ export const CreateBranchModal = () => { - + Include data {!hasPitrEnabled && Requires PITR} } @@ -422,6 +429,7 @@ export const CreateBranchModal = () => { > { setShowCreateBranchModal(false)} className="pointer-events-auto" - href={`/project/${ref}/settings/compute-and-disk`} + href={getInfrastructurePath(projectRef)} > - Compute and Disk + Infrastructure

diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index fdaa39ad42a..70af737772e 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -128,6 +128,7 @@ const FunctionDiff = ({
  • -
  • ))} diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx index 7186cf00078..493ae385d39 100644 --- a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -12,7 +12,7 @@ import { AlertDialogTrigger, Button, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useTrack } from '@/lib/telemetry/track' diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx index 8ead7ed8cd5..50118d69a6d 100644 --- a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx +++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx @@ -115,6 +115,7 @@ export const WorkflowLogs = ({ branch }: WorkflowLogsProps) => {
  • - ))} + // Container query: 2-col when the sheet is narrow; one equal row when there's room +
    +
    = 6 && '@[36rem]:grid-cols-6' + )} + > + {modes.map((mode, index) => ( + onChange(mode.id)} + {...getConnectModeButtonCornerVariants({ index, count, emptySlots })} + /> + ))} + + {Array.from({ length: emptySlots }, (_, index) => ( +
    + ))} +
    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx new file mode 100644 index 00000000000..3c6fd80b98e --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectModeButton.tsx @@ -0,0 +1,302 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { Box, Cable, Database, Server, Sparkles } from 'lucide-react' +import type { ComponentPropsWithoutRef, ReactNode } from 'react' +import { cn } from 'ui' + +import type { ConnectMode } from './Connect.types' + +const MODE_ICONS: Record = { + framework: , + direct: , + orm: , + mcp: , + server: , +} + +/** Maps mode count → container-query breakpoint used when collapsing to a single row. */ +type ModeCountBreakpoint = 3 | 4 | 5 | 6 + +function toModeCountBreakpoint(count: number): ModeCountBreakpoint { + if (count === 3 || count === 4 || count === 5) return count + return 6 +} + +const connectModeButtonVariants = cva( + [ + // Each cell owns a border; adjacent edges overlap (RadioGroupStacked-style) + 'relative -mb-px -mr-px flex cursor-pointer flex-col items-center gap-2 border bg-overlay/50 p-4 shadow-xs transition-colors', + 'focus-visible:z-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background', + ], + { + variants: { + selected: { + true: 'z-1 border-foreground-muted bg-surface-300 ring-1 ring-border', + false: + 'hover:z-1 hover:border-foreground-muted hover:bg-background dark:hover:bg-surface-200', + }, + // Narrow 2-col outer corners + topLeft: { + true: 'rounded-tl-lg', + false: '', + }, + topRight: { + true: 'rounded-tr-lg', + false: '', + }, + bottomLeft: { + true: 'rounded-bl-lg', + false: '', + }, + bottomRight: { + true: 'rounded-br-lg', + false: '', + }, + // Wide single-row reshaping (compounded with modeCount for the breakpoint) + singleRowStart: { + true: '', + false: '', + }, + singleRowEnd: { + true: '', + false: '', + }, + clearTopRight: { + true: '', + false: '', + }, + clearBottomLeft: { + true: '', + false: '', + }, + clearBottomRight: { + true: '', + false: '', + }, + modeCount: { + 3: '', + 4: '', + 5: '', + 6: '', + }, + }, + compoundVariants: [ + // Full class strings so Tailwind can see them (no dynamic fragment concatenation) + { + singleRowStart: true, + modeCount: 3, + class: '@[28rem]:rounded-tl-lg @[28rem]:rounded-bl-lg', + }, + { + singleRowStart: true, + modeCount: 4, + class: '@[30rem]:rounded-tl-lg @[30rem]:rounded-bl-lg', + }, + { + singleRowStart: true, + modeCount: 5, + class: '@[32rem]:rounded-tl-lg @[32rem]:rounded-bl-lg', + }, + { + singleRowStart: true, + modeCount: 6, + class: '@[36rem]:rounded-tl-lg @[36rem]:rounded-bl-lg', + }, + + { singleRowEnd: true, modeCount: 3, class: '@[28rem]:rounded-tr-lg @[28rem]:rounded-br-lg' }, + { singleRowEnd: true, modeCount: 4, class: '@[30rem]:rounded-tr-lg @[30rem]:rounded-br-lg' }, + { singleRowEnd: true, modeCount: 5, class: '@[32rem]:rounded-tr-lg @[32rem]:rounded-br-lg' }, + { singleRowEnd: true, modeCount: 6, class: '@[36rem]:rounded-tr-lg @[36rem]:rounded-br-lg' }, + + { clearTopRight: true, modeCount: 3, class: '@[28rem]:rounded-tr-none' }, + { clearTopRight: true, modeCount: 4, class: '@[30rem]:rounded-tr-none' }, + { clearTopRight: true, modeCount: 5, class: '@[32rem]:rounded-tr-none' }, + { clearTopRight: true, modeCount: 6, class: '@[36rem]:rounded-tr-none' }, + + { clearBottomLeft: true, modeCount: 3, class: '@[28rem]:rounded-bl-none' }, + { clearBottomLeft: true, modeCount: 4, class: '@[30rem]:rounded-bl-none' }, + { clearBottomLeft: true, modeCount: 5, class: '@[32rem]:rounded-bl-none' }, + { clearBottomLeft: true, modeCount: 6, class: '@[36rem]:rounded-bl-none' }, + + { clearBottomRight: true, modeCount: 3, class: '@[28rem]:rounded-br-none' }, + { clearBottomRight: true, modeCount: 4, class: '@[30rem]:rounded-br-none' }, + { clearBottomRight: true, modeCount: 5, class: '@[32rem]:rounded-br-none' }, + { clearBottomRight: true, modeCount: 6, class: '@[36rem]:rounded-br-none' }, + ], + defaultVariants: { + selected: false, + topLeft: false, + topRight: false, + bottomLeft: false, + bottomRight: false, + singleRowStart: false, + singleRowEnd: false, + clearTopRight: false, + clearBottomLeft: false, + clearBottomRight: false, + modeCount: 6, + }, + } +) + +const connectModeButtonIconVariants = cva('', { + variants: { + selected: { + true: 'text-foreground', + false: 'text-foreground-light', + }, + }, + defaultVariants: { + selected: false, + }, +}) + +const connectModeButtonLabelVariants = cva('heading-default text-center', { + variants: { + selected: { + true: 'text-foreground', + false: 'text-foreground-light', + }, + }, + defaultVariants: { + selected: false, + }, +}) + +const connectModeButtonDescriptionVariants = cva('text-sm leading-tight text-center', { + variants: { + selected: { + true: 'text-foreground-light', + false: 'text-foreground-lighter', + }, + }, + defaultVariants: { + selected: false, + }, +}) + +const connectModeEmptySlotVariants = cva( + // Sunk vs mode tiles (bg-overlay/50); surface-200 reads clearly recessed on light + 'relative -mb-px -mr-px rounded-br-lg border bg-surface-200 dark:bg-surface-100', + { + variants: { + modeCount: { + 3: '@[28rem]:hidden', + 4: '@[30rem]:hidden', + 5: '@[32rem]:hidden', + 6: '@[36rem]:hidden', + }, + }, + defaultVariants: { + modeCount: 6, + }, + } +) + +type ConnectModeButtonCornerVariants = Pick< + VariantProps, + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight' + | 'singleRowStart' + | 'singleRowEnd' + | 'clearTopRight' + | 'clearBottomLeft' + | 'clearBottomRight' + | 'modeCount' +> + +export interface ConnectModeButtonProps + extends Omit, 'children'>, ConnectModeButtonCornerVariants { + modeId: ConnectMode + label: string + description: string + selected?: boolean +} + +export function ConnectModeButton({ + modeId, + label, + description, + selected = false, + topLeft, + topRight, + bottomLeft, + bottomRight, + singleRowStart, + singleRowEnd, + clearTopRight, + clearBottomLeft, + clearBottomRight, + modeCount, + className, + type = 'button', + ...props +}: ConnectModeButtonProps) { + return ( + + ) +} + +/** Derive CVA corner variants from a cell's position in the mode grid. */ +export function getConnectModeButtonCornerVariants({ + index, + count, + emptySlots, +}: { + index: number + count: number + emptySlots: number +}): ConnectModeButtonCornerVariants { + const isLast = index === count - 1 + const narrowLastRowStart = (Math.ceil(count / 2) - 1) * 2 + const topLeft = index === 0 + const topRight = index === 1 + const bottomLeft = index === narrowLastRowStart + const bottomRight = emptySlots === 0 && isLast + + return { + modeCount: toModeCountBreakpoint(count), + topLeft, + topRight, + bottomLeft, + bottomRight, + // Once wide enough for a single row, reshape corners to left/right caps + singleRowStart: index === 0, + singleRowEnd: isLast, + clearTopRight: topRight && !isLast, + clearBottomLeft: bottomLeft && index !== 0, + clearBottomRight: bottomRight && !isLast, + } +} + +export function getConnectModeEmptySlotClasses(count: number) { + return connectModeEmptySlotVariants({ modeCount: toModeCountBreakpoint(count) }) +} diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 1d16e6fde89..a6684e24f0a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -159,13 +159,16 @@ export const ConnectSheet = () => { return ( - + Connect to your project Choose how you want to use Supabase -
    +
    {
    {activeFields.length > 0 && ( -
    +
    ) => { + const displayTitle = optional ? `${title} (optional)` : title + return (
    -
    +
    -
    -
    -

    {title}

    -

    {description}

    -
    -
    - {children} + {/* Container query: side-by-side title | content when the step row is wide enough. + Viewport 2xl never applied inside max-w-4xl sheets. */} +
    +
    +
    +

    + {title} + {optional && (optional)} +

    +

    {description}

    +
    +
    + {children} +
    diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 947e24b4c75..8daa1afee7e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -3,7 +3,7 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { useMemo, useRef, type ComponentType } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import type { @@ -23,10 +23,11 @@ import { shouldShowSelfHostedMcpNotice, shouldShowSessionPoolerNotice, } from './ConnectStepsSection.utils' -import { CopyPromptAdmonition } from './CopyPromptAdmonition' +import { CopyPromptButton } from './CopyPromptAdmonition' import { buildConnectionStringPooler, getConnectionStrings } from './DatabaseSettings.utils' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { DocsButton } from '@/components/ui/DocsButton' +import { InlineLink } from '@/components/ui/InlineLink' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' @@ -34,6 +35,7 @@ import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' @@ -49,10 +51,18 @@ interface ConnectStepsSectionProps { function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionStringPooler { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') + const isHighAvailability = useIsHighAvailability() const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) - const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + // Multigres has no pooler, so the pooler config endpoints don't apply + const { data: pgbouncerConfig } = usePgbouncerConfigQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) + const { data: supavisorConfig } = useSupavisorConfigurationQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) @@ -112,14 +122,22 @@ function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionSt connectionStringsShared, connectionStringsDedicated, ipv4Addon: !!ipv4Addon, + isHighAvailability, }), - [deploymentMode, connectionInfo, connectionStringsShared, connectionStringsDedicated, ipv4Addon] + [ + deploymentMode, + connectionInfo, + connectionStringsShared, + connectionStringsDedicated, + ipv4Addon, + isHighAvailability, + ] ) } // Vite needs `import.meta.glob` to statically discover the step content // modules because the `${filePath}` template can span multiple directory -// segments (`flask/supabasepy`, `steps/shadcn/explore`, ...) which Vite's +// segments (`flask/supabasepy`, `steps/shadcn/command`, ...) which Vite's // dynamic-import-vars plugin can't analyze. Skip the glob on the SSR bundle // — Vite replaces `import.meta.env.SSR` at build time and tree-shakes the // call so the 37 content modules stay out of the server graph (pulling them @@ -242,7 +260,10 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS return (
    -

    Connect your app

    +
    +

    Follow these steps

    + +
    {showDataApiDisabledWarning && ( + description={ + <> + Enable the dedicated IPv4 address add-on to connect from IPv4-only networks.{' '} + + Learn more + + + } + actions={ + , - , - ]} + + } /> )} {showSessionPoolerNotice && ( )} @@ -291,15 +319,14 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS /> )} - - -
    +
    {steps.map((step, index) => ( { - const [copiedMap, setCopiedMap] = useState>({}) + const copyAllText = parameters.map((param) => `${param.key}=${param.value}`).join('\n') return ( -
    - {parameters.map((param) => ( -
    -
    - {param.key}: - {param.value} - +
    +
    + Connection parameters + onCopy?.('all')} + /> +
    +
    + {parameters.map((param) => ( +
    + {param.key}: + + {param.value} + + onCopy?.(param.key)} + />
    -
    - ))} + ))} +
    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts index 16c3e900447..8158af03229 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts @@ -4,11 +4,17 @@ import type { ConnectionStringPooler } from './Connect.types' export const DEFAULT_PORT = '5432' export const PASSWORD_PLACEHOLDER = '[YOUR-PASSWORD]' +/** Appends query params to a connection string, joining with `?` or `&` as needed */ +export const appendConnectionStringParams = (uri: string, params: string) => + !uri || !params ? uri : `${uri}${uri.includes('?') ? '&' : '?'}${params}` + export type ConnectionParams = { host: string port: string user: string database: string + /** Raw query string including the leading `?`, or '' when the URI has none */ + search: string } export const resolveConnectionString = ({ @@ -44,6 +50,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', } } @@ -64,6 +71,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: parsed.port || DEFAULT_PORT, user: parsed.username ? decode(parsed.username) : 'hidden', database: parsed.pathname?.replace(/^\//, '') || 'hidden', + search: parsed.search, } } catch (error) { return { @@ -71,6 +79,7 @@ export const parseConnectionParams = (connectionString: string): ConnectionParam port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', } } } @@ -81,15 +90,22 @@ export const buildSafeConnectionString = ( ): string => { if (!connectionString) return '' - const search = (() => { - try { - return new URL(connectionString).search - } catch (error) { - return '' - } - })() + return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${params.search}` +} - return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${search}` +export const buildPsqlCommand = (params: ConnectionParams) => + params.search + ? // Query params (e.g. sslmode) can't be expressed as psql flags, so fall + // back to the URI form — psql prompts for the password. + `psql "postgresql://${params.user}@${params.host}:${params.port}/${params.database}${params.search}"` + : `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}` + +export const buildJdbcString = (params: ConnectionParams) => { + // pgJDBC (42.7.4+) spells libpq's `sslnegotiation` as `sslNegotiation` + const extraParams = params.search + ? `&${params.search.slice(1).replace('sslnegotiation=', 'sslNegotiation=')}` + : '' + return `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}${extraParams}` } export const buildConnectionStringWithPassword = ( diff --git a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx index 6b60b9065f3..4a522e3b6f1 100644 --- a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx @@ -1,10 +1,10 @@ -import { type RefObject } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Check, Copy } from 'lucide-react' +import { useEffect, useState, type RefObject } from 'react' +import { copyToClipboard } from 'ui' -import CopyButton from '@/components/ui/CopyButton' -import { BASE_PATH } from '@/lib/constants' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -interface CopyPromptAdmonitionProps { +interface CopyPromptButtonProps { stepsContainerRef: RefObject /** When set, the Copy prompt button uses this verbatim instead of scraping the steps. */ customPrompt?: string @@ -125,42 +125,32 @@ export const buildConnectPrompt = (stepsContainer: HTMLElement | null) => { return promptContent } -export function CopyPromptAdmonition({ - stepsContainerRef, - customPrompt, -}: CopyPromptAdmonitionProps) { - const handleCopyPrompt = () => { - return customPrompt ?? buildConnectPrompt(stepsContainerRef.current) - } +export function CopyPromptButton({ stepsContainerRef, customPrompt }: CopyPromptButtonProps) { + const [showCopied, setShowCopied] = useState(false) + + useEffect(() => { + if (!showCopied) return + const timer = setTimeout(() => setShowCopied(false), 2000) + return () => clearTimeout(timer) + }, [showCopied]) return ( - - } + : } + onClick={() => { + const textToCopy = customPrompt ?? buildConnectPrompt(stepsContainerRef.current) + setShowCopied(true) + copyToClipboard(textToCopy) + }} + tooltip={{ + content: { + side: 'left', + text: 'Copy these steps for your coding agent', + }, + }} > -
    - Supabase Grafana - Supabase Grafana -
    -
    - -
    -
    -

    Give your agent everything it needs

    -
    -
    - + {showCopied ? 'Copied' : 'Copy prompt'} + ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts index e0e75995c5a..4233c2479ee 100644 --- a/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts @@ -1,4 +1,21 @@ import type { ConnectionStringPooler, DeploymentMode } from './Connect.types' +import { appendConnectionStringParams } from './ConnectionString.utils' + +/** + * Multigres (high-availability) projects only accept TLS connections with + * direct SSL negotiation — without these params clients fail with + * "server closed the connection unexpectedly". + */ +export const HIGH_AVAILABILITY_SSL_PARAMS = 'sslmode=require&sslnegotiation=direct' + +/** + * No-op when the URI already carries `sslnegotiation`, so the params are never + * double-appended. + */ +export const appendHighAvailabilitySslParams = (uri: string) => + uri.includes('sslnegotiation=') + ? uri + : appendConnectionStringParams(uri, HIGH_AVAILABILITY_SSL_PARAMS) type ConnectionStrings = { psql: string @@ -228,12 +245,14 @@ export const buildConnectionStringPooler = ({ connectionStringsShared, connectionStringsDedicated, ipv4Addon, + isHighAvailability, }: { deploymentMode: DeploymentMode connectionInfo: { db_host: string; db_port: number | string } connectionStringsShared: { direct: ConnectionStrings; pooler: ConnectionStrings } connectionStringsDedicated?: { direct: ConnectionStrings; pooler: ConnectionStrings } ipv4Addon: boolean + isHighAvailability: boolean }): ConnectionStringPooler => { if (deploymentMode.isSelfHosted) { const dbHost = connectionInfo.db_host @@ -265,6 +284,20 @@ export const buildConnectionStringPooler = ({ } } + if (isHighAvailability) { + // Multigres has no pooler (neither Supavisor nor PgBouncer), so every slot + // falls back to the direct connection. + const directUri = appendHighAvailabilitySslParams(connectionStringsShared.direct.uri) + return { + transactionShared: directUri, + sessionShared: directUri, + transactionDedicated: undefined, + sessionDedicated: undefined, + ipv4SupportedForDedicatedPooler: false, + direct: directUri, + } + } + // Port-swap 6543→5432 derives session from transaction. For shared this is a // real Supavisor session connection; for dedicated it lands on direct Postgres // (PgBouncer has no session mode). diff --git a/apps/studio/components/interfaces/ConnectSheet/DirectConnectionExamples.tsx b/apps/studio/components/interfaces/ConnectSheet/DirectConnectionExamples.tsx index 80084d1337d..4f8ceb8b69e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/DirectConnectionExamples.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/DirectConnectionExamples.tsx @@ -1,6 +1,5 @@ export type Example = { installCommands?: string[] - postInstallCommands?: string[] files?: { name: string content: string @@ -58,9 +57,6 @@ func main() { installCommands: [ 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', ], - postInstallCommands: [ - 'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION', - ], }, python: { installCommands: ['pip install python-dotenv psycopg2'], diff --git a/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts b/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts new file mode 100644 index 00000000000..7b339be82ef --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/OrmConnection.utils.ts @@ -0,0 +1,39 @@ +import type { + ConnectionStringPooler, + DeploymentMode, +} from '@/components/interfaces/ConnectSheet/Connect.types' + +export type OrmConnectionScenario = + | 'cli' + | 'self-hosted' + | 'high-availability' + | 'dedicated-pooler' + | 'shared-pooler-with-dedicated-alternative' + | 'shared-pooler' + +/** + * Resolves which connection setup an ORM env template should render. + * Shared by the ORM step contents (Prisma, Drizzle) so they only differ in + * formatting, not in how the scenario is picked. + */ +export const resolveOrmConnectionScenario = ({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): OrmConnectionScenario => { + if (deploymentMode.isCli) return 'cli' + if (deploymentMode.isSelfHosted) return 'self-hosted' + if (isHighAvailability) return 'high-availability' + + if (connectionStringPooler.transactionDedicated) { + return connectionStringPooler.ipv4SupportedForDedicatedPooler + ? 'dedicated-pooler' + : 'shared-pooler-with-dedicated-alternative' + } + + return 'shared-pooler' +} diff --git a/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx index 7c8ea817b03..bef1c734a9d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx @@ -1,19 +1,12 @@ -import { Admonition } from 'ui-patterns/admonition' - import { InlineLink } from '@/components/ui/InlineLink' import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants' export const PasswordEncodingNote = () => { return ( - - If your database password contains special characters, you will need to{' '} - percent-encode them - in the connection string. - - } - /> +

    + If your database password contains special characters,{' '} + percent-encode them in + the connection string. +

    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts index 834714b8276..5a07d913ddf 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/ConnectionString.utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from 'vitest' import { + appendConnectionStringParams, buildConnectionParameters, buildConnectionStringWithPassword, + buildJdbcString, + buildPsqlCommand, buildSafeConnectionString, DEFAULT_PORT, parseConnectionParams, @@ -17,6 +20,7 @@ describe('parseConnectionParams', () => { port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', }) }) @@ -26,6 +30,7 @@ describe('parseConnectionParams', () => { port: DEFAULT_PORT, user: 'hidden', database: 'hidden', + search: '', }) }) @@ -37,9 +42,16 @@ describe('parseConnectionParams', () => { port: '6543', user: 'postgres.projref', database: 'postgres', + search: '', }) }) + test('keeps the query string in search', () => { + const uri = + 'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct' + expect(parseConnectionParams(uri).search).toBe('?sslmode=require&sslnegotiation=direct') + }) + test('decodes percent-encoded bracket placeholders in the user info', () => { // The URL parser percent-encodes the `[`/`]` in self-hosted's POOLER_TENANT_ID placeholder. // parseConnectionParams must decode so the displayed user matches what we wrote. @@ -159,6 +171,71 @@ describe('resolveConnectionString', () => { }) }) +describe('appendConnectionStringParams', () => { + test('joins with ? when the URI has no query string', () => { + expect(appendConnectionStringParams('postgresql://u@h:5432/db', 'pgbouncer=true')).toBe( + 'postgresql://u@h:5432/db?pgbouncer=true' + ) + }) + + test('joins with & when the URI already has a query string', () => { + expect( + appendConnectionStringParams('postgresql://u@h:5432/db?sslmode=require', 'pgbouncer=true') + ).toBe('postgresql://u@h:5432/db?sslmode=require&pgbouncer=true') + }) + + test('returns the URI unchanged for empty inputs', () => { + expect(appendConnectionStringParams('', 'pgbouncer=true')).toBe('') + expect(appendConnectionStringParams('postgresql://u@h:5432/db', '')).toBe( + 'postgresql://u@h:5432/db' + ) + }) +}) + +describe('buildPsqlCommand', () => { + const params = { + host: 'db.proj.supabase.co', + port: '5432', + user: 'postgres', + database: 'postgres', + search: '', + } + + test('uses flag form when there is no query string', () => { + expect(buildPsqlCommand(params)).toBe( + 'psql -h db.proj.supabase.co -p 5432 -d postgres -U postgres' + ) + }) + + test('falls back to the URI form when the query string must be carried', () => { + expect(buildPsqlCommand({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe( + 'psql "postgresql://postgres@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct"' + ) + }) +}) + +describe('buildJdbcString', () => { + const params = { + host: 'db.proj.supabase.co', + port: '5432', + user: 'postgres', + database: 'postgres', + search: '', + } + + test('builds the base string without extra params', () => { + expect(buildJdbcString(params)).toBe( + `jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}` + ) + }) + + test('appends the query string using pgJDBC casing for sslnegotiation', () => { + expect(buildJdbcString({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe( + `jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}&sslmode=require&sslNegotiation=direct` + ) + }) +}) + describe('buildConnectionParameters', () => { test('produces host/port/database/user rows in display order', () => { expect( @@ -167,6 +244,7 @@ describe('buildConnectionParameters', () => { port: '5432', user: 'u', database: 'd', + search: '', }) ).toEqual([ { key: 'host', value: 'h' }, diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts index c5697ed5655..dcfb2b95deb 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/DatabaseSettings.utils.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'vitest' import { + appendHighAvailabilitySslParams, buildConnectionStringPooler, getSelfHostedDirectStrings, getSelfHostedPoolerStrings, + HIGH_AVAILABILITY_SSL_PARAMS, } from '../DatabaseSettings.utils' import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' @@ -99,6 +101,7 @@ describe('buildConnectionStringPooler', () => { connectionStringsShared: sharedPlatform, connectionStringsDedicated: dedicatedPlatform, ipv4Addon: true, + isHighAvailability: false, }) expect(result.transactionShared).toBe(sharedPlatform.pooler.uri) @@ -115,6 +118,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.ipv4SupportedForDedicatedPooler).toBe(false) expect(result.transactionDedicated).toBeUndefined() @@ -128,6 +132,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.direct).toBe(directUri) @@ -144,6 +149,7 @@ describe('buildConnectionStringPooler', () => { connectionInfo: { db_host: 'supabase.example.com', db_port: 5432 }, connectionStringsShared: sharedPlatform, ipv4Addon: true, + isHighAvailability: false, }) expect(result.transactionShared).toBe( @@ -167,8 +173,69 @@ describe('buildConnectionStringPooler', () => { connectionInfo: { db_host: 'supabase.example.com', db_port: 0 }, connectionStringsShared: sharedPlatform, ipv4Addon: false, + isHighAvailability: false, }) expect(result.sessionShared).toContain(':5432/postgres') expect(result.direct).toContain(':5432/postgres') }) + + test('platform high availability: collapses every slot to the direct URI with SSL params', () => { + const directUri = `${sharedPlatform.direct.uri}?${HIGH_AVAILABILITY_SSL_PARAMS}` + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + connectionStringsDedicated: dedicatedPlatform, + ipv4Addon: true, + isHighAvailability: true, + }) + + expect(result.direct).toBe(directUri) + expect(result.transactionShared).toBe(directUri) + expect(result.sessionShared).toBe(directUri) + // No pooler on Multigres: dedicated slots stay empty and the IPv4 flag is + // off even when a dedicated pooler config and the addon were passed in + expect(result.transactionDedicated).toBeUndefined() + expect(result.sessionDedicated).toBeUndefined() + expect(result.ipv4SupportedForDedicatedPooler).toBe(false) + }) + + test('platform without high availability appends no SSL params', () => { + const result = buildConnectionStringPooler({ + deploymentMode: platform, + connectionInfo, + connectionStringsShared: sharedPlatform, + connectionStringsDedicated: dedicatedPlatform, + ipv4Addon: true, + isHighAvailability: false, + }) + + expect(result.direct).toBe(sharedPlatform.direct.uri) + expect(result.transactionShared).toBe(sharedPlatform.pooler.uri) + expect(result.direct).not.toContain('sslnegotiation') + expect(result.transactionShared).not.toContain('sslnegotiation') + }) +}) + +describe('appendHighAvailabilitySslParams', () => { + test('appends with ? when the URI has no query string', () => { + expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db')).toBe( + `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}` + ) + }) + + test('appends with & when the URI already has a query string', () => { + expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db?options=x')).toBe( + `postgresql://u:p@host:5432/db?options=x&${HIGH_AVAILABILITY_SSL_PARAMS}` + ) + }) + + test('does not double-append when sslnegotiation is already present', () => { + const uri = `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}` + expect(appendHighAvailabilitySslParams(uri)).toBe(uri) + }) + + test('leaves an empty string untouched', () => { + expect(appendHighAvailabilitySslParams('')).toBe('') + }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts new file mode 100644 index 00000000000..c511b002921 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/OrmConnection.utils.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'vitest' + +import type { ConnectionStringPooler } from '../Connect.types' +import { resolveOrmConnectionScenario } from '../OrmConnection.utils' +import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode' + +const platform: DeploymentMode = { isPlatform: true, isCli: false, isSelfHosted: false } +const cli: DeploymentMode = { isPlatform: false, isCli: true, isSelfHosted: false } +const selfHosted: DeploymentMode = { isPlatform: false, isCli: false, isSelfHosted: true } + +const makePooler = (overrides: Partial = {}): ConnectionStringPooler => ({ + transactionShared: 'postgresql://shared:6543/postgres', + sessionShared: 'postgresql://shared:5432/postgres', + ipv4SupportedForDedicatedPooler: false, + direct: 'postgresql://direct:5432/postgres', + ...overrides, +}) + +describe('resolveOrmConnectionScenario', () => { + test('resolves cli for CLI deployments', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: cli, + isHighAvailability: false, + }) + expect(scenario).toBe('cli') + }) + + test('resolves self-hosted for self-hosted deployments', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: selfHosted, + isHighAvailability: false, + }) + expect(scenario).toBe('self-hosted') + }) + + test('resolves high-availability for HA projects on platform', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: platform, + isHighAvailability: true, + }) + expect(scenario).toBe('high-availability') + }) + + test('cli wins over the HA flag', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: cli, + isHighAvailability: true, + }) + expect(scenario).toBe('cli') + }) + + test('resolves dedicated-pooler when the dedicated pooler exists and IPv4 is supported', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler({ + transactionDedicated: 'postgresql://dedicated:6543/postgres', + sessionDedicated: 'postgresql://dedicated:5432/postgres', + ipv4SupportedForDedicatedPooler: true, + }), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('dedicated-pooler') + }) + + test('resolves shared-pooler-with-dedicated-alternative when the dedicated pooler exists without IPv4 support', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler({ + transactionDedicated: 'postgresql://dedicated:6543/postgres', + sessionDedicated: 'postgresql://dedicated:5432/postgres', + ipv4SupportedForDedicatedPooler: false, + }), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('shared-pooler-with-dedicated-alternative') + }) + + test('falls back to shared-pooler when there is no dedicated pooler', () => { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler: makePooler(), + deploymentMode: platform, + isHighAvailability: false, + }) + expect(scenario).toBe('shared-pooler') + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/connect.schema.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/connect.schema.test.ts index 5c6f8224acf..f3b08063f56 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/connect.schema.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/connect.schema.test.ts @@ -123,6 +123,15 @@ describe('connect.schema:fields', () => { const field = connectSchema.fields.mcpFeatures expect(field.type).toBe('multi-select') expect(field.options).toEqual({ source: 'mcpFeatures' }) + expect(field.defaultValue).toEqual([ + 'docs', + 'account', + 'database', + 'debugging', + 'development', + 'functions', + 'branching', + ]) }) }) @@ -229,7 +238,7 @@ describe('connect.schema:steps resolution', () => { expect(steps.find((s) => s.id === 'shadcn-add')).toBeDefined() expect(steps.find((s) => s.id === 'shadcn-env')).toBeDefined() - expect(steps.find((s) => s.id === 'shadcn-explore')).toBeDefined() + expect(steps.find((s) => s.id === 'shadcn-explore')).toBeUndefined() }) test('should resolve steps for react without shadcn', () => { @@ -388,14 +397,6 @@ describe('connect.schema:step content paths', () => { expect(shadcnStep?.content).toBe('steps/shadcn/command') }) - test('shadcn explore step should have valid content path', () => { - const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true } - const steps = resolveSteps(connectSchema, state) - const exploreStep = steps.find((s) => s.id === 'shadcn-explore') - - expect(exploreStep?.content).toBe('steps/shadcn/explore') - }) - test('shadcn env step should have valid content path', () => { const state: ConnectState = { mode: 'framework', framework: 'nextjs', frameworkUi: true } const steps = resolveSteps(connectSchema, state) diff --git a/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts index 558fcf0fd60..8b6a5b21b67 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts @@ -498,6 +498,11 @@ describe('useConnectState', () => { // ============================================================================ describe('high availability projects', () => { + afterEach(async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(false) + }) + test('should hide connectionMethod field for HA projects', async () => { const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') vi.mocked(useIsHighAvailability).mockReturnValue(true) @@ -530,6 +535,34 @@ describe('useConnectState', () => { expect(connectionTypeField?.label).toBe('Connection Type') }) + test('should coerce pooler-flavored initial state to the direct method for HA projects', async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(true) + + // Simulates pooler selections restored from the URL or localStorage + const { result } = renderHook(() => + useConnectState({ mode: 'direct', connectionMethod: 'transaction', useSharedPooler: true }) + ) + + expect(result.current.state.connectionMethod).toBe('direct') + expect(result.current.state.useSharedPooler).toBe(false) + }) + + test('should coerce connectionMethod updates to the direct method for HA projects', async () => { + const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') + vi.mocked(useIsHighAvailability).mockReturnValue(true) + + const { result } = renderHook(() => useConnectState({ mode: 'direct' })) + + act(() => { + result.current.updateField('connectionMethod', 'session') + result.current.updateField('useSharedPooler', true) + }) + + expect(result.current.state.connectionMethod).toBe('direct') + expect(result.current.state.useSharedPooler).toBe(false) + }) + test('should not affect non-HA projects', async () => { const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject') vi.mocked(useIsHighAvailability).mockReturnValue(false) @@ -694,5 +727,46 @@ describe('useConnectState', () => { expect(result.current.state.connectionMethod).toBe('session') }) }) + + describe('mcp features for deployment mode', () => { + test('self-hosted defaults and options stay within the non-platform subset', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => useConnectState({ mode: 'framework' })) + act(() => { + result.current.setMode('mcp') + }) + + const features = result.current.state.mcpFeatures + expect(Array.isArray(features)).toBe(true) + expect(features).toEqual( + expect.arrayContaining(['docs', 'database', 'development', 'debugging']) + ) + expect(features).not.toContain('storage') + expect(features).not.toContain('account') + expect(features).not.toContain('auth') + + const options = result.current.getFieldOptions('mcpFeatures').map((o) => o.value) + expect(options).toEqual(['docs', 'database', 'debugging', 'development']) + }) + + test('self-hosted strips unsupported features when re-entering mcp mode', () => { + Object.assign(deploymentModeMock, { + isPlatform: false, + isCli: false, + isSelfHosted: true, + }) + const { result } = renderHook(() => + useConnectState({ mode: 'mcp', mcpFeatures: ['docs', 'account', 'database'] }) + ) + act(() => { + result.current.setMode('mcp') + }) + expect(result.current.state.mcpFeatures).toEqual(['docs', 'database']) + }) + }) }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts index 7874640a1f2..a1eb1d03e4a 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts @@ -95,6 +95,7 @@ export function resolveSteps(schema: ConnectSchema, state: ConnectState): Resolv id: step.id, title: step.title, description: step.description, + optional: step.optional, content: content ?? '', } }) diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts index 15204084a0f..dccc9ad38cb 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts @@ -1,5 +1,32 @@ +import { FEATURE_GROUPS_NON_PLATFORM, FEATURE_GROUPS_PLATFORM } from 'ui-patterns/McpUrlBuilder' + import type { ConnectSchema, StepDefinition } from './Connect.types' +/** + * MCP feature groups enabled by default on platform (Storage is excluded to keep + * tool counts manageable). + */ +export const DEFAULT_MCP_FEATURES = FEATURE_GROUPS_PLATFORM.filter( + (group) => group.id !== 'storage' +).map((group) => group.id) + +const NON_PLATFORM_MCP_FEATURE_IDS = new Set(FEATURE_GROUPS_NON_PLATFORM.map((group) => group.id)) + +/** Defaults (and supported IDs) for the current deployment mode. */ +export function getDefaultMcpFeatures(isPlatform: boolean): string[] { + if (isPlatform) return DEFAULT_MCP_FEATURES + return DEFAULT_MCP_FEATURES.filter((id) => NON_PLATFORM_MCP_FEATURE_IDS.has(id)) +} + +export function getSupportedMcpFeatureGroups(isPlatform: boolean) { + return isPlatform ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM +} + +export function normalizeMcpFeatures(features: string[], isPlatform: boolean): string[] { + if (isPlatform) return features + return features.filter((id) => NON_PLATFORM_MCP_FEATURE_IDS.has(id)) +} + /** * Base install commands for each library. */ @@ -69,7 +96,7 @@ const frameworkReactFilesStep: StepDefinition = { const frameworkShadcnStep: StepDefinition = { id: 'shadcn-add', title: 'Add Supabase UI components', - description: 'Run this command to install the Supabase shadcn components.', + description: 'Install Supabase UI components via the shadcn registry.', content: 'steps/shadcn/command', } @@ -80,13 +107,6 @@ const frameworkShadcnEnvStep: StepDefinition = { content: 'steps/shadcn/env', } -const frameworkShadcnExploreStep: StepDefinition = { - id: 'shadcn-explore', - title: 'Check out more UI components', - description: 'Add auth, realtime and storage functionality to your project', - content: 'steps/shadcn/explore', -} - const directConnectionStep: StepDefinition = { id: 'connection', title: 'Connection string', @@ -97,7 +117,7 @@ const directConnectionStep: StepDefinition = { const directInstallStep: StepDefinition = { id: 'direct-install', title: 'Install dependencies', - description: 'Run this command to install the required dependencies.', + description: 'Install the required dependencies.', content: 'steps/direct-install', } @@ -133,7 +153,7 @@ const codexAuthenticateStep: StepDefinition = { const codexVerifyStep: StepDefinition = { id: 'codex-verify', title: 'Verify authentication', - description: 'Run /mcp inside Codex to verify.', + description: 'Confirm the MCP server is authenticated.', content: 'steps/mcp/codex/verify', } @@ -148,7 +168,7 @@ const claudeAuthenticateStep: StepDefinition = { id: 'claude-authenticate', title: 'Authenticate', description: - 'After configuring the MCP server, you need to authenticate. In a regular terminal (not the IDE extension) run:', + 'After configuring the MCP server, you need to authenticate. Run this in a regular terminal, not an IDE extension.', content: 'steps/mcp/claude-code/authenticate', } @@ -169,8 +189,7 @@ const ormConfigureStep: StepDefinition = { const serverInstallStep: StepDefinition = { id: 'server-install', title: 'Install package', - description: - 'Add @supabase/server to your backend or API framework of choice. On Supabase Edge Functions you can import it directly, no install needed.', + description: 'Add @supabase/server to your backend.', content: 'server/install', } @@ -178,13 +197,14 @@ const serverEnvStep: StepDefinition = { id: 'server-env', title: 'Set environment variables', description: - 'Copy these into your environment so you can verify users and use the client/admin supabase-js library from the context of your handler. On Supabase Edge Functions they are injected automatically.', + 'Copy these into your environment so your handler can verify users and use supabase-js.', content: 'server/env', } const skillsInstallStep: StepDefinition = { id: 'install-skills', - title: 'Install Agent Skills (Optional)', + title: 'Install Agent Skills', + optional: true, description: 'Agent Skills give AI coding tools ready-made instructions, scripts, and resources for working with Supabase more accurately and efficiently.', content: 'steps/skills-install', @@ -192,9 +212,9 @@ const skillsInstallStep: StepDefinition = { const serverSkillsInstallStep: StepDefinition = { id: 'install-skills', - title: 'Install the Supabase Server skill (Optional)', - description: - 'Gives AI coding tools ready-made instructions for building APIs with @supabase/server.', + title: 'Install the Supabase Server skill', + optional: true, + description: 'Give AI coding tools instructions for building APIs with @supabase/server.', content: 'steps/skills-install', } @@ -302,7 +322,7 @@ export const connectSchema: ConnectSchema = { id: 'frameworkUi', type: 'switch', label: 'Shadcn', - description: 'Install components via the Supabase shadcn registry.', + description: 'Install Supabase UI components with shadcn.', defaultValue: false, dependsOn: { framework: ['nextjs', 'react'] }, }, @@ -326,8 +346,8 @@ export const connectSchema: ConnectSchema = { useSharedPooler: { id: 'useSharedPooler', type: 'switch', - label: 'Use IPv4 connection (Shared Pooler)', - description: 'Recommended when your network does not support IPv6', + label: 'Use IPv4 connection', + description: 'Uses the shared pooler. Recommended on networks that do not support IPv6.', defaultValue: false, dependsOn: { connectionMethod: ['transaction'] }, }, @@ -353,7 +373,7 @@ export const connectSchema: ConnectSchema = { id: 'mcpClient', type: 'select', label: 'Client', - description: 'Choose the MCP client you are using.', + description: 'The MCP client you are using.', options: { source: 'mcpClients' }, defaultValue: 'claude-code', }, @@ -361,7 +381,7 @@ export const connectSchema: ConnectSchema = { id: 'mcpReadonly', type: 'switch', label: 'Read-only', - description: 'Only allow read operations on your database', + description: 'Only allow read operations on your database.', defaultValue: false, }, mcpFeatures: { @@ -369,8 +389,9 @@ export const connectSchema: ConnectSchema = { type: 'multi-select', label: 'Feature groups', description: - 'Only enable a subset of features. Helps keep the number of tools within MCP client limits.', + 'Which MCP tools to include. Storage is off by default to keep tool counts manageable.', options: { source: 'mcpFeatures' }, + defaultValue: DEFAULT_MCP_FEATURES, }, }, @@ -390,7 +411,6 @@ export const connectSchema: ConnectSchema = { frameworkInstallPackagesStep, frameworkShadcnStep, frameworkShadcnEnvStep, - frameworkShadcnExploreStep, skillsInstallStep, ], DEFAULT: [ @@ -406,7 +426,6 @@ export const connectSchema: ConnectSchema = { frameworkInstallStep, frameworkShadcnStep, frameworkShadcnEnvStep, - frameworkShadcnExploreStep, skillsInstallStep, ], DEFAULT: [frameworkInstallStep, frameworkNextJsFilesStep, skillsInstallStep], @@ -420,7 +439,6 @@ export const connectSchema: ConnectSchema = { frameworkInstallStep, frameworkShadcnStep, frameworkShadcnEnvStep, - frameworkShadcnExploreStep, skillsInstallStep, ], DEFAULT: [frameworkInstallStep, frameworkReactFilesStep, skillsInstallStep], diff --git a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx index 10a6a6106d1..84d7e158fb7 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/drizzle/content.tsx @@ -1,37 +1,68 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { + ConnectionStringPooler, + DeploymentMode, + StepContentProps, +} from '@/components/interfaces/ConnectSheet/Connect.types' +import { resolveOrmConnectionScenario } from '@/components/interfaces/ConnectSheet/OrmConnection.utils' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' -const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { - const envCode = deploymentMode.isCli - ? ` +function getEnvCode({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): string { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler, + deploymentMode, + isHighAvailability, + }) + + switch (scenario) { + case 'cli': + return ` # Connect to Postgres via the direct connection DATABASE_URL="${connectionStringPooler.direct}" ` - : deploymentMode.isSelfHosted - ? ` + case 'self-hosted': + return ` # Connect to Postgres via the self-hosted transaction-mode pooler DATABASE_URL="${connectionStringPooler.transactionShared}" ` - : connectionStringPooler.transactionDedicated && - connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'high-availability': + return ` +# Multigres does not support connection pooling — connect to Postgres directly +DATABASE_URL="${connectionStringPooler.direct}" +` + case 'dedicated-pooler': + return ` # Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionDedicated}" ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'shared-pooler-with-dedicated-alternative': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionShared}" # For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler as an alternative # DATABASE_URL="${connectionStringPooler.transactionDedicated}" ` - : ` + case 'shared-pooler': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) DATABASE_URL="${connectionStringPooler.transactionShared}" ` + } +} + +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const isHighAvailability = useIsHighAvailability() + const envCode = getEnvCode({ connectionStringPooler, deploymentMode, isHighAvailability }) const files = [ { diff --git a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx index e9b3722ba28..382b36fc728 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/nextjs/app/supabasejs/content.tsx @@ -137,5 +137,4 @@ export const createClient = (request: NextRequest) => { } // [Joshen] Used as a dynamic import -// eslint-disable-next-line no-restricted-exports export default ContentFile diff --git a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx index d331286607c..86e2761783e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/prisma/content.tsx @@ -1,53 +1,91 @@ import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock' -import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import type { + ConnectionStringPooler, + DeploymentMode, + StepContentProps, +} from '@/components/interfaces/ConnectSheet/Connect.types' +import { appendConnectionStringParams } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { resolveOrmConnectionScenario } from '@/components/interfaces/ConnectSheet/OrmConnection.utils' +import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject' -const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { - const envCode = deploymentMode.isCli - ? ` +const withPgbouncerParam = (uri: string | undefined) => + appendConnectionStringParams(uri ?? '', 'pgbouncer=true') + +function getEnvCode({ + connectionStringPooler, + deploymentMode, + isHighAvailability, +}: { + connectionStringPooler: ConnectionStringPooler + deploymentMode: DeploymentMode + isHighAvailability: boolean +}): string { + const scenario = resolveOrmConnectionScenario({ + connectionStringPooler, + deploymentMode, + isHighAvailability, + }) + + switch (scenario) { + case 'cli': + return ` # Connect to Postgres via the direct connection DATABASE_URL="${connectionStringPooler.direct}" # Used for migrations DIRECT_URL="${connectionStringPooler.direct}" ` - : deploymentMode.isSelfHosted - ? ` + case 'self-hosted': + return ` # Connect to Postgres via the self-hosted transaction-mode pooler -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the self-hosted session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" ` - : connectionStringPooler.transactionDedicated && - connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'high-availability': + return ` +# Multigres does not support connection pooling — connect to Postgres directly +DATABASE_URL="${connectionStringPooler.direct}" + +# Used for migrations +DIRECT_URL="${connectionStringPooler.direct}" +` + case 'dedicated-pooler': + return ` # Connect to Postgres via the dedicated transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionDedicated)}" # Connect to Postgres directly (used for migrations) DIRECT_URL="${connectionStringPooler.sessionDedicated}" ` - : connectionStringPooler.transactionDedicated && - !connectionStringPooler.ipv4SupportedForDedicatedPooler - ? ` + case 'shared-pooler-with-dedicated-alternative': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the shared session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" # For paid projects, if your network supports IPv6, or you purchased the IPv4 add-on, use the dedicated transaction-mode pooler with a direct connection to Postgres for migrations as an alternative -# DATABASE_URL="${connectionStringPooler.transactionDedicated}?pgbouncer=true" +# DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionDedicated)}" # DIRECT_URL="${connectionStringPooler.sessionDedicated}" ` - : ` + case 'shared-pooler': + return ` # Connect to Postgres via the shared transaction-mode pooler (IPv4-only) -DATABASE_URL="${connectionStringPooler.transactionShared}?pgbouncer=true" +DATABASE_URL="${withPgbouncerParam(connectionStringPooler.transactionShared)}" # Connect to Postgres via the shared session-mode pooler (used for migrations) DIRECT_URL="${connectionStringPooler.sessionShared}" ` + } +} + +const ContentFile = ({ connectionStringPooler, deploymentMode }: StepContentProps) => { + const isHighAvailability = useIsHighAvailability() + const envCode = getEnvCode({ connectionStringPooler, deploymentMode, isHighAvailability }) const files = [ { diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/common/EnvRow.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/common/EnvRow.tsx index 47a26028642..daa065fd28d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/common/EnvRow.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/common/EnvRow.tsx @@ -8,7 +8,7 @@ export function EnvRow({ children: React.ReactNode }) { return ( -
    +
    {name}= {value} diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx index 43a12e20516..79391d22cd0 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/env/content.tsx @@ -1,7 +1,4 @@ import { useParams } from 'common' -import Link from 'next/link' -import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' import { EnvRow } from '../common/EnvRow' import { SecretEnvRow } from '../common/SecretRow' @@ -10,6 +7,7 @@ import { useConnectServerEnv, } from '@/components/interfaces/ConnectSheet/useConnectServerEnv' import CopyButton from '@/components/ui/CopyButton' +import { InlineLink } from '@/components/ui/InlineLink' function ServerEnvContent() { const { ref } = useParams() @@ -17,13 +15,14 @@ function ServerEnvContent() { useConnectServerEnv() return ( -
    -
    -
    +
    +
    +
    .env
    - - View API keys - , - ] - : undefined - } - /> +

    + On Edge Functions these are injected automatically. For other runtimes, copy the values + above + {ref ? ( + <> + . Manage keys in{' '} + API Keys settings. + + ) : ( + '.' + )} +

    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx index 5758dbd183a..8130f474837 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/server/install/content.tsx @@ -11,39 +11,44 @@ const INSTALL_OPTIONS = [ function ServerInstallContent() { return ( - - +
    + + + {INSTALL_OPTIONS.map((option) => ( + + {option.name} + + ))} + {INSTALL_OPTIONS.map((option) => ( - - {option.name} - +
    + + {option.command} + + +
    + ))} - - {INSTALL_OPTIONS.map((option) => ( - -
    - - {option.command} - - -
    -
    - ))} -
    + +

    + On Edge Functions you can import it directly, no install needed. +

    +
    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index 6c1be4ba4b9..d6d3dc3c130 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -1,7 +1,6 @@ import { useParams } from 'common' import { Check, KeyRound } from 'lucide-react' import { useMemo, useState } from 'react' -import { Badge } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' @@ -21,6 +20,8 @@ import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/Conne import { buildConnectionParameters, buildConnectionStringWithPassword, + buildJdbcString, + buildPsqlCommand, buildSafeConnectionString, parseConnectionParams, PASSWORD_PLACEHOLDER, @@ -39,12 +40,6 @@ import { DOCS_URL } from '@/lib/constants' import { pluckObjectFields } from '@/lib/helpers' import { useTrack } from '@/lib/telemetry/track' -const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) => - `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}` - -const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) => - `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}` - /** * [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings * from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here @@ -55,10 +50,18 @@ const buildJdbcString = (params: { host: string; port: string; database: string; const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { const { ref: projectRef } = useParams() const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') + const isHighAvailability = useIsHighAvailability() const { data: databases = [] } = useReadReplicasQuery({ projectRef }) - const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) - const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + // Multigres has no pooler, so the pooler config endpoints don't apply + const { data: pgbouncerConfig } = usePgbouncerConfigQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) + const { data: supavisorConfig } = useSupavisorConfigurationQuery( + { projectRef }, + { enabled: !isHighAvailability } + ) const { data: addons } = useProjectAddonsQuery({ projectRef }) const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) @@ -116,6 +119,7 @@ const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { connectionStringsShared, connectionStringsDedicated, ipv4Addon: !!ipv4Addon, + isHighAvailability, }), ] }) @@ -128,6 +132,7 @@ const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => { ipv4Addon, projectRef, deploymentMode, + isHighAvailability, ]) } @@ -225,23 +230,38 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { const poolerBadge = connectionMethod === 'transaction' ? useSharedPooler || !hasDedicatedPooler - ? 'Shared Pooler' - : 'Dedicated Pooler' + ? 'Shared pooler' + : 'Dedicated pooler' : connectionMethod === 'session' - ? 'Shared Pooler' + ? 'Shared pooler' : null + const showPasswordPlaceholder = connectionString.includes(PASSWORD_PLACEHOLDER) const showSelfHostedDirectNotice = deploymentMode.isSelfHosted && connectionMethod === 'direct' + const showPoolerTitle = deploymentMode.isPlatform && !!poolerBadge && !isHighAvailability + const showResetInTitle = + deploymentMode.isPlatform && showPasswordPlaceholder && !temporaryDatabasePassword + const showStringTitleRow = showPoolerTitle || showResetInTitle return ( -
    - {deploymentMode.isPlatform && poolerBadge && !isHighAvailability && ( -
    - {poolerBadge} -
    - )} - {connectionString.includes(PASSWORD_PLACEHOLDER) && } +
    + {showStringTitleRow && ( +
    + {showPoolerTitle ? ( + {poolerBadge} + ) : ( + + )} + {showResetInTitle && ( + } + onPasswordReset={setTemporaryDatabasePassword} + /> + )} +
    + )}
    - {deploymentMode.isPlatform && ( -
    -
    - {temporaryDatabasePassword ? ( - - - New password shown until refresh. - - ) : ( - 'Forgot your database password?' - )} -
    - } - onPasswordReset={setTemporaryDatabasePassword} - /> + {deploymentMode.isPlatform && temporaryDatabasePassword && ( +
    + + New password shown until refresh.
    )}
    + {showPasswordPlaceholder && } {showSelfHostedDirectNotice && ( -

    +

    Manually{' '} {config.passwordInUrl && } - {(config.postCommands ?? []).map((command) => ( -

    -

    {command.label}

    - - {command.command} - -
    - ))}
    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx index 26863205598..0786e93ac70 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/install/content.tsx @@ -1,6 +1,5 @@ -import { Copy } from 'lucide-react' -import { useMemo, useState } from 'react' -import { Button, copyToClipboard } from 'ui' +import { useMemo } from 'react' +import { CodeBlock } from 'ui-patterns/CodeBlock' import { EXTRA_PACKAGES, @@ -36,36 +35,21 @@ function getInstallCommand(state: StepContentProps['state']): string | null { function InstallContent({ state }: StepContentProps) { const installCommand = useMemo(() => getInstallCommand(state), [state]) - const [copyLabel, setCopyLabel] = useState('Copy') if (!installCommand) { return null } - const handleCopy = () => { - copyToClipboard(installCommand, () => { - setCopyLabel('Copied') - setTimeout(() => setCopyLabel('Copy'), 2000) - }) - } - return ( -
    -
    - {installCommand} -
    -
    - -
    -
    + + {installCommand} + ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx index 27106ed4c35..b5bc06d90ec 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/claude-code/authenticate/content.tsx @@ -11,9 +11,9 @@ function ClaudeAuthenticateContent(_props: StepContentProps) { hideLineNumbers language="bash" /> -

    - Select the supabase{' '} - server, then Authenticate to begin the flow. +

    + Select the supabase server, then{' '} + Authenticate to begin the flow.

    ) diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx index a866f26f6eb..58398267403 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/mcp/codex/verify/content.tsx @@ -1,11 +1,18 @@ +import { CodeBlock } from 'ui-patterns/CodeBlock' + import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' function CodexVerifyContent(_props: StepContentProps) { return (
    -

    - Run /mcp inside Codex - to verify authentication. + +

    + Run this inside Codex to verify authentication.

    ) diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx index ff53feb4617..349cfa824da 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/command/content.tsx @@ -1,8 +1,8 @@ -import { Copy } from 'lucide-react' -import { useMemo, useState } from 'react' -import { Button, copyToClipboard } from 'ui' +import { useMemo } from 'react' +import { CodeBlock } from 'ui-patterns/CodeBlock' import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' +import { InlineLink } from '@/components/ui/InlineLink' function getShadcnCommand(state: StepContentProps['state']): string | null { if (state.framework === 'nextjs') { @@ -18,33 +18,24 @@ function getShadcnCommand(state: StepContentProps['state']): string | null { function ShadcnCommandContent({ state }: StepContentProps) { const command = useMemo(() => getShadcnCommand(state), [state]) - const [copyLabel, setCopyLabel] = useState('Copy') if (!command) return null - const handleCopy = () => { - copyToClipboard(command, () => { - setCopyLabel('Copied') - setTimeout(() => setCopyLabel('Copy'), 2000) - }) - } - return ( -
    -
    - {command} -
    -
    - -
    +
    + + {command} + +

    + Add UI components for auth, realtime, storage, and more at{' '} + supabase.com/ui. +

    ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx deleted file mode 100644 index ddf1361fe53..00000000000 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/shadcn/explore/content.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ExternalLink } from 'lucide-react' -import { Button } from 'ui' - -import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' - -function ShadcnExploreContent(_props: StepContentProps) { - return ( - - ) -} - -export default ShadcnExploreContent diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx index 0943a9da403..f727860651e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/skills-install/content.tsx @@ -1,6 +1,4 @@ -import { Copy } from 'lucide-react' -import { useState } from 'react' -import { Button, copyToClipboard } from 'ui' +import { CodeBlock } from 'ui-patterns/CodeBlock' import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Connect.types' @@ -8,34 +6,18 @@ const DEFAULT_SKILLS_COMMAND = 'npx skills add supabase/agent-skills' const SERVER_SKILLS_COMMAND = 'npx skills add supabase/server' function SkillsInstallContent({ state }: StepContentProps) { - const [copyLabel, setCopyLabel] = useState('Copy') - const skillsCommand = state.mode === 'server' ? SERVER_SKILLS_COMMAND : DEFAULT_SKILLS_COMMAND - const handleCopy = () => { - copyToClipboard(skillsCommand, () => { - setCopyLabel('Copied') - setTimeout(() => setCopyLabel('Copy'), 2000) - }) - } - return ( -
    -
    - {skillsCommand} -
    -
    - -
    -
    + + {skillsCommand} + ) } diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index 4182fc684d4..82dbe3966de 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -1,6 +1,6 @@ import { useParams } from 'common' import { useCallback, useMemo, useState } from 'react' -import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' +import { MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' import { connectionStringMethodOptions, @@ -15,7 +15,12 @@ import { resetDependentFields, resolveSteps, } from './connect.resolver' -import { connectSchema } from './connect.schema' +import { + connectSchema, + getDefaultMcpFeatures, + getSupportedMcpFeatureGroups, + normalizeMcpFeatures, +} from './connect.schema' import type { ConnectMode, ConnectSchema, @@ -161,7 +166,7 @@ function getFieldOptionsFromSource({ })) case 'mcpFeatures': - return FEATURE_GROUPS_PLATFORM.map((f) => ({ + return getSupportedMcpFeatureGroups(deploymentMode.isPlatform).map((f) => ({ value: f.id, label: f.name, description: f.description, @@ -337,18 +342,34 @@ export function useConnectState(initialState?: Partial): UseConnec next.orm = ORMS[0]?.key ?? '' } - if (mode === 'mcp' && !next.mcpClient) { - next.mcpClient = MCP_CLIENTS[0]?.key ?? '' + if (mode === 'mcp') { + if (!next.mcpClient) { + next.mcpClient = MCP_CLIENTS[0]?.key ?? '' + } + if (next.mcpFeatures === undefined) { + next.mcpFeatures = getDefaultMcpFeatures(deploymentMode.isPlatform) + } else if (Array.isArray(next.mcpFeatures)) { + next.mcpFeatures = normalizeMcpFeatures(next.mcpFeatures, deploymentMode.isPlatform) + } } return next }) }, - [projectRef, deploymentMode.isSelfHosted] + [projectRef, deploymentMode.isSelfHosted, deploymentMode.isPlatform] + ) + + // Multigres has no pooler, so pooler-flavored selections restored from the + // URL or localStorage (shared across projects) must never leak into an HA + // project — every consumer sees the direct connection method. + const resolvedState = useMemo( + () => + isHighAvailability ? { ...state, connectionMethod: 'direct', useSharedPooler: false } : state, + [state, isHighAvailability] ) const activeFields = useMemo(() => { - let fields = getActiveFields(connectSchema, state) + let fields = getActiveFields(connectSchema, resolvedState) if (!hasDedicatedPooler || !deploymentMode.isPlatform) { // useSharedPooler is a platform-only toggle (CLI has no pooler; self-hosted // already uses Supavisor shared) @@ -360,21 +381,26 @@ export function useConnectState(initialState?: Partial): UseConnec .map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f)) } return fields - }, [state, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform]) + }, [resolvedState, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform]) - const resolvedSteps = useMemo(() => resolveSteps(connectSchema, state), [state]) + const resolvedSteps = useMemo(() => resolveSteps(connectSchema, resolvedState), [resolvedState]) const getFieldOptions = useCallback( (fieldId: string): FieldOption[] => { const field = activeFields.find((f) => f.id === fieldId) if (!field) return [] - return resolveFieldOptionsWithSource({ field, state, databases, deploymentMode }) + return resolveFieldOptionsWithSource({ + field, + state: resolvedState, + databases, + deploymentMode, + }) }, - [activeFields, state, databases, deploymentMode] + [activeFields, resolvedState, databases, deploymentMode] ) return { - state, + state: resolvedState, updateField, setMode, activeFields, diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index edac39d4c11..dadc2ee0820 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -4,7 +4,7 @@ import { Clock } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' import { toast } from 'sonner' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { TimestampInfo } from 'ui-patterns/TimestampInfo' diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsStorageAlert.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsStorageAlert.tsx index c2edcd30ddd..b6ec9b1e881 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsStorageAlert.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsStorageAlert.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' export const BackupsStorageAlert = () => { return ( diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx index 746446bc5b6..d39a85d6dc4 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx @@ -13,7 +13,7 @@ export const AdditionalMonthlySpend = ({ be able to update the compute size and increase the disk size after the new project is created in{' '} - Project Settings > Compute and Disk + Project Settings > Infrastructure

    diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx index 206aa08b206..884ca841a2d 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypeValueRow.tsx @@ -52,6 +52,7 @@ const EnumeratedTypeValueRow = ({ isDisabled ? 'text-foreground-lighter cursor-default!' : 'text-foreground' }`} type="button" + tabIndex={isDisabled ? -1 : 0} disabled={isDisabled} > diff --git a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index e9c36ea6bca..b16f953cf45 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' +import { useMemo } from 'react' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Badge, @@ -22,7 +23,7 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' @@ -32,6 +33,7 @@ import { DocsButton } from '@/components/ui/DocsButton' import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation' import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' +import { useSchemasFilteredForHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProtectedSchemas } from '@/hooks/useProtectedSchemas' import { DOCS_URL } from '@/lib/constants' @@ -72,10 +74,15 @@ export const EnableExtensionModal = ({ }, { enabled: visible } ) - const availableSchemas = schemas.filter( - (schema) => - schema.name === recommendedSchema || - !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name) + const visibleSchemas = useSchemasFilteredForHighAvailability(schemas) + const availableSchemas = useMemo( + () => + visibleSchemas.filter( + (schema) => + schema.name === recommendedSchema || + !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name) + ), + [visibleSchemas, recommendedSchema, protectedSchemas] ) // [Joshen] Hard-coding pg_cron here as this is enforced on our end (Not via pg_available_extension_versions) @@ -99,7 +106,7 @@ export const EnableExtensionModal = ({ resolver: zodResolver(FormSchema), defaultValues, }) - const { schema } = form.watch() + const schema = useWatch({ control: form.control, name: 'schema' }) const onSubmit = async (values: z.infer) => { if (project === undefined) return console.error('Project is required') diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx index 55123bf7d7b..f8df8b39561 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { extensions } from 'shared-data' import { toast } from 'sonner' import { Button, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { EnableExtensionModal } from './EnableExtensionModal' diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index c40a04a07e0..ba9e7d8b7fd 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -3,7 +3,7 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta/src/pg-forma import { isEmpty, isNull, keyBy, mapValues, partition } from 'lodash' import { Plus, Trash } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' +import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -91,7 +91,7 @@ export const CreateFunction = ({ const form = useForm>({ resolver: zodResolver(FormSchema), }) - const { type, language } = form.watch() + const [type, language] = useWatch({ control: form.control, name: ['type', 'language'] }) const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index 1e5a3657258..934d7e178a7 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -33,7 +33,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector, @@ -52,6 +52,7 @@ import { useDatabaseIndexCreateMutation } from '@/data/database-indexes/index-cr import { useSchemasQuery } from '@/data/database/schemas-query' import { useTableColumnsQuery } from '@/data/database/table-columns-query' import { useEntityTypesQuery } from '@/data/entity-types/entity-types-infinite-query' +import { useSchemasFilteredForHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' @@ -98,10 +99,11 @@ export const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelP const [schemaSearchTerm, setSchemaSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('') - const { data: schemas } = useSchemasQuery({ + const { data: allSchemas } = useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const schemas = useSchemasFilteredForHighAvailability(allSchemas) const { data: entities, isPending: isLoadingEntities } = useEntityTypesQuery({ schemas: [selectedSchema], sort: 'alphabetical', diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index 24f4763fa21..7c29d537f9d 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -16,7 +16,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' @@ -32,7 +32,7 @@ import { formatMigrationVersionLabel, parseMigrationVersion } from '@/lib/migrat import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -const Migrations = () => { +export const Migrations = () => { const [search, setSearch] = useState('') const [selectedMigration, setSelectedMigration] = useState() const searchInputRef = useRef(null) @@ -116,7 +116,7 @@ const Migrations = () => { placeholder="Search for a migration" value={search} className="w-full lg:w-52" - onChange={(e: any) => setSearch(e.target.value)} + onChange={(e) => setSearch(e.target.value)} icon={} /> @@ -221,10 +221,14 @@ const Migrations = () => {
    { ) } - -export default Migrations diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.tsx b/apps/studio/components/interfaces/Database/Policies/Policies.tsx index 6914e380755..e260bd4382e 100644 --- a/apps/studio/components/interfaces/Database/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Database/Policies/Policies.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useCallback, useState } from 'react' import { toast } from 'sonner' import { Button, Card, CardContent } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { usePoliciesData } from './PoliciesDataContext' @@ -157,7 +157,7 @@ export const Policies = ({ {!isSchemaExposedAPI && ( This schema is not exposed via the Supabase APIs. You may configure this in your diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx index 7ea2ad0db2e..debf11e9d9d 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx @@ -30,9 +30,8 @@ export const PolicyEditorPanelHeader = ({
    @@ -105,6 +104,7 @@ export const PolicyEditorPanelHeader = ({
    + } + /> + + + )} + /> + + ( + + + + + + )} + /> + + ( + + + + + + )} + /> +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts new file mode 100644 index 00000000000..374f82c22c2 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { DestinationPanelFormSchema } from './DestinationForm.schema' + +const requiredFields = { + name: 'BigQuery warehouse', + publicationName: 'analytics_publication', + tableSyncCopyMode: 'include_all_tables' as const, + tableSyncCopyTableIds: [], +} + +describe('DestinationPanelFormSchema', () => { + it.each([0, 1])('accepts a batch wait time of %i milliseconds', (value) => { + const result = DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxFillMs: value, + }) + + expect(result.success).toBe(true) + }) + + it.each([-1, 1.5])('rejects an unsupported batch wait time of %s', (value) => { + const result = DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxFillMs: value, + }) + + expect(result.success).toBe(false) + }) + + it.each([1, 65536])('accepts %i table sync workers', (value) => { + expect( + DestinationPanelFormSchema.safeParse({ ...requiredFields, maxTableSyncWorkers: value }) + .success + ).toBe(true) + }) + + it.each([0, 1.5])('rejects an unsupported table sync worker count of %s', (value) => { + expect( + DestinationPanelFormSchema.safeParse({ ...requiredFields, maxTableSyncWorkers: value }) + .success + ).toBe(false) + }) + + it.each([1, 65536])('accepts %i copy connections per table', (value) => { + expect( + DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxCopyConnectionsPerTable: value, + }).success + ).toBe(true) + }) + + it.each([0, 1.5])('rejects an unsupported copy connections per table count of %s', (value) => { + expect( + DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxCopyConnectionsPerTable: value, + }).success + ).toBe(false) + }) + + it('requires the BigQuery connection pool size to be greater than 0', () => { + expect( + DestinationPanelFormSchema.safeParse({ ...requiredFields, connectionPoolSize: 0 }).success + ).toBe(false) + expect( + DestinationPanelFormSchema.safeParse({ ...requiredFields, connectionPoolSize: 1 }).success + ).toBe(true) + }) + + it.each([0, 1, 65536])('accepts a maximum staleness of %i whole minutes', (value) => { + const result = DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxStalenessMins: value, + }) + + expect(result.success).toBe(true) + }) + + it.each([-1, 1.5])('rejects an unsupported maximum staleness of %s', (value) => { + const result = DestinationPanelFormSchema.safeParse({ + ...requiredFields, + maxStalenessMins: value, + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts index 08d16837a9d..9bfdd084c6b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts @@ -1,16 +1,30 @@ import * as z from 'zod' -// [Joshen] JFYI if we plan to add another type here, I reckon we split this out into smaller components -// then as FormSchema is getting quite complex with some fields that aren't necessary if the type is one or the other -export const DestinationPanelFormSchema = z.object({ - // Common fields +import { AnalyticsBucketFormSchema } from './AnalyticsBucket/AnalyticsBucket.schema' +import { BigQueryFormSchema } from './BigQuery/BigQuery.schema' +import { ClickHouseFormSchema } from './ClickHouse/ClickHouse.schema' +import { DuckLakeFormSchema } from './DuckLake/DuckLake.schema' +import { SnowflakeFormSchema } from './Snowflake/Snowflake.schema' + +const CommonFormSchema = z.object({ name: z.string().min(1, 'Name is required'), publicationName: z.string().min(1, 'Publication is required'), - maxFillMs: z.number().min(1, 'Max fill milliseconds must be greater than 0').int().optional(), + tableSyncCopyMode: z.enum([ + 'include_all_tables', + 'skip_all_tables', + 'include_tables', + 'skip_tables', + ]), + tableSyncCopyTableIds: z.array(z.string()), + maxFillMs: z + .number() + .int('Batch wait time must be a whole number of milliseconds') + .min(0, 'Batch wait time must be 0 or greater') + .optional(), maxTableSyncWorkers: z .number() .min(1, 'Max table sync workers must be greater than 0') - .int() + .int('Max table sync workers must be a whole number') .optional(), maxCopyConnectionsPerTable: z .number() @@ -18,56 +32,12 @@ export const DestinationPanelFormSchema = z.object({ .min(1, 'Max copy connections per table must be greater than 0') .optional(), invalidatedSlotBehavior: z.enum(['error', 'recreate']).optional(), - // BigQuery fields - projectId: z.string().optional(), - datasetId: z.string().optional(), - serviceAccountKey: z.string().optional(), - connectionPoolSize: z - .number() - .int() - .min(1, 'Connection pool size must be greater than 0') - .optional(), - maxStalenessMins: z.number().nonnegative().optional(), - // Analytics Bucket fields, only warehouse name and namespace are visible + editable fields - warehouseName: z.string().optional(), - namespace: z.string().optional(), - newNamespaceName: z.string().optional(), - catalogToken: z.string().optional(), - s3AccessKeyId: z.string().optional(), - s3SecretAccessKey: z.string().optional(), - s3Region: z.string().optional(), - // DuckLake fields - // `supabase` mode picks Supabase projects for catalog + storage (managed), while `custom` - // mode keeps the manual PostgreSQL catalog URL + S3-compatible credentials. - ducklakeMode: z.enum(['supabase', 'custom']).optional(), - // DuckLake "Use Supabase" fields - ducklakeCatalogProjectRef: z.string().optional(), - ducklakeStorageProjectRef: z.string().optional(), - ducklakeStorageBucket: z.string().optional(), - // DuckLake "Custom parameters" fields - ducklakeCatalogUrl: z.string().optional(), - ducklakeDataPath: z.string().optional(), - ducklakePoolSize: z - .number() - .int() - .min(1, 'Pool size must be greater than 0') - .max(6, 'Pool size must be 6 or less') - .optional(), - ducklakeS3AccessKeyId: z.string().optional(), - ducklakeS3SecretAccessKey: z.string().optional(), - ducklakeS3Region: z.string().optional(), - ducklakeS3Endpoint: z.string().optional(), - ducklakeS3UrlStyle: z.enum(['path', 'vhost']).optional(), - ducklakeS3UseSsl: z.boolean().optional(), - ducklakeMetadataSchema: z.string().optional(), - // Snowflake fields - snowflakeAccountId: z.string().optional(), - snowflakeUser: z.string().optional(), - snowflakePrivateKey: z.string().optional(), - snowflakePrivateKeyPassphrase: z.string().optional(), - snowflakeDatabase: z.string().optional(), - snowflakeSchema: z.string().optional(), - snowflakeRole: z.string().optional(), }) +export const DestinationPanelFormSchema = CommonFormSchema.extend(BigQueryFormSchema.shape) + .extend(AnalyticsBucketFormSchema.shape) + .extend(DuckLakeFormSchema.shape) + .extend(SnowflakeFormSchema.shape) + .extend(ClickHouseFormSchema.shape) + export type DestinationPanelSchemaType = z.infer diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts index a1ca8a68371..3c55a6a9de0 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts @@ -2,17 +2,26 @@ import { describe, expect, it, vi } from 'vitest' import { getAnalyticsBucketValidationIssues } from './AnalyticsBucket/AnalyticsBucket.utils' import { getBigQueryValidationIssues } from './BigQuery/BigQuery.utils' +import { getClickHouseValidationIssues } from './ClickHouse/ClickHouse.utils' import { CREATE_NEW_KEY, CREATE_NEW_NAMESPACE } from './DestinationForm.constants' import { + buildBatchConfig, buildDestinationConfig, buildDestinationConfigForValidation, + buildTableSyncCopyConfig, + generateDefaultValues, + pruneStaleSelectedTableIds, } from './DestinationForm.utils' import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils' import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils' +import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' +import type { ReplicationPublication } from '@/data/replication/publications-query' const baseDucklakeFormData = { name: 'DuckLake Destination', publicationName: 'pub', + tableSyncCopyMode: 'include_all_tables' as const, + tableSyncCopyTableIds: [], maxFillMs: undefined, maxTableSyncWorkers: undefined, maxCopyConnectionsPerTable: undefined, @@ -44,6 +53,8 @@ const baseDucklakeFormData = { const baseSnowflakeFormData = { name: 'Snowflake Destination', publicationName: 'pub', + tableSyncCopyMode: 'include_all_tables' as const, + tableSyncCopyTableIds: [], maxFillMs: undefined, maxTableSyncWorkers: undefined, maxCopyConnectionsPerTable: undefined, @@ -79,6 +90,153 @@ const baseSnowflakeFormData = { snowflakeRole: ' PIPELINES_ROLE ', } +describe('DestinationForm.utils table copy selection', () => { + it('preserves server-managed batch fields while changing the exposed fill interval', () => { + expect( + buildBatchConfig({ + maxFillMs: 500, + existingBatch: { + max_fill_ms: 200, + max_bytes: 8_388_608, + memory_budget_ratio: 0.2, + }, + }) + ).toEqual({ + maxFillMs: 500, + maxBytes: 8_388_608, + memoryBudgetRatio: 0.2, + }) + }) + + it.each(['include_all_tables', 'skip_all_tables'] as const)( + 'builds the %s config without table ids', + (mode) => { + expect( + buildTableSyncCopyConfig({ + mode, + selectedTableIds: [], + }) + ).toEqual({ type: mode }) + } + ) + + it.each(['include_tables', 'skip_tables'] as const)('uses selected table ids for %s', (mode) => { + expect( + buildTableSyncCopyConfig({ + mode, + selectedTableIds: ['202', '101'], + }) + ).toEqual({ type: mode, table_ids: [202, 101] }) + }) + + it('rejects an invalid selected table id', () => { + expect(() => + buildTableSyncCopyConfig({ + mode: 'skip_tables', + selectedTableIds: ['not-an-id'], + }) + ).toThrow('The selected table IDs are invalid') + }) + + it('hydrates configured pipeline table ids as selected table ids in edit mode', () => { + const pipelineData = { + config: { + publication_name: 'analytics', + table_sync_copy: { + type: 'include_tables', + table_ids: [202, 101], + }, + }, + } as unknown as ReplicationPipelineByIdData + + const defaults = generateDefaultValues({ + pipelineData, + catalogToken: '', + editMode: true, + }) + + expect(defaults.tableSyncCopyMode).toBe('include_tables') + expect(defaults.tableSyncCopyTableIds).toEqual(['202', '101']) + }) + + it('never hydrates an edit form with a newly revealed catalog token', () => { + const defaults = generateDefaultValues({ + catalogToken: 'newly-revealed-token', + editMode: true, + }) + + expect(defaults.catalogToken).toBe('') + }) + + it('drops selected ids that are no longer in the publication', () => { + const publications = [ + { name: 'analytics', tables: [{ id: 101, schema: 'public', name: 'orders' }] }, + ] as ReplicationPublication[] + + expect( + pruneStaleSelectedTableIds({ + mode: 'include_tables', + selectedTableIds: ['101', '202'], + publications, + publicationName: 'analytics', + }) + ).toEqual(['101']) + }) + + it('leaves selected ids untouched for non-selective modes', () => { + const publications = [ + { name: 'analytics', tables: [{ id: 101, schema: 'public', name: 'orders' }] }, + ] as ReplicationPublication[] + + expect( + pruneStaleSelectedTableIds({ + mode: 'include_all_tables', + selectedTableIds: ['202'], + publications, + publicationName: 'analytics', + }) + ).toEqual(['202']) + }) +}) + +const baseClickHouseFormData = { + name: 'ClickHouse Destination', + publicationName: 'pub', + tableSyncCopyMode: 'include_all_tables' as const, + tableSyncCopyTableIds: [], + maxFillMs: undefined, + maxTableSyncWorkers: undefined, + maxCopyConnectionsPerTable: undefined, + invalidatedSlotBehavior: undefined, + projectId: undefined, + datasetId: undefined, + serviceAccountKey: undefined, + connectionPoolSize: undefined, + maxStalenessMins: undefined, + warehouseName: undefined, + namespace: undefined, + newNamespaceName: undefined, + catalogToken: undefined, + s3AccessKeyId: undefined, + s3SecretAccessKey: undefined, + s3Region: undefined, + ducklakeCatalogUrl: undefined, + ducklakeDataPath: undefined, + ducklakePoolSize: undefined, + ducklakeS3AccessKeyId: undefined, + ducklakeS3SecretAccessKey: undefined, + ducklakeS3Region: undefined, + ducklakeS3Endpoint: undefined, + ducklakeS3UrlStyle: undefined, + ducklakeS3UseSsl: undefined, + ducklakeMetadataSchema: undefined, + clickhouseUrl: ' https://your-cluster.clickhouse.cloud:8443 ', + clickhouseUser: ' default ', + clickhousePassword: ' secret password ', + clickhouseDatabase: ' analytics ', + clickhouseEngine: 'replacing_merge_tree' as const, +} + describe('DestinationForm.utils DuckLake', () => { it('builds DuckLake validation config with required fields trimmed and blank optionals removed', () => { const config = buildDestinationConfigForValidation({ @@ -440,6 +598,138 @@ describe('DestinationForm.utils Snowflake', () => { }) }) +describe('DestinationForm.utils ClickHouse', () => { + it('builds ClickHouse validation config with required fields trimmed and blank optionals removed', () => { + const config = buildDestinationConfigForValidation({ + projectRef: 'project-ref', + selectedType: 'ClickHouse', + data: { + ...baseClickHouseFormData, + clickhousePassword: '', + }, + }) + + expect(config).toEqual({ + clickHouse: { + url: 'https://your-cluster.clickhouse.cloud:8443', + user: 'default', + password: undefined, + database: 'analytics', + engine: 'replacing_merge_tree', + }, + }) + }) + + it('builds ClickHouse submit config with normalized values', async () => { + const createS3AccessKey = vi.fn() + const resolveNamespace = vi.fn() + + const config = await buildDestinationConfig({ + projectRef: 'project-ref', + selectedType: 'ClickHouse', + data: baseClickHouseFormData, + createS3AccessKey, + resolveNamespace, + }) + + expect(config).toEqual({ + clickHouse: { + url: 'https://your-cluster.clickhouse.cloud:8443', + user: 'default', + password: ' secret password ', + database: 'analytics', + engine: 'replacing_merge_tree', + }, + }) + expect(createS3AccessKey).not.toHaveBeenCalled() + expect(resolveNamespace).not.toHaveBeenCalled() + }) + + it('returns required-field errors for missing ClickHouse settings', () => { + const issues = getClickHouseValidationIssues({ + clickhouseUrl: '', + clickhouseUser: '', + clickhouseDatabase: '', + }) + + expect(issues).toEqual([ + { path: 'clickhouseUrl', message: 'URL is required' }, + { path: 'clickhouseUser', message: 'User is required' }, + { path: 'clickhouseDatabase', message: 'Database is required' }, + ]) + }) + + it('rejects ClickHouse URLs that are not https or target internal addresses', () => { + expect( + getClickHouseValidationIssues({ + clickhouseUrl: 'http://example.clickhouse.cloud:8443', + clickhouseUser: 'default', + clickhouseDatabase: 'analytics', + }) + ).toEqual([{ path: 'clickhouseUrl', message: 'ClickHouse URL must use https://' }]) + + expect( + getClickHouseValidationIssues({ + clickhouseUrl: 'https://127.0.0.1:8443', + clickhouseUser: 'default', + clickhouseDatabase: 'analytics', + }) + ).toEqual([ + { path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' }, + ]) + }) + + it.each([ + ['loopback hostname', 'https://localhost:8443'], + ['loopback subdomain', 'https://foo.localhost:8443'], + ['IPv4 loopback', 'https://127.0.0.1:8443'], + ['IPv4 unspecified', 'https://0.0.0.0:8443'], + ['RFC 1918 10.0.0.0/8', 'https://10.1.2.3:8443'], + ['RFC 1918 172.16.0.0/12', 'https://172.16.0.1:8443'], + ['RFC 1918 172.16.0.0/12 upper bound', 'https://172.31.255.254:8443'], + ['RFC 1918 192.168.0.0/16', 'https://192.168.1.1:8443'], + ['link-local 169.254.0.0/16', 'https://169.254.1.1:8443'], + ['CGNAT 100.64.0.0/10', 'https://100.64.0.1:8443'], + ['CGNAT 100.64.0.0/10 upper bound', 'https://100.127.255.254:8443'], + ['benchmarking 198.18.0.0/15', 'https://198.18.0.1:8443'], + ['multicast/reserved 224.0.0.0/4+', 'https://224.0.0.1:8443'], + ['broadcast', 'https://255.255.255.255:8443'], + ['IPv6 loopback', 'https://[::1]:8443'], + ['IPv6 unspecified', 'https://[::]:8443'], + ['IPv6 link-local', 'https://[fe80::1]:8443'], + ['IPv6 unique local (fc00::/7)', 'https://[fc00::1]:8443'], + ['IPv6 unique local (fd00::/8)', 'https://[fd12:3456::1]:8443'], + ['IPv4-mapped IPv6', 'https://[::ffff:127.0.0.1]:8443'], + ['NAT64', 'https://[64:ff9b::127.0.0.1]:8443'], + ['decimal-encoded IPv4 loopback', 'https://2130706433:8443'], + ['hex-encoded IPv4 loopback', 'https://0x7f000001:8443'], + ])('rejects ClickHouse URLs targeting an internal address: %s', (_label, clickhouseUrl) => { + expect( + getClickHouseValidationIssues({ + clickhouseUrl, + clickhouseUser: 'default', + clickhouseDatabase: 'analytics', + }) + ).toEqual([ + { path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' }, + ]) + }) + + it.each([ + ['public IPv4 address', 'https://8.8.8.8:8443'], + ['public hostname', 'https://your-cluster.clickhouse.cloud:8443'], + ['public IPv6 address', 'https://[2606:4700:4700::1111]:8443'], + ])('accepts ClickHouse URLs targeting a public address: %s', (_label, clickhouseUrl) => { + expect( + getClickHouseValidationIssues({ + clickhouseUrl, + clickhouseUser: 'default', + clickhouseDatabase: 'analytics', + }) + ).toEqual([]) + }) +}) + describe('DestinationForm.utils BigQuery', () => { it('returns required-field errors for missing BigQuery settings', () => { const issues = getBigQueryValidationIssues({ diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts index dbbd98a2c0b..3ddee07c982 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts @@ -3,6 +3,7 @@ import { snakeCase } from 'lodash' import z from 'zod' import { DestinationType } from '../DestinationPanel.types' +import { type ClickHouseApiConfig } from './ClickHouse/ClickHouse.utils' import { CREATE_NEW_KEY, CREATE_NEW_NAMESPACE, @@ -24,16 +25,20 @@ import { import { type DucklakeApiConfig } from './DuckLake/DuckLake.utils' import { type SnowflakeApiConfig } from './Snowflake/Snowflake.utils' import { + BatchConfig, BigQueryDestinationConfig, + ClickHouseDestinationConfig, DestinationConfig, DucklakeDestinationConfig, DucklakeManualDestinationConfig, DucklakeSupabaseDestinationConfig, IcebergDestinationConfig, SnowflakeDestinationConfig, + TableSyncCopyConfig, } from '@/data/replication/create-destination-pipeline-mutation' import { type ReplicationDestinationByIdData } from '@/data/replication/destination-by-id-query' import { type ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' +import { type ReplicationPublication } from '@/data/replication/publications-query' import { type ValidationFailure } from '@/data/replication/validate-destination-mutation' import { type CreateS3AccessKeyCredentialVariables, @@ -70,29 +75,31 @@ export const generateDefaultValues = ({ editMode: boolean }): DestinationPanelSchemaType => { const config = destinationData?.config - const isBigQueryConfig = config && 'big_query' in config - const isIcebergConfig = config && 'iceberg' in config - const ducklakeConfigValue = - config && 'ducklake' in (config as Record) - ? (config as Record).ducklake + + const bigQueryConfig = config && 'big_query' in config ? config.big_query : undefined + const icebergConfig = + config && 'iceberg' in config && 'supabase' in config.iceberg + ? config.iceberg.supabase : undefined const ducklakeConfig = - ducklakeConfigValue && typeof ducklakeConfigValue === 'object' - ? (ducklakeConfigValue as DucklakeApiConfig) - : undefined - const snowflakeConfigValue = - config && 'snowflake' in (config as Record) - ? (config as Record).snowflake - : undefined + config && 'ducklake' in config ? (config.ducklake as DucklakeApiConfig) : undefined const snowflakeConfig = - snowflakeConfigValue && typeof snowflakeConfigValue === 'object' - ? (snowflakeConfigValue as SnowflakeApiConfig) - : undefined + config && 'snowflake' in config ? (config.snowflake as SnowflakeApiConfig) : undefined + const clickhouseConfig = + config && 'clickhouse' in config ? (config.clickhouse as ClickHouseApiConfig) : undefined + + const tableSyncCopy = pipelineData?.config.table_sync_copy ?? { + type: 'include_all_tables' as const, + } + const tableSyncCopyTableIds = + 'table_ids' in tableSyncCopy ? tableSyncCopy.table_ids.map((id) => String(id)) : [] return { // Common fields name: destinationData?.name ?? '', publicationName: pipelineData?.config.publication_name ?? '', + tableSyncCopyMode: tableSyncCopy.type, + tableSyncCopyTableIds, maxFillMs: pipelineData?.config?.batch?.max_fill_ms ?? DEFAULT_MAX_FILL_MS, maxTableSyncWorkers: pipelineData?.config?.max_table_sync_workers ?? DEFAULT_MAX_TABLE_SYNC_WORKERS, @@ -103,21 +110,23 @@ export const generateDefaultValues = ({ (pipelineData?.config as { invalidated_slot_behavior?: 'error' | 'recreate' } | undefined) ?.invalidated_slot_behavior ?? undefined, // BigQuery fields - projectId: isBigQueryConfig ? config.big_query.project_id : '', - datasetId: isBigQueryConfig ? config.big_query.dataset_id : '', - serviceAccountKey: isBigQueryConfig ? config.big_query.service_account_key : '', + projectId: bigQueryConfig?.project_id ?? '', + datasetId: bigQueryConfig?.dataset_id ?? '', + // Destination response DTOs intentionally omit stored secrets. Edit submissions + // leave blank secret fields unset so the existing values are preserved. + serviceAccountKey: '', connectionPoolSize: (config as { big_query?: { connection_pool_size?: number } } | undefined)?.big_query ?.connection_pool_size ?? DEFAULT_CONNECTION_POOL_SIZE, - maxStalenessMins: isBigQueryConfig ? config.big_query.max_staleness_mins : undefined, // Default: null + maxStalenessMins: bigQueryConfig?.max_staleness_mins ?? undefined, // Default: null // Analytics Bucket fields - warehouseName: isIcebergConfig ? config.iceberg.supabase.warehouse_name : '', - namespace: isIcebergConfig ? config.iceberg.supabase.namespace : '', + warehouseName: icebergConfig?.warehouse_name ?? '', + namespace: icebergConfig?.namespace ?? '', newNamespaceName: '', - catalogToken: isIcebergConfig ? config.iceberg.supabase.catalog_token : catalogToken, - s3AccessKeyId: isIcebergConfig ? config.iceberg.supabase.s3_access_key_id : '', - s3SecretAccessKey: isIcebergConfig ? config.iceberg.supabase.s3_secret_access_key : '', - s3Region: region ?? (isIcebergConfig ? config.iceberg.supabase.s3_region : ''), + catalogToken: editMode || icebergConfig ? '' : catalogToken, + s3AccessKeyId: '', + s3SecretAccessKey: '', + s3Region: region ?? icebergConfig?.s3_region ?? '', // DuckLake fields // New destinations default to the managed "Use Supabase" mode with the current project // pre-selected as both catalog and storage. Existing destinations always read back as the @@ -144,9 +153,87 @@ export const generateDefaultValues = ({ snowflakeDatabase: snowflakeConfig?.database ?? '', snowflakeSchema: snowflakeConfig?.schema ?? '', snowflakeRole: snowflakeConfig?.role ?? '', + // ClickHouse fields + clickhouseUrl: clickhouseConfig?.url ?? '', + clickhouseUser: clickhouseConfig?.user ?? '', + clickhousePassword: clickhouseConfig?.password ?? '', + clickhouseDatabase: clickhouseConfig?.database ?? '', + clickhouseEngine: clickhouseConfig?.engine ?? 'replacing_merge_tree', } } +export const buildTableSyncCopyConfig = ({ + mode, + selectedTableIds, +}: { + mode: DestinationPanelSchemaType['tableSyncCopyMode'] + selectedTableIds: string[] +}): TableSyncCopyConfig => { + if (mode === 'include_all_tables' || mode === 'skip_all_tables') return { type: mode } + + if (selectedTableIds.length === 0) { + throw new Error('Select at least one table for the initial sync') + } + + const tableIds = selectedTableIds.map(Number) + if (tableIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + throw new Error('The selected table IDs are invalid. Refresh and try again.') + } + + return { + type: mode, + table_ids: tableIds, + } +} + +export const buildBatchConfig = ({ + maxFillMs, + existingBatch, +}: { + maxFillMs?: number + existingBatch?: ReplicationPipelineByIdData['config']['batch'] +}): BatchConfig | undefined => { + if (maxFillMs === undefined && existingBatch === undefined) return undefined + + return { + maxFillMs, + maxBytes: existingBatch?.max_bytes, + memoryBudgetRatio: existingBatch?.memory_budget_ratio, + } +} + +// The set of table ids (as strings, matching form state) currently in the +// selected publication. Selective table-copy only ever offers/keeps ids that +// are in this set; ids selected previously that fall out of it are dropped at +// submit time rather than resolved or displayed. +export const getPublicationTableIds = ( + publications: ReplicationPublication[], + publicationName: string +): Set => { + const publication = publications.find(({ name }) => name === publicationName) + return new Set((publication?.tables ?? []).map(({ id }) => String(id))) +} + +// Drops selected table ids that are no longer in the current publication. +// Create validation rejects ids that aren't published, while edits are checked +// against the latest loaded publication before submitting. +export const pruneStaleSelectedTableIds = ({ + mode, + selectedTableIds, + publications, + publicationName, +}: { + mode: DestinationPanelSchemaType['tableSyncCopyMode'] + selectedTableIds: string[] + publications: ReplicationPublication[] + publicationName: string +}): string[] => { + if (mode === 'include_all_tables' || mode === 'skip_all_tables') return selectedTableIds + + const publicationTableIds = getPublicationTableIds(publications, publicationName) + return selectedTableIds.filter((id) => publicationTableIds.has(id)) +} + const buildBigQueryConfig = ( data: z.infer ): BigQueryDestinationConfig => ({ @@ -169,6 +256,16 @@ const buildSnowflakeConfig = ( role: normalizeOptionalString(data.snowflakeRole), }) +const buildClickHouseConfig = ( + data: z.infer +): ClickHouseDestinationConfig => ({ + url: normalizeRequiredString(data.clickhouseUrl), + user: normalizeRequiredString(data.clickhouseUser), + password: normalizeOptionalUntrimmedString(data.clickhousePassword), + database: normalizeRequiredString(data.clickhouseDatabase), + engine: data.clickhouseEngine, +}) + // Builds the studio-side DuckLake config from form data, picking the right shape for the // selected mode. The create / update / validate mutations convert this to the API payload. const buildDucklakeConfig = ( @@ -243,6 +340,8 @@ export const buildDestinationConfigForValidation = ({ return { ducklake: buildDucklakeConfig(data) } } else if (selectedType === 'Snowflake') { return { snowflake: buildSnowflakeConfig(data) } + } else if (selectedType === 'ClickHouse') { + return { clickHouse: buildClickHouseConfig(data) } } else { throw new Error('Invalid destination type') } @@ -302,6 +401,8 @@ export const buildDestinationConfig = async ({ destinationConfig = { ducklake: buildDucklakeConfig(data) } } else if (selectedType === 'Snowflake') { destinationConfig = { snowflake: buildSnowflakeConfig(data) } + } else if (selectedType === 'ClickHouse') { + destinationConfig = { clickHouse: buildClickHouseConfig(data) } } return destinationConfig diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.schema.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.schema.ts new file mode 100644 index 00000000000..37da7aec648 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/DuckLake.schema.ts @@ -0,0 +1,27 @@ +import * as z from 'zod' + +// `supabase` mode picks Supabase projects for catalog + storage (managed), while `custom` +// mode keeps the manual PostgreSQL catalog URL + S3-compatible credentials. +export const DuckLakeFormSchema = z.object({ + ducklakeMode: z.enum(['supabase', 'custom']).optional(), + // DuckLake "Use Supabase" fields + ducklakeCatalogProjectRef: z.string().optional(), + ducklakeStorageProjectRef: z.string().optional(), + ducklakeStorageBucket: z.string().optional(), + // DuckLake "Custom parameters" fields + ducklakeCatalogUrl: z.string().optional(), + ducklakeDataPath: z.string().optional(), + ducklakePoolSize: z + .number() + .int() + .min(1, 'Pool size must be greater than 0') + .max(6, 'Pool size must be 6 or less') + .optional(), + ducklakeS3AccessKeyId: z.string().optional(), + ducklakeS3SecretAccessKey: z.string().optional(), + ducklakeS3Region: z.string().optional(), + ducklakeS3Endpoint: z.string().optional(), + ducklakeS3UrlStyle: z.enum(['path', 'vhost']).optional(), + ducklakeS3UseSsl: z.boolean().optional(), + ducklakeMetadataSchema: z.string().optional(), +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx index 263d70410ad..629633df830 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx @@ -1,6 +1,6 @@ import { Check, Database, Eye, EyeOff, Loader2, Plus, SlidersHorizontal } from 'lucide-react' import { useMemo, useState } from 'react' -import type { UseFormReturn } from 'react-hook-form' +import { useWatch, type UseFormReturn } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -22,7 +22,7 @@ import { SelectTrigger, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -76,6 +76,7 @@ const DuckLakeModeSelector = ({ -
    + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx index 4bbfaa2dc49..848b14a7109 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/NoDestinationsAvailable.tsx @@ -11,8 +11,8 @@ export const NoDestinationsAvailable = () => {

    No destinations available

    No Pipelines destinations are available for this project yet. Request{' '} - alpha access to use - managed replication pipelines. + Pipelines access to + use managed pipelines.

    ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx new file mode 100644 index 00000000000..d7020cc3ec4 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.test.tsx @@ -0,0 +1,130 @@ +import { screen } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { PipelineCostDialog } from './PipelineCostDialog' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type CostEstimateResponse = components['schemas']['CostEstimateResponse'] +type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse'] + +const tables = [ + { + schema: 'public', + name: 'orders', + estimated_bytes: 600_000_000, + estimated_cost: 0.6, + is_row_filtered: false, + }, + { + schema: 'billing', + name: 'invoices', + estimated_bytes: 9_400_000_000, + estimated_cost: 9.4, + is_row_filtered: false, + }, +] + +let costEstimateTables = tables + +const mockSources: ReplicationSourcesResponse = { + sources: [ + { + id: 1, + name: 'default', + tenant_id: 'tenant', + config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' }, + }, + ], +} + +const publicationTables = [ + { id: 101, schema: 'public', name: 'orders' }, + { id: 202, schema: 'billing', name: 'invoices' }, +] + +const renderDialog = ( + tableSyncCopy: { type: 'include_tables'; table_ids: number[] } | { type: 'skip_all_tables' } +) => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources', + response: () => HttpResponse.json(mockSources), + }) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources/:source_id/publications/:publication_name/cost-estimate', + response: () => + HttpResponse.json({ + currency: 'usd', + pipeline: { hourly_cost: 0.05, monthly_cost: 36.5 }, + streaming: { rate_per_gb: 3 }, + table_copy: { + rate_per_gb: 0.6, + total_bytes: 10_000_000_000, + total_cost: 10, + tables: costEstimateTables, + }, + }), + }) + + return customRender( + + ) +} + +describe('PipelineCostDialog', () => { + beforeEach(() => { + costEstimateTables = tables + }) + + it('shows only the tables selected for initial sync', async () => { + renderDialog({ type: 'include_tables', table_ids: [101] }) + + expect(await screen.findByText('public.orders')).toBeInTheDocument() + expect(screen.queryByText('billing.invoices')).not.toBeInTheDocument() + expect(screen.getAllByText('$0.60')).toHaveLength(3) + expect(screen.queryByText('$10.00')).not.toBeInTheDocument() + }) + + it('shows a zero initial sync charge while retaining ongoing replication rates', async () => { + renderDialog({ type: 'skip_all_tables' }) + + expect(await screen.findByText(/No tables will run an initial sync/)).toBeInTheDocument() + expect(screen.getByText('$0.00')).toBeInTheDocument() + expect(screen.getByText('$0.05/hour')).toBeInTheDocument() + expect(screen.getByText('$3.00/GB')).toBeInTheDocument() + expect(screen.queryByText('public.orders')).not.toBeInTheDocument() + }) + + it('does not show a partial total when a selected table estimate is missing', async () => { + costEstimateTables = [tables[0]] + + renderDialog({ type: 'include_tables', table_ids: [101, 202] }) + + expect( + await screen.findByText(/estimate is unavailable for one or more selected tables/) + ).toBeInTheDocument() + expect(screen.getByText('Unavailable')).toBeInTheDocument() + expect(screen.queryByText('$0.60')).not.toBeInTheDocument() + }) + + it('explains that row filters can reduce processed data compared with the estimate', async () => { + costEstimateTables = [{ ...tables[0], is_row_filtered: true }] + + renderDialog({ type: 'include_tables', table_ids: [101] }) + + expect(await screen.findByText(/Row filters can reduce the data processed/)).toBeInTheDocument() + expect(screen.getByText(/Quick planning estimate/)).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx index 124b6f442d2..b472551c66f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineCostDialog.tsx @@ -1,4 +1,5 @@ -import { useEffect } from 'react' +import { useParams } from 'common' +import { useEffect, useMemo } from 'react' import { Button, Card, @@ -20,8 +21,17 @@ import { } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { + getTableCopyTargets, + summarizeTableCopyEstimate, + type ReplicationTableIdentity, + type TableSyncCopyConfig, +} from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' +import { InlineLink } from '@/components/ui/InlineLink' import { useReplicationCostEstimateQuery } from '@/data/replication/cost-estimate-query' +import { useReplicationSourceId } from '@/data/replication/sources-query' import { useLatest } from '@/hooks/misc/useLatest' +import { DOCS_URL } from '@/lib/constants' import { formatBytes, formatCurrency } from '@/lib/helpers' const MAX_VISIBLE_TABLES = 10 @@ -29,16 +39,16 @@ const MAX_VISIBLE_TABLES = 10 interface PipelineCostDialogProps { open: boolean isConfirming: boolean - projectRef?: string - sourceId?: number publicationName?: string + publicationTables: ReplicationTableIdentity[] + tableSyncCopy?: TableSyncCopyConfig onOpenChange: (open: boolean) => void onConfirm: () => void } /** - * Shows an estimate of what the pipeline will cost (one-time initial copy, hourly pipeline - * fee, and the usage-based streaming rate). + * Shows an estimate of what the pipeline will cost (one-time initial sync, hourly pipeline + * fee, and the ongoing replication rate). * * This should be a non-blocking enhancement - so if there's an error while fetching the * pricing estimate, we skip this gate rather than block the user from creating pipeline @@ -46,12 +56,15 @@ interface PipelineCostDialogProps { export const PipelineCostDialog = ({ open, isConfirming, - projectRef, - sourceId, publicationName, + publicationTables, + tableSyncCopy, onOpenChange, onConfirm, }: PipelineCostDialogProps) => { + const { ref: projectRef } = useParams() + const sourceId = useReplicationSourceId({ projectRef }) + const onConfirmRef = useLatest(onConfirm) const { @@ -61,14 +74,25 @@ export const PipelineCostDialog = ({ isSuccess, } = useReplicationCostEstimateQuery({ projectRef, sourceId, publicationName }, { enabled: open }) - const tables = estimate?.table_copy.tables ?? [] - const tableCount = tables.length + const copyTargets = useMemo( + () => getTableCopyTargets(publicationTables, tableSyncCopy), + [publicationTables, tableSyncCopy] + ) + const copyEstimate = useMemo( + () => + estimate === undefined + ? undefined + : summarizeTableCopyEstimate(estimate.table_copy.tables, copyTargets), + [copyTargets, estimate] + ) + const tables = copyEstimate?.tables ?? [] + const tableCount = publicationTables.length + const copyTableCount = copyTargets.length const visibleTables = tables.slice(0, MAX_VISIBLE_TABLES) - const hiddenTableCount = tableCount - visibleTables.length - const hasRowFilteredTables = tables.some((table) => table.is_row_filtered) + const hiddenTableCount = tables.length - visibleTables.length + const hasRowFilteredTables = copyEstimate?.hasRowFilteredTables ?? false - const firstMonthTotal = - (estimate?.table_copy.total_cost ?? 0) + (estimate?.pipeline.monthly_cost ?? 0) + const initialSyncTotal = copyEstimate?.estimatedCost ?? 0 useEffect(() => { if (open && isError) onConfirmRef.current() @@ -115,15 +139,24 @@ export const PipelineCostDialog = ({

    -

    Initial table copy

    +

    Initial sync

    +

    + Quick planning estimate using available source table information. Final usage is + measured from the data successfully processed during initial sync. +

    - {tableCount > 0 ? ( + {copyTableCount === 0 ? ( +

    + No tables will run an initial sync. Ongoing replication will still process new + changes for every publication table, with no initial sync charge. +

    + ) : copyEstimate?.isComplete ? ( Table - Est. size + Est. volume Est. cost ({formatCurrency(estimate.table_copy.rate_per_gb)}/GB) @@ -131,7 +164,7 @@ export const PipelineCostDialog = ({ {visibleTables.map((table) => ( - + {table.schema}.{table.name} @@ -156,10 +189,10 @@ export const PipelineCostDialog = ({ Total - {formatBytes(estimate.table_copy.total_bytes)} + {formatBytes(copyEstimate.estimatedBytes)} - {formatCurrency(estimate.table_copy.total_cost)} + {formatCurrency(copyEstimate.estimatedCost)} @@ -167,7 +200,8 @@ export const PipelineCostDialog = ({ ) : (

    - This publication has no tables to copy. + An initial sync estimate is unavailable for one or more selected tables. You + can still create the pipeline.

    )} @@ -179,48 +213,53 @@ export const PipelineCostDialog = ({

    Ongoing

    - Active pipeline + Configured pipeline - ${estimate.pipeline.hourly_cost}/hour{' '} - - (~{formatCurrency(estimate.pipeline.monthly_cost)}/month) - + ${estimate.pipeline.hourly_cost}/hour
    - Streaming changes + + Ongoing replication data processed + {formatCurrency(estimate.streaming.rate_per_gb)}/GB

    - Streaming is billed on the volume of changes replicated after the initial copy, - so the total depends on how often your data changes. + Ongoing replication is billed on Postgres row data accepted by the destination, + so the total depends on how often your published data changes. +

    +

    + Destination-provider charges, such as BigQuery ingestion, storage, and compute, + are separate. See{' '} + + how data processed is measured + + .

    -

    - Estimated first month total -

    +

    Estimated initial sync

    - Initial copy + first pipeline fee, excluding usage-based streaming + Pipeline hours and ongoing replication are billed separately.

    - {formatCurrency(firstMonthTotal)} - {hasRowFilteredTables ? '*' : null} + {copyEstimate?.isComplete ? formatCurrency(initialSyncTotal) : 'Unavailable'} + {copyEstimate?.isComplete && hasRowFilteredTables ? '*' : null}
    - {hasRowFilteredTables && ( + {copyEstimate?.isComplete && hasRowFilteredTables && (

    - *Tables with row filters may cost less than shown. + *Row filters can reduce the data processed compared with this estimate.

    )}
    diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx index db4c7ae7563..e0ac6818d71 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PublicationSelection.tsx @@ -1,28 +1,28 @@ import { useParams } from 'common' import { useMemo } from 'react' -import type { UseFormReturn } from 'react-hook-form' +import { useWatch, type UseFormReturn } from 'react-hook-form' import { FormControl, FormField } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import type { DestinationPanelSchemaType } from './DestinationForm.schema' import { PublicationsComboBox } from './PublicationsComboBox' import { useReplicationPublicationsQuery } from '@/data/replication/publications-query' +import { useReplicationSourceId } from '@/data/replication/sources-query' type PublicationSelectionProps = { form: UseFormReturn - sourceId?: number - visible: boolean onSelectNewPublication: () => void } export const PublicationSelection = ({ form, - sourceId, onSelectNewPublication, }: PublicationSelectionProps) => { const { ref: projectRef } = useParams() - const { publicationName } = form.watch() + const publicationName = useWatch({ control: form.control, name: 'publicationName' }) + + const sourceId = useReplicationSourceId({ projectRef }) const { data: publications, isSuccess: isSuccessPublications } = useReplicationPublicationsQuery({ projectRef, @@ -45,16 +45,30 @@ export const PublicationSelection = ({ > { + if (value !== field.value) { + form.setValue('tableSyncCopyTableIds', [], { + shouldDirty: true, + shouldValidate: true, + }) + } + field.onChange(value) + }, + }} sourceId={sourceId} onNewPublicationClick={() => onSelectNewPublication()} /> {isSelectedPublicationMissing && ( - +

    - The publication {publicationName} was - not found, it may have been renamed or deleted, please select another one. + It may have been renamed or deleted. Select another publication.

    )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx index b5eaccceef8..f77d1af4796 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/Snowflake/Fields.tsx @@ -11,9 +11,11 @@ import type { DestinationPanelSchemaType } from '../DestinationForm.schema' export const SnowflakeFields = ({ form, editMode, + hasStoredPrivateKeyPassphrase, }: { form: UseFormReturn editMode: boolean + hasStoredPrivateKeyPassphrase: boolean }) => { const [showPrivateKeyPassphrase, setShowPrivateKeyPassphrase] = useState(false) @@ -157,7 +159,7 @@ export const SnowflakeFields = ({ layout="horizontal" label="Private key passphrase" description={ - editMode + hasStoredPrivateKeyPassphrase ? 'Stored passphrase setting is hidden. Enter a new passphrase to replace it.' : 'Optional passphrase for encrypted private keys' } @@ -166,13 +168,19 @@ export const SnowflakeFields = ({ field.onChange(event.target.value)} actions={
    + ), + DialogSectionSeparator: () => null, + Form: ({ children }: PropsWithChildren) => children, + Select: ({ children }: PropsWithChildren) =>
    {children}
    , + SelectContent: ({ children }: PropsWithChildren) =>
    {children}
    , + SelectItem: ({ children }: PropsWithChildren) =>
    {children}
    , + SelectTrigger: ({ children }: PropsWithChildren) =>
    {children}
    , + SelectValue: () => null, + SheetFooter: ({ children }: PropsWithChildren) =>
    {children}
    , + SheetSection: ({ children }: PropsWithChildren) =>
    {children}
    , + Tooltip: ({ children }: PropsWithChildren) =>
    {children}
    , + TooltipContent: ({ children }: PropsWithChildren) =>
    {children}
    , + TooltipTrigger: ({ children }: PropsWithChildren) => {children}, +})) + +vi.mock('ui-patterns/Admonition', () => ({ + Admonition: ({ children }: PropsWithChildren) =>
    {children}
    , +})) + +vi.mock('ui-patterns/form/FormItemLayout/FormItemLayout', () => ({ + FormItemLayout: ({ children }: PropsWithChildren) =>
    {children}
    , +})) + +vi.mock('../../useIsETLPrivateAlpha', () => ({ + useIsETLBigQueryPrivateAlpha: () => true, + useIsETLClickHousePrivateAlpha: () => false, + useIsETLDucklakePrivateAlpha: () => false, + useIsETLIcebergPrivateAlpha: () => false, + useIsETLSnowflakePrivateAlpha: () => false, +})) + +vi.mock('./useDestinationForm', () => ({ + useDestinationForm: () => ({ + isValidating: false, + validateConfiguration: mocks.validateConfiguration, + isSaving: false, + submitPipeline: mocks.submitPipeline, + hasRunValidation: false, + destinationValidationFailures: [], + pipelineValidationFailures: [], + resetValidation: mocks.resetValidation, + }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: () => ({ can: false }), +})) + +vi.mock('@/data/api-keys/api-keys-query', () => ({ useAPIKeys: () => ({ data: undefined }) })) + +vi.mock('@/data/config/project-settings-v2-query', () => ({ + useProjectSettingsV2Query: () => ({ data: projectSettings }), +})) + +vi.mock('@/data/replication/sources-query', () => ({ + useReplicationSourcesQuery: () => ({ data: sourcesData }), + useReplicationSourceId: () => sourcesData.sources[0]?.id, +})) + +vi.mock('@/data/replication/publications-query', () => ({ + useReplicationPublicationsQuery: () => ({ + data: publications, + isPending: false, + isError: false, + isSuccess: true, + refetch: mocks.refetchPublications, + }), +})) + +vi.mock('@/data/replication/destination-by-id-query', () => ({ + useReplicationDestinationByIdQuery: () => ({ + data: destinationData, + isError: false, + isSuccess: true, + }), +})) + +vi.mock('@/data/replication/pipeline-by-id-query', () => ({ + useReplicationPipelineByIdQuery: () => ({ + data: pipelineData, + isError: false, + isSuccess: true, + }), +})) + +vi.mock('./DestinationNameInput', () => ({ DestinationNameInput: () => null })) +vi.mock('./PublicationSelection', () => ({ PublicationSelection: () => null })) +vi.mock('./TableCopySelection', () => ({ + TableCopySelection: ({ form }: { form: UseFormReturn }) => ( +
    {form.formState.errors.tableSyncCopyTableIds?.message}
    + ), +})) +vi.mock('./AdvancedSettings', () => ({ AdvancedSettings: () => null })) +vi.mock('./BigQuery/Fields', () => ({ BigQueryFields: () => null })) +vi.mock('./AnalyticsBucket/Fields', () => ({ AnalyticsBucketFields: () => null })) +vi.mock('./DuckLake/Fields', () => ({ DuckLakeFields: () => null })) +vi.mock('./Snowflake/Fields', () => ({ SnowflakeFields: () => null })) +vi.mock('./ClickHouse/Fields', () => ({ ClickHouseFields: () => null })) +vi.mock('./NewPublicationPanel', () => ({ NewPublicationPanel: () => null })) +vi.mock('./NoDestinationsAvailable', () => ({ NoDestinationsAvailable: () => null })) +vi.mock('./PipelineCostDialog', () => ({ PipelineCostDialog: () => null })) +vi.mock('./ValidationFailuresSection', () => ({ ValidationFailuresSection: () => null })) +vi.mock('./ValidationWarningsDialog', () => ({ ValidationWarningsDialog: () => null })) +vi.mock('@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet', () => ({ + CreateAnalyticsBucketSheet: () => null, +})) + +describe('DestinationForm edit submission', () => { + beforeEach(() => { + vi.clearAllMocks() + pipelineData.config.table_sync_copy.table_ids = [101, 999] + mocks.submitPipeline.mockResolvedValue(undefined) + mocks.validateConfiguration.mockResolvedValue({ canContinue: true, warnings: [] }) + }) + + it('bypasses create validation and submits the pruned table policy with the existing batch', async () => { + const onClose = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Apply and restart pipeline' })) + + await waitFor(() => expect(mocks.submitPipeline).toHaveBeenCalledOnce()) + + expect(mocks.validateConfiguration).not.toHaveBeenCalled() + expect(mocks.submitPipeline).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tableSyncCopyMode: 'include_tables', + tableSyncCopyTableIds: ['101'], + }), + existingDestination, + existingBatch, + onSuccess: expect.any(Function), + onClose, + }) + }) + + it('rejects an edit when every selected table has left the publication', async () => { + pipelineData.config.table_sync_copy.table_ids = [999] + + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Apply and restart pipeline' })) + + expect(await screen.findByText('Select at least one table')).toBeInTheDocument() + expect(mocks.validateConfiguration).not.toHaveBeenCalled() + expect(mocks.submitPipeline).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index cd419005366..69b6b7b1e8f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -3,9 +3,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { Loader2 } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { RefObject, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useForm, useWatch } from 'react-hook-form' import { AWS_REGIONS } from 'shared-data' +import { toast } from 'sonner' import { Button, DialogSectionSeparator, @@ -17,12 +18,17 @@ import { SelectValue, SheetFooter, SheetSection, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { useIsETLBigQueryPrivateAlpha, + useIsETLClickHousePrivateAlpha, useIsETLDucklakePrivateAlpha, useIsETLIcebergPrivateAlpha, useIsETLSnowflakePrivateAlpha, @@ -33,8 +39,15 @@ import { getAnalyticsBucketValidationIssues } from './AnalyticsBucket/AnalyticsB import { AnalyticsBucketFields } from './AnalyticsBucket/Fields' import { getBigQueryValidationIssues } from './BigQuery/BigQuery.utils' import { BigQueryFields } from './BigQuery/Fields' +import { getClickHouseValidationIssues } from './ClickHouse/ClickHouse.utils' +import { ClickHouseFields } from './ClickHouse/Fields' import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.schema' -import { areValidationFailuresEqual, generateDefaultValues } from './DestinationForm.utils' +import { + areValidationFailuresEqual, + buildTableSyncCopyConfig, + generateDefaultValues, + pruneStaleSelectedTableIds, +} from './DestinationForm.utils' import { DestinationNameInput } from './DestinationNameInput' import { getDucklakeValidationIssues } from './DuckLake/DuckLake.utils' import { DuckLakeFields } from './DuckLake/Fields' @@ -44,16 +57,18 @@ import { PipelineCostDialog } from './PipelineCostDialog' import { PublicationSelection } from './PublicationSelection' import { SnowflakeFields } from './Snowflake/Fields' import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils' +import { TableCopySelection } from './TableCopySelection' import { useDestinationForm } from './useDestinationForm' import { ValidationFailuresSection } from './ValidationFailuresSection' import { ValidationWarningsDialog } from './ValidationWarningsDialog' import { CreateAnalyticsBucketSheet } from '@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet' +import { InlineLinkClassName } from '@/components/ui/InlineLink' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query' import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query' import { useReplicationPublicationsQuery } from '@/data/replication/publications-query' -import { useReplicationSourcesQuery } from '@/data/replication/sources-query' +import { useReplicationSourceId } from '@/data/replication/sources-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { BASE_PATH, IS_STAGING_OR_LOCAL } from '@/lib/constants' @@ -67,14 +82,20 @@ interface DestinationFormProps { selectedType: DestinationType visible: boolean existingDestination?: ExistingDestination + typeSelection?: ReactNode + checkIsDirtyRef?: RefObject<() => boolean> onClose: () => void + onCancel?: () => void } export const DestinationForm = ({ selectedType, visible, existingDestination, + typeSelection, + checkIsDirtyRef, onClose, + onCancel = onClose, }: DestinationFormProps) => { const { ref: projectRef } = useParams() @@ -82,6 +103,7 @@ export const DestinationForm = ({ const etlEnableIceberg = useIsETLIcebergPrivateAlpha() const etlEnableDucklake = useIsETLDucklakePrivateAlpha() const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha() + const etlEnableClickHouse = useIsETLClickHousePrivateAlpha() const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const [showValidationWarningsDialog, setShowValidationWarningsDialog] = useState(false) @@ -104,12 +126,18 @@ export const DestinationForm = ({ destinations.push({ value: 'Analytics Bucket', label: 'Analytics Bucket' }) if (etlEnableDucklake) destinations.push({ value: 'DuckLake', label: 'DuckLake' }) if (etlEnableSnowflake) destinations.push({ value: 'Snowflake', label: 'Snowflake' }) + if (etlEnableClickHouse) destinations.push({ value: 'ClickHouse', label: 'ClickHouse' }) return destinations - }, [etlEnableBigQuery, etlEnableDucklake, etlEnableIceberg, etlEnableSnowflake]) + }, [ + etlEnableBigQuery, + etlEnableDucklake, + etlEnableIceberg, + etlEnableSnowflake, + etlEnableClickHouse, + ]) const hasNoAvailableDestinations = availableDestinations.length === 0 - const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) - const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + const sourceId = useReplicationSourceId({ projectRef }) const { data: publications = [], @@ -117,19 +145,32 @@ export const DestinationForm = ({ refetch: refetchPublications, } = useReplicationPublicationsQuery({ projectRef, sourceId }) - const { data: destinationData } = useReplicationDestinationByIdQuery({ + const { + data: destinationData, + isError: isErrorDestination, + isSuccess: isSuccessDestination, + } = useReplicationDestinationByIdQuery({ projectRef, destinationId: existingDestination?.destinationId, }) - const { data: pipelineData } = useReplicationPipelineByIdQuery({ + const { + data: pipelineData, + isError: isErrorPipeline, + isSuccess: isSuccessPipeline, + } = useReplicationPipelineByIdQuery({ projectRef, pipelineId: existingDestination?.pipelineId, }) + const isErrorExistingConfig = editMode && (isErrorDestination || isErrorPipeline) + const isExistingConfigReady = !editMode || (isSuccessDestination && isSuccessPipeline) + // Revealed API keys are only ever consumed as the default catalog token for + // Analytics Bucket (Iceberg) destinations, so don't fetch them for other + // destination types. const { data: apiKeysData } = useAPIKeys( { projectRef, reveal: true }, - { enabled: canReadAPIKeys } + { enabled: canReadAPIKeys && selectedType === 'Analytics Bucket' && !editMode } ) const { serviceKey } = apiKeysData ?? {} const catalogToken = serviceKey?.api_key ?? '' @@ -145,9 +186,7 @@ export const DestinationForm = ({ destinationValidationFailures, pipelineValidationFailures, resetValidation, - } = useDestinationForm({ - selectedType, - }) + } = useDestinationForm({ selectedType }) const defaultValues = useMemo( () => @@ -175,6 +214,22 @@ export const DestinationForm = ({ }) } + const selectedPublicationTableIds = pruneStaleSelectedTableIds({ + mode: data.tableSyncCopyMode, + selectedTableIds: data.tableSyncCopyTableIds, + publications, + publicationName: data.publicationName, + }) + + if ( + isSuccessPublications && + (data.tableSyncCopyMode === 'include_tables' || + data.tableSyncCopyMode === 'skip_tables') && + selectedPublicationTableIds.length === 0 + ) { + addRequiredFieldError('tableSyncCopyTableIds', 'Select at least one table') + } + if (selectedType === 'BigQuery') { getBigQueryValidationIssues(data, { secretsOptional: editMode }).forEach( ({ path, message }) => { @@ -200,13 +255,21 @@ export const DestinationForm = ({ addRequiredFieldError(path, message) } ) + } else if (selectedType === 'ClickHouse') { + getClickHouseValidationIssues(data).forEach(({ path, message }) => { + addRequiredFieldError(path, message) + }) } }) ), defaultValues, }) - const { publicationName } = form.watch() + // Always destructure formState values otherwise they won't be updated + // See https://react-hook-form.com/docs/useform/formstate + const { isDirty } = form.formState + + const publicationName = useWatch({ control: form.control, name: 'publicationName' }) const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) const isSelectedPublicationMissing = @@ -216,8 +279,28 @@ export const DestinationForm = ({ const hasValidationFailures = allValidationFailures.some((f) => f.failure_type === 'critical') const validationWarnings = allValidationFailures.filter((f) => f.failure_type === 'warning') + const pendingTableSyncCopy = useMemo( + () => + pendingFormValues === null + ? undefined + : buildTableSyncCopyConfig({ + mode: pendingFormValues.tableSyncCopyMode, + selectedTableIds: pendingFormValues.tableSyncCopyTableIds, + }), + [pendingFormValues] + ) + const pendingPublicationTables = useMemo( + () => + publications.find(({ name }) => name === pendingFormValues?.publicationName)?.tables ?? [], + [pendingFormValues?.publicationName, publications] + ) + const isSubmitDisabled = - isSaving || isSelectedPublicationMissing || (!editMode && hasNoAvailableDestinations) + isSaving || + !isExistingConfigReady || + !isSuccessPublications || + isSelectedPublicationMissing || + (!editMode && hasNoAvailableDestinations) const getSubmitButtonText = () => { if (editMode) { @@ -240,12 +323,32 @@ export const DestinationForm = ({ setShowCostDialog(true) } - const onSubmit = async (data: z.infer) => { - // Editing an existing pipeline doesn't incur a new initial copy, so it skips the cost gate. + const onSubmit = async (rawData: z.infer) => { + if (!isSuccessPublications) { + toast.error('Publication tables are unavailable. Refresh and try again.') + return + } + + // Drop any previously selected id that has since fallen out of the + // publication before validating a create or submitting an edit. + const data: z.infer = { + ...rawData, + tableSyncCopyTableIds: pruneStaleSelectedTableIds({ + mode: rawData.tableSyncCopyMode, + selectedTableIds: rawData.tableSyncCopyTableIds, + publications, + publicationName: rawData.publicationName, + }), + } + + // Pipeline prerequisite validation models a new pipeline and cannot + // account for resources already owned by an existing pipeline. Edits keep + // the established direct-update flow after pruning stale table ids. if (editMode) { await submitPipeline({ data, existingDestination, + existingBatch: pipelineData?.config.batch, onSuccess: () => form.reset(defaultValues), onClose, }) @@ -291,8 +394,7 @@ export const DestinationForm = ({ openCostDialog(data) } - // Confirming the warnings advances to the cost dialog rather than submitting directly, so the - // cost estimate is always the last thing shown before the pipeline starts. + // Confirming create warnings advances to the cost dialog. const handleValidationWarningsConfirm = () => { if (!pendingFormValues) return setShowValidationWarningsDialog(false) @@ -308,17 +410,29 @@ export const DestinationForm = ({ await submitPipeline({ data: values, existingDestination, + existingBatch: pipelineData?.config.batch, onSuccess: () => form.reset(defaultValues), onClose, }) } useEffect(() => { - if (visible && !form.formState.isDirty) { + if (!checkIsDirtyRef) return + + checkIsDirtyRef.current = () => isDirty + return () => { + checkIsDirtyRef.current = () => false + } + }, [checkIsDirtyRef, isDirty]) + + useEffect(() => { + // Reset when closed (including after discard) so reopening does not restore + // discarded values, and when open but pristine so async defaults can apply. + if (!visible || !isDirty) { form.reset(defaultValues) resetValidation() } - }, [visible, defaultValues, form, resetValidation]) + }, [visible, defaultValues, form, isDirty, resetValidation]) useEffect(() => { if (visible && projectRef && sourceId) { @@ -329,87 +443,118 @@ export const DestinationForm = ({ return ( <> + {typeSelection} {hasNoAvailableDestinations && !editMode ? ( ) : (
    -
    -

    Destination details

    +
    +
    + {isErrorExistingConfig && ( + +

    + The existing destination or pipeline settings could not be loaded. Refresh + before applying changes. +

    +
    + )} +

    Destination details

    -
    - - setPublicationPanelVisible(true)} - /> - - - -
    -
    - - - - {selectedType === 'BigQuery' && etlEnableBigQuery ? ( - - ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? ( - setNewBucketSheetVisible(true)} - /> - ) : selectedType === 'DuckLake' && etlEnableDucklake ? ( - - ) : selectedType === 'Snowflake' && etlEnableSnowflake ? ( - - ) : null} - - - - - - {!editMode && hasRunValidation && !isValidating && ( - <> - - -
    - + + setPublicationPanelVisible(true)} /> + + + Pipelines run in{' '} + + + {PIPELINE_REGION.displayName} + + {PIPELINE_REGION.code} + + . In your destination provider, choose the closest available region. + + } + > + +
    - - )} +
    + + + + {selectedType === 'BigQuery' && etlEnableBigQuery ? ( + + ) : selectedType === 'Analytics Bucket' && etlEnableIceberg ? ( + setNewBucketSheetVisible(true)} + /> + ) : selectedType === 'DuckLake' && etlEnableDucklake ? ( + + ) : selectedType === 'Snowflake' && etlEnableSnowflake ? ( + + ) : selectedType === 'ClickHouse' && etlEnableClickHouse ? ( + + ) : null} + + + + + + {!editMode && hasRunValidation && !isValidating && ( + <> + + +
    + +
    + + )} + )} @@ -441,7 +586,7 @@ export const DestinationForm = ({ )}
    - - + + Request Pipelines access + + + +
    - - + + ) : replicationNotEnabled ? ( - - - +
    + {pipelinesTypeSelection} + + + +
    ) : ( )} + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.types.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.types.ts index 0d14e2ac3d4..c85032eadaf 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.types.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.types.ts @@ -4,6 +4,7 @@ export type DestinationType = | 'Analytics Bucket' | 'DuckLake' | 'Snowflake' + | 'ClickHouse' export type ExistingDestination = { sourceId?: number diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx index f5d2d442785..9ef30c6a292 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx @@ -20,12 +20,14 @@ const mockBigQueryEnabled = vi.fn() const mockIcebergEnabled = vi.fn() const mockDucklakeEnabled = vi.fn() const mockSnowflakeEnabled = vi.fn() +const mockClickHouseEnabled = vi.fn() vi.mock('../useIsETLPrivateAlpha', () => ({ useIsETLBigQueryPrivateAlpha: () => mockBigQueryEnabled(), useIsETLIcebergPrivateAlpha: () => mockIcebergEnabled(), useIsETLDucklakePrivateAlpha: () => mockDucklakeEnabled(), useIsETLSnowflakePrivateAlpha: () => mockSnowflakeEnabled(), + useIsETLClickHousePrivateAlpha: () => mockClickHouseEnabled(), })) vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ @@ -57,6 +59,7 @@ describe('DestinationTypeSelection', () => { mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() customRender() @@ -69,6 +72,7 @@ describe('DestinationTypeSelection', () => { mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() customRender() @@ -84,6 +88,7 @@ describe('DestinationTypeSelection', () => { mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() customRender() @@ -99,6 +104,7 @@ describe('DestinationTypeSelection', () => { mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() customRender() @@ -113,11 +119,12 @@ describe('DestinationTypeSelection', () => { expect(screen.queryByText('Pipelines')).not.toBeInTheDocument() }) - test('shows alpha warning when an alpha destination type is selected', async () => { + test('shows the public alpha warning for a Pipelines destination', async () => { mockBigQueryEnabled.mockReturnValue(true) mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() customRender() @@ -125,7 +132,7 @@ describe('DestinationTypeSelection', () => { fireEvent.click(await screen.findByRole('combobox')) fireEvent.click(await screen.findByText('BigQuery')) - expect(await screen.findByText(/This destination type is in alpha/)).toBeInTheDocument() + expect(await screen.findByText(/In public alpha and may change/)).toBeInTheDocument() }) test('disables the selector in edit mode so the destination type cannot be changed', async () => { @@ -133,6 +140,7 @@ describe('DestinationTypeSelection', () => { mockIcebergEnabled.mockReturnValue(false) mockDucklakeEnabled.mockReturnValue(false) mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) addBackgroundMocks() // Edit mode triggers useDestinationInformation({ id: 1 }) which fires destination-by-id addAPIMock({ @@ -147,7 +155,7 @@ describe('DestinationTypeSelection', () => { big_query: { project_id: 'gcp-proj', dataset_id: 'analytics', - service_account_key: '{}', + connection_pool_size: 5, }, }, }), diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index c528ff2e08e..f5123816e9b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -1,5 +1,3 @@ -import { AnalyticsBucket, BigQuery, Database } from 'icons' -import { Snowflake } from 'lucide-react' import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs' import { Badge, @@ -13,9 +11,11 @@ import { } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { DestinationIcon } from '../DestinationIcon' import { useDestinationInformation } from '../useDestinationInformation' import { useIsETLBigQueryPrivateAlpha, + useIsETLClickHousePrivateAlpha, useIsETLDucklakePrivateAlpha, useIsETLIcebergPrivateAlpha, useIsETLSnowflakePrivateAlpha, @@ -28,8 +28,7 @@ interface DestinationTypeOption { value: DestinationType label: string description: string - icon: typeof Database - isAlpha: boolean + stage: 'Public Alpha' | 'Early Access' | 'Deprecated' | null enabled: boolean } @@ -43,6 +42,7 @@ export const DestinationTypeSelection = () => { const etlEnableIceberg = useIsETLIcebergPrivateAlpha() const etlEnableDucklake = useIsETLDucklakePrivateAlpha() const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha() + const etlEnableClickHouse = useIsETLClickHousePrivateAlpha() const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) const [urlDestinationType, setDestinationType] = useQueryState( @@ -53,6 +53,7 @@ export const DestinationTypeSelection = () => { 'Analytics Bucket', 'DuckLake', 'Snowflake', + 'ClickHouse', ]).withOptions({ history: 'push', clearOnDefault: true, @@ -82,8 +83,7 @@ export const DestinationTypeSelection = () => { label: 'Read Replica', description: 'Deploy a read-only database in another region for lower latency and workload isolation', - icon: Database, - isAlpha: false, + stage: null, enabled: isOptionVisible('Read Replica', infrastructureReadReplicas), }, ], @@ -95,35 +95,38 @@ export const DestinationTypeSelection = () => { value: 'Analytics Bucket', label: 'Analytics Bucket', description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows', - icon: AnalyticsBucket, - isAlpha: true, + stage: 'Deprecated', enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg), }, { value: 'BigQuery', label: 'BigQuery', - description: "Stream changes to Google Cloud's data warehouse for analytics and BI", - icon: BigQuery, - isAlpha: true, + description: "Replicate changes to Google Cloud's data warehouse for analytics and BI", + stage: 'Public Alpha', enabled: isOptionVisible('BigQuery', etlEnableBigQuery), }, { value: 'DuckLake', label: 'DuckLake', - description: 'Stream changes to a DuckLake catalog backed by S3-compatible storage', - icon: Database, - isAlpha: true, + description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage', + stage: 'Early Access', enabled: isOptionVisible('DuckLake', etlEnableDucklake), }, { value: 'Snowflake', label: 'Snowflake', description: - 'Stream changes to Snowflake for warehouse analytics and downstream data workflows', - icon: Snowflake, - isAlpha: true, + 'Replicate changes to Snowflake for warehouse analytics and downstream data workflows', + stage: 'Early Access', enabled: isOptionVisible('Snowflake', etlEnableSnowflake), }, + { + value: 'ClickHouse', + label: 'ClickHouse', + description: 'Stream changes to a ClickHouse cluster for fast columnar analytics', + stage: 'Early Access', + enabled: isOptionVisible('ClickHouse', etlEnableClickHouse), + }, ], }, ] @@ -136,24 +139,41 @@ export const DestinationTypeSelection = () => { .flatMap((group) => group.options) .find((option) => option.value === destinationType) + const stageDescription = + selectedOption?.stage === 'Public Alpha' ? ( + <> + In public alpha and may change.{' '} + + Leave feedback + + + ) : selectedOption?.stage === 'Early Access' ? ( + <> + In early access and may change.{' '} + + Leave feedback + + + ) : selectedOption?.stage === 'Deprecated' ? ( + 'This destination type is deprecated.' + ) : null + + const typeDescription = + !editMode || stageDescription ? ( + + {!editMode && 'Cannot be changed after creation.'} + {!editMode && stageDescription ? ' ' : null} + {stageDescription} + + ) : undefined + return ( - This destination type is in alpha and may be unstable or introduce breaking changes - while we iterate based on customer feedback.{' '} - - Leave feedback - - - ) - } + description={typeDescription} > } - value={filterString} - className="w-full lg:w-52" - onChange={(e) => setFilterString(e.target.value)} - actions={ - filterString.length > 0 && ( - - - {organization?.slug && ( - - )} - - {canDisablePipelines && ( - - - + @@ -297,13 +324,6 @@ export const Destinations = () => { /> )} - {isLocalETLNotSetUp && ( - - )} - {isLoading ? ( ) : hasReplicas || hasDestinations ? ( @@ -361,37 +381,35 @@ export const Destinations = () => { ) : ( !isLoading && !hasErrorsFetchingData && ( -
    -

    Replication keeps your data in sync across systems

    -

    - Deploy Read Replicas for lower latency and workload isolation, or add a Pipelines - destination for analytics workloads. -

    -
    + ) )} setStatusRefetchInterval(5000)} /> + + - + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx index d9bab23243e..b99df2f5920 100644 --- a/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DisablePipelinesDialog.tsx @@ -12,7 +12,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useDeleteReplicationTenantMutation } from '@/data/replication/delete-tenant-mutation' @@ -40,8 +40,12 @@ export const DisablePipelinesDialog = ({ open, setOpen }: DisablePipelinesDialog try { if (!projectRef) throw new Error('Project ref is required') await deleteReplicationTenant({ projectRef }) - } catch (error: any) { - setError(error.message ?? 'An unknown error occurred') + } catch (error) { + setError( + error instanceof Error + ? error.message + : 'An unknown error occurred while disabling Pipelines' + ) throw error } } @@ -51,13 +55,13 @@ export const DisablePipelinesDialog = ({ open, setOpen }: DisablePipelinesDialog Disable Pipelines - -

    + + This will remove the etl schema and all - connected resources from your database. Any active replication pipelines sending - changes to external destinations will stop. -

    -

    Read replicas are not affected.

    + Pipelines-managed resources from your database. Data already written to destination + systems is not deleted. + + Read replicas are not affected.
    {error && ( diff --git a/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx index 602bda15097..0c0eb4151bd 100644 --- a/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx +++ b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx @@ -13,17 +13,32 @@ import { DialogTitle, DialogTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DocsButton } from '@/components/ui/DocsButton' +import { InlineLink } from '@/components/ui/InlineLink' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useCreateTenantSourceMutation } from '@/data/replication/create-tenant-source-mutation' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { DOCS_URL } from '@/lib/constants' -const EnablePipelinesModal = () => { +type EnablePipelinesModalProps = + | { open: boolean; onOpenChange: (open: boolean) => void } + | { open?: never; onOpenChange?: never } + +export const EnablePipelinesModal = ({ + open: extOpen, + onOpenChange, +}: EnablePipelinesModalProps) => { const { ref: projectRef } = useParams() - const [open, setOpen] = useState(false) + const [_open, _setOpen] = useState(false) + + const open = extOpen ?? _open + const setOpen = onOpenChange ?? _setOpen + const hideTrigger = extOpen !== undefined && onOpenChange !== undefined + + const { hasAccess } = useCheckEntitlements('replication.etl') const { mutate: createTenantSource, isPending: creatingTenantSource } = useCreateTenantSourceMutation({ @@ -43,12 +58,14 @@ const EnablePipelinesModal = () => { return ( - - - - + {!hideTrigger && ( + + + + )} + Enable Pipelines @@ -57,25 +74,43 @@ const EnablePipelinesModal = () => { -

    - Alpha features can be unstable and may introduce breaking changes while we evaluate - the product direction, refine the feature set, and incorporate customer feedback. -

    -

    - Pricing is not finalized. You can enable Pipelines now; we'll announce pricing later - and notify you before any charges apply. -

    + {hasAccess ? ( + <> +

    + Public alpha features may change as we refine the product and incorporate customer + feedback. +

    +

    + Pipelines is billed for configured pipeline hours and Postgres row data processed + during initial sync and ongoing replication. Review the{' '} + + Pipelines pricing + {' '} + before enabling it. +

    + + ) : ( +

    + Supabase Pipelines replicates database changes to supported destination systems.{' '} + {hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to + replicate database changes to data warehouses and analytics platforms. +

    + )}
    - + {hasAccess ? ( + + ) : ( + + )}
    @@ -85,20 +120,19 @@ const EnablePipelinesModal = () => { export const EnablePipelinesCallout = ({ type, className, - hasAccess, }: { type?: DestinationType | null className?: string - hasAccess: boolean }) => { + const { hasAccess } = useCheckEntitlements('replication.etl') + return (

    Enable Pipelines

    - Pipelines creates managed replication pipelines that stream database changes to - destination systems.{' '} - {hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to stream + Supabase Pipelines replicates database changes to supported destination systems.{' '} + {hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to replicate database changes to {type ?? 'data warehouses and analytics platforms'}.

    diff --git a/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx b/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx index 2a56d42d8c7..1bc5ff7cdd3 100644 --- a/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx @@ -14,7 +14,7 @@ export const ErroredTableDetails = ({ table }: ErroredTableDetailsProps) => { const { ref: projectRef } = useParams() const state = table.state as Extract - const tableName = table.table_name + const tableName = `${table.schema}.${table.name}` const retryPolicy = state.retry_policy.policy if (!isValidRetryPolicy(state.retry_policy)) { diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts index 9064e4fd2f1..a2c834dbc2c 100644 --- a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -59,7 +59,7 @@ const PIPELINE_DISPLAY_STATES: Record { resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' return ( -
    +
    -> = { - BigQuery, - 'Analytics Bucket': AnalyticsBucket, - DuckLake: Database, - Snowflake, -} - const NodeContainer = ({ className, children }: PropsWithChildren<{ className?: string }>) => { return (
    { const statusName = getStatusName(pipelineStatusData?.status) const type = getReplicationDestinationType(destination?.config) - const DestinationIcon = type ? destinationIconByType[type] : undefined return ( - {DestinationIcon ? : null} + {type ? : null}

    {type}

    @@ -145,7 +133,7 @@ export const ReadReplicaNode = ({ id }: { id: string }) => { return ( - +

    Read Replica

    diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.test.ts index d4921fb8db8..73eeaabd83c 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.test.ts @@ -19,6 +19,10 @@ describe('getReplicationDestinationType', () => { expect(getReplicationDestinationType({ snowflake: {} })).toBe('Snowflake') }) + it('returns ClickHouse for clickhouse configs', () => { + expect(getReplicationDestinationType({ clickhouse: {} })).toBe('ClickHouse') + }) + it('returns undefined for unknown or missing configs', () => { expect(getReplicationDestinationType({})).toBeUndefined() expect(getReplicationDestinationType(undefined)).toBeUndefined() diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.ts index cbd5b4772dc..3bdc913d6a3 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.utils.ts @@ -1,4 +1,9 @@ -export type ReplicationDestinationType = 'BigQuery' | 'Analytics Bucket' | 'DuckLake' | 'Snowflake' +export type ReplicationDestinationType = + | 'BigQuery' + | 'Analytics Bucket' + | 'DuckLake' + | 'Snowflake' + | 'ClickHouse' export const getReplicationDestinationType = ( config?: Record @@ -8,5 +13,6 @@ export const getReplicationDestinationType = ( if ('iceberg' in config) return 'Analytics Bucket' if ('ducklake' in config) return 'DuckLake' if ('snowflake' in config) return 'Snowflake' + if ('clickhouse' in config) return 'ClickHouse' return undefined } diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx index 83a9555cee7..4f1412a4cce 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx @@ -121,7 +121,7 @@ const ReplicationDiagramContent = () => { }, [nodes, isSuccessDestinations, skipRenderingDestinations, isSuccessReplicas]) return ( -
    +
    { } | null>(null) const [showRestartDialog, setShowRestartDialog] = useState(false) const [selectedTableForRestart, setSelectedTableForRestart] = useState<{ - tableId: number - tableName: string + id: number + schema: string + name: string } | null>(null) const [showBatchRestartDialog, setShowBatchRestartDialog] = useState(false) const [batchRestartMode, setBatchRestartMode] = useState<'all' | 'errored' | null>(null) @@ -143,11 +144,11 @@ export const ReplicationPipelineStatus = () => { const displayState = getPipelineDisplayState(requestStatus, statusName) const config = getDisabledStateConfig({ requestStatus, statusName }) - // Sort tables by name for consistent ordering (memoized) + // Sort tables by schema and name for consistent ordering (memoized) const tableStatuses = useMemo( () => - (replicationStatusData?.table_statuses || []).sort((a, b) => - a.table_name.localeCompare(b.table_name) + (replicationStatusData?.table_statuses || []).sort( + (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) ), [replicationStatusData?.table_statuses] ) @@ -160,7 +161,7 @@ export const ReplicationPipelineStatus = () => { searchString.length === 0 ? tableStatuses : tableStatuses.filter((table) => - table.table_name.toLowerCase().includes(searchString.toLowerCase()) + `${table.schema}.${table.name}`.toLowerCase().includes(searchString.toLowerCase()) ), [tableStatuses, searchString] ) @@ -171,13 +172,7 @@ export const ReplicationPipelineStatus = () => { ) const erroredTables = useMemo( - () => - tableStatuses.filter( - (table) => - table.state.name === 'error' && - 'retry_policy' in table.state && - table.state.retry_policy?.policy === 'manual_retry' - ), + () => tableStatuses.filter((table) => table.state.name === 'error'), [tableStatuses] ) @@ -382,9 +377,9 @@ export const ReplicationPipelineStatus = () => {
      {tablesWithLag.map((table) => ( -
    • +
    • @@ -454,7 +449,7 @@ export const ReplicationPipelineStatus = () => { tooltip={{ content: { side: 'left', - text: !hasErroredTables ? 'No tables require manual retry' : undefined, + text: !hasErroredTables ? 'No failed tables' : undefined, }, }} > @@ -485,7 +480,7 @@ export const ReplicationPipelineStatus = () => { {filteredTableStatuses.map((table) => { - const isRestarting = restartingTableIds.has(table.table_id) + const isRestarting = restartingTableIds.has(table.id) const isErrorState = table.state.name === 'error' const errorReason = isErrorState && 'reason' in table.state ? table.state.reason : undefined @@ -493,7 +488,7 @@ export const ReplicationPipelineStatus = () => { isErrorState && 'solution' in table.state ? table.state.solution : undefined return ( { isPipelineStopped={statusName === PipelineStatusName.STOPPED} onSelectRestart={() => { setSelectedTableForRestart({ - tableId: table.table_id, - tableName: table.table_name, + id: table.id, + schema: table.schema, + name: table.name, }) setShowRestartDialog(true) }} @@ -511,7 +507,7 @@ export const ReplicationPipelineStatus = () => { isErrorState && errorReason ? () => { setSelectedTableError({ - tableName: table.table_name, + tableName: `${table.schema}.${table.name}`, reason: errorReason, solution: errorSolution, }) @@ -581,16 +577,18 @@ export const ReplicationPipelineStatus = () => { { - setRestartingTableIds((prev) => new Set(prev).add(selectedTableForRestart.tableId)) + setRestartingTableIds((prev) => new Set(prev).add(selectedTableForRestart.id)) }} onRestartComplete={() => { setRestartingTableIds((prev) => { const next = new Set(prev) - next.delete(selectedTableForRestart.tableId) + next.delete(selectedTableForRestart.id) return next }) }} @@ -614,9 +612,10 @@ export const ReplicationPipelineStatus = () => { open={showBatchRestartDialog} onOpenChange={setShowBatchRestartDialog} mode={batchRestartMode} - totalTables={tableStatuses.length} - erroredTablesCount={erroredTables.length} tables={tableStatuses} + sourceId={pipeline?.source_id} + publicationName={pipeline?.config.publication_name} + tableSyncCopy={pipeline?.config.table_sync_copy} pipelineStatusName={statusName} onRestartStart={(tableIds) => { setRestartingTableIds((prev) => new Set([...prev, ...tableIds])) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts index 568cadb6125..6b9a80dc8a5 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.types.ts @@ -28,6 +28,12 @@ export type SlotLagMetricKey = | 'reply_time_lag' export type TableState = { + id: number + schema: string + name: string + // Deprecated compatibility aliases for `id` and `schema`.`name`. Prefer the + // structured fields above; the API keeps these only until platform and + // etl-api remove them in a follow-up release. table_id: number table_name: string state: diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx index 9ab2ebcd4c2..293251ae5da 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx @@ -19,22 +19,22 @@ export const getStatusConfig = (state: TableState['state']) => { case 'copying_table': return { badge: Copying, - description: "Table's existing rows are being copied before live streaming begins.", - tooltip: "Table's existing rows are being copied before live streaming begins.", + description: "Table's existing rows are being copied during the initial sync.", + tooltip: "Table's existing rows are being copied during the initial sync.", color: 'text-brand-600', } case 'copied_table': return { badge: Copied, - description: "Table copy is complete and it's preparing to follow WAL changes.", - tooltip: "Table copy is complete and it's preparing to follow WAL changes.", + description: 'Initial sync is complete and the table is preparing for ongoing replication.', + tooltip: 'Initial sync is complete and the table is preparing for ongoing replication.', color: 'text-success-600', } case 'following_wal': return { badge: Live, - description: 'Table is streaming new changes in real time from the WAL.', - tooltip: 'Table is streaming new changes in real time from the WAL.', + description: 'Table is receiving ongoing changes from the WAL.', + tooltip: 'Table is receiving ongoing changes from the WAL.', color: 'text-success-600', } case 'error': diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx index cede6f71a1d..767322c381a 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx @@ -30,12 +30,12 @@ const SLOT_LAG_FIELDS: SlotLagField[] = [ }, { key: 'safe_wal_size_bytes', - label: 'Room before pausing', + label: 'WAL retention remaining', type: 'bytes', description: ( <> - How much more can pile up before the pipeline has to be set up again. Controlled by the{' '} - max_slot_wal_keep_size setting. + How much more WAL can accumulate before the replication slot is at risk of being lost. + Controlled by the max_slot_wal_keep_size setting. ), nullLabel: 'Unlimited', @@ -133,8 +133,9 @@ export const SlotLagMetricsList = ({ diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx index 70a1397f20a..3bbeada6407 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx @@ -64,8 +64,9 @@ export const SlotStatusLegend = () => { diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx index 17f61d935c7..68135368a2b 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx @@ -39,7 +39,9 @@ export const TableReplicationRow = ({
      -

      {table.table_name}

      +

      + {table.schema}.{table.name} +

      @@ -81,7 +83,11 @@ export const TableReplicationRow = ({
      {statusConfig.description}{' '} {isErrorState && 'reason' in table.state && ( - )} @@ -100,7 +106,7 @@ export const TableReplicationRow = ({ className="w-7" icon={} disabled={showDisabledState || isRestarting || isAnyRestartInProgress} - aria-label={`Restart replication for ${table.table_name}`} + aria-label={`Restart replication for ${table.schema}.${table.name}`} onClick={onSelectRestart} /> diff --git a/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.render.test.tsx b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.render.test.tsx new file mode 100644 index 00000000000..e9ce2deb6ce --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.render.test.tsx @@ -0,0 +1,42 @@ +import { screen } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' + +import { RestartCostEstimate } from './RestartCostEstimate' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type CostEstimateResponse = components['schemas']['CostEstimateResponse'] + +describe('RestartCostEstimate', () => { + it('does not request an estimate when every target skips initial sync', () => { + const costEstimateRequest = vi.fn(() => + HttpResponse.json({ + currency: 'usd', + pipeline: { hourly_cost: 0.05, monthly_cost: 36.5 }, + streaming: { rate_per_gb: 3 }, + table_copy: { rate_per_gb: 0.6, total_bytes: 0, total_cost: 0, tables: [] }, + }) + ) + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources/:source_id/publications/:publication_name/cost-estimate', + response: costEstimateRequest, + }) + + customRender( + + ) + + expect(screen.getByText('No additional initial sync charge')).toBeInTheDocument() + expect(screen.getByText('$0.00')).toBeInTheDocument() + expect(costEstimateRequest).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.test.ts b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.test.ts new file mode 100644 index 00000000000..397d907a5d7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { calculateRestartCostEstimate } from './RestartCostEstimate' + +const tables = [ + { + schema: 'public', + name: 'orders', + estimated_bytes: 2_000_000_000, + estimated_cost: 1.25, + is_row_filtered: false, + }, + { + schema: 'public', + name: 'customers', + estimated_bytes: 1_000_000_000, + estimated_cost: 0.75, + is_row_filtered: true, + }, + { + schema: 'internal', + name: 'audit_log', + estimated_bytes: 500_000_000, + estimated_cost: 0.3, + is_row_filtered: false, + }, +] + +describe('calculateRestartCostEstimate', () => { + it('sums only the tables being restarted', () => { + expect( + calculateRestartCostEstimate(tables, [ + { schema: 'public', name: 'orders' }, + { schema: 'public', name: 'customers' }, + ]) + ).toMatchObject({ + isComplete: true, + estimatedBytes: 3_000_000_000, + estimatedCost: 2, + hasRowFilteredTables: true, + }) + }) + + it('marks the estimate incomplete when a restarted table is missing', () => { + expect( + calculateRestartCostEstimate(tables, [ + { schema: 'public', name: 'orders' }, + { schema: 'public', name: 'missing' }, + ]).isComplete + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.tsx b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.tsx new file mode 100644 index 00000000000..55bae36ad55 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { + summarizeTableCopyEstimate, + type ReplicationTableIdentity, + type TableCopyEstimate, +} from './TableSyncCopy.utils' +import { useReplicationCostEstimateQuery } from '@/data/replication/cost-estimate-query' +import { formatBytes, formatCurrency } from '@/lib/helpers' + +interface RestartCostEstimateProps { + open: boolean + projectRef?: string + sourceId?: number + publicationName?: string + tables: Pick[] +} + +export const calculateRestartCostEstimate = ( + estimates: TableCopyEstimate[], + tables: Pick[] +) => summarizeTableCopyEstimate(estimates, tables) + +export const RestartCostEstimate = ({ + open, + projectRef, + sourceId, + publicationName, + tables, +}: RestartCostEstimateProps) => { + const { data: estimate, isFetching } = useReplicationCostEstimateQuery( + { projectRef, sourceId, publicationName }, + { enabled: open && tables.length > 0 } + ) + const restartEstimate = useMemo( + () => + estimate === undefined + ? undefined + : calculateRestartCostEstimate(estimate.table_copy.tables, tables), + [estimate, tables] + ) + return ( +
      + {tables.length === 0 ? ( +
      +
      +

      No additional initial sync charge

      +

      + This restart will skip initial sync based on the pipeline's settings. +

      +
      + + {formatCurrency(0)} + +
      + ) : isFetching ? ( + + ) : restartEstimate?.isComplete ? ( +
      +
      +

      Estimated additional initial sync cost

      +

      + Based on an estimated {formatBytes(restartEstimate.estimatedBytes)} of initial sync + data across {tables.length} {tables.length === 1 ? 'table' : 'tables'} +

      +
      + + {formatCurrency(restartEstimate.estimatedCost)} + {restartEstimate.hasRowFilteredTables ? '*' : null} + +
      + ) : ( +

      + A cost estimate is unavailable. You can still restart the{' '} + {tables.length === 1 ? 'table' : 'tables'}. +

      + )} + {restartEstimate?.isComplete && restartEstimate.hasRowFilteredTables && ( +

      + *Row filters can reduce the data processed compared with this estimate. +

      + )} + {restartEstimate?.isComplete && ( +

      + Quick planning estimate; the final charge is based on successfully processed initial sync + data, which is billed again. +

      + )} +
      + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx b/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx index d8bc697e96f..59ce94d5f69 100644 --- a/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx @@ -12,13 +12,21 @@ import { } from 'ui' import { PipelineStatusName } from './Replication.constants' +import { RestartCostEstimate } from './RestartCostEstimate' +import { + shouldCopyTable, + type ReplicationTableIdentity, + type TableSyncCopyConfig, +} from './TableSyncCopy.utils' import { useRollbackTablesMutation } from '@/data/replication/rollback-tables-mutation' interface RestartTableDialogProps { open: boolean onOpenChange: (open: boolean) => void - tableId: number - tableName: string + table: ReplicationTableIdentity + tableSyncCopy?: TableSyncCopyConfig + sourceId?: number + publicationName?: string pipelineStatusName?: PipelineStatusName onRestartStart?: () => void onRestartComplete?: () => void @@ -27,14 +35,18 @@ interface RestartTableDialogProps { export const RestartTableDialog = ({ open, onOpenChange, - tableId, - tableName, + table, + tableSyncCopy, + sourceId, + publicationName, pipelineStatusName, onRestartStart, onRestartComplete, }: RestartTableDialogProps) => { const { ref: projectRef, pipelineId: _pipelineId } = useParams() const pipelineId = Number(_pipelineId) + const tableName = `${table.schema}.${table.name}` + const willCopyTable = shouldCopyTable(tableSyncCopy, table.id) const { mutate: rollbackTables, isPending: isResetting } = useRollbackTablesMutation({ onSuccess: () => { @@ -59,7 +71,7 @@ export const RestartTableDialog = ({ rollbackTables({ projectRef, pipelineId, - target: { type: 'single_table', table_id: tableId }, + target: { type: 'single_table', table_id: table.id }, rollbackType: 'full', pipelineStatusName, }) @@ -79,10 +91,19 @@ export const RestartTableDialog = ({ {tableName} from scratch:

        -
      • - The table copy will be re-initialized. All data will be copied - again from the source. -
      • + {willCopyTable ? ( +
      • + The table's initial sync will restart. Existing source rows + will be synced again. Data successfully processed during this initial sync is + billed again. +
      • + ) : ( +
      • + The table will skip initial sync. Replication will resume with + new changes only, without syncing existing source rows. There is no additional + initial sync charge. +
      • + )}
      • Existing downstream data will be deleted. Any replicated data for this table will be removed. @@ -98,6 +119,13 @@ export const RestartTableDialog = ({
      + Cancel diff --git a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts new file mode 100644 index 00000000000..023afa18441 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' + +import { + getTableCopyTargets, + shouldCopyTable, + summarizeTableCopyEstimate, + type TableSyncCopyConfig, +} from './TableSyncCopy.utils' + +const tables = [ + { id: 101, schema: 'public', name: 'orders' }, + { id: 202, schema: 'billing', name: 'orders' }, +] + +describe('shouldCopyTable', () => { + it('defaults an omitted policy to copying every table', () => { + expect(shouldCopyTable(undefined, 101)).toBe(true) + }) + + it.each<[TableSyncCopyConfig, number[]]>([ + [{ type: 'include_all_tables' }, [101, 202]], + [{ type: 'skip_all_tables' }, []], + [{ type: 'include_tables', table_ids: [202] }, [202]], + [{ type: 'skip_tables', table_ids: [202] }, [101]], + ])('filters copy targets for $0', (config, expectedIds) => { + expect(getTableCopyTargets(tables, config).map(({ id }) => id)).toEqual(expectedIds) + }) +}) + +describe('summarizeTableCopyEstimate', () => { + const estimates = [ + { + schema: 'public', + name: 'orders.v2', + estimated_bytes: 100, + estimated_cost: 1, + is_row_filtered: false, + }, + { + schema: 'billing', + name: 'orders.v2', + estimated_bytes: 200, + estimated_cost: 2, + is_row_filtered: true, + }, + ] + + it('matches schema-qualified identities without dot-delimited collisions', () => { + expect( + summarizeTableCopyEstimate(estimates, [{ schema: 'billing', name: 'orders.v2' }]) + ).toMatchObject({ + isComplete: true, + estimatedBytes: 200, + estimatedCost: 2, + hasRowFilteredTables: true, + tables: [estimates[1]], + }) + }) + + it('marks an estimate incomplete instead of presenting a partial total', () => { + expect( + summarizeTableCopyEstimate(estimates, [ + { schema: 'public', name: 'orders.v2' }, + { schema: 'missing', name: 'table' }, + ]) + ).toMatchObject({ + isComplete: false, + estimatedBytes: 100, + estimatedCost: 1, + }) + }) + + it('returns a complete zero-cost summary when no table will be copied', () => { + expect(summarizeTableCopyEstimate(estimates, [])).toEqual({ + isComplete: true, + tables: [], + estimatedBytes: 0, + estimatedCost: 0, + hasRowFilteredTables: false, + }) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts new file mode 100644 index 00000000000..71fe5b9f0e1 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/TableSyncCopy.utils.ts @@ -0,0 +1,66 @@ +export type TableSyncCopyConfig = + | { type: 'include_all_tables' } + | { type: 'skip_all_tables' } + | { type: 'include_tables'; table_ids: number[] } + | { type: 'skip_tables'; table_ids: number[] } + +export type ReplicationTableIdentity = { + id: number + schema: string + name: string +} + +export type TableCopyEstimate = { + schema: string + name: string + estimated_bytes: number + estimated_cost: number + is_row_filtered: boolean +} + +export const shouldCopyTable = ( + config: TableSyncCopyConfig | null | undefined, + tableId: number +) => { + const resolvedConfig: TableSyncCopyConfig = config ?? { type: 'include_all_tables' } + + switch (resolvedConfig.type) { + case 'include_all_tables': + return true + case 'skip_all_tables': + return false + case 'include_tables': + return resolvedConfig.table_ids.includes(tableId) + case 'skip_tables': + return !resolvedConfig.table_ids.includes(tableId) + } +} + +export const getTableCopyTargets = ( + tables: readonly T[], + config: TableSyncCopyConfig | null | undefined +) => tables.filter(({ id }) => shouldCopyTable(config, id)) + +const getTableIdentityKey = ({ schema, name }: { schema: string; name: string }) => + JSON.stringify([schema, name]) + +export const summarizeTableCopyEstimate = ( + estimates: readonly TableCopyEstimate[], + targets: readonly Pick[] +) => { + const estimatesByIdentity = new Map( + estimates.map((estimate) => [getTableIdentityKey(estimate), estimate]) + ) + const matchedTables = targets.flatMap((target) => { + const estimate = estimatesByIdentity.get(getTableIdentityKey(target)) + return estimate === undefined ? [] : [estimate] + }) + + return { + isComplete: matchedTables.length === targets.length, + tables: matchedTables, + estimatedBytes: matchedTables.reduce((total, table) => total + table.estimated_bytes, 0), + estimatedCost: matchedTables.reduce((total, table) => total + table.estimated_cost, 0), + hasRowFilteredTables: matchedTables.some((table) => table.is_row_filtered), + } +} diff --git a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts index 3f9c695be9f..b53d9320a4b 100644 --- a/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts +++ b/apps/studio/components/interfaces/Database/Replication/useIsETLPrivateAlpha.ts @@ -39,16 +39,22 @@ export const useIsETLSnowflakePrivateAlpha = () => { return useIsCurrentOrgInFlagList('etlEnableSnowflakePrivateAlpha') } +export const useIsETLClickHousePrivateAlpha = () => { + return useIsCurrentOrgInFlagList('etlEnableClickHousePrivateAlpha') +} + export const useIsETLPrivateAlpha = () => { const hasAccessToETLBigQuery = useIsCurrentOrgInFlagList('etlEnableBigQueryPrivateAlpha') const hasAccessToETLIceberg = useIsCurrentOrgInFlagList('etlEnableIcebergPrivateAlpha') const hasAccessToETLDucklake = useIsCurrentOrgInFlagList('etlEnableDucklakePrivateAlpha') const hasAccessToETLSnowflake = useIsCurrentOrgInFlagList('etlEnableSnowflakePrivateAlpha') + const hasAccessToETLClickHouse = useIsCurrentOrgInFlagList('etlEnableClickHousePrivateAlpha') return ( hasAccessToETLBigQuery || hasAccessToETLIceberg || hasAccessToETLDucklake || - hasAccessToETLSnowflake + hasAccessToETLSnowflake || + hasAccessToETLClickHouse ) } diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx index 40da5f66a2a..30319bc6fd3 100644 --- a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx +++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx @@ -3,7 +3,7 @@ import { Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { Alert, AlertDescription, AlertTitle, Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { PreviousRestoreItem } from './PreviousRestoreItem' @@ -14,6 +14,7 @@ import { CreateNewProjectDialog } from '@/components/interfaces/Database/Backups import { projectSpecToMonthlyPrice } from '@/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils' import { DiskType } from '@/components/interfaces/DiskManagement/ui/DiskManagement.constants' import { Markdown } from '@/components/interfaces/Markdown' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { AlertError } from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import { NoPermission } from '@/components/ui/NoPermission' @@ -158,7 +159,7 @@ export const RestoreToNewProject = () => { diff --git a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx index 68f082b276c..21937060ab0 100644 --- a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx @@ -117,6 +117,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
      + ) : ( + + ) + } + /> + { + form.setValue('totalSize', suggestedDiskSizeForCustomIops, { + shouldDirty: true, + shouldValidate: true, + }) + }} + > + Increase to {suggestedDiskSizeForCustomIops} GB + + ) : undefined + } + /> + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 742568c4b5c..b019b69a5f7 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -2,53 +2,40 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { toast } from 'sonner' +import { Button, cn, Form } from 'ui' +import { PageContainer } from 'ui-patterns/PageContainer' import { - Button, - cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - DialogSectionSeparator, - Form, - Separator, -} from 'ui' -import { Admonition } from 'ui-patterns/admonition' + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' -import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge' +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' import { DiskManagementMessage } from './DiskManagement.types' import { calculateDiskSizeRequiredForIopsWithGp3, mapComputeSizeNameToAddonVariantId, } from './DiskManagement.utils' +import { AdvancedSection, ComputeSection, DiskSection } from './DiskManagementForm.sections' import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection' import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog' -import { AutoScaleFields } from './fields/AutoScaleFields' -import { ComputeSizeField } from './fields/ComputeSizeField' -import { DiskSizeField } from './fields/DiskSizeField' -import { IOPSField } from './fields/IOPSField' -import { StorageTypeField } from './fields/StorageTypeField' -import { ThroughputField } from './fields/ThroughputField' -import { DiskCountdownRadial } from './ui/DiskCountdownRadial' +import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks' +import { BillingChangeBadge } from './ui/BillingChangeBadge' import { DISK_LIMITS, DiskType, PLAN_DETAILS, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, } from './ui/DiskManagement.constants' -import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' -import { - MAX_WIDTH_CLASSES, - PADDING_CLASSES, - ScaffoldContainer, -} from '@/components/layouts/Scaffold' -import { DocsButton } from '@/components/ui/DocsButton' -import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' +import { NoticeBar } from './ui/NoticeBar' +import { PADDING_CLASSES } from '@/components/layouts/Scaffold' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useDiskAttributesQuery, @@ -73,15 +60,18 @@ import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery, } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL, GB, PROJECT_STATUS } from '@/lib/constants' +import { GB, PROJECT_STATUS } from '@/lib/constants' -export function DiskManagementForm() { +export function DiskManagementForm({ chartsClassName }: { chartsClassName?: string } = {}) { const { ref: projectRef } = useParams() const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { setProjectStatus } = useSetProjectStatus() - const advancedSettingsRef = useRef(null) + const autoscaleSettingsRef = useRef(null) + const storageSettingsRef = useRef(null) + const computeSettingsRef = useRef(null) + const diskSizeSettingsRef = useRef(null) const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -110,7 +100,6 @@ export function DiskManagementForm() { const [isDialogOpen, setIsDialogOpen] = useState(false) const [refetchInterval, setRefetchInterval] = useState(false) const [message, setMessageState] = useState(null) - const [advancedSettingsOpen, setAdvancedSettingsOpenState] = useState(false) const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef }) const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery( @@ -169,7 +158,7 @@ export function DiskManagementForm() { reValidateMode: 'onChange', }) - const { computeSize: modifiedComputeSize } = form.watch() + const modifiedComputeSize = useWatch({ control: form.control, name: 'computeSize' }) const isSuccess = isAddonsSuccess && @@ -183,6 +172,18 @@ export function DiskManagementForm() { const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) const isPlanUpgradeRequired = !hasAccess + const { + computeSizePrice, + diskSizePrice, + totalBeforePrice, + totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, + } = useDiskManagementReviewChanges(form, readReplicas.length) + const { formState } = form const errors = formState.errors const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 @@ -194,8 +195,8 @@ export function DiskManagementForm() { !isSpendCapEnabled && RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize) - const watchedTotalSize = form.watch('totalSize') ?? 0 - const watchedStorageType = form.watch('storageType') + const watchedTotalSize = useWatch({ control: form.control, name: 'totalSize' }) ?? 0 + const watchedStorageType = useWatch({ control: form.control, name: 'storageType' }) // Minimum disk size where the platform API will accept an IOPS payload (500 IOPS/GB rule). const minDiskSizeForCustomIops = calculateDiskSizeRequiredForIopsWithGp3( DISK_LIMITS[DiskType.GP3].minIops @@ -301,9 +302,10 @@ export function DiskManagementForm() { setIsDialogOpen(false) form.reset(data as DiskStorageSchemaType) - toast.success( - `Successfully updated disk settings!${willUpdateDiskConfiguration ? ' The requested changes will be applied to your disk shortly.' : ''}` - ) + // Disk resizes get their own completion toast once polling confirms it's applied + if (!willUpdateDiskConfiguration) { + toast.success('Successfully updated disk settings!') + } } catch (error: unknown) { setMessageState({ message: error instanceof Error ? error.message : 'An unknown error occurred', @@ -345,7 +347,7 @@ export function DiskManagementForm() { form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) } } - }, [modifiedComputeSize, isDialogOpen, project]) + }, [modifiedComputeSize, form, isDialogOpen, project]) useEffect(() => { // Initialize field values properly when data has been loaded, preserving any user changes @@ -357,287 +359,172 @@ export function DiskManagementForm() { useEffect(() => { const fieldErrors = Object.keys(errors) - if (fieldErrors.length > 0) { - if ( - fieldErrors.includes('throughput') || - fieldErrors.includes('provisionedIOPS') || - fieldErrors.includes('maxSizeGb') - ) { - setAdvancedSettingsOpenState(true) + if (fieldErrors.length === 0) return - // [Joshen] The timeout is to let the collapsible open prior to scrolling - const timeoutId = setTimeout(() => { - advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 100) + const scrollTargets = [ + { + hasError: + fieldErrors.includes('maxSizeGb') || + fieldErrors.includes('growthPercent') || + fieldErrors.includes('minIncrementGb'), + ref: autoscaleSettingsRef, + }, + { + hasError: fieldErrors.includes('throughput') || fieldErrors.includes('provisionedIOPS'), + ref: storageSettingsRef, + }, + { hasError: fieldErrors.includes('totalSize'), ref: diskSizeSettingsRef }, + { hasError: fieldErrors.includes('computeSize'), ref: computeSettingsRef }, + ] + const scrollTarget = scrollTargets.find(({ hasError }) => hasError)?.ref ?? null - return () => clearTimeout(timeoutId) - } - } + if (!scrollTarget) return + + const timeoutId = setTimeout(() => { + scrollTarget.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + + return () => clearTimeout(timeoutId) }, [errors]) return ( - <> - - {isEntitlementsLoaded && isPlanUpgradeRequired && ( - - )} +
      + + + + + + + - {(isProjectResizing || - isProjectRequestingDiskChanges || - (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( -
      - - {isProjectRequestingDiskChanges && ( - + + - )} - {isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions && ( - +
      + )} + + + + + Scaling + + + {isEntitlementsLoaded && isPlanUpgradeRequired && ( + )} -
    - )} + + - - + - - - - - - {isDiskNoticeVisible && } - - - -
    - {isDiskNoticeVisible && ( - )} - {isAws && ( - <> -
    - - {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( - - - - )} - {isReadOnlyMode && ( - - - - )} -
    + + + - - - )} -
    - - {isAws && ( - <> - - - setAdvancedSettingsOpenState((prev) => !prev)} - > - -
    - Advanced disk settings - - Specify additional settings for your disk, including autoscaling - configuration, IOPS, throughput, and disk type. - -
    - -
    - -
    -
    - -
    - -
    - {!!disableIopsThroughputConfig && ( - { - form.setValue('computeSize', 'ci_large') - }} - > - Change to LARGE Compute - - ) : ( - - ) - } - /> - )} - {isDiskTooSmallForCustomIops && - !disableIopsThroughputConfig && - !disableDiskInputs && ( - { - form.setValue('totalSize', suggestedDiskSizeForCustomIops, { - shouldDirty: true, - shouldValidate: true, - }) - }} - > - Increase to {suggestedDiskSizeForCustomIops} GB - - ) : undefined - } - /> - )} - - - -
    -
    -
    -
    - - )} -
    - - - {isDirty ? ( - + {isDirty ? ( + +
    -
    + - -
    - - ) : null} - - - - + Cancel + + +
    +
    + ) : null} +
    + + ) } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 8257d48faed..724da3d3ec6 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { PageSection, PageSectionContent, @@ -11,6 +11,7 @@ import { } from 'ui-patterns/PageSection' import { DocsButton } from '../../ui/DocsButton' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { DOCS_URL } from '@/lib/constants' // [Joshen] Only used for non AWS projects @@ -30,12 +31,10 @@ export function DiskManagementPanelForm() { type="default" layout="responsive" title="Disk Management has moved" - description="Disk configuration is now managed alongside Project Compute on the new Compute and Disk page." + description="Disk configuration is now managed alongside Project Compute on the Infrastructure page." actions={ } /> diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts new file mode 100644 index 00000000000..9c9edcfbf1f --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest' + +import { shouldShowComputeBillingBadge } from './DiskManagementReviewAndSubmitDialog.hooks' + +describe('shouldShowComputeBillingBadge', () => { + test('shows for a dirty, valid compute price change', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(true) + }) + + test('hides when the selected compute size has the same price', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '10', + }) + ).toBe(false) + }) + + test('hides when the form is not dirty', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: false, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(false) + }) + + test('hides when compute size validation fails', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: true, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts index a61cd844209..56db829918d 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { UseFormReturn } from 'react-hook-form' +import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form' import { DiskStorageSchemaType } from '../DiskManagement.schema' import { ComputeInstanceAddonVariantId } from '../DiskManagement.types' @@ -26,10 +26,48 @@ const COMPUTE_SIZES_BELOW_LARGE: Array = [ 'ci_medium', ] +export function shouldShowComputeBillingBadge({ + isDirty, + hasComputeSizeError, + oldPrice, + newPrice, +}: { + isDirty: boolean + hasComputeSizeError: boolean + oldPrice: string | number + newPrice: string | number +}) { + return isDirty && !hasComputeSizeError && Number(oldPrice) !== Number(newPrice) +} + export function useDiskManagementReviewChanges( form: UseFormReturn, numReplicas: number ) { + const [ + computeSize, + totalSize, + storageType, + provisionedIOPS, + throughput, + growthPercent, + minIncrementGb, + maxSizeGb, + ] = useWatch({ + control: form.control, + name: [ + 'computeSize', + 'totalSize', + 'storageType', + 'provisionedIOPS', + 'throughput', + 'growthPercent', + 'minIncrementGb', + 'maxSizeGb', + ], + }) + const { isDirty, errors, defaultValues } = useFormState({ control: form.control }) + const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const isAwsNimbus = useIsAwsNimbusCloudProvider() @@ -48,29 +86,29 @@ export function useDiskManagementReviewChanges( const computeSizePrice = calculateComputeSizePrice({ availableOptions, - oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro', - newComputeSize: form.getValues('computeSize'), + oldComputeSize: defaultValues?.computeSize || 'ci_micro', + newComputeSize: computeSize, plan: planId, }) const diskSizePrice = calculateDiskSizePrice({ planId, - oldSize: form.formState.defaultValues?.totalSize || 0, - oldStorageType: form.formState.defaultValues?.storageType as DiskType, - newSize: form.getValues('totalSize'), - newStorageType: form.getValues('storageType') as DiskType, + oldSize: defaultValues?.totalSize || 0, + oldStorageType: defaultValues?.storageType as DiskType, + newSize: totalSize, + newStorageType: storageType as DiskType, numReplicas, }) const iopsPrice = calculateIOPSPrice({ - oldStorageType: form.formState.defaultValues?.storageType as DiskType, - oldProvisionedIOPS: form.formState.defaultValues?.provisionedIOPS || 0, - newStorageType: form.getValues('storageType') as DiskType, - newProvisionedIOPS: form.getValues('provisionedIOPS'), + oldStorageType: defaultValues?.storageType as DiskType, + oldProvisionedIOPS: defaultValues?.provisionedIOPS || 0, + newStorageType: storageType as DiskType, + newProvisionedIOPS: provisionedIOPS, numReplicas, }) const throughputPrice = calculateThroughputPrice({ - storageType: form.getValues('storageType') as DiskType, - newThroughput: form.getValues('throughput') || 0, - oldThroughput: form.formState.defaultValues?.throughput || 0, + storageType: storageType as DiskType, + newThroughput: throughput || 0, + oldThroughput: defaultValues?.throughput || 0, numReplicas, }) @@ -86,49 +124,55 @@ export function useDiskManagementReviewChanges( Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + const advancedBeforePrice = Number(iopsPrice.oldPrice) + Number(throughputPrice.oldPrice) + const advancedAfterPrice = Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + + const showComputeBillingBadge = shouldShowComputeBillingBadge({ + isDirty, + hasComputeSizeError: !!errors.computeSize, + oldPrice: computeSizePrice.oldPrice, + newPrice: computeSizePrice.newPrice, + }) + + const showDiskBillingBadge = + isDirty && + !errors.totalSize && + Number(diskSizePrice.oldPrice) !== Number(diskSizePrice.newPrice) + + const showAdvancedBillingBadge = + isDirty && + advancedBeforePrice !== advancedAfterPrice && + !errors.provisionedIOPS && + !errors.throughput + // --- Change flags --- - const hasComputeChanges = - form.formState.defaultValues?.computeSize !== form.getValues('computeSize') + const hasComputeChanges = defaultValues?.computeSize !== computeSize const hasTotalSizeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.totalSize !== form.getValues('totalSize') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.totalSize !== totalSize const hasStorageTypeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.storageType !== form.getValues('storageType') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.storageType !== storageType const hasThroughputChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.throughput !== form.getValues('throughput') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.throughput !== throughput const hasIOPSChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.provisionedIOPS !== provisionedIOPS const hasGrowthPercentChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.growthPercent !== growthPercent const hasMinIncrementChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.minIncrementGb !== minIncrementGb const hasMaxSizeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.maxSizeGb !== maxSizeGb // --- Derived predicates --- - const storageTypeAfter = form.getValues('storageType') as DiskType + const storageTypeAfter = storageType as DiskType // Show hero whenever any line-item price actually changes, not just compute const anyBillableDiskChange = @@ -143,11 +187,9 @@ export function useDiskManagementReviewChanges( const hasExtendedDowntimeRisk = hasComputeChanges && (COMPUTE_SIZES_BELOW_LARGE.includes( - (form.formState.defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId + (defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId ) || - COMPUTE_SIZES_BELOW_LARGE.includes( - form.getValues('computeSize') as ComputeInstanceAddonVariantId - )) + COMPUTE_SIZES_BELOW_LARGE.includes(computeSize as ComputeInstanceAddonVariantId)) // Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is // derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so @@ -171,10 +213,8 @@ export function useDiskManagementReviewChanges( // --- Labels --- - const oldComputeLabel = mapAddOnVariantIdToComputeSize( - form.formState.defaultValues?.computeSize ?? 'ci_nano' - ) - const newComputeLabel = mapAddOnVariantIdToComputeSize(form.getValues('computeSize')) + const oldComputeLabel = mapAddOnVariantIdToComputeSize(defaultValues?.computeSize ?? 'ci_nano') + const newComputeLabel = mapAddOnVariantIdToComputeSize(computeSize) return { // prices @@ -184,6 +224,11 @@ export function useDiskManagementReviewChanges( throughputPrice, totalBeforePrice, totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, // change flags hasComputeChanges, hasTotalSizeChanges, diff --git a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx index 08aecb8389d..d5651c249df 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx @@ -1,5 +1,5 @@ import { useParams } from 'common' -import { UseFormReturn } from 'react-hook-form' +import { UseFormReturn, useWatch } from 'react-hook-form' import { FormControl, FormField, @@ -8,7 +8,7 @@ import { InputGroupAddon, InputGroupText, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' @@ -26,7 +26,10 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => { setValue, formState: { errors }, } = form - const { totalSize, growthPercent, maxSizeGb, minIncrementGb } = form.watch() + const [totalSize, growthPercent, maxSizeGb, minIncrementGb] = useWatch({ + control, + name: ['totalSize', 'growthPercent', 'maxSizeGb', 'minIncrementGb'], + }) const { isError } = useDiskAutoscaleCustomConfigQuery({ projectRef }) @@ -55,7 +58,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => { render={({ field }) => { return ( { { render={({ field }) => { return ( { { render={({ field }) => { return ( { Hardware resources allocated to your Postgres database

    +} + +type ComputeSectionBillingBadgeProps = { + form: UseFormReturn + show: boolean + beforePrice: number + afterPrice: number +} + +export function ComputeSectionBillingBadge({ + form, + show, + beforePrice, + afterPrice, +}: ComputeSectionBillingBadgeProps) { + const computeSize = useWatch({ control: form.control, name: 'computeSize' }) + const { showMicroUpgradeBadge } = useShowMicroUpgradeBadge() + + return ( + + ) +} + export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() - const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() - - const { hasAccess: entitledUpdateCompute, isLoading: isEntitlementLoading } = - useCheckEntitlements('instances.compute_update_available_sizes') + const { project, isProjectLoading, isEntitlementLoading, showMicroUpgradeBadge } = + useShowMicroUpgradeBadge() const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price') - const { computeSize } = form.watch() - const { data: addons, isPending: isAddonsLoading, @@ -68,7 +86,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading - const { control, formState, setValue, trigger } = form + const { control, setValue, trigger } = form const availableAddons = useMemo(() => { return addons?.available_addons ?? [] @@ -82,324 +100,239 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { return getAvailableComputeOptions(availableAddons, project?.cloud_provider) }, [availableAddons, project?.cloud_provider]) - // Expand by default if the project's current compute size is beyond the initial visible set - const [showAllSizes, setShowAllSizes] = useState(() => { - const idx = availableOptions.findIndex((o) => o.identifier === computeSize) - return idx >= INITIALLY_VISIBLE_COUNT - }) - - // Expand whenever the selected size falls outside the visible set — covers both initial data - // load (availableOptions starts empty) and computeSize changes after mount (e.g. form reset) - useEffect(() => { - const idx = availableOptions.findIndex((o) => o.identifier === computeSize) - if (idx >= INITIALLY_VISIBLE_COUNT) { - setShowAllSizes(true) - } - }, [computeSize, availableOptions]) - const subscriptionPitr = addons?.selected_addons.find((addon) => addon.type === 'pitr') - const computeSizePrice = calculateComputeSizePrice({ - availableOptions: availableOptions, - oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro', - newComputeSize: form.getValues('computeSize'), - plan: org?.plan.id ?? 'free', - }) - - const projectComputeSize = project?.infra_compute_size ?? 'nano' - const showUpgradeBadge = entitledUpdateCompute && projectComputeSize === 'nano' - - const selectedOptionIndex = availableOptions.findIndex((o) => o.identifier === computeSize) - const selectedOptionIsHidden = selectedOptionIndex >= INITIALLY_VISIBLE_COUNT - - // Always show all options if the selected one would be outside the visible slice, - // so the active card is never hidden from the user. - const visibleOptions = - showAllSizes || selectedOptionIsHidden - ? availableOptions - : availableOptions.slice(0, INITIALLY_VISIBLE_COUNT) - const hasHiddenOptions = availableOptions.length > INITIALLY_VISIBLE_COUNT + const showSkeletons = isLoading + const showLoadError = !isLoading && !!addonsError + const showComputeOptions = !isLoading && !addonsError return ( ( - { - setValue('computeSize', value, { - shouldDirty: true, - shouldValidate: true, - }) - trigger('provisionedIOPS') - trigger('throughput') - }} - defaultValue={field.value} - disabled={disabled} - > - - -

    - Hardware resources allocated to your Postgres database -

    - -
    - -
    - - {showUpgradeBadge && form.watch('computeSize') === 'ci_nano' && ( - - )} - - } +
    + { + setValue('computeSize', value, { + shouldDirty: true, + shouldValidate: true, + }) + trigger('provisionedIOPS') + trigger('throughput') + }} + defaultValue={field.value} + disabled={disabled} + className={cn( + !addonsError && 'grid grid-cols-2 gap-4 @[680px]:grid-cols-3 @[900px]:grid-cols-4' + )} > -
    - {isLoading ? ( - Array(INITIALLY_VISIBLE_COUNT) - .fill(0) - .map((_, i) => ) - ) : addonsError ? ( - -

    {addonsError?.message}

    -
    - ) : ( - <> - {visibleOptions.map((compute) => { - const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider) + {showSkeletons && + Array(SKELETON_PLACEHOLDER_COUNT) + .fill(0) + .map((_, i) => )} + {showLoadError && ( + +

    {addonsError?.message}

    +
    + )} + {showComputeOptions && ( + <> + {availableOptions.map((compute) => { + const lockedMicroDueToPITR = + compute.identifier === 'ci_micro' && !!subscriptionPitr + const lockedNanoDueToPlan = + org?.plan.id !== 'free' && + project?.infra_compute_size !== 'nano' && + compute.identifier === 'ci_nano' - const lockedMicroDueToPITR = - compute.identifier === 'ci_micro' && !!subscriptionPitr - const lockedNanoDueToPlan = - org?.plan.id !== 'free' && - project?.infra_compute_size !== 'nano' && - compute.identifier === 'ci_nano' + const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR - const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR + // Nano on a paid plan is billed at the Micro rate + const isNanoBilledAsMicro = + org?.plan.id !== 'free' && + project?.infra_compute_size === 'nano' && + compute.identifier === 'ci_nano' - const price = - org?.plan.id !== 'free' && - project?.infra_compute_size === 'nano' && - compute.identifier === 'ci_nano' - ? availableOptions.find( - (option: ComputeAddonVariant) => option.identifier === 'ci_micro' - )?.price - : compute.price + const price = isNanoBilledAsMicro + ? availableOptions.find( + (option: ComputeAddonVariant) => option.identifier === 'ci_micro' + )?.price + : compute.price - const cpuLabel = (() => { - const cpuCores = compute.meta?.cpu_cores - if (typeof cpuCores === 'number') { - return `${cpuCores}-core ${cpuArchitecture} CPU` - } - if (cpuCores) { - return `${cpuCores} CPU` - } - return 'CPU' - })() + const cpuLabel = (() => { + const cpuCores = compute.meta?.cpu_cores + if (typeof cpuCores === 'number') { + return `${cpuCores}-core CPU` + } + if (cpuCores) { + return `${cpuCores} CPU` + } + return 'CPU' + })() - return ( - - -
    - {showUpgradeBadge && compute.identifier === 'ci_micro' && ( -
    - No additional charge -
    - )} -
    -
    - -
    - {lockedOption ? ( -
    - -
    - ) : ( - showComputePrice && ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - - ) - )} -
    -
    - -
    -
    -
    - - - {compute.identifier === 'ci_nano' && 'Up to '} - {compute.meta?.memory_gb ?? 0} GB memory - -
    -
    - - {cpuLabel} -
    -
    -
    -
    -
    -
    - {lockedMicroDueToPITR && ( - - Project has PITR enabled which requires a minimum of Small compute. - Please{' '} - - disable PITR - {' '} - first before selecting Micro - - )} - - } - /> - ) - })} - - {showAllSizes && ( + return ( e.preventDefault()} + id={compute.identifier} + key={compute.identifier} + value={compute.identifier} className={cn( - 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]' + 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]', + lockedOption && 'opacity-50' )} + disabled={disabled || lockedOption} label={ - -
    -
    - + + +
    + {showMicroUpgradeBadge && compute.identifier === 'ci_micro' && ( + + +
    e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + Free Upgrade +
    +
    + e.stopPropagation()} + > +

    + Upgrade to Micro Compute +

    +

    + This Project is already paying for Micro Compute. You can + upgrade to Micro Compute at any time when convenient. +

    +
    +
    + )} +
    +
    + +
    + {lockedOption && ( +
    + +
    + )} + {!lockedOption && showComputePrice && ( + <> + + ${price} + + + {' '} + /{' '} + {compute.price_interval === 'monthly' ? 'month' : 'hour'} + + + )} +
    +
    -
    - Contact Us -
    -
    -
    -
    -
    - - Custom memory -
    -
    - - Custom CPU +
    +
    +
    + + + {compute.identifier === 'ci_nano' && 'Up to '} + {compute.meta?.memory_gb ?? 0} GB memory + +
    +
    + + {cpuLabel} +
    +
    -
    - + + {lockedMicroDueToPITR && ( + + Project has PITR enabled which requires a minimum of Small compute. + Please{' '} + + disable PITR + {' '} + first before selecting Micro + + )} + } /> + ) + })} +
    - )} -
    + > + +
    +
    + - {!isLoading && !addonsError && hasHiddenOptions && ( - +
    + Contact Us +
    +
    +
    +
    +
    + + Custom memory +
    +
    + + Custom CPU +
    +
    +
    +
    +
    +
    + )} - - + +
    )} /> ) diff --git a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx index df65d74b239..8b4342b999f 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import dayjs from 'dayjs' import { RotateCcw } from 'lucide-react' -import { UseFormReturn } from 'react-hook-form' +import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form' import { Button, FormControl, @@ -15,33 +15,24 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' -import { calculateDiskSizePrice } from '../DiskManagement.utils' -import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants' import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas' -import { DiskSpaceBar } from '../ui/DiskSpaceBar' import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection' import FormMessage from '../ui/FormMessage' -import { DocsButton } from '@/components/ui/DocsButton' import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL, GB } from '@/lib/constants' +import { GB } from '@/lib/constants' type DiskSizeFieldProps = { form: UseFormReturn disableInput: boolean - setAdvancedSettingsOpenState: (state: boolean) => void } -export function DiskSizeField({ - form, - disableInput, - setAdvancedSettingsOpenState, -}: DiskSizeFieldProps) { +export function DiskSizeField({ form, disableInput }: DiskSizeFieldProps) { const { ref: projectRef } = useParams() - const { control, formState, setValue, trigger, getValues, resetField, watch } = form + const { control, setValue, trigger, resetField } = form const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() @@ -70,8 +61,8 @@ export function DiskSizeField({ dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < 10 || project?.status === 'COMING_UP' - const watchedStorageType = watch('storageType') - const watchedTotalSize = watch('totalSize') + const watchedStorageType = useWatch({ control, name: 'storageType' }) + const watchedTotalSize = useWatch({ control, name: 'totalSize' }) const planId = org?.plan.id ?? 'free' @@ -79,125 +70,115 @@ export function DiskSizeField({ PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} const includedDiskGB = includedDiskGBMeta[watchedStorageType] - const { defaultValues, dirtyFields, isDirty, errors } = formState - const diskSizePrice = calculateDiskSizePrice({ - planId, - oldSize: defaultValues?.totalSize || 0, - oldStorageType: defaultValues?.storageType as DiskType, - newSize: getValues('totalSize'), - newStorageType: getValues('storageType') as DiskType, - }) + const { defaultValues, dirtyFields } = useFormState({ control }) const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 return ( -
    -
    - ( - - - - e.currentTarget.blur()} - onChange={(e) => { - setValue('totalSize', e.target.valueAsNumber, { - shouldDirty: true, - shouldValidate: true, - }) + ( + + {includedDiskGB > 0 && org?.plan.id && ( +

    + Your plan includes up to {includedDiskGB} GB of {watchedStorageType} storage. +

    + )} + + { + setValue('storageType', 'io2', { shouldDirty: true }) + trigger('provisionedIOPS') + trigger('totalSize') + }} + > + Change to High Performance SSD + + } + /> + + {isProjectNew ? ( + + ) : ( + error && ( + + {error?.message} + + ) + )} + + +
    + } + > + + + e.currentTarget.blur()} + onChange={(e) => { + // valueAsNumber is NaN while the input is empty, which would otherwise + // propagate into the price calculations and the read replica sizing + const value = e.target.valueAsNumber + setValue('totalSize', Number.isNaN(value) ? 0 : value, { + shouldDirty: true, + shouldValidate: true, + }) + trigger('provisionedIOPS') + trigger('throughput') + }} + min={includedDiskGB} + /> + + GB + {isDirty ? ( + { + resetField('totalSize') trigger('provisionedIOPS') trigger('throughput') }} - min={includedDiskGB} - /> - - GB - {isDirty ? ( - { - resetField('totalSize') - trigger('provisionedIOPS') - }} - title="Reset" - > - - ) : null} - - - - - )} - /> -
    - - - {includedDiskGB > 0 && - org?.plan.id && - `Your plan includes up to ${includedDiskGB} GB of ${watchedStorageType} storage.`} - -
    - -
    -
    - { - setValue('storageType', 'io2') - trigger('provisionedIOPS') - trigger('totalSize') - setAdvancedSettingsOpenState(true) - }} - > - Change to High Performance SSD - - } - /> -
    -
    -
    - - - {isProjectNew ? ( - - ) : ( - error && ( - - {error?.message} - - ) - )} - - -
    -
    + title="Reset" + > +
    +
    + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx index f023019118d..43a7f76b405 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.Header.tsx @@ -3,6 +3,7 @@ import type { KeyboardEvent, Ref } from 'react' import { Button } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' +import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton' import { onSearchInputEscape } from '@/lib/keyboard' interface CronJobsTabHeaderProps { @@ -59,6 +60,7 @@ export const CronJobsTabHeader = ({ />
    + diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx index a58c636f2e4..931be8d92ee 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx @@ -1,4 +1,4 @@ -import { UseFormReturn } from 'react-hook-form' +import { UseFormReturn, useWatch } from 'react-hook-form' import { FormField, SheetSection } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -11,7 +11,7 @@ interface SqlFunctionSectionProps { } export const SqlFunctionSection = ({ form }: SqlFunctionSectionProps) => { - const schema = form.watch('values.schema') + const schema = useWatch({ control: form.control, name: 'values.schema' }) return ( diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx index db601d2f0c5..685d90f948a 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApiURLSettings.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { AlertCircle } from 'lucide-react' import { Alert, AlertTitle, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx index 93a6b91e333..224f4cdb711 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/IntrospectionDisabledNotice.tsx @@ -2,7 +2,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { ChevronDown, ChevronUp } from 'lucide-react' import { useState } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { PG_GRAPHQL_CONFIG_DOCS_URL } from './constants' import { IntrospectionConfirmModal } from './IntrospectionConfirmModal' diff --git a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx index 39e3e2aab3f..48047caf069 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx @@ -1,7 +1,7 @@ import { Settings, Trash2, TriangleAlert } from 'lucide-react' import Link from 'next/link' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { type ResourceGroup } from './MarketplaceIntegrationSettingsTab.types' import { type ConnectedResource } from '@/components/interfaces/Integrations/Landing/Landing.utils' diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx index 805ba873c3a..68dd6a4a79f 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.test.tsx @@ -77,7 +77,7 @@ describe('IntegrationOverviewTab', () => { customRender( Enable webhooks} + actions={} /> ) @@ -87,7 +87,9 @@ describe('IntegrationOverviewTab', () => { }) it('disables actions when extensions are uninstalled and hideRequiredExtensionsSection is false', () => { - customRender(Enable integration} />) + customRender( + Enable integration} /> + ) const actionsArea = screen.getByText('Enable integration').closest('[aria-disabled]') expect(actionsArea).toHaveAttribute('aria-disabled', 'true') diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/AdvancedSettings.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/AdvancedSettings.tsx index 8913a641fe7..a488cf484c9 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/AdvancedSettings.tsx @@ -1,4 +1,4 @@ -import { type Dispatch, type SetStateAction } from 'react' +import { useMemo, type Dispatch, type SetStateAction } from 'react' import { Accordion, AccordionContent, @@ -21,6 +21,7 @@ import { type ExtensionsSchema, type InstallIntegrationSheetProps } from './Inst import { extensionsWithRecommendedSchemas } from '@/components/interfaces/Database/Extensions/Extensions.constants' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' +import { useSchemasFilteredForHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProtectedSchemas } from '@/hooks/useProtectedSchemas' @@ -48,8 +49,14 @@ export const AdvancedSettings = ({ { projectRef: project?.ref, connectionString: project?.connectionString }, { enabled: involvesExtensions } ) - const availableSchemas = schemas.filter( - (schema) => !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name) + const visibleSchemas = useSchemasFilteredForHighAvailability(schemas) + const availableSchemas = useMemo( + () => + visibleSchemas.filter( + (schema) => + !protectedSchemas.some((protectedSchema) => protectedSchema.name === schema.name) + ), + [visibleSchemas, protectedSchemas] ) return ( diff --git a/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx b/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx index 5f201f69ebe..42f3529de91 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/LegacyIntegrationPage.tsx @@ -9,7 +9,7 @@ import { NavMenu, NavMenuItem, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageHeader, diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx index b156d96ad94..105b7ac30e4 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx @@ -58,6 +58,8 @@ export const MarkdownContent = ({ {supportExpanding && (
    + )}
    ) diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts new file mode 100644 index 00000000000..00cd23ecbf7 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest' + +import { + getValidVercelReturnUrl, + isVercelUrl, +} from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils' + +describe('isVercelUrl', () => { + test('accepts https vercel.com urls', () => { + expect(isVercelUrl('https://vercel.com/callback')).toBe(true) + }) + + test('rejects non-vercel and invalid urls', () => { + expect(isVercelUrl('https://example.com')).toBe(false) + expect(isVercelUrl('http://vercel.com')).toBe(false) + expect(isVercelUrl('not-a-url')).toBe(false) + }) +}) + +describe('getValidVercelReturnUrl', () => { + test('returns the url when it is a valid vercel return url', () => { + expect(getValidVercelReturnUrl('https://vercel.com/callback')).toBe( + 'https://vercel.com/callback' + ) + }) + + test('returns undefined for missing or invalid next values', () => { + expect(getValidVercelReturnUrl(undefined)).toBeUndefined() + expect(getValidVercelReturnUrl('https://example.com')).toBeUndefined() + expect(getValidVercelReturnUrl('not-a-url')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts index a6802341ab7..05db949804d 100644 --- a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts @@ -10,6 +10,12 @@ export function isVercelUrl(url: string): boolean { } } +/** Returns `next` when it is a safe Vercel return URL; otherwise undefined. */ +export function getValidVercelReturnUrl(next: string | undefined): string | undefined { + if (typeof next === 'string' && isVercelUrl(next)) return next + return undefined +} + export function findVercelIntegrationByConfigurationId( integrations: Integration[] | undefined, configurationId: string | undefined diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial.tsx b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial.tsx index f6c8960261f..6b57a41255b 100644 --- a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial.tsx +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ENV_VAR_RAW_KEYS } from './Integrations-Vercel.constants' import { LogoPair, PartnerLogo, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx index a256f0dd418..2a491957170 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx @@ -1,81 +1,19 @@ -import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { HTMLAttributes, ReactNode, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { - Badge, - Button, - cn, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - Popover, - PopoverContent, - PopoverTrigger, -} from 'ui' -import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' -import ShimmerLine from '@/components/ui/ShimmerLine' import { - IntegrationConnectionsCreateVariables, - IntegrationProjectConnection, -} from '@/data/integrations/integrations.types' + ActionButtons, + ForeignProjectSelector, + Panel, + SupabaseProjectSelector, +} from './ProjectLinkerComponents' +import { Project, ProjectLinkerProps } from './VercelGithub.types' +import ShimmerLine from '@/components/ui/ShimmerLine' import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { BASE_PATH } from '@/lib/constants' -import { openInstallGitHubIntegrationWindow } from '@/lib/github' import { EMPTY_ARR } from '@/lib/void' -interface Project { - name: string - ref: string -} - -export interface ForeignProject { - id: string - name: string - installation_id?: number -} - -const Panel = ({ children, className, ...props }: HTMLAttributes) => { - return ( -
    - {children} -
    - ) -} - -interface ProjectLinkerProps { - slug?: string - organizationIntegrationId?: string - foreignProjects: ForeignProject[] - onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void - installedConnections?: IntegrationProjectConnection[] - isLoading?: boolean - integrationIcon: ReactNode - getForeignProjectIcon?: (project: ForeignProject) => ReactNode - choosePrompt?: string - onSkip?: () => void - loadingForeignProjects?: boolean - showNoEntitiesState?: boolean - defaultSupabaseProject?: Project - defaultForeignProjectId?: string - mode: 'Vercel' | 'GitHub' - variant?: 'default' | 'interstitial' -} - export const ProjectLinker = ({ slug, organizationIntegrationId, @@ -94,9 +32,6 @@ export const ProjectLinker = ({ mode, variant = 'default', }: ProjectLinkerProps) => { - const router = useRouter() - const projectCreationEnabled = useIsFeatureEnabled('projects:create') - const [openProjectsDropdown, setOpenProjectsDropdown] = useState(false) const [openForeignProjectsComboBox, setOpenForeignProjectsComboBox] = useState(false) const [foreignProjectId, setForeignProjectId] = useState( @@ -110,16 +45,6 @@ export const ProjectLinker = ({ }) const numProjects = orgProjects?.pages[0].pagination.count ?? 0 - useEffect(() => { - if (defaultSupabaseProject !== undefined && selectedSupabaseProject === undefined) - setSelectedSupabaseProject(defaultSupabaseProject) - }, [defaultSupabaseProject, selectedSupabaseProject]) - - useEffect(() => { - if (defaultForeignProjectId !== undefined && foreignProjectId === undefined) - setForeignProjectId(defaultForeignProjectId) - }, [defaultForeignProjectId, foreignProjectId]) - // create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..] const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id)) @@ -171,199 +96,15 @@ export const ProjectLinker = ({ !selectedSupabaseProject || !selectedForeignProject - const supabaseProjectSelector = ( - { - setSelectedSupabaseProject(project) - setOpenProjectsDropdown(false) - }} - renderRow={(project) => { - return ( -
    -
    - {variant === 'default' && ( -
    - Supabase -
    - )} -

    {project.name}

    - {project.status === 'INACTIVE' && Paused} - {project.status === 'GOING_DOWN' && Pausing} -
    - {project.ref === selectedSupabaseProject?.ref && } -
    - ) - }} - renderTrigger={() => { - return ( - - ) - }} - renderActions={() => { - return ( - projectCreationEnabled && ( - - { - setOpenProjectsDropdown(false) - router.push(`/new/${selectedOrganization?.slug}`) - }} - onClick={() => setOpenProjectsDropdown(false)} - > - { - setOpenProjectsDropdown(false) - }} - className="w-full flex items-center gap-2" - > - -

    Create a new project

    - -
    -
    - ) - ) - }} - /> - ) + useEffect(() => { + if (defaultSupabaseProject !== undefined && selectedSupabaseProject === undefined) + setSelectedSupabaseProject(defaultSupabaseProject) + }, [defaultSupabaseProject, selectedSupabaseProject]) - const foreignProjectSelector = ( - - -
    - ) : undefined - } - iconRight={ - - - - } - > - - {(selectedForeignProject && selectedForeignProject.name) ?? choosePrompt} - - - - - - - - No results found. - - {foreignProjects.map((project, i) => { - return ( - { - if (project.id) setForeignProjectId(project.id) - setOpenForeignProjectsComboBox(false) - }} - > -
    {getForeignProjectIcon?.(project) ?? integrationIcon}
    - - {project.name} - -
    - ) - })} - {foreignProjects.length === 0 && No results found.} -
    - {mode === 'GitHub' && ( - <> - - - openInstallGitHubIntegrationWindow('install')} - > - - Add GitHub Repositories - - - - )} -
    -
    -
    - - ) - - const actionButtons = ( -
    - - {onSkip !== undefined && ( - - )} -
    - ) + useEffect(() => { + if (defaultForeignProjectId !== undefined && foreignProjectId === undefined) + setForeignProjectId(defaultForeignProjectId) + }, [defaultForeignProjectId, foreignProjectId]) if (variant === 'interstitial') { return ( @@ -376,7 +117,8 @@ export const ProjectLinker = ({ ) : showNoEntitiesState && (noSupabaseProjects || noForeignProjects) ? (
    No {missingEntity} projects found. Create a {missingEntity} project to link to a{' '} - {oppositeMissingEntity} project, or skip and connect later. + {oppositeMissingEntity} project + {onSkip !== undefined ? ', or skip and connect later.' : '.'}
    ) : ( <> @@ -384,19 +126,50 @@ export const ProjectLinker = ({

    Supabase project

    - {supabaseProjectSelector} +

    Vercel project

    - {foreignProjectSelector} +
    )} - {actionButtons} +
    ) } @@ -420,8 +193,12 @@ export const ProjectLinker = ({

    You will need to create a {missingEntity} Project to link to a {oppositeMissingEntity}{' '} Project. -
    - You can skip this and create a Project Connection later. + {onSkip !== undefined && ( + <> +
    + You can skip this and create a Project Connection later. + + )}

    ) : ( @@ -431,7 +208,16 @@ export const ProjectLinker = ({ Supabase
    - {supabaseProjectSelector} +
    @@ -441,13 +227,37 @@ export const ProjectLinker = ({ {integrationIcon}
    - {foreignProjectSelector} +
    )}
    -
    {actionButtons}
    +
    + +
    ) } diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx new file mode 100644 index 00000000000..dcdf496453f --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinkerComponents.tsx @@ -0,0 +1,325 @@ +import { useParams } from 'common' +import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { HTMLAttributes } from 'react' +import { + Badge, + Button, + cn, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + Popover, + PopoverContent, + PopoverTrigger, +} from 'ui' + +import { Project, type ForeignProject, type ProjectLinkerProps } from './VercelGithub.types' +import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { BASE_PATH } from '@/lib/constants' +import { openInstallGitHubIntegrationWindow } from '@/lib/github' + +export const Panel = ({ children, className, ...props }: HTMLAttributes) => { + return ( +
    + {children} +
    + ) +} + +export const ForeignProjectSelector = ({ + open, + mode, + variant, + choosePrompt, + selectedForeignProject, + loadingForeignProjects, + foreignProjects, + integrationIcon, + onOpenChange, + setForeignProjectId, + getForeignProjectIcon, +}: { + open: boolean + selectedForeignProject?: ForeignProject + setForeignProjectId: (id: string) => void + onOpenChange: (val: boolean) => void +} & Pick< + ProjectLinkerProps, + | 'mode' + | 'variant' + | 'choosePrompt' + | 'loadingForeignProjects' + | 'foreignProjects' + | 'getForeignProjectIcon' + | 'integrationIcon' +>) => { + return ( + + +
    + ) : undefined + } + iconRight={ + + + + } + > + + {(selectedForeignProject && selectedForeignProject.name) ?? choosePrompt} + + + + + + + + No results found. + + {foreignProjects.map((project, i) => { + return ( + { + if (project.id) setForeignProjectId(project.id) + onOpenChange(false) + }} + > +
    {getForeignProjectIcon?.(project) ?? integrationIcon}
    + + {project.name} + +
    + ) + })} + {foreignProjects.length === 0 && No results found.} +
    + {mode === 'GitHub' && ( + <> + + + openInstallGitHubIntegrationWindow('install')} + > + + Add GitHub Repositories + + + + )} +
    +
    +
    + + ) +} + +export const SupabaseProjectSelector = ({ + open, + variant, + slug, + defaultSupabaseProject, + selectedSupabaseProject, + loadingSupabaseProjects, + setOpen, + setSelectedSupabaseProject, +}: { + open: boolean + selectedSupabaseProject?: Project + loadingSupabaseProjects: boolean + setOpen: (val: boolean) => void + setSelectedSupabaseProject: (project: Project) => void +} & Pick) => { + const router = useRouter() + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const projectCreationEnabled = useIsFeatureEnabled('projects:create') + + return ( + { + setSelectedSupabaseProject(project) + setOpen(false) + }} + renderRow={(project) => { + return ( +
    +
    + {variant === 'default' && ( +
    + Supabase +
    + )} +

    {project.name}

    + {project.status === 'INACTIVE' && Paused} + {project.status === 'GOING_DOWN' && Pausing} +
    + {project.ref === selectedSupabaseProject?.ref && } +
    + ) + }} + renderTrigger={() => { + return ( + + ) + }} + renderActions={() => { + return ( + projectCreationEnabled && ( + + { + setOpen(false) + router.push(`/new/${selectedOrganization?.slug}`) + }} + onClick={() => setOpen(false)} + > + setOpen(false)} + > + +

    Create a new project

    + +
    +
    + ) + ) + }} + /> + ) +} + +export const ActionButtons = ({ + slug, + mode, + variant, + showCreateProject, + connectDisabled, + isLoading, + foreignProjectId, + onCreateConnections, + onSkip, +}: { + showCreateProject: boolean + connectDisabled: boolean + foreignProjectId: string | undefined + onCreateConnections: () => void +} & Pick) => { + const { next, externalId, currentProjectId } = useParams() + const organizationSlug = slug + const vercelProjectId = foreignProjectId ?? currentProjectId + // Deploy-button create is only for the install interstitial; settings side panels use /new. + const newProjectURL = + mode === 'Vercel' && variant === 'interstitial' && organizationSlug + ? `/integrations/vercel/${organizationSlug}/deploy-button/new-project?${new URLSearchParams({ + ...(next ? { next } : {}), + ...(vercelProjectId ? { currentProjectId: vercelProjectId } : {}), + ...(externalId ? { externalId } : {}), + })}` + : `/new/${organizationSlug}` + + return ( +
    + {showCreateProject ? ( + + ) : ( + + )} + {onSkip !== undefined && ( + + )} +
    + ) +} diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts b/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts new file mode 100644 index 00000000000..aa745b4d43b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/VercelGithub.types.ts @@ -0,0 +1,36 @@ +import { ReactNode } from 'react' + +import { + type IntegrationConnectionsCreateVariables, + type IntegrationProjectConnection, +} from '@/data/integrations/integrations.types' + +export interface Project { + name: string + ref: string +} + +export interface ForeignProject { + id: string + name: string + installation_id?: number +} + +export interface ProjectLinkerProps { + slug?: string + organizationIntegrationId?: string + foreignProjects: ForeignProject[] + onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void + installedConnections?: IntegrationProjectConnection[] + isLoading?: boolean + integrationIcon: ReactNode + getForeignProjectIcon?: (project: ForeignProject) => ReactNode + choosePrompt?: string + onSkip?: () => void + loadingForeignProjects?: boolean + showNoEntitiesState?: boolean + defaultSupabaseProject?: Project + defaultForeignProjectId?: string + mode: 'Vercel' | 'GitHub' + variant?: 'default' | 'interstitial' +} diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index 454acf34276..ad20f391e68 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { toast } from 'sonner' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index 93991554b41..1a908dae33e 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -144,7 +144,7 @@ export const CreateIcebergWrapperSheet = ({ resolver: zodResolver(formSchema), defaultValues: INITIAL_VALUES, }) - const { resetField, formState, setError, watch } = form + const { resetField, formState, setError } = form const { isDirty, isSubmitting } = formState useEffect(() => { @@ -153,21 +153,23 @@ export const CreateIcebergWrapperSheet = ({ const currentTarget = useRef(INITIAL_VALUES.target) useEffect(() => { - const subscription = watch((values) => { - if (!values.target || values.target === currentTarget.current) return - currentTarget.current = values.target + return form.subscribe({ + name: 'target', + formState: { values: true }, + callback: ({ values }) => { + if (!values.target || values.target === currentTarget.current) return + currentTarget.current = values.target - const fields = targetFields[values.target] - if (!fields) return + const fields = targetFields[values.target] + if (!fields) return - wrapperMeta.server.options.forEach((option) => { - // @ts-expect-error Can't reconcile with form schema - resetField(option.name, { defaultValue: option.defaultValue ?? '' }) - }) + wrapperMeta.server.options.forEach((option) => { + // @ts-expect-error Can't reconcile with form schema + resetField(option.name, { defaultValue: option.defaultValue ?? '' }) + }) + }, }) - - return () => subscription.unsubscribe() - }, [resetField, watch, wrapperMeta]) + }, [form, resetField, wrapperMeta]) const onSubmit: SubmitHandler = async (values) => { const foundSchema = schemas?.find((s) => s.name === values.target_schema) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index d231545c009..c4a38b2e5ed 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -18,7 +18,7 @@ import { SheetTitle, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index 464467d0de7..d4a071b7d26 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { parseAsBoolean, useQueryState } from 'nuqs' import { useState } from 'react' import { Button, Sheet, SheetContent } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' @@ -14,6 +14,7 @@ import { CreateWrapperSheet } from './CreateWrapperSheet' import { WRAPPERS } from './Wrappers.constants' import { WrapperTable } from './WrapperTable' import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -59,7 +60,7 @@ const WrapperOverviewContent = () => { {!!CreateWrapperSheetComponent && !!wrapperMeta && ( - + { diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx index 0cadcef8c83..c458bd96eb9 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx @@ -110,7 +110,7 @@ export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => { if (!open) setIsClosingEditWrapper(true) }} > - + {selectedWrapperToEdit && ( { const { data: project } = useSelectedProjectQuery() - const { data: schemas, isPending: isLoading } = useSchemasQuery({ + const { data: allSchemas, isPending: isLoading } = useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const schemas = useSchemasFilteredForHighAvailability(allSchemas) const requiredOptions: TableOption[] = [] const optionalOptions: TableOption[] = [] diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index bd2a4cc4bed..52243d3a910 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -84,7 +84,7 @@ export const WrappersTab = () => { )} - + {wrapperMeta && ( - +
    (val === '' || val === null || val === undefined ? undefined : val), - z.coerce - .number({ - required_error: 'Must have a JWT expiry value', - invalid_type_error: 'Must have a JWT expiry value', - }) - .positive('Must be greater than 0') - .max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`) - ), -}) -const formId = 'jwt-exp-form' - const customJwtSecretFormSchema = z.object({ customToken: z .string() @@ -115,10 +94,6 @@ export const JWTSettings = () => { PermissionAction.INFRA_EXECUTE, 'queue_job.projects.update_jwt' ) - const { can: canUpdateConfig } = useAsyncCheckPermissions( - PermissionAction.UPDATE, - 'custom_config_gotrue' - ) const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }, { enabled: IS_PLATFORM }) const { data: config, isError } = useProjectPostgrestConfigQuery({ projectRef }) @@ -135,13 +110,6 @@ export const JWTSettings = () => { { enabled: IS_PLATFORM && canReadAPIKeys } ) - const { data: authConfig, isPending: isLoadingAuthConfig } = useAuthConfigQuery( - { projectRef }, - { enabled: IS_PLATFORM } - ) - const { mutate: updateAuthConfig, isPending: isUpdatingAuthConfig } = - useAuthConfigUpdateMutation() - const { Failed, Updated, Updating } = JwtSecretUpdateStatus const isJwtSecretUpdateFailed = data?.jwtSecretUpdateStatus === Failed @@ -153,47 +121,11 @@ export const JWTSettings = () => { const jwtSecretUpdateProgressMessage = JWT_SECRET_UPDATE_PROGRESS_MESSAGES[data?.jwtSecretUpdateProgress as JwtSecretUpdateProgress] - const INITIAL_VALUES = useMemo( - () => ({ - JWT_EXP: authConfig?.JWT_EXP ?? 3600, - }), - [authConfig] - ) - - const form = useForm>({ - defaultValues: INITIAL_VALUES, - resolver: zodResolver(formSchema), - }) - const customJwtSecretForm = useForm>({ defaultValues: { customToken: '' }, resolver: zodResolver(customJwtSecretFormSchema), }) - const { reset, formState } = form - const { isDirty } = formState - - useEffect(() => { - reset(INITIAL_VALUES) - }, [INITIAL_VALUES, reset]) - - const onUpdateJwtExp: SubmitHandler> = async (values) => { - if (!projectRef) return console.error('Project ref is required') - - updateAuthConfig( - { projectRef, config: values }, - { - onError: (error) => { - toast.error(`Failed to update JWT expiry: ${error?.message}`) - }, - onSuccess: (newValues) => { - toast.success('Successfully updated JWT expiry') - reset({ JWT_EXP: newValues.JWT_EXP ?? values.JWT_EXP }) - }, - } - ) - } - async function handleJwtSecretUpdate( jwt_secret: string, setModalVisibility: Dispatch> @@ -215,16 +147,14 @@ export const JWTSettings = () => { return ( - - - - - + + + ) @@ -232,158 +162,89 @@ export const JWTSettings = () => { return ( <> - - -
    - } - > + -
    - - {isError ? ( -
    - -

    Failed to retrieve JWT settings

    -
    - ) : ( - <> - {legacyKey && legacyKey.status !== 'revoked' && ( - -

    - Legacy JWT secret can only be changed by rotating to a standby key and then - revoking it. It is used to{' '} - - {legacyKey.status === 'in_use' ? 'sign and verify' : 'only verify'} - {' '} - JSON Web Tokens by Supabase products. -

    - - {legacyAPIKeysStatus && legacyAPIKeysStatus.enabled && ( -

    - - This includes the anon and{' '} - service_role JWT based API - keys. - {' '} - Consider switching to publishable and secret API keys to disable them. -

    - )} - - -
    - )} - {legacyKey && legacyKey.status === 'revoked' && ( - - )} - + {isError ? ( +
    + +

    Failed to retrieve JWT settings

    +
    + ) : ( + <> + {legacyKey && legacyKey.status !== 'revoked' && ( + - -
    +

    + Legacy JWT secret can only be changed by rotating to a standby key and then + revoking it. It is used to{' '} + + {legacyKey.status === 'in_use' ? 'sign and verify' : 'only verify'} + {' '} + JSON Web Tokens by Supabase products. +

    - ( - -

    - How long access tokens are valid for before a refresh token has to be - used. -

    -

    Recommendation: 3600 (1 hour).

    - - } - > - - - - field.onChange( - isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber - ) - } - /> - - seconds - - - -
    + {legacyAPIKeysStatus && legacyAPIKeysStatus.enabled && ( +

    + + This includes the anon and{' '} + service_role JWT based API keys. + {' '} + Consider switching to publishable and secret API keys to disable them. +

    )} + + +
    + )} + {legacyKey && legacyKey.status === 'revoked' && ( + - - )} - - + )} + + + + + )} +
    {!isPending && !legacyKey && ( <> diff --git a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx index 5d9ccfb4690..f3373c1ae4c 100644 --- a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx +++ b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx @@ -4,7 +4,7 @@ import { EyeOff, Lock } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { Badge, Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index a9b00f7fda9..2e8a7b0c11c 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -16,6 +16,7 @@ import { Badge, Button } from 'ui' import { asGraphqlExposureLint, GraphqlExposureLintCTA } from './GraphqlExposureLintCTA' import { LINTER_LEVELS, LintInfo } from '@/components/interfaces/Linter/Linter.constants' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { Lint, LINT_TYPES } from '@/data/lint/lint-query' import { DOCS_URL } from '@/lib/constants' @@ -305,7 +306,7 @@ export const lintInfoMap: LintInfo[] = [ name: 'vulnerable_postgres_version', title: 'Postgres version has security patches available', icon: , - link: ({ projectRef }) => `/project/${projectRef}/settings/infrastructure`, + link: ({ projectRef }) => getServiceVersionsPath(projectRef), linkText: 'View settings', docsLink: `${DOCS_URL}/guides/platform/upgrading`, category: 'security', diff --git a/apps/studio/components/interfaces/LocalDropdown.test.tsx b/apps/studio/components/interfaces/LocalDropdown.test.tsx index f34fa0c2b7f..5afef5f725f 100644 --- a/apps/studio/components/interfaces/LocalDropdown.test.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.test.tsx @@ -70,7 +70,9 @@ vi.mock('ui', async () => { children, ...props }: React.ButtonHTMLAttributes & { children?: ReactNode }) => ( - + ), cn: (...classes: Array) => classes.filter(Boolean).join(' '), DropdownMenu: ({ children }: { children: ReactNode }) =>
    {children}
    , @@ -92,6 +94,7 @@ vi.mock('ui', async () => {
    {children}
    ) : ( , + }) => ( + + ), Tooltip: ({ children }: { children: ReactNode }) =>
    {children}
    , TooltipContent: ({ children }: { children: ReactNode }) =>
    {children}
    , TooltipTrigger: ({ children }: { children: ReactNode }) =>
    {children}
    , diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index 4bbcfbdd78a..56c288a0ef6 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { IS_PLATFORM } from 'common' import Link from 'next/link' import { ReactNode, useEffect, useMemo, useRef } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -393,8 +393,8 @@ export function LogDrainDestinationSheetForm({ values: formValues, }) - const type = form.watch('type') - const tls = form.watch('tls') + const type = useWatch({ control: form.control, name: 'type' }) + const tls = useWatch({ control: form.control, name: 'tls' }) useEffect(() => { if (mode === 'create' && !open) { @@ -411,12 +411,7 @@ export function LogDrainDestinationSheetForm({ return ( - + Add destination diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainsCard.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainsCard.tsx index b3a5c6b5e9f..839f5fcfe8c 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainsCard.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainsCard.tsx @@ -16,7 +16,7 @@ export const LogDrainsCard = ({ onClick, }: LogDrainsCardProps) => { return ( -
    + + + State + Query · Session + Duration + Blocked by + + + + + + {isPending ? ( + Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + + + + + )) + ) : (activities ?? []).length === 0 ? ( + + {hasNoFiltersApplied ? ( + +

    No active sessions

    +

    + There are currently no active database connections for the anon, + authenticated, and postgres roles. +

    +
    + ) : ( + +

    No results found

    +

    + There are no sessions that match the selected filters. Try adjusting or + clearing them. +

    + +
    + )} +
    + ) : null} + + {activities?.map((activity) => ( + + ))} +
    +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx new file mode 100644 index 00000000000..ee10aafd1d2 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx @@ -0,0 +1,440 @@ +import { ChevronRight, Minus, MoreVertical, StopCircle } from 'lucide-react' +import { parseAsInteger, parseAsString, useQueryState } from 'nuqs' +import { Fragment, useState } from 'react' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + cn, + copyToClipboard, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + HoverCard, + HoverCardContent, + HoverCardTrigger, + TableCell, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { CodeBlock } from 'ui-patterns/CodeBlock' + +import { + QUERY_STATE_TOOLTIP, + WARN_DURATION_ACTIVE_QUERY, + WARN_DURATION_IDLE_TXN, +} from './DatabaseConnections.constants' +import { + getBadgeVariant, + getBlockChain, + getBlockingChain, + getDuration, +} from './DatabaseConnections.utils' +import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' +import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' +import { InlineLinkClassName } from '@/components/ui/InlineLink' +import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' +import { useDatabaseActivityQuery, type DatabaseActivity } from '@/data/database/activity-query' +import { useQueryAbortMutation } from '@/data/sql/abort-query-mutation' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatSql } from '@/lib/formatSql' +import { useTrack } from '@/lib/telemetry/track' + +export const GroupedActivityRow = ({ activity }: { activity: DatabaseActivity }) => { + const { data: project } = useSelectedProjectQuery() + const [view] = useQueryState('view', parseAsString.withDefault('')) + + const [expanded, setExpanded] = useState(false) + + const { data } = useDatabaseActivityQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const queriesBlockedBy = getBlockingChain(activity.pid, data ?? []) + .map((pid) => data?.find((x) => x.pid === pid)) + .filter((x) => x !== undefined) + + return ( + <> + 0 && view === 'blockers' + ? () => setExpanded((prev) => !prev) + : undefined + } + /> + + {expanded && + view === 'blockers' && + queriesBlockedBy.map((x, index) => ( + + ))} + + ) +} + +export const ActivityRow = ({ + activity, + expanded, + nested, + isLast, + onExpand, +}: { + activity: DatabaseActivity + expanded?: boolean + nested?: boolean + isLast?: boolean + onExpand?: () => void +}) => { + const track = useTrack() + const { data: project } = useSelectedProjectQuery() + const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false) + const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger) + + const { data } = useDatabaseActivityQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { data: roles } = useDatabaseRolesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const superuserRoles = roles?.filter((role) => role.isSuperuser).map((role) => role.name) + + const { mutateAsync: abortQuery } = useQueryAbortMutation({ + onSuccess: () => { + toast.success(`Successfully aborted query (ID: ${activity.pid})`) + }, + }) + + const durationSeconds = getDuration(activity) + const badgeVariant = getBadgeVariant(activity) + + /** + * Queries in "active state": 30s threshold is long enough (most CRUD queries should be quick) + * Queries in "idle in transaction" state: This actively holds locks and blocks autovacuum while contributing nothing, so important to surface early at 10s threshold + */ + const queryRunningLongWarning = + !!durationSeconds && + ((activity.state === 'active' && durationSeconds >= WARN_DURATION_ACTIVE_QUERY) || + ((activity.state === 'idle in transaction' || + activity.state === 'idle in transaction (aborted)') && + durationSeconds >= WARN_DURATION_IDLE_TXN)) + + const onConfirmTerminate = async () => { + const isBlocking = (data ?? []).some((x) => x.blocked_by.includes(activity.pid)) + track('session_terminate_submitted', { activityState: activity.state, isBlocking }) + try { + await abortQuery({ + pid: activity.pid, + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + } catch (error) {} + } + + return ( + <> + td]:py-3', nested && 'bg-alternative')} + > + + {selectedPid === activity.pid && ( +
    + )} + + {/* Absolute (not inline in the flex row) so top-0/bottom-0 ignore the cell's padding and touch the adjacent row */} + {nested && + (isLast ? ( +
    + ) : ( + <> +
    +
    + + ))} + + {/* Starts right below the expand button (row's own py-3 top padding + button height), reaches bottom-0 to touch the first nested row's border */} + {!!onExpand && expanded && ( +
    + )} + +
    + {nested &&
    } + + {!!onExpand && ( +
    + + + + + +

    + {!!activity.query ? activity.query : 'No query'} +

    +
    + {activity.query && ( + + code]:text-xs max-h-64', + '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11' + )} + wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-0')} + language="pgsql" + value={formatSql(activity.query)} + /> + + )} +
    +
    + + { + toast.success('Copied PID') + copyToClipboard(activity.pid.toString()) + }} + > + PID: {activity.pid} + + Click to copy + + · + {activity.role_name} + {activity.application_name && ( + <> + · + {activity.application_name} + + )} +
    +
    + + +

    + {durationSeconds !== null ? ( + formatDuration(durationSeconds * 1000, 0) + ) : ( + + )} +

    +
    + + + {activity.blocked_by.length > 0 ? ( + activity.blocked_by.map((pid, index) => { + const blockChain = getBlockChain(pid, data ?? []) + + return ( + + {index > 0 && ', '} + + + setSelectedPid(pid)} + > + {pid} + + +

    + Blocked via {blockChain.length} hop{blockChain.length > 1 ? 's' : ''} +

    + +
    + {blockChain.map((chainPid, chainIndex) => { + const chainProcess = data?.find((x) => x.pid === chainPid) + const isLastProcess = chainIndex === blockChain.length - 1 + return ( +
    + {chainIndex > 0 && ( +
    + )} +
    +
    + setSelectedPid(chainPid)} + > + PID: {chainPid} + + {isLastProcess ? ( + Holding lock + ) : ( + Waiting + )} +
    +

    + {chainProcess?.query} +

    + {isLastProcess && ( +
    + {chainProcess?.role_name} + · + {chainProcess?.application_name && ( + {chainProcess.application_name} + )} +
    + )} +
    +
    + ) + })} +
    + + + + ) + }) + ) : ( + + )} + + + + + +
    + ) +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx index f101e93db6c..13c5d8eeb54 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx @@ -39,7 +39,6 @@ export const DatabaseInfrastructureSection = ({ const { data: project } = useSelectedProjectQuery() // refreshKey forces date recalculation when user clicks refresh button - // eslint-disable-next-line react-hooks/exhaustive-deps const { startDate, endDate, infraInterval } = useMemo(() => { const now = dayjs() const end = now.toISOString() diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx index a8f567a8d70..3b6063e7376 100644 --- a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -127,6 +127,7 @@ const ServiceCell = ({
    ) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.test.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.test.tsx index e2ae0ef10ab..203c3879cc1 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.test.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.test.tsx @@ -1,4 +1,5 @@ import { screen } from '@testing-library/react' +import { mockIntersectionObserver } from 'jsdom-testing-mocks' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,6 +7,8 @@ import { BillingCustomerData } from './BillingCustomerData' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { createMockOrganization, render } from '@/tests/helpers' +mockIntersectionObserver() + const mockSelectedOrganization = vi.hoisted(() => vi.fn()) vi.mock('common', async (importOriginal) => { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 98c584f1744..42f0369936f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { useTheme } from 'next-themes' import { useEffect, useMemo, useRef, useState } from 'react' +import { useInView } from 'react-intersection-observer' import { toast } from 'sonner' import { Button, Card, CardFooter, Form } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -47,19 +48,24 @@ export const BillingCustomerData = () => { 'stripe.customer' ) + const { ref, inView } = useInView({ triggerOnce: true }) + const { data: customerProfile, error, isPending: isLoading, isSuccess, - } = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData }) + } = useOrganizationCustomerProfileQuery( + { slug }, + { enabled: canReadBillingCustomerData && inView } + ) const { data: taxId, error: errorLoadingTaxId, isPending: isLoadingTaxId, isSuccess: loadedTaxId, - } = useOrganizationTaxIdQuery({ slug }) + } = useOrganizationTaxIdQuery({ slug }, { enabled: inView }) const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({ onError: () => {}, @@ -165,7 +171,7 @@ export const BillingCustomerData = () => { ) return ( - +

    Billing Address & Tax ID

    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx index e992caf60f0..01190934173 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx @@ -6,7 +6,7 @@ import type { } from '@stripe/stripe-js' import { Check, ChevronsUpDown, Info, X } from 'lucide-react' import { useEffect, useId, useMemo, useRef, useState } from 'react' -import { UseFormReturn } from 'react-hook-form' +import { UseFormReturn, useWatch } from 'react-hook-form' import { Button, cn, @@ -78,7 +78,7 @@ export const BillingCustomerDataForm = ({ form.setValue('tax_id_value', '', { shouldDirty: true }) } - const { tax_id_name } = form.watch() + const tax_id_name = useWatch({ control: form.control, name: 'tax_id_name' }) const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) const availableTaxIds = useMemo(() => { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 31a0e5566f5..f2b7e943f76 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -3,7 +3,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Form, FormControl, FormField } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' import { useEffect } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' +import { useInView } from 'react-intersection-observer' import { toast } from 'sonner' import { FormMessage, Input } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -51,8 +52,10 @@ const BillingEmail = () => { 'organizations' ) + const { ref, inView } = useInView({ triggerOnce: true }) + const { data: billingCustomer, isPending: loadingBillingCustomer } = - useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail }) + useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail && inView }) const form = useForm>({ resolver: zodResolver(formSchema), @@ -61,7 +64,10 @@ const BillingEmail = () => { additionalBillingEmails: billingCustomer?.additional_emails ?? [], }, }) - const { additionalBillingEmails } = form.watch() + const additionalBillingEmails = useWatch({ + control: form.control, + name: 'additionalBillingEmails', + }) const { errors } = form.formState const additionalEmailsError = errors.additionalBillingEmails ?? [] @@ -100,7 +106,7 @@ const BillingEmail = () => { }, [billingCustomer]) return ( - +

    Email Recipient

    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 0c095da8531..9cb79b0c33e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -5,7 +5,7 @@ import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' import { Alert, AlertTitle, Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index 082f5728550..0cf99a92b90 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react' import { pricing } from 'shared-data/pricing' import { toast } from 'sonner' import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import Table from '@/components/to-be-cleaned/Table' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx index 95c44733543..cb95d666dec 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx @@ -22,7 +22,7 @@ import { Input, Separator, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 25feaef32e1..d92d9651af2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -8,7 +8,7 @@ import { useDebounce } from '@uidotdev/usehooks' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Alert, @@ -104,7 +104,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const debouncedAddress = useDebounce(billingAddress, 1000) const debouncedTaxId = useDebounce(billingTaxId, 1000) - const watchedAmount = form.watch('amount') + const watchedAmount = useWatch({ control: form.control, name: 'amount' }) const debouncedAmount = useDebounce(watchedAmount, 1000) const parsedAmount = Number(debouncedAmount) const validAmount = @@ -301,8 +301,9 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {

    On successful payment, an invoice will be issued and you'll be granted credits equal - to the pre-tax amount. Credits will be applied to future invoices only and are not - refundable. The topped up credits do not expire. + to the pre-tax amount. Credits will be applied to future invoices only. They cannot be + used to pay or adjust outstanding invoices. Credits are non-refundable and do not + expire.

    For larger discounted credit packages, please reach out to us via{' '} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 76a9ccbfbd3..5b4dd4f5ed9 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -10,7 +10,7 @@ import { DialogSectionSeparator, DialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { isBeforeFreeTierTemplateBlockCutoff } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils' import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index f34dc2f63ad..093a5f52f79 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -13,7 +13,7 @@ import { DialogTitle, TextArea, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 811ea8e7061..f620c0f3be8 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -2,7 +2,7 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/ import { useFlag, useParams } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { Restriction } from '../Restriction' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index d0ce8da62a6..f67b5a04540 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -8,7 +8,7 @@ import { useMemo, useRef, useState } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { toast } from 'sonner' import { Button, cn, Dialog, DialogContent } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx index 97d226169d9..e701eb9efd5 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.components.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from 'react' import { Card, CardContent } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { @@ -94,11 +94,16 @@ export function getContractIneligibilityDescription( ) { switch (reason) { case 'AWS_ACTIVATE_CREDITS_DEAL': - return 'No further action is required for this AWS Activate credits offer' + return 'This private offer grants you credits on the Supabase platform' case 'AGREEMENT_BASED_OFFER': return 'This private offer updated an existing AWS Marketplace subscription' - case 'NO_ACTIVE_CONTRACT_FOUND': + case 'NO_CONTRACT_FOUND': + case 'CONTRACT_IN_SETTLING_WINDOW': return 'AWS is still syncing this Marketplace subscription' + case 'CONTRACT_INACTIVE': + return '' + case 'CONTRACT_TERMINATED_EARLY': + return 'Subscription was terminated' default: return 'This AWS Marketplace subscription cannot be linked right now' } @@ -114,7 +119,7 @@ export function ContractIneligibilityNotice({ return ( ) @@ -126,7 +131,8 @@ export function ContractIneligibilityNotice({ description="Your existing Supabase organization remains linked to AWS Marketplace and your projects will continue to run as usual." /> ) - case 'NO_ACTIVE_CONTRACT_FOUND': + case 'NO_CONTRACT_FOUND': + case 'CONTRACT_IN_SETTLING_WINDOW': return ( ) + case 'CONTRACT_INACTIVE': + return ( + + ) + case 'CONTRACT_TERMINATED_EARLY': + return ( + + ) default: return } diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx index ff6449a20c6..42fababc170 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -1,8 +1,7 @@ import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useEffectEvent, useMemo, useState } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { @@ -20,7 +19,10 @@ import { type CloudMarketplaceOnboardingInfo, } from '@/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query' import { NewAwsMarketplaceOrgModal } from '@/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal' -import { InterstitialAccountRow } from '@/components/layouts/InterstitialLayout' +import { + InterstitialAccountRow, + InterstitialActionError, +} from '@/components/layouts/InterstitialLayout' import { InlineLink } from '@/components/ui/InlineLink' import { useOrganizationLinkAwsMarketplaceMutation } from '@/data/organizations/organization-link-aws-marketplace-mutation' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' @@ -37,12 +39,6 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const [linkedOrgSlug, setLinkedOrgSlug] = useState(null) const [showOrgCreationDialog, setShowOrgCreationDialog] = useState(false) - useEffect(() => { - setSelectedOrgSlug(null) - setLinkedOrgSlug(null) - setShowOrgCreationDialog(false) - }, [buyerId]) - const { data: organizations, error: organizationsError, @@ -72,15 +68,20 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } { enabled: !!shouldLoadOnboardingInfo } ) - const { mutate: linkOrganization, isPending: isLinkingOrganization } = - useOrganizationLinkAwsMarketplaceMutation({ - onSuccess: (_, variables) => { - setLinkedOrgSlug(variables.slug) - }, - onError: (error) => { - toast.error(error.message, { duration: 7_000 }) - }, - }) + const { + mutate: linkOrganization, + isPending: isLinkingOrganization, + error: linkOrganizationError, + reset: resetLinkOrganizationError, + } = useOrganizationLinkAwsMarketplaceMutation({ + onSuccess: (_, variables) => { + setLinkedOrgSlug(variables.slug) + }, + onError: () => undefined, + }) + const linkError = linkOrganizationError + ? `Failed to link organization: ${linkOrganizationError.message}` + : undefined const effectiveOrganizations = useMemo( () => organizations ?? EMPTY_ORGANIZATIONS, @@ -142,6 +143,18 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } }, [onboardingInfo, effectiveOrganizations]) + const resetOnBuyerChange = useEffectEvent(() => { + setSelectedOrgSlug(null) + setLinkedOrgSlug(null) + setShowOrgCreationDialog(false) + resetLinkOrganizationError() + }) + + useEffect(() => { + resetOnBuyerChange() + // eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet) + }, [buyerId]) + if (!buyerId) { return ( @@ -249,6 +262,7 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } const primaryAction = hasLinkableOrganizations ? () => { if (!selectedOrgSlug || !buyerId) return + resetLinkOrganizationError() linkOrganization({ slug: selectedOrgSlug, buyerId }) } : () => setShowOrgCreationDialog(true) @@ -278,7 +292,10 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } } selectedSlug={selectedOrgSlug} disabled={isLinking} - onSelect={setSelectedOrgSlug} + onSelect={(slug) => { + setSelectedOrgSlug(slug) + resetLinkOrganizationError() + }} createLabel={hasLinkableOrganizations ? 'Create new organization' : undefined} onCreate={hasLinkableOrganizations ? () => setShowOrgCreationDialog(true) : undefined} /> @@ -292,21 +309,26 @@ export const AwsMarketplaceOnboardingScreen = ({ buyerId }: { buyerId?: string } )}

    - -

    - - Learn more - {' '} - about billing through AWS. -

    +
    + + +
    + {!linkError && ( +

    + + Learn more + {' '} + about billing through AWS. +

    + )}
    diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx index b9b232ab637..1f372319b83 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Form } from 'ui' import { @@ -28,7 +28,7 @@ export const NewAwsMarketplaceOrgForm = ({ onSubmit }: Props) => { }, }) - const kind = form.watch('kind') + const kind = useWatch({ control: form.control, name: 'kind' }) return (
    diff --git a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx index d66e124a097..974ccc2621f 100644 --- a/apps/studio/components/interfaces/Organization/Documents/DPA.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/DPA.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react' -import { toast } from 'sonner' import { Button } from 'ui' import { @@ -8,96 +6,38 @@ import { ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' -import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' -import { useDpaRequestMutation } from '@/data/documents/dpa-request-mutation' -import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' -import { useProfile } from '@/lib/profile' import { useTrack } from '@/lib/telemetry/track' export const DPA = () => { - const { profile } = useProfile() - const { data: organization } = useSelectedOrganizationQuery() - const slug = organization?.slug - - const [isOpen, setIsOpen] = useState(false) - const track = useTrack() - const { mutate: requestDpa, isPending: isRequesting } = useDpaRequestMutation({ - onSuccess: () => { - toast.success('DPA request sent successfully') - setIsOpen(false) - }, - }) - - const onConfirmRequest = async () => { - if (!slug) return toast.error('Organization not found.') - if (!profile?.primary_email) return toast.error('Profile email not found.') - requestDpa({ recipient_email: profile?.primary_email, slug: slug }) - } return ( - <> - - -

    Data Processing Addendum (DPA)

    -
    -

    - All organizations can sign our Data Processing Addendum ("DPA") as part of their GDPR - compliance. -

    -

    - You can review a static PDF version of our latest DPA document{' '} - track('dpa_pdf_opened', { source: 'studio' })} - > - here - - . -

    -
    -
    - -
    - -
    -
    -
    - - setIsOpen(false)} - onConfirm={() => onConfirmRequest()} - > -
    + + +

    Data Processing Addendum (DPA)

    +

    - To make the DPA legally binding, you need to sign and complete the details through a - PandaDoc document that we prepare. -

    -

    - Please enter your email address to request an executable version of the DPA. You will - receive a document link via PandaDoc in the next 24 hours. -

    -

    - Once signed, the DPA will be considered executed and you'll be notified of any future - updates via this email. + Our Data Processing Addendum is incorporated into our{' '} + Terms of Service, so all + organizations get its protections automatically. No separate signed DPA is needed.

    +

    If you signed a DPA with us previously, that agreement remains binding.

    - - +
    + + + +
    ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index fb4379b66ac..9eadf9704f4 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DeleteOrganizationButton } from './DeleteOrganizationButton' import PartnerManagedResource from '@/components/ui/PartnerManagedResource' diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index 9a5d547a858..774ee1f63d7 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -3,11 +3,9 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { SidePanel } from 'ui' +import { type ForeignProject } from '../../Integrations/VercelGithub/VercelGithub.types' import { ENV_VAR_RAW_KEYS } from '@/components/interfaces/Integrations/Vercel/Integrations-Vercel.constants' -import { - ForeignProject, - ProjectLinker, -} from '@/components/interfaces/Integrations/VercelGithub/ProjectLinker' +import { ProjectLinker } from '@/components/interfaces/Integrations/VercelGithub/ProjectLinker' import { Markdown } from '@/components/interfaces/Markdown' import { vercelIcon } from '@/components/to-be-cleaned/ListIcons' import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.test.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.test.tsx index 2c9d8d58344..baffd645a80 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.test.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.test.tsx @@ -1,10 +1,13 @@ import { screen } from '@testing-library/react' +import { mockIntersectionObserver } from 'jsdom-testing-mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' import { InvoicesSettings } from './InvoicesSettings' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { createMockOrganization, render } from '@/tests/helpers' +mockIntersectionObserver() + const { mockSelectedOrganization, mockInvoicesQuery, mockInvoicesCountQuery } = vi.hoisted(() => ({ mockSelectedOrganization: vi.fn(), mockInvoicesQuery: vi.fn(), diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index b25978116dd..ebbb793c58d 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -1,6 +1,7 @@ import dayjs from 'dayjs' import { ChevronLeft, ChevronRight, FileText, Receipt, ScrollText } from 'lucide-react' import { useEffect, useState } from 'react' +import { useInView } from 'react-intersection-observer' import { toast } from 'sonner' import { Button, @@ -56,13 +57,16 @@ export const InvoicesSettings = () => { const isPartnerBilledOrganization = isPartnerBillingOrganization( selectedOrganization?.billing_partner ) + + const { ref, inView } = useInView({ triggerOnce: true }) + const offset = (page - 1) * PAGE_LIMIT const { data: count, isError: isErrorCount } = useInvoicesCountQuery( { slug, }, - { enabled: !isPartnerBilledOrganization } + { enabled: !isPartnerBilledOrganization && inView } ) const { data, @@ -75,7 +79,7 @@ export const InvoicesSettings = () => { offset, limit: PAGE_LIMIT, }, - { enabled: !isPartnerBilledOrganization } + { enabled: !isPartnerBilledOrganization && inView } ) const invoices = data || [] @@ -118,7 +122,7 @@ export const InvoicesSettings = () => { isLoading || invoices.length === 0 ? 'text-foreground-muted' : undefined return ( - + diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 64443884b40..f8028027a28 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -9,7 +9,7 @@ import { useTheme } from 'next-themes' import { useRouter } from 'next/router' import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -201,8 +201,8 @@ export const NewOrgForm = ({ setLatestTaxId(taxId) }, []) - const selectedPlan = form.watch('plan') - const selectedSpendCap = form.watch('spend_cap') + const selectedPlan = useWatch({ control: form.control, name: 'plan' }) + const selectedSpendCap = useWatch({ control: form.control, name: 'spend_cap' }) useEffect(() => { if (selectedPlan === 'FREE' || !setupIntent) { @@ -445,7 +445,7 @@ export const NewOrgForm = ({
    ( {children} )} @@ -494,7 +494,7 @@ export const NewOrgForm = ({ )} - {form.watch('plan') === 'PRO' && ( + {selectedPlan === 'PRO' && ( <> )} - {setupIntent && form.watch('plan') !== 'FREE' && ( + {setupIntent && selectedPlan !== 'FREE' && ( { + redirectUri?: string | null +}) => { const [failedIcon, setFailedIcon] = useState(null) const { resolvedTheme } = useTheme() const logo = useMemo( - () => getRequesterLogo({ icon, name, useDarkVariant: resolvedTheme === 'dark' }), - [icon, name, resolvedTheme] + () => + getRequesterLogo({ + icon, + name, + redirectUri, + useDarkVariant: resolvedTheme === 'dark', + }), + [icon, name, redirectUri, resolvedTheme] ) - const showLetter = !logo.src || failedIcon === logo.src + const hasUsableLogo = Boolean(logo.src) && failedIcon !== logo.src + + if (!hasUsableLogo) { + return + } + + const forceLightPair = !logo.isKnownClient return ( - - {showLetter ? ( - {name.slice(0, 1)} - ) : ( - {name} setFailedIcon(logo.src)} - /> - )} - + + {name} setFailedIcon(logo.src)} + /> + + } + right={} + /> + ) +} + +export const AuthorizeImpersonationWarning = ({ + name, + redirectUri, +}: { + name: string + redirectUri?: string | null +}) => { + const warning = getOAuthImpersonationWarning({ name, redirectUri }) + if (!warning) return null + + return ( + ) } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx index 1005c82abb4..b79b9fdc108 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/DeleteAppModal.tsx @@ -10,7 +10,7 @@ import { AlertDialogFooter, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useOAuthAppDeleteMutation } from '@/data/oauth/oauth-app-delete-mutation' import type { OAuthApp } from '@/data/oauth/oauth-apps-query' diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts new file mode 100644 index 00000000000..46f5f78ceeb --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.ts @@ -0,0 +1,168 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' +import { describe, expect, test } from 'vitest' + +import { + findTrustedPartnerByRedirectUri, + getOAuthImpersonationWarning, + getRedirectHostname, + getRequesterLogo, + hostMatchesAllowlist, + isLocalRedirectHost, +} from './OAuthApps.utils' + +describe('hostMatchesAllowlist', () => { + test('allows exact and subdomain hosts', () => { + expect(hostMatchesAllowlist('claude.ai', ['claude.ai'])).toBe(true) + expect(hostMatchesAllowlist('api.claude.ai', ['claude.ai'])).toBe(true) + }) + + test('rejects lookalike hosts', () => { + expect(hostMatchesAllowlist('claude.ai.evil.com', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('notclaude.ai', ['claude.ai'])).toBe(false) + expect(hostMatchesAllowlist('evilclaude.ai', ['claude.ai'])).toBe(false) + }) +}) + +describe('isLocalRedirectHost', () => { + test.each(['localhost', '127.0.0.1', '[::1]', '::1', 'app.localhost'])( + 'treats %s as local', + (host) => { + expect(isLocalRedirectHost(host)).toBe(true) + } + ) + + test('treats public hosts as remote', () => { + expect(isLocalRedirectHost('claude.ai')).toBe(false) + expect(isLocalRedirectHost('evil.com')).toBe(false) + }) +}) + +describe('getRedirectHostname', () => { + test('parses https redirect URIs', () => { + expect(getRedirectHostname('https://claude.ai/api/mcp/auth_callback')).toBe('claude.ai') + }) + + test('returns null for invalid URIs', () => { + expect(getRedirectHostname('not-a-url')).toBe(null) + expect(getRedirectHostname(null)).toBe(null) + }) +}) + +describe('findTrustedPartnerByRedirectUri', () => { + test('resolves Claude from redirect host', () => { + expect( + findTrustedPartnerByRedirectUri('https://claude.ai/api/mcp/auth_callback')?.displayName + ).toBe('Claude') + }) + + test('ignores localhost redirects', () => { + expect(findTrustedPartnerByRedirectUri('http://127.0.0.1:42813/callback')).toBe(null) + }) +}) + +describe('getRequesterLogo', () => { + test('uses curated assets when redirect host is allowlisted', () => { + const trusted = getRequesterLogo({ + icon: null, + name: 'Claude', + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + useDarkVariant: false, + }) + expect(trusted).toEqual({ + src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), + isKnownClient: true, + }) + }) + + test('uses curated assets for localhost when the name matches a trusted partner', () => { + expect( + getRequesterLogo({ + icon: null, + name: 'Claude', + redirectUri: 'http://127.0.0.1:42813/callback', + useDarkVariant: false, + }) + ).toEqual({ + src: getMcpClientIconSrc({ icon: 'claude', useDarkVariant: false }), + isKnownClient: true, + }) + }) + + test('does not use curated assets from name alone on a remote host', () => { + expect( + getRequesterLogo({ + icon: null, + name: 'Claude', + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + ).toEqual({ src: '', isKnownClient: false }) + }) + + test('falls back to the supplied icon URL when redirect is not trusted', () => { + expect( + getRequesterLogo({ + icon: 'https://example.com/icon.png', + name: 'Acme', + redirectUri: 'https://evil.com/callback', + useDarkVariant: false, + }) + ).toEqual({ src: 'https://example.com/icon.png', isKnownClient: false }) + }) +}) + +describe('getOAuthImpersonationWarning', () => { + test('warns when a trusted name redirects to a remote non-allowlisted host', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude Desktop', + redirectUri: 'https://evil.com/callback', + }) + ).toEqual({ + brandDisplayName: 'Claude', + redirectHost: 'evil.com', + }) + }) + + test('skips localhost MCP redirects', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'http://127.0.0.1:42813/callback', + }) + ).toBe(null) + }) + + test('skips when redirect host matches the named partner', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + }) + ).toBe(null) + }) + + test('skips when the name does not match a trusted partner', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Acme Tools', + redirectUri: 'https://evil.com/callback', + }) + ).toBe(null) + }) + + test('skips missing or unparsable redirect URIs', () => { + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: null, + }) + ).toBe(null) + expect( + getOAuthImpersonationWarning({ + name: 'Claude', + redirectUri: 'not-a-url', + }) + ).toBe(null) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts new file mode 100644 index 00000000000..e8f942cf7a0 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -0,0 +1,174 @@ +import { getMcpClientIconSrc } from 'ui-patterns/McpUrlBuilder' + +export type TrustedOAuthPartner = { + /** Substrings matched against the requester name (case-insensitive). */ + nameMatchers: readonly string[] + displayName: string + icon: string + hasDistinctDarkIcon: boolean + /** Exact host or parent host for redirect_uri (subdomains allowed). */ + redirectHosts: readonly string[] +} + +/** + * High-traffic MCP / OAuth partners with curated Connect logos. + * Logos resolve from allowlisted redirect_uri hosts, or from a trusted name when + * redirect_uri is localhost / loopback (common for local MCP clients). + * Never from self-asserted name alone on a remote host. + */ +export const TRUSTED_OAUTH_PARTNERS: readonly TrustedOAuthPartner[] = [ + { + nameMatchers: ['claude'], + displayName: 'Claude', + icon: 'claude', + hasDistinctDarkIcon: false, + redirectHosts: ['claude.ai', 'anthropic.com'], + }, + { + nameMatchers: ['cursor'], + displayName: 'Cursor', + icon: 'cursor', + hasDistinctDarkIcon: true, + redirectHosts: ['cursor.com', 'cursor.sh'], + }, + { + nameMatchers: ['chatgpt', 'openai'], + displayName: 'ChatGPT', + icon: 'openai', + hasDistinctDarkIcon: true, + redirectHosts: ['chatgpt.com', 'openai.com'], + }, + { + nameMatchers: ['perplexity'], + displayName: 'Perplexity', + icon: 'perplexity', + hasDistinctDarkIcon: true, + redirectHosts: ['perplexity.ai'], + }, +] + +const LOCAL_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +export function getRedirectHostname(redirectUri: string | null | undefined): string | null { + if (!redirectUri) return null + try { + const { hostname } = new URL(redirectUri) + return hostname.toLowerCase() || null + } catch { + return null + } +} + +export function isLocalRedirectHost(hostname: string | null | undefined): boolean { + if (!hostname) return false + const host = hostname.toLowerCase() + return LOCAL_REDIRECT_HOSTS.has(host) || host.endsWith('.localhost') +} + +export function hostMatchesAllowlist(hostname: string, allowedHosts: readonly string[]): boolean { + const host = hostname.toLowerCase() + return allowedHosts.some((allowed) => { + const entry = allowed.toLowerCase() + return host === entry || host.endsWith(`.${entry}`) + }) +} + +export function findTrustedPartnerByRedirectUri( + redirectUri: string | null | undefined +): TrustedOAuthPartner | null { + const hostname = getRedirectHostname(redirectUri) + if (!hostname || isLocalRedirectHost(hostname)) return null + + return ( + TRUSTED_OAUTH_PARTNERS.find((partner) => + hostMatchesAllowlist(hostname, partner.redirectHosts) + ) ?? null + ) +} + +export function findTrustedPartnerByName(name: string): TrustedOAuthPartner | null { + const searchable = name.toLowerCase() + return ( + TRUSTED_OAUTH_PARTNERS.find((partner) => + partner.nameMatchers.some((matcher) => searchable.includes(matcher)) + ) ?? null + ) +} + +function curatedLogoForPartner( + partner: TrustedOAuthPartner, + useDarkVariant: boolean +): { src: string; isKnownClient: boolean } | null { + const customLogoUrl = getMcpClientIconSrc({ + icon: partner.icon, + useDarkVariant, + hasDistinctDarkIcon: partner.hasDistinctDarkIcon, + }) + if (!customLogoUrl) return null + return { src: customLogoUrl, isKnownClient: true } +} + +export function getRequesterLogo({ + icon, + name, + redirectUri, + useDarkVariant, +}: { + icon: string | null + name?: string | null + redirectUri: string | null | undefined + useDarkVariant: boolean +}): { src: string; isKnownClient: boolean } { + const byRedirect = findTrustedPartnerByRedirectUri(redirectUri) + if (byRedirect) { + const curated = curatedLogoForPartner(byRedirect, useDarkVariant) + if (curated) return curated + } + + // Local MCP clients (Claude Desktop, Cursor, etc.) use loopback redirects. + // Name match is enough there — remote hosts still require the allowlist. + const hostname = getRedirectHostname(redirectUri) + if (hostname && isLocalRedirectHost(hostname) && name) { + const byName = findTrustedPartnerByName(name) + if (byName) { + const curated = curatedLogoForPartner(byName, useDarkVariant) + if (curated) return curated + } + } + + return { src: icon || '', isKnownClient: false } +} + +export type OAuthImpersonationWarning = { + /** Trusted partner label used in the caution copy. */ + brandDisplayName: string + redirectHost: string +} + +/** + * Warn when the requester name looks like a known partner but redirect_uri is a + * remote host outside that partner's allowlist. Localhost redirects are skipped + * (common for local MCP clients). Missing or malformed redirect URIs are skipped. + */ +export function getOAuthImpersonationWarning({ + name, + redirectUri, +}: { + name: string + redirectUri: string | null | undefined +}): OAuthImpersonationWarning | null { + const namedPartner = findTrustedPartnerByName(name) + if (!namedPartner) return null + + const hostname = getRedirectHostname(redirectUri) + if (!hostname || isLocalRedirectHost(hostname)) return null + + if (hostMatchesAllowlist(hostname, namedPartner.redirectHosts)) { + return null + } + + return { + brandDisplayName: namedPartner.displayName, + redirectHost: hostname, + } +} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx index 2f4dbd6b39e..0d47c4163ca 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/PublishAppSidePanel/index.tsx @@ -449,12 +449,7 @@ export const PublishAppSidePanel = ({
    - +

    Select an organization to grant API access to

    diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx index c1442d2cb7d..47784797f7d 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -11,8 +11,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' +import { InlineLink } from '@/components/ui/InlineLink' import { useAuthorizedAppRevokeMutation } from '@/data/oauth/authorized-app-revoke-mutation' import type { AuthorizedApp } from '@/data/oauth/authorized-apps-query' @@ -49,15 +50,15 @@ export const RevokeAppModal = ({ {`Revoke access for ${selectedApp?.name}?`} -
    +
    -
      -
    • +
      +
      Before you remove this app, consider: @@ -73,10 +74,19 @@ export const RevokeAppModal = ({ Restoring access will require an organization administrator to re-authorize the application.
    • +
    • + The application may also have a Secret API key with access. + Navigate to{' '} + + Integrations + {' '} + on the project this app was installed, and remove any listed Secret API key + in the "Settings" tab of the integration to fully revoke its access. +
    - - +
    +
    diff --git a/apps/studio/components/interfaces/Organization/OrgNotFound.tsx b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx index ae68260ed67..8413d552af7 100644 --- a/apps/studio/components/interfaces/Organization/OrgNotFound.tsx +++ b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx @@ -1,5 +1,5 @@ import { Skeleton } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { OrganizationCard } from './OrganizationCard' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index 93075704bc3..32dbb9554f7 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -118,6 +118,7 @@ export const CreateOrganizationCard = ({ return ( ) diff --git a/apps/studio/components/interfaces/Organization/restriction.constants.tsx b/apps/studio/components/interfaces/Organization/restriction.constants.tsx index dc13ebb5f8d..6f609a823b0 100644 --- a/apps/studio/components/interfaces/Organization/restriction.constants.tsx +++ b/apps/studio/components/interfaces/Organization/restriction.constants.tsx @@ -11,12 +11,11 @@ export const RESTRICTION_MESSAGES = { const label = dayjs(date).format('DD MMM, YYYY') return ( <> - You have a grace period until{' '} - . After that, your - projects will be restricted while your organization is over quota.{' '} + Projects will be restricted from{' '} + if your + organization remains over quota.{' '} Review usage or{' '} - manage your plan to avoid - restrictions. + billing. ) }, diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx similarity index 92% rename from apps/studio/tests/components/OrganizationInvite.test.tsx rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx index 7c0c2ff8fe2..f6f4201b7cc 100644 --- a/apps/studio/tests/components/OrganizationInvite.test.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.test.tsx @@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { OrganizationInvite } from '@/components/interfaces/OrganizationInvite/OrganizationInvite' +import { OrganizationInvite } from './OrganizationInvite' import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import type { ProfileContextType } from '@/lib/profile' import { render } from '@/tests/helpers' @@ -156,7 +156,7 @@ describe('OrganizationInvite', () => { expect(screen.getByText('Signed in as')).toBeInTheDocument() expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects') + expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/organizations') }) test('accepts an invite with the current slug and token', async () => { @@ -172,6 +172,20 @@ describe('OrganizationInvite', () => { }) }) + test('renders an inline error when joining the organization fails', () => { + mocks.useAcceptInvitationMutation.mockReturnValue({ + mutate: mocks.acceptInvitation, + isPending: false, + error: responseError('You are already a member of this organization'), + }) + + render() + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Failed to join organization: You are already a member of this organization' + ) + }) + test('renders a wrong-account warning and signs out', async () => { const user = userEvent.setup() mocks.useInvitationQuery.mockReturnValue({ @@ -225,7 +239,7 @@ describe('OrganizationInvite', () => { rerender() - expect(screen.getByText('Invite invalid')).toBeInTheDocument() + expect(screen.getByText('Invalid invitation')).toBeInTheDocument() expect( screen.getByText( 'Open the full invite link again, or ask the organization owner for a new invite.' diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index 28de171f715..bdd1c39c301 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -1,10 +1,9 @@ import { useIsLoggedIn, useParams } from 'common' import Link from 'next/link' import { useRouter } from 'next/router' -import type { ReactNode } from 'react' -import { toast } from 'sonner' +import { type ReactNode } from 'react' import { Button, Card, CardContent } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { @@ -14,6 +13,7 @@ import { import { OrganizationInviteError } from './OrganizationInviteError' import { InterstitialAccountRow, + InterstitialActionError, InterstitialLayout, SupabaseLogo, } from '@/components/layouts/InterstitialLayout' @@ -25,9 +25,9 @@ import { useProfile, useProfileNameAndPicture } from '@/lib/profile' export const OrganizationInvite = () => { const router = useRouter() const isLoggedIn = useIsLoggedIn() + const { slug, token } = useParams() const { profile, isLoading: isLoadingProfile } = useProfile() const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture() - const { slug, token } = useParams() const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up') @@ -70,15 +70,17 @@ export const OrganizationInvite = () => { const mfaRequiredError = error?.message.includes('MFA required') - const { mutate: joinOrganization, isPending: isJoining } = - useOrganizationAcceptInvitationMutation({ - onSuccess: () => { - router.push('/organizations') - }, - onError: (error) => { - toast.error(`Failed to join organization: ${error.message}`) - }, - }) + const { + mutate: joinOrganization, + isPending: isJoining, + error: joinError, + } = useOrganizationAcceptInvitationMutation({ + onSuccess: () => { + router.push('/organizations') + }, + // [Joshen] Silence the default toast handler + onError: () => {}, + }) async function handleJoinOrganization() { if (!slug) return console.error('Slug is required') @@ -194,8 +196,11 @@ export const OrganizationInvite = () => { Accept invite + ) diff --git a/apps/studio/tests/components/OrganizationInvite.utils.test.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts similarity index 96% rename from apps/studio/tests/components/OrganizationInvite.utils.test.ts rename to apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts index c119fb46531..6d6c7cc7c8d 100644 --- a/apps/studio/tests/components/OrganizationInvite.utils.test.ts +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.test.ts @@ -4,7 +4,7 @@ import { getOrganizationInviteContent, getOrganizationInviteStatus, type OrganizationInviteStatus, -} from '@/components/interfaces/OrganizationInvite/OrganizationInvite.utils' +} from './OrganizationInvite.utils' import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import type { ResponseError } from '@/types' @@ -101,7 +101,7 @@ describe('OrganizationInvite utils', () => { ['ready', 'Join Acme Corp', 'You have been invited to join this Supabase organization'], ['wrong-account', 'Wrong account', undefined], ['expired', 'Invite expired', undefined], - ['invalid', 'Invite invalid', undefined], + ['invalid', 'Invalid invitation', undefined], ['no-longer-valid', 'Invite no longer available', undefined], ['error', 'Unable to load invitation', undefined], ])('returns content for %s', (status, title, description) => { diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts index 8738739589b..53526db5ccf 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts @@ -88,7 +88,7 @@ export function getOrganizationInviteContent({ if (status === 'wrong-account') return { title: 'Wrong account' } if (status === 'expired') return { title: 'Invite expired' } - if (status === 'invalid') return { title: 'Invite invalid' } + if (status === 'invalid') return { title: 'Invalid invitation' } if (status === 'no-longer-valid') return { title: 'Invite no longer available' } if (status === 'error') { if (error?.message.includes('MFA required')) { diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx index c4b94077499..f4d747fb56e 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import { useSignOut } from '@/lib/auth' diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx index 579f7c8c796..4f1a0327051 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.test.tsx @@ -27,7 +27,7 @@ vi.mock('@/components/ui/ButtonTooltip', () => ({ type: _type, ...props }: any) => ( - diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx index 358ada0b955..b7d67fd8641 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -321,7 +321,7 @@ export const PlatformWebhooksEndpointDetails = ({ paginatedDeliveries.map((row) => ( onOpenDelivery(row.original.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx index 766c1997268..732202461f9 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx @@ -170,7 +170,7 @@ export const PlatformWebhooksEndpointList = ({ return ( buildEventTypeGroups(scope, eventTypes), [scope, eventTypes] diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx index 815751dc7b4..fc9a0c6f7e9 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx @@ -21,7 +21,7 @@ import { DropdownMenuTrigger, Label, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' diff --git a/apps/studio/components/interfaces/Project/APIKeys.tsx b/apps/studio/components/interfaces/Project/APIKeys.tsx index 2a652fed5b3..da544374048 100644 --- a/apps/studio/components/interfaces/Project/APIKeys.tsx +++ b/apps/studio/components/interfaces/Project/APIKeys.tsx @@ -3,7 +3,7 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useParams } from 'common' import { Loader } from 'lucide-react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx index 0b78c9f0378..5053016ed39 100644 --- a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx +++ b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { useRouter } from 'next/router' -import { useMemo, useState, type ComponentPropsWithoutRef } from 'react' +import { useMemo, useRef, useState, type ComponentPropsWithoutRef } from 'react' import { useForm } from 'react-hook-form' import { AWS_REGIONS, CloudProvider } from 'shared-data' import { toast } from 'sonner' @@ -99,6 +99,7 @@ export const ResumeProjectButton = ({ mode: 'onChange', defaultValues: { postgresVersionSelection: '' }, }) + const lastValidPostgresVersionSelection = useRef('') const onSelectRestore = () => { if (project?.status !== PROJECT_STATUS.INACTIVE) { @@ -209,6 +210,7 @@ export const ResumeProjectButton = ({ dbRegion={region?.displayName ?? ''} cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider} organizationSlug={selectedOrganization?.slug} + lastValidSelectionRef={lastValidPostgresVersionSelection} /> )} /> diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx index 8fabf212664..2a2b7f3fa31 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx @@ -86,6 +86,7 @@ export const FirstLevelNav = (): ReactNode => { return (
    - - - Project - Compute Size - Monthly Costs - - - - {organizationProjects.map((project) => { - const primaryDb = project.databases.find( - (db) => db.identifier === project.ref - ) - return ( - - {project.name} - - {instanceLabel(primaryDb?.infra_compute_size)} - - - ${monthlyInstancePrice(primaryDb?.infra_compute_size)} - - - ) - })} +
    + + + Project + Compute Size + Monthly Costs + + + + {organizationProjects.map((project) => { + const primaryDb = project.databases.find( + (db) => db.identifier === project.ref + ) + return ( + + {project.name} + + {instanceLabel(primaryDb?.infra_compute_size)} + + + ${monthlyInstancePrice(primaryDb?.infra_compute_size)} + + + ) + })} - - - - {form.getValues('projectName') || 'New project'} - - New - - {instanceLabel(instanceSize)} - - ${monthlyInstancePrice(instanceSize)} - - - -
    - - - - - Compute Credits - - -$10 - - - - - - - Total Monthly Compute Costs - {/** - * API currently doesnt output replica information on the projects list endpoint. Until then, we cannot correctly calculate the costs including RRs. - * Will be adjusted in the future [kevin] - */} - {organizationProjects.length > 0 && ( -

    - Excluding Read replicas -

    - )} -
    - - ${monthlyComputeCosts} - -
    -
    -
    - -
    + + + + {form.getValues('projectName') || 'New project'} + + New + + {instanceLabel(instanceSize)} + + ${monthlyInstancePrice(instanceSize)} + + + + + + + + + Compute Credits + + -$10 + + + + + + + Total Monthly Compute Costs + {/** + * API currently doesnt output replica information on the projects list endpoint. Until then, we cannot correctly calculate the costs including RRs. + * Will be adjusted in the future [kevin] + */} + {organizationProjects.length > 0 && ( +

    Excluding Read replicas

    + )} +
    + + ${monthlyComputeCosts} + +
    +
    +
    +
    - )} -
    +
    +
    + )} + +
    + {cancelAction === 'hidden' ? null : showCloseWindowHint ? ( +

    + Close window to cancel +

    + ) : ( + + )} -
    -
    + !isVercelIntegrationFlow && ( +
    +

    Create a new project

    +

    + Your project will have its own dedicated instance and full Postgres database. An + API will be set up so you can easily interact with your new database. +

    +
    + ) } footer={ { organizationProjects={organizationProjects} isCreatingNewProject={isCreatingNewProject} isSuccessNewProject={isSuccessNewProject} + cancelAction={isVercelIntegrationFlow ? 'close' : 'studio'} /> } > @@ -546,11 +641,14 @@ export const ProjectCreationForm = () => { ) : (
    - + {canCreateProject && ( <> - {canConfigureGitHubOnCreate && ( + {!isVercelIntegrationFlow && canConfigureGitHubOnCreate && ( { )} + + {canChooseInstanceSize && } @@ -593,13 +696,15 @@ export const ProjectCreationForm = () => { instanceSize={instanceSize as DesiredInstanceSize} /> - + {isVercelIntegrationFlow && !!externalId && } + + {showInternalOnlyConfiguration && } - {showAdvancedConfig && !!availableOrioleVersion && ( - - )} + {showAdvancedConfig && + !!availableOrioleVersion && + highAvailability !== true && } {shouldShowFreeProjectInfo ? ( { const { slug } = useParams() const cloudProvider = form.getValues('cloudProvider') as CloudProvider + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + const dbRegion = useWatch({ control: form.control, name: 'dbRegion' }) + const highAvailabilityRegionCode = getHighAvailabilityRegionCode() const { hasLoaded: flagsLoaded } = useFeatureFlags() const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' @@ -91,8 +101,11 @@ export const RegionSelector = ({ { enabled: smartRegionEnabled, staleTime: 1000 * 60 * 5 } // 5 minutes ) - const smartRegions = availableRegionsData?.all.smartGroup ?? [] + const allSmartRegions = availableRegionsData?.all.smartGroup ?? [] const allRegions = availableRegionsData?.all.specific ?? [] + const restrictHighAvailabilityRegion = + highAvailability && highAvailabilityRegionCode !== undefined + const smartRegions = highAvailability ? [] : allSmartRegions const recommendedSmartRegions = new Set( [availableRegionsData?.recommendations.smartGroup.code].filter(Boolean) @@ -111,7 +124,11 @@ export const RegionSelector = ({ } }) - const regionOptions = smartRegionEnabled ? allRegions : regionsArray + const unfilteredRegionOptions = smartRegionEnabled ? allRegions : regionsArray + const regionOptions = filterHighAvailabilityRegions( + [...unfilteredRegionOptions], + highAvailability + ) const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion const showNonProdFields = @@ -120,6 +137,32 @@ export const RegionSelector = ({ const allSelectableRegions = [...smartRegions, ...regionOptions] + // react-hook-form intermittently drops this field's value when its Controller + // remounts (e.g. a sibling section mounting/unmounting in the same update, such as + // toggling high availability), so a one-shot effect isn't enough. Instead this effect + // re-asserts off the watched value: a region present in the current list is kept (and + // remembered in lastValidRegionRef), and when it's missing or cleared out from under us + // it restores the last valid region. allSelectableRegions is intentionally omitted from + // deps — it's a new array every render, and comparing it by reference would defeat the + // point of reacting to genuine content changes on every render where they occur. + const lastValidRegionRef = useRef(undefined) + useEffect(() => { + if (allSelectableRegions.length === 0) return + const isRegionAvailable = (name: string | undefined) => + !!name && allSelectableRegions.some((region) => region.name === name) + + if (isRegionAvailable(dbRegion)) { + lastValidRegionRef.current = dbRegion + return + } + + const lastValidRegion = lastValidRegionRef.current + if (lastValidRegion !== undefined && isRegionAvailable(lastValidRegion)) { + form.setValue('dbRegion', lastValidRegion) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbRegion, form]) + if (isErrorAvailableRegions) { return } @@ -131,9 +174,16 @@ export const RegionSelector = ({ name="dbRegion" render={({ field }) => { const selectedRegion = allSelectableRegions.find((region) => { - return !!region.name && region.name === field.value + return !!region.name && region.name === dbRegion }) + const selectedRegionLabel = selectedRegion?.name + ? getDisplayNameForSmartRegion(selectedRegion.name) + : dbRegion + const triggerLabel = isLoadingAvailableRegions + ? 'Loading available regions...' + : selectedRegionLabel + const affectingIncidents = incidents.filter((incident) => { const affectedRegions = incident.cache?.affected_regions ?? [] if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false @@ -156,21 +206,28 @@ export const RegionSelector = ({ description={ <>

    Select the region closest to your users for the best performance.

    - {showNonProdFields && ( + {restrictHighAvailabilityRegion ? (
    -

    Only these regions are supported for local/staging projects:

    -
      -
    • East US (North Virginia)
    • -
    • Central EU (Frankfurt)
    • -
    • Southeast Asia (Singapore)
    • -
    + High Availability projects are currently limited to{' '} + {regionOptions[0]?.name ?? highAvailabilityRegionCode}.
    + ) : ( + showNonProdFields && ( +
    +

    Only these regions are supported for local/staging projects:

    +
      +
    • East US (North Virginia)
    • +
    • Central EU (Frankfurt)
    • +
    • Southeast Asia (Singapore)
    • +
    +
    + ) )} } > - - {field.value !== undefined && ( + {dbRegion !== undefined && (
    + {isLoadingAvailableRegions && ( + + )} {selectedRegion?.code && ( // For some reason, Safari considered the empty string alt text on this icon as misspelled (with VoiceOver) // Only way to fix it is to set the role. Not needed for the combobox options @@ -194,17 +254,13 @@ export const RegionSelector = ({ src={`${BASE_PATH}/img/regions/${selectedRegion.code}.svg`} /> )} - - {selectedRegion?.name - ? getDisplayNameForSmartRegion(selectedRegion.name) - : field.value} - + {triggerLabel}
    )}
    - {smartRegionEnabled && ( + {smartRegionEnabled && !highAvailability && ( <> General regions @@ -244,7 +300,9 @@ export const RegionSelector = ({ )} - Specific regions + + {highAvailability ? 'High Availability Regions' : 'Specific regions'} + {regionOptions.map((value) => { return ( layout?: 'vertical' | 'horizontal' + surface: 'main' | 'vercel' } -export const SecurityOptions = ({ form, layout = 'horizontal' }: SecurityOptionsProps) => { +export const SecurityOptions = ({ form, layout = 'horizontal', surface }: SecurityOptionsProps) => { + const { slug } = useParams() const dataApi = useWatch({ control: form.control, name: 'dataApi' }) const dataApiDefaultPrivileges = useWatch({ control: form.control, @@ -34,11 +37,20 @@ export const SecurityOptions = ({ form, layout = 'horizontal' }: SecurityOptions }) const hasUserModified = form.getFieldState('dataApiDefaultPrivileges', form.formState).isDirty - useTrackDefaultPrivilegesExposure({ - surface: 'main', - dataApiDefaultPrivileges: dataApiDefaultPrivileges ?? true, - hasUserModified, - }) + useTrackDefaultPrivilegesExposure( + surface === 'main' + ? { + surface: 'main', + dataApiDefaultPrivileges: dataApiDefaultPrivileges ?? true, + hasUserModified, + } + : { + surface: 'vercel', + orgSlug: slug, + dataApiDefaultPrivileges: dataApiDefaultPrivileges ?? true, + hasUserModified, + } + ) return ( diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.test.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.test.tsx new file mode 100644 index 00000000000..6316987f744 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { BranchStatValue } from './ActivityStats' + +describe('BranchStatValue', () => { + it('shows branches as unavailable for HA projects without cached branch data', () => { + render( + + ) + + expect(screen.getByText('Unavailable')).toBeInTheDocument() + expect(screen.queryByText('No branches')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx index 220d9d71063..cc68eb0a6bd 100644 --- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx @@ -8,28 +8,91 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { HighAvailabilityBadge } from './HighAvailabilityBadge' import { ServiceStatus } from './ServiceStatus' import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper' +import { DisableInteraction } from '@/components/ui/DisableInteraction' import { SingleStat } from '@/components/ui/SingleStat' import { useBranchesQuery } from '@/data/branches/branches-query' import { useBackupsQuery } from '@/data/database/backups-query' import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' import { EMPTY_ARR } from '@/lib/void' +interface BranchStatValueProps { + currentBranch?: { created_at?: string } + isDefaultProject: boolean + isError: boolean + isHighAvailability: boolean + isLoading: boolean + latestNonDefaultBranch?: { name?: string } +} + +export const BranchStatValue = ({ + currentBranch, + isDefaultProject, + isError, + isHighAvailability, + isLoading, + latestNonDefaultBranch, +}: BranchStatValueProps) => { + if (isHighAvailability) { + return

    Unavailable

    + } + + if (isLoading) { + return + } + + if (isError) { + return

    Unable to load

    + } + + if (isDefaultProject) { + return ( +

    + {latestNonDefaultBranch?.name ?? 'No branches'} +

    + ) + } + + if (currentBranch?.created_at) { + return ( + + ) + } + + return

    Unknown

    +} + export const ActivityStats = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const { data: organization } = useSelectedOrganizationQuery() const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug }) const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref) const parentProjectRef = project?.parent_project_ref ?? project?.ref - const { data: branchesData, isPending: isLoadingBranches } = useBranchesQuery({ - projectRef: parentProjectRef, - }) + const { + data: branchesData, + isPending: isLoadingBranches, + isError: isBranchesError, + } = useBranchesQuery( + { + projectRef: parentProjectRef, + }, + { enabled: !isHighAvailability } + ) const isDefaultProject = project?.parent_project_ref === undefined const currentBranch = useMemo( () => (branchesData ?? []).find((b) => b.project_ref === ref), @@ -132,38 +195,27 @@ export const ActivityStats = () => { } /> - } - label={{isDefaultProject ? 'Recent branch' : 'Branch Created'}} - trackingProperties={{ - stat_type: 'branches', - stat_value: branchesData?.length ?? 0, - }} - value={ - isLoadingBranches ? ( - - ) : isDefaultProject ? ( -

    - {latestNonDefaultBranch?.name ?? 'No branches'} -

    - ) : currentBranch?.created_at ? ( - + } + label={{isDefaultProject ? 'Recent branch' : 'Branch Created'}} + trackingProperties={{ + stat_type: 'branches', + stat_value: branchesData?.length ?? 0, + }} + value={ + - ) : ( -

    Unknown

    - ) - } - /> + } + /> + { diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx index ef6267c18b2..b2e4d3e18f7 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.tsx @@ -103,6 +103,7 @@ export const QueryInsightsChart = ({ ) : ( - + - {showSearch && } + {showSearch && } No options found. @@ -90,9 +93,7 @@ export const ReportsSelectFilter = ({