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 175aec83c51..8f48264e86e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -24,7 +24,6 @@ pnpm 11 + Turborepo monorepo. Requires Node >= 22.13. ## Common Commands ```bash -pnpm install # install dependencies pnpm dev:studio # run Studio dev server → http://localhost:8082 pnpm dev:docs # run docs dev server pnpm dev:www # run www dev server @@ -63,6 +62,7 @@ The skills in `.claude/skills/` are the source of truth for conventions — load - `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 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/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md new file mode 100644 index 00000000000..29571cb24c7 --- /dev/null +++ b/.claude/skills/react-hook-form/SKILL.md @@ -0,0 +1,278 @@ +--- +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 })`. +- 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 && - - - )} - - - - - - - - @@ -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/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/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 8bd01d65a4d..1f50e17d6d6 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -8,7 +8,7 @@ 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, @@ -41,6 +41,7 @@ import { estimateRestoreTime, } from './BranchManagement.utils' 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' @@ -111,7 +112,10 @@ 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 { @@ -522,9 +526,9 @@ 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/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/ConnectSheet/Connect.constants.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts index 2d7673555a0..86071ccaef9 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.constants.ts @@ -396,7 +396,7 @@ export const connectionStringMethodOptions: Record< value: 'direct', label: 'Direct connection', description: - 'Ideal for applications with persistent and long-lived connections, such as those running on virtual machines or long-standing containers.', + 'Ideal for applications with persistent and long-lived connections such as those running on virtual machines or long-standing containers.', }, transaction: { value: 'transaction', @@ -408,6 +408,6 @@ export const connectionStringMethodOptions: Record< value: 'session', label: 'Session pooler', description: - 'Only recommended as an alternative to Direct Connection, when connecting via an IPv4 network.', + 'Only recommended as an alternative to direct connection when connecting via an IPv4 network.', }, } diff --git a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts index 675d6d7c473..2c863fdabb8 100644 --- a/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts +++ b/apps/studio/components/interfaces/ConnectSheet/Connect.types.ts @@ -103,6 +103,8 @@ export interface StepDefinition { id: string title: string description: string + /** When true, renders a muted "(optional)" label next to the title. */ + optional?: boolean // Component identifier or content file path, can be conditional content: ConditionalValue } @@ -142,6 +144,7 @@ export interface ResolvedStep { id: string title: string description: string + optional?: boolean content: string // Resolved component identifier } diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx index ac4ef2009a1..bb21ae25718 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx @@ -1,4 +1,3 @@ -import { Box, Cable, Database, Server, Sparkles } from 'lucide-react' import { cn, RadioGroupStacked, @@ -21,14 +20,11 @@ import { import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types' import { ConnectionIcon } from './ConnectionIcon' - -const MODE_ICONS: Record = { - framework: , - direct: , - orm: , - mcp: , - server: , -} +import { + ConnectModeButton, + getConnectModeButtonCornerVariants, + getConnectModeEmptySlotClasses, +} from './ConnectModeButton' interface ConnectConfigSectionProps { activeFields: ResolvedField[] @@ -101,27 +97,22 @@ export function ConnectConfigSection({ onFieldChange(field.id, v)} + className="min-w-0 w-full" > {options.map((option) => ( -
-
+ className="min-w-0 w-full text-left" + label={ + {option.icon && } - {option.label} -
- {option.description && ( - - {option.description} - - )} -
-
+ {option.label} + + } + description={option.description} + /> ))}
@@ -201,7 +192,7 @@ export function ConnectConfigSection({ > @@ -244,31 +235,43 @@ interface ModeSelectorProps { } export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) { + const count = modes.length + // 2-col layout leaves an empty cell when count is odd; hide it once we switch to a single row + const emptySlots = count % 2 === 1 ? 1 : 0 + return ( -
- {modes.map((mode) => ( - - ))} + // 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..6ea2ce855cd 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -159,13 +159,17 @@ 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..808e91f7950 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' @@ -119,7 +120,7 @@ function useConnectionStringPooler(deploymentMode: DeploymentMode): ConnectionSt // 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 +243,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 +302,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} - + onClick={() => onCopy?.(param.key)} + />
-
- ))} + ))} +
) } 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/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/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__/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..5e9ac4a6b3d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/__tests__/useConnectState.test.ts @@ -694,5 +694,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/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..c5b79d422d9 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' @@ -225,23 +224,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..85325cb8eff 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,14 +342,21 @@ 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] ) const activeFields = useMemo(() => { 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/Extensions/EnableExtensionModal.tsx b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx index 5e63927a2eb..b16f953cf45 100644 --- a/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/EnableExtensionModal.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMemo } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Badge, @@ -23,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' @@ -106,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 f563566b069..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, 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/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx index 4e59521d12b..e24db3b4374 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/index.tsx @@ -14,7 +14,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { isEqual } from 'lodash' import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -130,7 +130,10 @@ export const PolicyEditorPanel = memo(function ({ defaultValues, }) - const { name, table, behavior, command, roles } = form.watch() + const [name, table, behavior, command, roles] = useWatch({ + control: form.control, + name: ['name', 'table', 'behavior', 'command', 'roles'], + }) const supportWithCheck = ['update', 'all'].includes(command) const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx index 9b04640fe63..6f3a7746778 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx @@ -12,7 +12,7 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { usePoliciesData } from '../PoliciesDataContext' diff --git a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx index 556c808c992..1f8b0b7bfc6 100644 --- a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx +++ b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx @@ -10,7 +10,7 @@ import { DialogTitle, DialogTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { INTERNAL_SCHEMAS, useIsProtectedSchema } from '@/hooks/useProtectedSchemas' diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx new file mode 100644 index 00000000000..244222531a9 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { PublicationsAvailability } from './PublicationsAvailability' + +const { mockUseHighAvailability } = vi.hoisted(() => ({ + mockUseHighAvailability: vi.fn(), +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +describe('PublicationsAvailability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the disabled empty state instead of page content for High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true }) + + render( + +
Publications content
+
+ ) + + expect( + screen.getByText('Publications unavailable on High Availability projects') + ).toBeInTheDocument() + expect( + screen.getByText( + "We're working to bring publications to High Availability projects. Contact support if this is blocking your work." + ) + ).toBeInTheDocument() + expect(screen.queryByText('Publications content')).not.toBeInTheDocument() + }) + + it('renders page content for non-High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false }) + + render( + +
Publications content
+
+ ) + + expect(screen.getByText('Publications content')).toBeInTheDocument() + expect( + screen.queryByText('Publications unavailable on High Availability projects') + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx new file mode 100644 index 00000000000..d38156ada71 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx @@ -0,0 +1,23 @@ +import { BookOpen } from 'lucide-react' +import type { PropsWithChildren } from 'react' + +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' + +export const PublicationsAvailability = ({ children }: PropsWithChildren) => { + const { isHighAvailability } = useHighAvailability() + + if (isHighAvailability) { + return ( +
+ +
+ ) + } + + return children +} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index 05646721e50..349c8101272 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { Search } from 'lucide-react' import { useMemo, useRef, useState } from 'react' import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { PublicationTablesSkeleton } from './PublicationSkeleton' diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx new file mode 100644 index 00000000000..cae640ff9a7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.test.tsx @@ -0,0 +1,81 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { BatchRestartDialog } from './BatchRestartDialog' +import type { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query' + +const mocks = vi.hoisted(() => ({ + rollbackTables: vi.fn().mockResolvedValue({ pipeline_id: 9, tables: [] }), +})) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'project-ref', pipelineId: '9' }), +})) +vi.mock('@/data/replication/rollback-tables-mutation', () => ({ + useRollbackTablesMutation: () => ({ + mutateAsync: mocks.rollbackTables, + isPending: false, + }), +})) +vi.mock('./RestartCostEstimate', () => ({ + RestartCostEstimate: ({ tables }: { tables: { schema: string; name: string }[] }) => ( +
+ {tables.map(({ schema, name }) => `${schema}.${name}`).join(',')} +
+ ), +})) + +const table = ( + id: number, + state: ReplicationPipelineTableStatus['state'] +): ReplicationPipelineTableStatus => ({ + id, + schema: 'public', + name: `table_${id}`, + table_id: id, + table_name: `public.table_${id}`, + state, +}) + +describe('BatchRestartDialog', () => { + it('describes every table reset by the all-errored backend target', async () => { + const onRestartStart = vi.fn() + const tables = [ + table(1, { name: 'error', reason: 'manual', retry_policy: { policy: 'manual_retry' } }), + table(2, { name: 'error', reason: 'terminal', retry_policy: { policy: 'no_retry' } }), + table(3, { + name: 'error', + reason: 'timed', + retry_policy: { policy: 'timed_retry', next_retry: '2026-07-21T12:00:00Z' }, + }), + table(4, { name: 'following_wal' }), + ] + + render( + + ) + + expect(screen.getByText(/3 currently failed tables/)).toBeInTheDocument() + expect(screen.getByTestId('copy-targets')).toHaveTextContent('public.table_1,public.table_2') + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Restart failed tables' })) + }) + + expect(onRestartStart).toHaveBeenCalledWith([1, 2, 3]) + expect(mocks.rollbackTables).toHaveBeenCalledWith( + expect.objectContaining({ + pipelineId: 9, + target: { type: 'all_errored_tables' }, + rollbackType: 'full', + }) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx index 5ee178156e6..5c9b209f0fe 100644 --- a/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/BatchRestartDialog.tsx @@ -14,6 +14,7 @@ import { import { PipelineStatusName } from './Replication.constants' import { RestartCostEstimate } from './RestartCostEstimate' +import { getTableCopyTargets, type TableSyncCopyConfig } from './TableSyncCopy.utils' import { ReplicationPipelineTableStatus } from '@/data/replication/pipeline-replication-status-query' import { useRollbackTablesMutation } from '@/data/replication/rollback-tables-mutation' @@ -24,6 +25,7 @@ interface BatchRestartDialogProps { tables: ReplicationPipelineTableStatus[] sourceId?: number publicationName?: string + tableSyncCopy?: TableSyncCopyConfig pipelineStatusName?: PipelineStatusName onRestartStart?: (tableIds: number[]) => void onRestartComplete?: (tableIds: number[]) => void @@ -36,6 +38,7 @@ export const BatchRestartDialog = ({ tables, sourceId, publicationName, + tableSyncCopy, pipelineStatusName, onRestartStart, onRestartComplete, @@ -46,23 +49,41 @@ export const BatchRestartDialog = ({ if (mode === 'all') { return tables } else { - return tables.filter( - (t) => - t.state.name === 'error' && - 'retry_policy' in t.state && - t.state.retry_policy?.policy === 'manual_retry' - ) + return tables.filter((table) => table.state.name === 'error') } }, [mode, tables]) - const affectedTableIds = useMemo( - () => affectedTables.map((table) => table.table_id), - [affectedTables] + const affectedTableIds = useMemo(() => affectedTables.map((table) => table.id), [affectedTables]) + + const copiedTables = useMemo( + () => getTableCopyTargets(affectedTables, tableSyncCopy), + [affectedTables, tableSyncCopy] ) - const affectedTableNames = useMemo( - () => affectedTables.map((table) => table.table_name), - [affectedTables] - ) + const initialSyncDescription = + copiedTables.length === 0 ? ( +
  • + No table will run an initial sync. Replication will resume with new changes + only, without syncing existing source rows. There is no additional initial sync charge. +
  • + ) : copiedTables.length === affectedTables.length ? ( +
  • + + {copiedTables.length === 1 + ? 'The table will run its initial sync again.' + : `All ${copiedTables.length} tables will run initial sync again.`} + {' '} + Existing source rows will be synced again. Data successfully processed during this initial + sync is billed again. +
  • + ) : ( +
  • + + {copiedTables.length} of {affectedTables.length} tables will run initial sync again. + {' '} + Existing source rows for those tables will be synced again and billed again. The remaining + tables will resume replication with new changes only. +
  • + ) const { mutateAsync: rollbackTables, isPending: isResetting } = useRollbackTablesMutation({ onSuccess: (data) => { @@ -107,11 +128,7 @@ export const BatchRestartDialog = ({ {affectedTables.length === 1 ? '' : 's'} in this pipeline from scratch:

      -
    • - Every table's initial sync will restart. All table data will be - copied again from the source. This initial sync is billed in addition to previous - initial syncs. -
    • + {initialSyncDescription}
    • All downstream data will be deleted. All replicated data will be removed. @@ -130,22 +147,18 @@ export const BatchRestartDialog = ({ description: (

      - This will restart replication for{' '} - all {affectedTables.length} failed tables from scratch: + This will restart replication for all{' '} + {affectedTables.length} currently failed tables from scratch:

        -
      • - Each failed table's initial sync will restart. These tables will - be copied again from the source. This initial sync is billed in addition to - previous initial syncs. -
      • + {initialSyncDescription}
      • Existing downstream data will be deleted. Replicated data for these tables will be removed.
      • - All other tables remain untouched. Only failed tables are - affected. + Tables that are not failed remain untouched. The request resets + every table that is failed when it runs.
      • The pipeline will restart automatically. This is required to @@ -169,7 +182,7 @@ export const BatchRestartDialog = ({ projectRef={projectRef} sourceId={sourceId} publicationName={publicationName} - tableNames={affectedTableNames} + tables={copiedTables} /> Cancel diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx index 65945d11068..d004564c635 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx @@ -118,9 +118,9 @@ export const AdvancedSettings = ({ name="maxCopyConnectionsPerTable" render={({ field }) => ( diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx index eb674d27ea5..4c66ffe3352 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx @@ -1,8 +1,7 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Eye, EyeOff, Loader2 } from 'lucide-react' import { useState } from 'react' -import type { UseFormReturn } from 'react-hook-form' +import { useWatch, type UseFormReturn } from 'react-hook-form' import { Button, FormControl, @@ -16,7 +15,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' @@ -27,11 +26,9 @@ import { } from '../DestinationForm.constants' import type { DestinationPanelSchemaType } from '../DestinationForm.schema' import { InlineLink } from '@/components/ui/InlineLink' -import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useAnalyticsBucketsQuery } from '@/data/storage/analytics-buckets-query' import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-query' import { useStorageCredentialsQuery } from '@/data/storage/s3-access-key-query' -import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' /** * [Joshen] JFYI I'd foresee a possible UX friction point here regarding S3 access key IDs and secret access keys @@ -64,20 +61,15 @@ export const AnalyticsBucketFields = ({ editMode: boolean onSelectNewBucket: () => void }) => { - const { warehouseName, s3AccessKeyId, namespace } = form.watch() + const [warehouseName, s3AccessKeyId, namespace] = useWatch({ + control: form.control, + name: ['warehouseName', 's3AccessKeyId', 'namespace'], + }) const [showCatalogToken, setShowCatalogToken] = useState(false) const [showSecretAccessKey, setShowSecretAccessKey] = useState(false) const { ref: projectRef } = useParams() - const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') - const { data: apiKeysData } = useAPIKeys( - { projectRef, reveal: true }, - { enabled: canReadAPIKeys } - ) - const { serviceKey } = apiKeysData ?? {} - const serviceApiKey = serviceKey?.api_key ?? '' - const { data: keysData, isSuccess: isSuccessKeys, @@ -96,7 +88,7 @@ export const AnalyticsBucketFields = ({ isError: isErrorBuckets, } = useAnalyticsBucketsQuery({ projectRef }) - const canSelectNamespace = !!warehouseName && !!serviceApiKey + const canSelectNamespace = !!warehouseName const { data: namespaces = [], @@ -104,7 +96,7 @@ export const AnalyticsBucketFields = ({ isError: isErrorNamespaces, } = useIcebergNamespacesQuery( { projectRef, warehouse: warehouseName }, - { enabled: !!serviceApiKey } + { enabled: !!warehouseName } ) return ( @@ -293,7 +285,7 @@ export const AnalyticsBucketFields = ({ type={showCatalogToken ? 'text' : 'password'} placeholder={editMode ? STORED_SECRET_PLACEHOLDER : 'Auto-populated'} actions={ - serviceApiKey ? ( + field.value ? (
        - - -
        - - - + onClose()}> + +
        + + Create a new publication + Choose which tables to replicate to destinations. + + +
        + + ( + + + + + + )} + /> + ( + + + + + + + {tables?.map((table) => ( + + {`${table.schema}.${table.name}`} + + ))} + + + + + + )} + /> + + +
        + + + + +
        +
        +
        ) } 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 a7cfe11ea98..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,9 +39,9 @@ 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 } @@ -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,13 +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 initialSyncTotal = estimate?.table_copy.total_cost ?? 0 + const initialSyncTotal = copyEstimate?.estimatedCost ?? 0 useEffect(() => { if (open && isError) onConfirmRef.current() @@ -115,14 +140,23 @@ export const PipelineCostDialog = ({

        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) @@ -130,7 +164,7 @@ export const PipelineCostDialog = ({ {visibleTables.map((table) => ( - + {table.schema}.{table.name} @@ -155,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)} @@ -166,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.

        )} @@ -184,18 +219,24 @@ export const PipelineCostDialog = ({
        - Ongoing replication data + + Ongoing replication data processed + {formatCurrency(estimate.streaming.rate_per_gb)}/GB

        - Ongoing replication is billed on the volume of changes processed after the - initial sync, 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. + are separate. See{' '} + + how data processed is measured + + .

        @@ -204,21 +245,21 @@ export const PipelineCostDialog = ({

        Estimated initial sync

        - Pipeline hours and ongoing replication data are billed separately + Pipeline hours and ongoing replication are billed separately.

        - {formatCurrency(initialSyncTotal)} - {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 14b853877ab..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,7 +45,18 @@ export const PublicationSelection = ({ > { + if (value !== field.value) { + form.setValue('tableSyncCopyTableIds', [], { + shouldDirty: true, + shouldValidate: true, + }) + } + field.onChange(value) + }, + }} sourceId={sourceId} onNewPublicationClick={() => onSelectNewPublication()} /> diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx new file mode 100644 index 00000000000..4fdb27d3371 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.test.tsx @@ -0,0 +1,164 @@ +import { screen } from '@testing-library/react' +import type { components } from 'api-types' +import { HttpResponse } from 'msw' +import { useForm } from 'react-hook-form' +import { Form } from 'ui' +import { describe, expect, it } from 'vitest' + +import type { DestinationPanelSchemaType } from './DestinationForm.schema' +import { TableCopySelection } from './TableCopySelection' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +type ReplicationSourcesResponse = components['schemas']['ReplicationSourcesResponse'] +type ReplicationPublicationsResponse = components['schemas']['ReplicationPublicationsResponse'] + +const mockSources: ReplicationSourcesResponse = { + sources: [ + { + id: 1, + name: 'default', + tenant_id: 'tenant', + config: { host: 'db.internal', name: 'main-db', port: 5432, username: 'etl_user' }, + }, + ], +} + +const mockPublications: ReplicationPublicationsResponse = { + publications: [ + { + name: 'analytics', + tables: [ + { id: 101, schema: 'public', name: 'orders' }, + { id: 202, schema: 'billing', name: 'invoices' }, + ], + }, + ], +} + +const mockSourcesEndpoint = () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources', + response: () => HttpResponse.json(mockSources), + }) +} + +const mockPublicationsSuccess = () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources/:source_id/publications', + response: () => HttpResponse.json(mockPublications), + }) +} + +const mockPublicationsError = () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources/:source_id/publications', + response: () => HttpResponse.json({ message: 'Boom' }, { status: 500 }), + }) +} + +const mockPublicationsPending = () => { + addAPIMock({ + method: 'get', + path: '/platform/replication/:ref/sources/:source_id/publications', + response: () => new Promise(() => {}), + }) +} + +const TableCopySelectionHarness = ({ + editMode, + mode = 'include_all_tables', + selectedTableIds = [], +}: { + editMode: boolean + mode?: DestinationPanelSchemaType['tableSyncCopyMode'] + selectedTableIds?: string[] +}) => { + const form = useForm({ + defaultValues: { + name: 'Analytics', + publicationName: 'analytics', + tableSyncCopyMode: mode, + tableSyncCopyTableIds: selectedTableIds, + }, + }) + + return ( +
        + + + ) +} + +describe('TableCopySelection', () => { + it('explains that editing the policy does not recopy synchronized tables', async () => { + mockSourcesEndpoint() + mockPublicationsSuccess() + + customRender() + + expect( + await screen.findByText(/will not sync existing rows again unless you restart them/) + ).toBeInTheDocument() + }) + + it('does not show the edit-mode explanation while creating a pipeline', async () => { + mockSourcesEndpoint() + mockPublicationsSuccess() + + customRender() + + await screen.findByText('All tables') + expect( + screen.queryByText(/will not sync existing rows again unless you restart them/) + ).not.toBeInTheDocument() + }) + + it('summarizes selective copy choices against the publication table count', async () => { + mockSourcesEndpoint() + mockPublicationsSuccess() + + customRender( + + ) + + expect( + await screen.findByText( + '1 of 2 publication tables will run initial sync. Ongoing replication will still include every publication table.' + ) + ).toBeInTheDocument() + }) + + it('blocks selection and explains when publication tables cannot be loaded', async () => { + mockSourcesEndpoint() + mockPublicationsError() + + customRender( + + ) + + expect(await screen.findByText(/Publication tables could not be loaded/)).toBeInTheDocument() + expect(screen.queryByText(/previously selected table/)).not.toBeInTheDocument() + }) + + it('disables selective table choices while publication tables are loading', async () => { + mockSourcesEndpoint() + mockPublicationsPending() + + customRender( + + ) + + const loadingLabel = await screen.findByText('Loading publication tables...') + expect(loadingLabel).toBeInTheDocument() + expect(loadingLabel.closest('button')).toBeDisabled() + expect(screen.queryByText(/previously selected table/)).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx new file mode 100644 index 00000000000..3aff0d660c3 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/TableCopySelection.tsx @@ -0,0 +1,211 @@ +import { useParams } from 'common' +import { useMemo } from 'react' +import { useWatch, type UseFormReturn } from 'react-hook-form' +import { FormControl, FormField, Select, SelectContent, SelectItem, SelectTrigger } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { MultiSelector } from 'ui-patterns/multi-select' + +import type { DestinationPanelSchemaType } from './DestinationForm.schema' +import { getPublicationTableIds } from './DestinationForm.utils' +import { useReplicationPublicationsQuery } from '@/data/replication/publications-query' +import { useReplicationSourceId } from '@/data/replication/sources-query' + +interface TableCopySelectionProps { + form: UseFormReturn + editMode: boolean +} + +const INITIAL_SYNC_LABELS = { + include_all_tables: 'All tables', + skip_all_tables: 'No tables', + include_tables: 'Selected tables only', + skip_tables: 'All except selected tables', +} + +const isSelectiveMode = (mode: DestinationPanelSchemaType['tableSyncCopyMode']) => + mode === 'include_tables' || mode === 'skip_tables' + +const tableLabel = ({ schema, name }: { schema: string; name: string }) => `${schema}.${name}` + +export const TableCopySelection = ({ form, editMode }: TableCopySelectionProps) => { + const { ref: projectRef } = useParams() + const sourceId = useReplicationSourceId({ projectRef }) + + const [publicationName, tableSyncCopyMode, tableSyncCopyTableIds] = useWatch({ + control: form.control, + name: ['publicationName', 'tableSyncCopyMode', 'tableSyncCopyTableIds'], + }) + + const { + data: publications = [], + isPending: isLoadingPublications, + isError: isErrorPublications, + } = useReplicationPublicationsQuery({ projectRef, sourceId }) + + // Only publication tables are ever selectable or displayed by name. A table + // id is never resolved outside of the publication response — there's no + // other source for a name to come from without a separate, source-wide + // catalog fetch, which selective table-copy doesn't need. + const publicationTables = useMemo(() => { + const publication = publications.find(({ name }) => name === publicationName) + return [...(publication?.tables ?? [])].sort((a, b) => + tableLabel(a).localeCompare(tableLabel(b)) + ) + }, [publicationName, publications]) + + const publicationTableIds = useMemo( + () => getPublicationTableIds(publications, publicationName), + [publications, publicationName] + ) + const tableLabelsById = new Map( + publicationTables.map((table) => [String(table.id), tableLabel(table)]) + ) + const selectedPublicationCount = tableSyncCopyTableIds.filter((id) => + publicationTableIds.has(id) + ).length + const staleSelectedCount = + isLoadingPublications || isErrorPublications + ? 0 + : tableSyncCopyTableIds.filter((id) => !publicationTableIds.has(id)).length + const tableCount = publicationTables.length + + return ( +
        + ( + + + + + + )} + /> + + {editMode && ( + +

        + Tables that have completed initial sync will not sync existing rows again unless you + restart them. +

        +
        + )} + + {isErrorPublications && ( + +

        + Refresh the page before changing or saving the initial sync settings. +

        +
        + )} + + {isSelectiveMode(tableSyncCopyMode) && ( + ( + + + { + field.onChange(x) + }} + disabled={ + isLoadingPublications || + isErrorPublications || + !publicationName || + tableCount === 0 + } + > + tableLabelsById.get(id) ?? `Table ${id}`} + label={ + isLoadingPublications + ? 'Loading publication tables...' + : publicationName + ? 'Select tables...' + : 'Select a publication first' + } + /> + + + {publicationTables.map((table) => ( + + {tableLabel(table)} + + ))} + + + + + + {staleSelectedCount > 0 && ( + +

        + {staleSelectedCount === 1 ? 'It' : 'They'} will be excluded from this pipeline's + initial sync settings when you save. +

        +
        + )} +
        + )} + /> + )} +
        + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx index 23712bca42f..1cdf1541349 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationFailuresSection.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Badge, Card } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Markdown } from '@/components/interfaces/Markdown' import { InlineLink } from '@/components/ui/InlineLink' @@ -34,12 +34,12 @@ export const ValidationFailuresSection = ({

        {hasCriticalFailures ? `Please fix all required issues below${hasWarnings ? ' and review the others' : ''} before continuing.` - : 'The following issues were identified, although you may still create the pipeline and start replication to the destination.'} + : 'The following issues were identified. You may still continue after reviewing them.'}

        Pipeline options are under Advanced settings above. Source database diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx index 51cf36f9663..201c8cba410 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/ValidationWarningsDialog.tsx @@ -25,6 +25,7 @@ export const ValidationWarningsDialog = ({ onConfirm, }: ValidationWarningsDialogProps) => { const hasWarnings = warningCount > 0 + const action = 'Create and start pipeline' return ( @@ -32,8 +33,8 @@ export const ValidationWarningsDialog = ({ {hasWarnings - ? `Create and start pipeline with ${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}?` - : 'Create and start pipeline?'} + ? `${action} with ${warningCount} ${warningCount === 1 ? 'warning' : 'warnings'}?` + : `${action}?`} {hasWarnings @@ -49,7 +50,7 @@ export const ValidationWarningsDialog = ({ loading={isLoading} onClick={onConfirm} > - {hasWarnings ? 'Create and start pipeline anyway' : 'Create and start pipeline'} + {hasWarnings ? `${action} anyway` : action} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx new file mode 100644 index 00000000000..675c129e84b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx @@ -0,0 +1,235 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ButtonHTMLAttributes, PropsWithChildren } from 'react' +import type { UseFormReturn } from 'react-hook-form' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DestinationPanelSchemaType } from './DestinationForm.schema' +import { DestinationForm } from './index' + +const mocks = vi.hoisted(() => ({ + refetchPublications: vi.fn(), + resetValidation: vi.fn(), + submitPipeline: vi.fn(), + validateConfiguration: vi.fn(), +})) + +const existingBatch = { + max_fill_ms: 500, + max_bytes: 8_388_608, + memory_budget_ratio: 0.2, +} + +const existingDestination = { + destinationId: 7, + pipelineId: 8, + enabled: true, + statusName: 'started', +} + +const sourcesData = { sources: [{ id: 42, name: 'project-ref' }] } +const publications = [ + { + name: 'analytics', + tables: [{ id: 101, schema: 'public', name: 'orders' }], + }, +] +const destinationData = { + name: 'Analytics', + config: { + big_query: { + project_id: 'example-project', + dataset_id: 'analytics', + connection_pool_size: 5, + }, + }, +} +const pipelineData = { + config: { + publication_name: 'analytics', + table_sync_copy: { type: 'include_tables' as const, table_ids: [101, 999] }, + batch: existingBatch, + max_table_sync_workers: 4, + max_copy_connections_per_table: 1, + }, +} +const projectSettings = { region: 'ap-southeast-1' } + +vi.mock('common', () => ({ useParams: () => ({ ref: 'project-ref' }) })) + +vi.mock('framer-motion', () => ({ + AnimatePresence: ({ children }: PropsWithChildren) => children, + motion: { div: ({ children }: PropsWithChildren) =>

        {children}
        }, +})) + +vi.mock('ui', () => ({ + Button: ({ + children, + loading: _loading, + ...props + }: ButtonHTMLAttributes & { loading?: boolean }) => ( + + ), + 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 cd7cc1c9b93..a88710f4b53 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 { 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, @@ -21,6 +22,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' @@ -40,7 +42,12 @@ 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' @@ -50,6 +57,7 @@ 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' @@ -60,7 +68,7 @@ import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-que 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' @@ -74,6 +82,7 @@ interface DestinationFormProps { selectedType: DestinationType visible: boolean existingDestination?: ExistingDestination + typeSelection?: ReactNode onClose: () => void } @@ -81,6 +90,7 @@ export const DestinationForm = ({ selectedType, visible, existingDestination, + typeSelection, onClose, }: DestinationFormProps) => { const { ref: projectRef } = useParams() @@ -123,8 +133,7 @@ export const DestinationForm = ({ ]) 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 = [], @@ -132,19 +141,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 ?? '' @@ -160,9 +182,7 @@ export const DestinationForm = ({ destinationValidationFailures, pipelineValidationFailures, resetValidation, - } = useDestinationForm({ - selectedType, - }) + } = useDestinationForm({ selectedType }) const defaultValues = useMemo( () => @@ -190,6 +210,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 }) => { @@ -225,7 +261,7 @@ export const DestinationForm = ({ defaultValues, }) - const { publicationName } = form.watch() + const publicationName = useWatch({ control: form.control, name: 'publicationName' }) const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) const isSelectedPublicationMissing = @@ -235,8 +271,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) { @@ -259,11 +315,32 @@ export const DestinationForm = ({ setShowCostDialog(true) } - const onSubmit = async (data: z.infer) => { + 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, }) @@ -309,8 +386,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) @@ -326,6 +402,7 @@ export const DestinationForm = ({ await submitPipeline({ data: values, existingDestination, + existingBatch: pipelineData?.config.batch, onSuccess: () => form.reset(defaultValues), onClose, }) @@ -347,108 +424,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)} - /> - - 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 && ( - <> - - -
        - + + 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 && ( + <> + + +
        + +
        + + )} + )} @@ -490,10 +577,18 @@ export const DestinationForm = ({ { - if (newPublication) form.setValue('publicationName', newPublication) + if (newPublication) { + form.setValue('tableSyncCopyTableIds', [], { + shouldDirty: true, + shouldValidate: true, + }) + form.setValue('publicationName', newPublication, { + shouldDirty: true, + shouldValidate: true, + }) + } setPublicationPanelVisible(false) }} /> @@ -514,9 +609,9 @@ export const DestinationForm = ({ diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx new file mode 100644 index 00000000000..ea4832617bb --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.test.tsx @@ -0,0 +1,205 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { DestinationPanelSchemaType } from './DestinationForm.schema' +import { useDestinationForm } from './useDestinationForm' + +const mocks = vi.hoisted(() => ({ + validateDestination: vi.fn(), + validatePipeline: vi.fn(), + createS3AccessKey: vi.fn(), + createNamespace: vi.fn(), + createDestinationPipeline: vi.fn(), + updateDestinationPipeline: vi.fn(), + startPipeline: vi.fn(), + setRequestStatus: vi.fn(), +})) + +vi.mock('common', () => ({ useParams: () => ({ ref: 'project-ref' }) })) +vi.mock('@/data/replication/sources-query', () => ({ + useReplicationSourcesQuery: () => ({ + data: { sources: [{ id: 42, name: 'project-ref' }] }, + }), +})) +vi.mock('@/data/replication/validate-destination-mutation', () => ({ + useValidateDestinationMutation: () => ({ + mutateAsync: mocks.validateDestination, + isPending: false, + }), +})) +vi.mock('@/data/replication/validate-pipeline-mutation', () => ({ + useValidatePipelineMutation: () => ({ + mutateAsync: mocks.validatePipeline, + isPending: false, + }), +})) +vi.mock('@/data/storage/s3-access-key-create-mutation', () => ({ + useS3AccessKeyCreateMutation: () => ({ + mutateAsync: mocks.createS3AccessKey, + isPending: false, + }), +})) +vi.mock('@/data/storage/iceberg-namespace-create-mutation', () => ({ + useIcebergNamespaceCreateMutation: () => ({ + mutateAsync: mocks.createNamespace, + isPending: false, + }), +})) +vi.mock('@/data/replication/create-destination-pipeline-mutation', () => ({ + useCreateDestinationPipelineMutation: () => ({ + mutateAsync: mocks.createDestinationPipeline, + isPending: false, + }), +})) +vi.mock('@/data/replication/update-destination-pipeline-mutation', () => ({ + useUpdateDestinationPipelineMutation: () => ({ + mutateAsync: mocks.updateDestinationPipeline, + isPending: false, + }), +})) +vi.mock('@/data/replication/start-pipeline-mutation', () => ({ + useStartPipelineMutation: () => ({ + mutateAsync: mocks.startPipeline, + isPending: false, + }), +})) +vi.mock('@/state/replication-pipeline-request-status', () => ({ + PipelineStatusRequestStatus: { + RestartRequested: 'restart-requested', + StartRequested: 'start-requested', + }, + usePipelineRequestStatus: () => ({ setRequestStatus: mocks.setRequestStatus }), +})) + +const formData = { + name: 'Analytics', + publicationName: 'analytics', + tableSyncCopyMode: 'include_tables', + tableSyncCopyTableIds: ['101'], + maxFillMs: 500, + projectId: 'example-project', + datasetId: 'analytics', + serviceAccountKey: '', + connectionPoolSize: 5, +} as DestinationPanelSchemaType + +describe('useDestinationForm validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.validateDestination.mockResolvedValue({ validation_failures: [] }) + mocks.validatePipeline.mockResolvedValue({ validation_failures: [] }) + }) + + it('validates both destination and pipeline while creating', async () => { + const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) + + await act(async () => { + await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail: vi.fn(), + }) + }) + + expect(mocks.validateDestination).toHaveBeenCalledOnce() + expect(mocks.validatePipeline).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: 'project-ref', + sourceId: 42, + publicationName: 'analytics', + tableSyncCopy: { type: 'include_tables', table_ids: [101] }, + }) + ) + }) + + it('blocks creation when pipeline validation returns a critical failure', async () => { + const failure = { + failure_type: 'critical', + name: 'Invalid table selection', + reason: 'Refresh the publication selection.', + } + mocks.validatePipeline.mockResolvedValue({ validation_failures: [failure] }) + const onValidationFail = vi.fn() + const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) + + let validationResult: Awaited> + await act(async () => { + validationResult = await result.current.validateConfiguration({ + data: { ...formData, serviceAccountKey: '{"type":"service_account"}' }, + onValidationFail, + }) + }) + + expect(validationResult!).toEqual({ canContinue: false, warnings: [] }) + expect(onValidationFail).toHaveBeenCalledOnce() + }) + + it('preserves hidden batch fields and submits the selected table-copy policy on edit', async () => { + const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) + + await act(async () => { + await result.current.submitPipeline({ + data: formData, + existingDestination: { + destinationId: 7, + pipelineId: 8, + enabled: true, + statusName: 'started', + }, + existingBatch: { + max_fill_ms: 200, + max_bytes: 8_388_608, + memory_budget_ratio: 0.2, + }, + onSuccess: vi.fn(), + onClose: vi.fn(), + }) + }) + + expect(mocks.updateDestinationPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + destinationId: 7, + pipelineId: 8, + pipelineConfig: expect.objectContaining({ + tableSyncCopy: { type: 'include_tables', table_ids: [101] }, + batch: { + maxFillMs: 500, + maxBytes: 8_388_608, + memoryBudgetRatio: 0.2, + }, + }), + }), + expect.any(Object) + ) + expect(mocks.createDestinationPipeline).not.toHaveBeenCalled() + expect(mocks.startPipeline).not.toHaveBeenCalled() + }) + + it('omits an unchanged batch when editing only the table-copy policy', async () => { + const { result } = renderHook(() => useDestinationForm({ selectedType: 'BigQuery' })) + + await act(async () => { + await result.current.submitPipeline({ + data: formData, + existingDestination: { + destinationId: 7, + pipelineId: 8, + enabled: true, + statusName: 'started', + }, + existingBatch: { + max_fill_ms: formData.maxFillMs, + max_bytes: 0, + memory_budget_ratio: 2, + }, + onSuccess: vi.fn(), + onClose: vi.fn(), + }) + }) + + const updateParams = mocks.updateDestinationPipeline.mock.calls[0][0] + expect(updateParams.pipelineConfig).toMatchObject({ + tableSyncCopy: { type: 'include_tables', table_ids: [101] }, + }) + expect(updateParams.pipelineConfig).not.toHaveProperty('batch') + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts index 6f69c79eddb..b317f4e5f98 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/useDestinationForm.ts @@ -4,16 +4,19 @@ import { toast } from 'sonner' import * as z from 'zod' import { type DestinationType, type ExistingDestination } from '../DestinationPanel.types' -import { CREATE_NEW_NAMESPACE } from './DestinationForm.constants' +import { CREATE_NEW_NAMESPACE, DEFAULT_MAX_FILL_MS } from './DestinationForm.constants' import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.schema' import { + buildBatchConfig, buildDestinationConfig, buildDestinationConfigForValidation, + buildTableSyncCopyConfig, } from './DestinationForm.utils' import { useCreateDestinationPipelineMutation, type BatchConfig, } from '@/data/replication/create-destination-pipeline-mutation' +import type { ReplicationPipelineByIdData } from '@/data/replication/pipeline-by-id-query' import { useReplicationSourcesQuery } from '@/data/replication/sources-query' import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation' import { useUpdateDestinationPipelineMutation } from '@/data/replication/update-destination-pipeline-mutation' @@ -111,6 +114,17 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination }) => { if (!projectRef || !sourceId) return { canContinue: false, warnings: [] } + let tableSyncCopy + try { + tableSyncCopy = buildTableSyncCopyConfig({ + mode: data.tableSyncCopyMode, + selectedTableIds: data.tableSyncCopyTableIds, + }) + } catch (error) { + toast.error((error as Error).message) + return { canContinue: false, warnings: [] } + } + setHasRunValidation(true) // Call both validation endpoints in parallel and wait for both to complete @@ -118,13 +132,18 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination const results = await Promise.allSettled([ validateDestination({ projectRef, - destinationConfig: buildDestinationConfigForValidation({ projectRef, selectedType, data }), + destinationConfig: buildDestinationConfigForValidation({ + projectRef, + selectedType, + data, + }), sourceId, publicationName: data.publicationName, maxFillMs: data.maxFillMs, maxTableSyncWorkers: data.maxTableSyncWorkers, maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, invalidatedSlotBehavior: data.invalidatedSlotBehavior, + tableSyncCopy, }), validatePipeline({ projectRef, @@ -134,6 +153,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination maxTableSyncWorkers: data.maxTableSyncWorkers, maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, invalidatedSlotBehavior: data.invalidatedSlotBehavior, + tableSyncCopy, }), ]) @@ -180,11 +200,13 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination const submitPipeline = async ({ data, existingDestination, + existingBatch, onSuccess, onClose, }: { data: z.infer existingDestination?: ExistingDestination + existingBatch?: ReplicationPipelineByIdData['config']['batch'] onSuccess: () => void onClose: () => void }) => { @@ -204,15 +226,26 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination if (!destinationConfig) throw new Error('Destination configuration is missing') - const batchConfig: BatchConfig | undefined = - data.maxFillMs !== undefined ? { maxFillMs: data.maxFillMs } : undefined + const shouldSendBatch = + !editMode || data.maxFillMs !== (existingBatch?.max_fill_ms ?? DEFAULT_MAX_FILL_MS) + const batchConfig: BatchConfig | undefined = shouldSendBatch + ? buildBatchConfig({ + maxFillMs: data.maxFillMs, + existingBatch, + }) + : undefined const hasBatchFields = batchConfig !== undefined + const tableSyncCopy = buildTableSyncCopyConfig({ + mode: data.tableSyncCopyMode, + selectedTableIds: data.tableSyncCopyTableIds, + }) const pipelineConfig = { publicationName: data.publicationName, maxTableSyncWorkers: data.maxTableSyncWorkers, maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, invalidatedSlotBehavior: data.invalidatedSlotBehavior, + tableSyncCopy, ...(hasBatchFields ? { batch: batchConfig } : {}), } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index a6e9f94be47..d7f6f9cdcd7 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -1,3 +1,4 @@ +import { useParams } from 'common' import { ArrowUpRight } from 'lucide-react' import Link from 'next/link' import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs' @@ -14,6 +15,7 @@ import { SheetSection, SheetTitle, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { EnablePipelinesCallout } from '../EnablePipelinesCallout' import { PipelineStatusName } from '../Replication.constants' @@ -24,7 +26,8 @@ import { DestinationType } from './DestinationPanel.types' import { DestinationTypeSelection } from './DestinationTypeSelection' import { ReadReplicaForm } from './ReadReplicaForm' import { DocsButton } from '@/components/ui/DocsButton' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' +import { checkLocalETLNotSetUp } from '@/data/replication/utils' import { DOCS_URL } from '@/lib/constants' interface DestinationPanelProps { @@ -32,8 +35,10 @@ interface DestinationPanelProps { } export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => { + const { ref: projectRef } = useParams() const enablePgReplicate = useIsETLPrivateAlpha() - const { hasAccess: hasETLReplicationAccess } = useCheckEntitlements('replication.etl') + const { error: destinationsError } = useReplicationDestinationsQuery({ projectRef }) + const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError) const [urlDestinationType, setDestinationType] = useQueryState( 'destinationType', @@ -89,7 +94,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane } const docsUrl = - urlDestinationType === 'BigQuery' + destinationType === 'BigQuery' ? `${DOCS_URL}/guides/database/replication/bigquery#configure-bigquery-as-a-destination` : `${DOCS_URL}/guides/database/replication/pipelines#step-3-configure-a-destination` @@ -100,71 +105,104 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane } }, [edit, invalidExistingDestination, setEdit]) + const typeSelection = ( + <> + + + + ) + + const pipelinesTypeSelection = ( + <> + {typeSelection} + {destinationType != null && isLocalETLNotSetUp && ( + + + + )} + + ) + return ( <> - - -
        + { + if (!open) onClose() + }} + > + +
        - {editMode ? 'Edit destination' : 'Add new destination'} + {editMode ? 'Edit destination' : 'Add destination'} {editMode ? 'Update the configuration for this destination.' - : 'Set up a read replica or Supabase Pipelines destination for near real-time replication'} + : 'Add a read replica or an external destination.'}
        - +
        - - - - {destinationType === 'Read Replica' ? ( - onSuccessCreateReadReplica?.()} /> + onSuccessCreateReadReplica?.()} + /> ) : !enablePgReplicate ? ( - -
        -
        -

        Request Pipelines access

        -

        - Pipelines is in public alpha and - being rolled out gradually. Request access below to join the waitlist. Read - replicas are available now. -

        -
        -
        - - + + Request Pipelines access + + + +
        -
        - + +
        ) : replicationNotEnabled ? ( - - - +
        + {pipelinesTypeSelection} + + + +
        ) : ( )} 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 fea7170f381..9ef30c6a292 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.test.tsx @@ -132,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 public 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 () => { @@ -155,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 6761906bc21..792e0f9f837 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -1,6 +1,7 @@ import { AnalyticsBucket, BigQuery, ClickHouse, Database } from 'icons' import { Snowflake } from 'lucide-react' import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs' +import type { ElementType } from 'react' import { Badge, Select, @@ -29,7 +30,7 @@ interface DestinationTypeOption { value: DestinationType label: string description: string - icon: typeof Database + icon: ElementType<{ size?: number; className?: string; strokeWidth?: number }> stage: 'Public Alpha' | 'Early Access' | 'Deprecated' | null enabled: boolean } @@ -39,6 +40,12 @@ interface DestinationTypeGroup { options: DestinationTypeOption[] } +function DestinationOptionIcon({ option }: { option: DestinationTypeOption }) { + const Icon = option.icon + + return +} + export const DestinationTypeSelection = () => { const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() const etlEnableIceberg = useIsETLIcebergPrivateAlpha() @@ -147,29 +154,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 ( - {selectedOption.stage === 'Public Alpha' - ? 'This destination type is in public alpha and may change as we iterate based on customer feedback. ' - : selectedOption.stage === 'Deprecated' - ? 'This destination type is deprecated.' - : 'This destination type is available through early access and may change as we iterate based on customer feedback. '} - {selectedOption.stage !== 'Deprecated' && ( - - 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 && ( - - - + @@ -304,13 +324,6 @@ export const Destinations = () => { /> )} - {isLocalETLNotSetUp && ( - - )} - {isLoading ? ( ) : hasReplicas || hasDestinations ? ( @@ -368,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 9b666d6bf01..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 Pipelines-managed resources from your database. Data already written to destination systems is not deleted. -

        -

        Read replicas are not affected.

        + + 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 6dcc2c0769b..0c0eb4151bd 100644 --- a/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx +++ b/apps/studio/components/interfaces/Database/Replication/EnablePipelinesCallout.tsx @@ -13,18 +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({ @@ -44,12 +58,14 @@ const EnablePipelinesModal = () => { return ( - - - - + {!hideTrigger && ( + + + + )} + Enable Pipelines @@ -60,27 +76,41 @@ const EnablePipelinesModal = () => { className="rounded-none border-0" title="Pipelines is currently in public alpha" > -

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

        -

        - Pipelines is billed for configured pipeline hours, initial sync data, and ongoing - replication data. Review the{' '} - - Pipelines pricing - {' '} - before enabling it. -

        + {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 ? ( + + ) : ( + + )} @@ -90,12 +120,12 @@ const EnablePipelinesModal = () => { export const EnablePipelinesCallout = ({ type, className, - hasAccess, }: { type?: DestinationType | null className?: string - hasAccess: boolean }) => { + const { hasAccess } = useCheckEntitlements('replication.etl') + return (
        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/ReplicationDiagram/EmptyReplicationDiagram.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx index f9f861f6025..105e544adf7 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx @@ -30,7 +30,7 @@ const ReplicationDiagramContent = () => { resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' return ( -
        +
        { }, [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,18 +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 }) }} @@ -619,6 +615,7 @@ export const ReplicationPipelineStatus = () => { 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/TableReplicationRow.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/TableReplicationRow.tsx index 8aac3f7cce7..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} +

          @@ -104,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 index c63198b7cb5..397d907a5d7 100644 --- a/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.test.ts @@ -28,7 +28,12 @@ const tables = [ describe('calculateRestartCostEstimate', () => { it('sums only the tables being restarted', () => { - expect(calculateRestartCostEstimate(tables, ['public.orders', 'public.customers'])).toEqual({ + expect( + calculateRestartCostEstimate(tables, [ + { schema: 'public', name: 'orders' }, + { schema: 'public', name: 'customers' }, + ]) + ).toMatchObject({ isComplete: true, estimatedBytes: 3_000_000_000, estimatedCost: 2, @@ -38,7 +43,10 @@ describe('calculateRestartCostEstimate', () => { it('marks the estimate incomplete when a restarted table is missing', () => { expect( - calculateRestartCostEstimate(tables, ['public.orders', 'public.missing']).isComplete + 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 index 2558a0b6b9a..55bae36ad55 100644 --- a/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RestartCostEstimate.tsx @@ -1,6 +1,11 @@ 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' @@ -9,58 +14,55 @@ interface RestartCostEstimateProps { projectRef?: string sourceId?: number publicationName?: string - tableNames: string[] + tables: Pick[] } -interface EstimatedTable { - schema: string - name: string - estimated_bytes: number - estimated_cost: number - is_row_filtered: boolean -} - -export const calculateRestartCostEstimate = (tables: EstimatedTable[], tableNames: string[]) => { - const tableNameSet = new Set(tableNames) - const matchedTables = tables.filter((table) => tableNameSet.has(`${table.schema}.${table.name}`)) - - return { - isComplete: matchedTables.length === tableNames.length, - 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), - } -} +export const calculateRestartCostEstimate = ( + estimates: TableCopyEstimate[], + tables: Pick[] +) => summarizeTableCopyEstimate(estimates, tables) export const RestartCostEstimate = ({ open, projectRef, sourceId, publicationName, - tableNames, + tables, }: RestartCostEstimateProps) => { const { data: estimate, isFetching } = useReplicationCostEstimateQuery( { projectRef, sourceId, publicationName }, - { enabled: open } + { enabled: open && tables.length > 0 } ) const restartEstimate = useMemo( () => estimate === undefined ? undefined - : calculateRestartCostEstimate(estimate.table_copy.tables, tableNames), - [estimate, tableNames] + : calculateRestartCostEstimate(estimate.table_copy.tables, tables), + [estimate, tables] ) return (
          - {isFetching ? ( + {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

          +

          Estimated additional initial sync cost

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

          @@ -71,12 +73,18 @@ export const RestartCostEstimate = ({ ) : (

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

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

          - *Tables with row filters may cost less than shown. + *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 17561ae774f..59ce94d5f69 100644 --- a/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RestartTableDialog.tsx @@ -13,13 +13,18 @@ import { 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 @@ -30,8 +35,8 @@ interface RestartTableDialogProps { export const RestartTableDialog = ({ open, onOpenChange, - tableId, - tableName, + table, + tableSyncCopy, sourceId, publicationName, pipelineStatusName, @@ -40,6 +45,8 @@ export const RestartTableDialog = ({ }: 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: () => { @@ -64,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, }) @@ -84,11 +91,19 @@ export const RestartTableDialog = ({ {tableName} from scratch:

            -
          • - The table's initial sync will restart. All existing data will be - copied again from the source. This initial sync is billed in addition to previous - initial syncs. -
          • + {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. @@ -109,7 +124,7 @@ export const RestartTableDialog = ({ projectRef={projectRef} sourceId={sourceId} publicationName={publicationName} - tableNames={[tableName]} + tables={willCopyTable ? [table] : []} /> 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/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/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 2dcbf39311b..9598732384d 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -38,7 +38,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor' import { DefaultEdge } from './DefaultEdge' diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index 8ae2a45bffb..edce0bf6399 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import type { PGTrigger } from '@supabase/pg-meta' import { Terminal } from 'lucide-react' 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, @@ -136,7 +136,10 @@ export const TriggerSheet = ({ resolver: zodResolver(FormSchema), defaultValues, }) - const { function_name, function_schema } = form.watch() + const [function_name, function_schema] = useWatch({ + control: form.control, + name: ['function_name', 'function_schema'], + }) const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx new file mode 100644 index 00000000000..155dc75c4ea --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx @@ -0,0 +1,277 @@ +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' +import type { InfraMonitoringMultiResponse } from '@/data/analytics/infra-monitoring-query' + +const { mockGetInfraMonitoringAttributes, mockUseInfraMonitoringAttributesQuery, mockUseProject } = + vi.hoisted(() => ({ + mockGetInfraMonitoringAttributes: vi.fn(), + mockUseInfraMonitoringAttributesQuery: vi.fn(), + mockUseProject: vi.fn(), + })) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'project-ref' }), +})) + +vi.mock('ui', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})) + +vi.mock('ui-patterns/Chart', async () => { + const React = await vi.importActual('react') + const ChartStateContext = React.createContext({ isLoading: false, isErrored: false }) + + return { + Chart: ({ + children, + isLoading, + isErrored, + }: { + children: ReactNode + isLoading: boolean + isErrored: boolean + }) => ( + + {children} + + ), + ChartCard: ({ children, className }: { children: ReactNode; className?: string }) => ( +
            {children}
            + ), + ChartContent: ({ + children, + isEmpty, + loadingState, + errorState, + emptyState, + }: { + children: ReactNode + isEmpty: boolean + loadingState: ReactNode + errorState: ReactNode + emptyState: ReactNode + }) => { + const { isLoading, isErrored } = React.useContext(ChartStateContext) + + if (isLoading) return loadingState + if (isErrored) return errorState + if (isEmpty) return emptyState + return children + }, + ChartEmptyState: ({ title, description }: { title: string; description?: string }) => ( +
            + {title} + {description && {description}} +
            + ), + ChartHeader: ({ children }: { children: ReactNode }) =>
            {children}
            , + ChartLine: ({ dataKey, dataKeys }: { dataKey: string; dataKeys: string[] }) => ( +
            + ), + ChartLoadingState: () =>
            Loading chart
            , + ChartMetric: ({ + label, + value, + status, + tooltip, + }: { + label: string + value: ReactNode + status?: string + tooltip?: ReactNode + }) => ( +
            + {label}: {value} +
            + ), + } +}) + +vi.mock('@/data/analytics/infra-monitoring-query', () => ({ + getInfraMonitoringAttributes: mockGetInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery: mockUseInfraMonitoringAttributesQuery, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseProject, +})) + +vi.mock('@/lib/helpers', () => ({ + formatBytes: (bytes: number) => `${bytes} bytes`, +})) + +const buildUsageResponse = ({ + cpu = 40, + memory = 50, + diskIo = 60, + database = 40, + wal = 5, + system = 5, + diskSize = 100, +}: { + cpu?: number + memory?: number + diskIo?: number + database?: number + wal?: number + system?: number + diskSize?: number +} = {}): InfraMonitoringMultiResponse => ({ + series: {}, + data: [ + { + period_start: '2026-07-20T00:00:00.000Z', + values: { + max_cpu_usage: String(cpu), + ram_usage: String(memory), + disk_io_consumption: String(diskIo), + pg_database_size: String(database), + disk_fs_used_wal: String(wal), + disk_fs_used_system: String(system), + disk_fs_size: String(diskSize), + }, + }, + ], +}) + +describe('ComputeAndDiskUsageCharts', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProject.mockReturnValue({ data: { infra_compute_size: 'micro' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse(), + isLoading: false, + isError: false, + }) + mockGetInfraMonitoringAttributes.mockResolvedValue(buildUsageResponse()) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders warning and critical summaries and all chart data', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ + cpu: 80, + memory: 91, + diskIo: 70, + database: 80, + wal: 5, + system: 5, + }), + isLoading: false, + isError: false, + }) + + const { container } = render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('91%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-CPU')).toHaveAttribute('data-status', 'warning') + expect(screen.getByTestId('metric-Memory')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-Disk IO')).toHaveAttribute('data-status', 'default') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-has-tooltip', 'true') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-has-tooltip', 'true') + for (const label of ['CPU', 'Memory', 'Disk IO', 'Database', 'WAL', 'System']) { + expect(screen.getByTestId(`metric-${label}`)).toHaveAttribute('data-has-tooltip', 'false') + } + expect(screen.getByTestId('metric-Disk')).toHaveTextContent('90%') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage,diskIoConsumption' + ) + expect(container.firstElementChild).toHaveClass( + 'grid-cols-1', + '@[680px]:grid-cols-2', + '@[680px]:items-stretch' + ) + expect(container.querySelector('#cpu')).toBeInTheDocument() + expect(container.querySelector('#ram')).toBeInTheDocument() + expect(container.querySelector('#disk_io')).toBeInTheDocument() + expect(container.querySelector('#disk')).toBeInTheDocument() + }) + + it('hides burst-only disk IO and excludes it from status on dedicated-I/O compute', () => { + mockUseProject.mockReturnValue({ data: { infra_compute_size: '4xlarge' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ cpu: 40, memory: 50, diskIo: 95 }), + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('50%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'default') + expect(screen.queryByTestId('metric-Disk IO')).not.toBeInTheDocument() + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage' + ) + }) + + it('renders empty states without presenting missing disk metrics as zero', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByText('No compute data')).toBeInTheDocument() + expect(screen.getByText('No disk data')).toBeInTheDocument() + expect(screen.getByTestId('metric-Database')).toHaveTextContent('—') + expect(screen.getByTestId('metric-WAL')).toHaveTextContent('—') + expect(screen.getByTestId('metric-System')).toHaveTextContent('—') + }) + + it.each([ + { + state: 'loading', + queryState: { data: undefined, isLoading: true, isError: false }, + expectedText: 'Loading chart', + }, + { + state: 'error', + queryState: { data: undefined, isLoading: false, isError: true }, + expectedText: 'Failed to load usage data', + }, + ])('renders both $state states', ({ queryState, expectedText }) => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue(queryState) + + render() + + expect(screen.getAllByText(expectedText)).toHaveLength(2) + }) + + it('uses a fresh rolling seven-day window when the query refetches', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-20T12:00:00.000Z')) + + render() + + const [, options] = mockUseInfraMonitoringAttributesQuery.mock.calls[0] + vi.setSystemTime(new Date('2026-07-27T12:00:00.000Z')) + await options.queryFn({ signal: undefined }) + + expect(mockGetInfraMonitoringAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: 'project-ref', + startDate: '2026-07-20T12:00:00.000Z', + endDate: '2026-07-27T12:00:00.000Z', + interval: '1d', + }), + undefined + ) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx new file mode 100644 index 00000000000..ec8d8b68dbc --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx @@ -0,0 +1,327 @@ +import { useParams } from 'common' +import dayjs from 'dayjs' +import { Activity, BarChart2, Database } from 'lucide-react' +import { useMemo } from 'react' +import type { ChartConfig } from 'ui' +import { cn } from 'ui' +import { + Chart, + ChartCard, + ChartContent, + ChartEmptyState, + ChartHeader, + ChartLine, + ChartLoadingState, + ChartMetric, +} from 'ui-patterns/Chart' + +import { + buildUsageChartData, + formatUsagePercent, + getComputeUsageSummary, + getDiskUsageSummary, + getUsageMetricStatus, + type UsageMetricStatus, +} from './ComputeAndDiskUsageCharts.utils' +import { hasBurstableIO } from './DiskManagement.utils' +import type { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query' +import { + getInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery, +} from '@/data/analytics/infra-monitoring-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatBytes } from '@/lib/helpers' + +const USAGE_ATTRIBUTES = [ + 'max_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'disk_fs_used_system', + 'disk_fs_used_wal', + 'pg_database_size', + 'disk_fs_size', +] satisfies InfraMonitoringAttribute[] + +const ROLLING_WINDOW_DAYS = 7 + +const getRollingWindow = () => { + const now = dayjs() + + return { + startDate: now.subtract(ROLLING_WINDOW_DAYS, 'day').toISOString(), + endDate: now.toISOString(), + } +} + +const COMPUTE_CHART_CONFIG = { + maxCpuUsage: { + label: 'CPU', + color: 'hsl(var(--brand-default))', + }, + ramUsage: { + label: 'Memory', + color: 'hsl(var(--warning-default))', + }, + diskIoConsumption: { + label: 'Disk IO', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const DISK_CHART_CONFIG = { + databaseUsagePercent: { + label: 'Database', + color: 'hsl(var(--brand-default))', + }, + walUsagePercent: { + label: 'WAL', + color: 'hsl(var(--warning-default))', + }, + systemUsagePercent: { + label: 'System', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const PERCENTAGE_Y_AXIS_PROPS = { + domain: [0, 100] as [number, number], + allowDataOverflow: true, + ticks: [0, 25, 50, 75, 100], + tickFormatter: (value: number) => `${Math.round(Number(value))}%`, + width: 64, +} + +const getUsageCardClassName = (status: UsageMetricStatus) => + cn( + 'h-full flex flex-col transition-colors', + status === 'warning' && 'border-warning-400 bg-warning-200/30', + status === 'negative' && 'border-destructive-400 bg-destructive-200/30' + ) + +export const ComputeAndDiskUsageCharts = ({ className }: { className?: string }) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + const supportsBurstableIO = hasBurstableIO(project?.infra_compute_size) + + // Anchor query key dates to mount time; fetch uses a rolling 7-day window on each refetch. + const { startDate, endDate } = useMemo(getRollingWindow, []) + + const { + data: usageData, + isLoading, + isError, + } = useInfraMonitoringAttributesQuery( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + startDate, + endDate, + interval: '1d', + }, + { + queryFn: ({ signal }) => + getInfraMonitoringAttributes( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + ...getRollingWindow(), + interval: '1d', + }, + signal + ), + } + ) + + const { computeChartData, diskChartData } = useMemo( + () => buildUsageChartData(usageData), + [usageData] + ) + + const { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: computeUsageStatus, + } = useMemo( + () => getComputeUsageSummary(computeChartData, supportsBurstableIO), + [computeChartData, supportsBurstableIO] + ) + + const { + latestDataPoint: latestDiskDataPoint, + usagePercent: latestDiskUsage, + status: diskUsageStatus, + } = useMemo(() => getDiskUsageSummary(diskChartData), [diskChartData]) + + const isComputeEmpty = computeChartData.length === 0 + const isDiskEmpty = diskChartData.length === 0 + + return ( +
            +
            + + + + + + +
            + + + {supportsBurstableIO && ( + + )} +
            +
            + } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No compute data" + description="Usage data may take a few minutes to appear." + /> + } + > +
            + +
            +
            +
            +
            +
            + +
            + + + + +
            + + + +
            +
            + } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No disk data" + description="Disk metrics may take a few minutes to appear." + /> + } + > +
            + +
            +
            +
            +
            +
            +
            + ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts new file mode 100644 index 00000000000..ad79f432675 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from 'vitest' + +import { + buildUsageChartData, + clampPercentage, + formatUsagePercent, + getComputeUsageSummary, + getDiskUsageSummary, + getPeakChartValue, + getUsageMetricStatus, + getWorstUsageMetricStatus, + toNumber, + type ComputeUsageChartDatum, + type DiskUsageChartDatum, +} from './ComputeAndDiskUsageCharts.utils' +import type { + InfraMonitoringMultiResponse, + InfraMonitoringSingleResponse, +} from '@/data/analytics/infra-monitoring-query' + +const GB = 1024 ** 3 + +const buildMultiResponse = ( + data: InfraMonitoringMultiResponse['data'] +): InfraMonitoringMultiResponse => ({ + data, + series: {}, +}) + +describe('ComputeAndDiskUsageCharts utils', () => { + describe('toNumber', () => { + test('parses numeric strings', () => { + expect(toNumber('42')).toBe(42) + expect(toNumber('3.14')).toBe(3.14) + expect(toNumber('-5')).toBe(-5) + }) + + test('passes through finite numbers', () => { + expect(toNumber(0)).toBe(0) + expect(toNumber(99)).toBe(99) + }) + + test('falls back to 0 for undefined, empty, and non-numeric values', () => { + expect(toNumber(undefined)).toBe(0) + expect(toNumber('')).toBe(0) + expect(toNumber('not-a-number')).toBe(0) + }) + + test('falls back to 0 for non-finite values', () => { + expect(toNumber(Infinity)).toBe(0) + expect(toNumber('Infinity')).toBe(0) + expect(toNumber(NaN)).toBe(0) + }) + }) + + describe('clampPercentage', () => { + test('returns the value when within range', () => { + expect(clampPercentage(50)).toBe(50) + }) + + test('clamps to the [0, 100] bounds', () => { + expect(clampPercentage(-10)).toBe(0) + expect(clampPercentage(150)).toBe(100) + }) + + test('keeps exact boundary values', () => { + expect(clampPercentage(0)).toBe(0) + expect(clampPercentage(100)).toBe(100) + }) + }) + + describe('formatUsagePercent', () => { + test('renders an em dash for undefined', () => { + expect(formatUsagePercent(undefined)).toBe('—') + }) + + test('renders a rounded whole-number percentage', () => { + expect(formatUsagePercent(0)).toBe('0%') + expect(formatUsagePercent(42.4)).toBe('42%') + expect(formatUsagePercent(42.6)).toBe('43%') + expect(formatUsagePercent(100)).toBe('100%') + }) + }) + + describe('getUsageMetricStatus', () => { + test('returns default for undefined', () => { + expect(getUsageMetricStatus(undefined)).toBe('default') + }) + + test('returns default below the warning threshold', () => { + expect(getUsageMetricStatus(0)).toBe('default') + expect(getUsageMetricStatus(74.9)).toBe('default') + }) + + test('returns warning between 75 and 90 (inclusive of 75)', () => { + expect(getUsageMetricStatus(75)).toBe('warning') + expect(getUsageMetricStatus(89.9)).toBe('warning') + }) + + test('returns negative at or above 90', () => { + expect(getUsageMetricStatus(90)).toBe('negative') + expect(getUsageMetricStatus(100)).toBe('negative') + }) + }) + + describe('getWorstUsageMetricStatus', () => { + test('returns default with no values or all-default values', () => { + expect(getWorstUsageMetricStatus()).toBe('default') + expect(getWorstUsageMetricStatus(10, 20, undefined)).toBe('default') + }) + + test('escalates to warning when any value is in the warning band', () => { + expect(getWorstUsageMetricStatus(10, 80, 50)).toBe('warning') + }) + + test('escalates to negative when any value is critical, regardless of others', () => { + expect(getWorstUsageMetricStatus(80, 95, 10)).toBe('negative') + }) + }) + + describe('getPeakChartValue', () => { + test('returns undefined for an empty array', () => { + expect(getPeakChartValue([], 'maxCpuUsage')).toBeUndefined() + }) + + test('returns undefined when no values are numbers', () => { + const data = [{ maxCpuUsage: undefined }, { maxCpuUsage: undefined }] as unknown as Array< + Record + > + expect(getPeakChartValue(data, 'maxCpuUsage')).toBeUndefined() + }) + + test('returns the maximum numeric value for the key', () => { + const data = [{ value: 10 }, { value: 55 }, { value: 30 }] + expect(getPeakChartValue(data, 'value')).toBe(55) + }) + + test('ignores non-numeric entries when computing the peak', () => { + const data = [{ value: 10 }, { value: undefined }, { value: 42 }] as unknown as Array< + Record + > + expect(getPeakChartValue(data, 'value')).toBe(42) + }) + }) + + describe('buildUsageChartData', () => { + test('returns empty series for undefined data', () => { + expect(buildUsageChartData(undefined)).toEqual({ + computeChartData: [], + diskChartData: [], + }) + }) + + test('returns empty series for the single-attribute response shape', () => { + const singleResponse = { + yAxisLimit: 0, + format: '%', + total: 0, + totalAverage: 0, + data: [{ period_start: '2024-01-01T00:00:00Z', max_cpu_usage: '50' }], + } satisfies InfraMonitoringSingleResponse + + expect(buildUsageChartData(singleResponse)).toEqual({ + computeChartData: [], + diskChartData: [], + }) + }) + + test('maps compute attributes and clamps them to [0, 100]', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { + max_cpu_usage: '120', // over 100 -> clamped + ram_usage: '-5', // under 0 -> clamped + disk_io_consumption: '33', + }, + }, + ]) + ) + + expect(result.computeChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + maxCpuUsage: 100, + ramUsage: 0, + diskIoConsumption: 33, + }, + ]) + }) + + test('computes disk usage percentages relative to the disk size', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { + pg_database_size: String(25 * GB), + disk_fs_used_wal: String(25 * GB), + disk_fs_used_system: String(10 * GB), + disk_fs_size: String(100 * GB), + }, + }, + ]) + ) + + expect(result.diskChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + databaseBytes: 25 * GB, + walBytes: 25 * GB, + systemBytes: 10 * GB, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 25, + walUsagePercent: 25, + systemUsagePercent: 10, + }, + ]) + }) + + test('drops disk points without a known disk size', () => { + const result = buildUsageChartData( + buildMultiResponse([ + { + period_start: '2024-01-01T00:00:00Z', + values: { pg_database_size: String(GB), disk_fs_size: '0' }, + }, + { + period_start: '2024-01-02T00:00:00Z', + values: { pg_database_size: String(GB), disk_fs_size: String(10 * GB) }, + }, + ]) + ) + + // First point dropped (disk_fs_size = 0), compute still has both timestamps + expect(result.diskChartData).toHaveLength(1) + expect(result.diskChartData[0].timestamp).toBe('2024-01-02T00:00:00Z') + expect(result.computeChartData).toHaveLength(2) + }) + + test('treats missing compute values as 0', () => { + const result = buildUsageChartData( + buildMultiResponse([{ period_start: '2024-01-01T00:00:00Z', values: {} }]) + ) + + expect(result.computeChartData).toEqual([ + { + timestamp: '2024-01-01T00:00:00Z', + maxCpuUsage: 0, + ramUsage: 0, + diskIoConsumption: 0, + }, + ]) + }) + }) + + describe('getComputeUsageSummary', () => { + test('returns undefined peaks and default status for empty data', () => { + expect(getComputeUsageSummary([])).toEqual({ + peakCpuUsage: undefined, + peakMemoryUsage: undefined, + peakDiskIoUsage: undefined, + peakComputeUsage: undefined, + status: 'default', + }) + }) + + test('computes per-metric peaks, the overall peak, and the worst status', () => { + const data: ComputeUsageChartDatum[] = [ + { timestamp: 't1', maxCpuUsage: 40, ramUsage: 92, diskIoConsumption: 10 }, + { timestamp: 't2', maxCpuUsage: 60, ramUsage: 80, diskIoConsumption: 20 }, + ] + + expect(getComputeUsageSummary(data)).toEqual({ + peakCpuUsage: 60, + peakMemoryUsage: 92, + peakDiskIoUsage: 20, + peakComputeUsage: 92, + status: 'negative', // memory peak of 92 is critical + }) + }) + + test('excludes disk IO from the overall peak and status for dedicated-I/O instances', () => { + const data: ComputeUsageChartDatum[] = [ + { timestamp: 't1', maxCpuUsage: 40, ramUsage: 50, diskIoConsumption: 95 }, + ] + + expect(getComputeUsageSummary(data, false)).toEqual({ + peakCpuUsage: 40, + peakMemoryUsage: 50, + peakDiskIoUsage: 95, + peakComputeUsage: 50, + status: 'default', + }) + }) + }) + + describe('getDiskUsageSummary', () => { + test('returns undefined usage and default status for empty data', () => { + expect(getDiskUsageSummary([])).toEqual({ + latestDataPoint: undefined, + usedBytes: 0, + sizeBytes: 0, + usagePercent: undefined, + status: 'default', + }) + }) + + test('summarizes the latest data point and clamps the usage percentage', () => { + const data: DiskUsageChartDatum[] = [ + { + timestamp: 't1', + databaseBytes: 10 * GB, + walBytes: 0, + systemBytes: 0, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 10, + walUsagePercent: 0, + systemUsagePercent: 0, + }, + { + timestamp: 't2', + databaseBytes: 80 * GB, + walBytes: 10 * GB, + systemBytes: 5 * GB, + diskSizeBytes: 100 * GB, + databaseUsagePercent: 80, + walUsagePercent: 10, + systemUsagePercent: 5, + }, + ] + + const summary = getDiskUsageSummary(data) + expect(summary.latestDataPoint?.timestamp).toBe('t2') + expect(summary.usedBytes).toBe(95 * GB) + expect(summary.sizeBytes).toBe(100 * GB) + expect(summary.usagePercent).toBe(95) + expect(summary.status).toBe('negative') + }) + + test('returns undefined usage when the latest disk size is 0', () => { + const data: DiskUsageChartDatum[] = [ + { + timestamp: 't1', + databaseBytes: 0, + walBytes: 0, + systemBytes: 0, + diskSizeBytes: 0, + databaseUsagePercent: 0, + walUsagePercent: 0, + systemUsagePercent: 0, + }, + ] + + expect(getDiskUsageSummary(data).usagePercent).toBeUndefined() + }) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts new file mode 100644 index 00000000000..5f18c7a23d7 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts @@ -0,0 +1,149 @@ +import type { InfraMonitoringResponse } from '@/data/analytics/infra-monitoring-query' + +export type UsageMetricStatus = 'default' | 'warning' | 'negative' + +export type ComputeUsageChartDatum = { + timestamp: string + maxCpuUsage: number + ramUsage: number + diskIoConsumption: number +} + +export type DiskUsageChartDatum = { + timestamp: string + databaseBytes: number + walBytes: number + systemBytes: number + diskSizeBytes: number + databaseUsagePercent: number + walUsagePercent: number + systemUsagePercent: number +} + +/** Coerces an API value (string | number | undefined) into a finite number, defaulting to 0. */ +export const toNumber = (value: string | number | undefined) => { + const parsedValue = Number(value) + return Number.isFinite(parsedValue) ? parsedValue : 0 +} + +/** Constrains a percentage to the [0, 100] range so charts never overflow their axis. */ +export const clampPercentage = (value: number) => Math.min(Math.max(value, 0), 100) + +export const formatUsagePercent = (value: number | undefined) => + value === undefined ? '—' : `${value.toFixed(0)}%` + +export const getUsageMetricStatus = (value: number | undefined): UsageMetricStatus => { + if (value === undefined) return 'default' + if (value >= 90) return 'negative' + if (value >= 75) return 'warning' + return 'default' +} + +export const getWorstUsageMetricStatus = ( + ...values: Array +): UsageMetricStatus => { + const statuses = values.map(getUsageMetricStatus) + if (statuses.includes('negative')) return 'negative' + if (statuses.includes('warning')) return 'warning' + return 'default' +} + +/** Returns the highest numeric value for a given key across all data points, or undefined when empty. */ +export const getPeakChartValue = >( + data: T[], + dataKey: keyof T +): number | undefined => { + const values = data + .map((point) => point[dataKey] as unknown) + .filter((value): value is number => typeof value === 'number') + + if (values.length === 0) return undefined + + return Math.max(...values) +} + +/** + * Transforms a raw infra-monitoring response into the compute and disk chart series. + * Disk points without a known disk size are dropped so usage percentages stay meaningful. + */ +export const buildUsageChartData = ( + usageData: InfraMonitoringResponse | undefined +): { computeChartData: ComputeUsageChartDatum[]; diskChartData: DiskUsageChartDatum[] } => { + if (!usageData || !('series' in usageData)) { + return { computeChartData: [], diskChartData: [] } + } + + const computeChartData = usageData.data.map((point) => ({ + timestamp: point.period_start, + maxCpuUsage: clampPercentage(toNumber(point.values.max_cpu_usage)), + ramUsage: clampPercentage(toNumber(point.values.ram_usage)), + diskIoConsumption: clampPercentage(toNumber(point.values.disk_io_consumption)), + })) + + const diskChartData = usageData.data.flatMap((point) => { + const databaseBytes = toNumber(point.values.pg_database_size) + const walBytes = toNumber(point.values.disk_fs_used_wal) + const systemBytes = toNumber(point.values.disk_fs_used_system) + const totalBytes = toNumber(point.values.disk_fs_size) + if (totalBytes <= 0) return [] + + return { + timestamp: point.period_start, + databaseBytes, + walBytes, + systemBytes, + diskSizeBytes: totalBytes, + databaseUsagePercent: clampPercentage((databaseBytes / totalBytes) * 100), + walUsagePercent: clampPercentage((walBytes / totalBytes) * 100), + systemUsagePercent: clampPercentage((systemBytes / totalBytes) * 100), + } + }) + + return { computeChartData, diskChartData } +} + +/** Derives peak compute values and the worst-case status for metrics supported by the instance. */ +export const getComputeUsageSummary = ( + data: ComputeUsageChartDatum[], + includeDiskIo: boolean = true +) => { + const peakCpuUsage = getPeakChartValue(data, 'maxCpuUsage') + const peakMemoryUsage = getPeakChartValue(data, 'ramUsage') + const peakDiskIoUsage = getPeakChartValue(data, 'diskIoConsumption') + + const supportedPeaks = [ + peakCpuUsage, + peakMemoryUsage, + ...(includeDiskIo ? [peakDiskIoUsage] : []), + ] + const peaks = supportedPeaks.filter((value): value is number => value !== undefined) + const peakComputeUsage = peaks.length > 0 ? Math.max(...peaks) : undefined + + return { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: getWorstUsageMetricStatus(...supportedPeaks), + } +} + +/** Derives the latest used/total bytes, overall usage percentage, and status for the disk card. */ +export const getDiskUsageSummary = (data: DiskUsageChartDatum[]) => { + const latestDataPoint = data[data.length - 1] + + const usedBytes = + (latestDataPoint?.databaseBytes ?? 0) + + (latestDataPoint?.walBytes ?? 0) + + (latestDataPoint?.systemBytes ?? 0) + const sizeBytes = latestDataPoint?.diskSizeBytes ?? 0 + const usagePercent = sizeBytes > 0 ? clampPercentage((usedBytes / sizeBytes) * 100) : undefined + + return { + latestDataPoint, + usedBytes, + sizeBytes, + usagePercent, + status: getUsageMetricStatus(usagePercent), + } +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts index c7e44d07d1e..74a67404a06 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' +import { CreateDiskStorageSchema } from './DiskManagement.schema' import { calculateBaselineIopsForComputeSize, calculateComputeSizeRequiredForIops, @@ -147,6 +148,18 @@ describe('DiskManagement.utils.ts:calculateIOPSPrice', () => { expect(result.oldPrice).toBe('357.00') expect(result.newPrice).toBe('595.00') }) + test('includes IOPS charges for read replicas', () => { + const result = calculateIOPSPrice({ + oldStorageType: DiskType.GP3, + oldProvisionedIOPS: 3000, + newStorageType: DiskType.GP3, + newProvisionedIOPS: 5000, + numReplicas: 2, + }) + + expect(result.oldPrice).toBe('0.00') + expect(result.newPrice).toBe('144.00') + }) }) describe('DiskManagement.utils.ts:calculateThroughputPrice', () => { @@ -168,4 +181,109 @@ describe('DiskManagement.utils.ts:calculateThroughputPrice', () => { expect(result.oldPrice).toBe('0.00') expect(result.newPrice).toBe('0.00') }) + test('includes throughput charges for read replicas', () => { + const result = calculateThroughputPrice({ + storageType: DiskType.GP3, + oldThroughput: 125, + newThroughput: 150, + numReplicas: 2, + }) + + expect(result.oldPrice).toBe('0.00') + expect(result.newPrice).toBe('7.13') + }) +}) + +describe('CreateDiskStorageSchema', () => { + const validGp3Config = { + storageType: DiskType.GP3, + totalSize: 8, + provisionedIOPS: 3000, + throughput: 125, + computeSize: 'ci_large' as const, + growthPercent: null, + minIncrementGb: null, + maxSizeGb: null, + } + + test('enforces the GP3 500 IOPS per GB limit', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider: 'AWS', + isSpendCapEnabled: false, + }) + + const result = schema.safeParse({ + ...validGp3Config, + provisionedIOPS: 6000, + }) + + expect(result.success).toBe(false) + if (result.success) return + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['provisionedIOPS'], + message: 'Larger Disk size of at least 12 GB required. Current max is 4,000 IOPS.', + }) + ) + }) + + test('allows a legacy disk below 8 GB when its size is unchanged', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 2, + cloudProvider: 'AWS', + isSpendCapEnabled: false, + }) + + expect( + schema.safeParse({ + ...validGp3Config, + totalSize: 2, + }).success + ).toBe(true) + }) + + test('prevents disk growth above 8 GB while spend cap is enabled', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider: 'AWS', + isSpendCapEnabled: true, + }) + + const result = schema.safeParse({ + ...validGp3Config, + totalSize: 10, + }) + + expect(result.success).toBe(false) + if (result.success) return + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['totalSize'], + message: 'Disable spend cap to increase disk above 8 GB.', + }) + ) + }) + + test.each(['FLY', 'AWS_NIMBUS', 'AWS_K8S'] as const)( + 'skips platform disk constraints for %s projects', + (cloudProvider) => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider, + isSpendCapEnabled: true, + }) + + expect( + schema.safeParse({ + ...validGp3Config, + totalSize: 1, + provisionedIOPS: 100_000, + throughput: 10_000, + }).success + ).toBe(true) + } + ) }) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx new file mode 100644 index 00000000000..8acdbfccf65 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx @@ -0,0 +1,327 @@ +import { type RefObject } from 'react' +import { type UseFormReturn } from 'react-hook-form' +import { Button, Card, CardContent } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import { DiskStorageSchemaType } from './DiskManagement.schema' +import { AutoScaleFields } from './fields/AutoScaleFields' +import { + ComputeSectionBillingBadge, + ComputeSizeField, + ComputeSizeFieldMeta, +} from './fields/ComputeSizeField' +import { DiskSizeField } from './fields/DiskSizeField' +import { IOPSField } from './fields/IOPSField' +import { StorageTypeField } from './fields/StorageTypeField' +import { ThroughputField } from './fields/ThroughputField' +import { BillingChangeBadge } from './ui/BillingChangeBadge' +import { DiskCountdownRadial } from './ui/DiskCountdownRadial' +import { DiskSpaceBar } from './ui/DiskSpaceBar' +import { NoticeBar } from './ui/NoticeBar' +import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' +import { DocsButton } from '@/components/ui/DocsButton' +import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' +import { DOCS_URL } from '@/lib/constants' + +interface ComputeSectionProps { + form: UseFormReturn + settingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + disabled: boolean +} + +export function ComputeSection({ + form, + settingsRef, + showBillingBadge, + beforePrice, + afterPrice, + disabled, +}: ComputeSectionProps) { + return ( + + + + Compute size + + + + + + + + + + + + + + ) +} + +interface DiskSectionProps { + form: UseFormReturn + settingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + isAws: boolean + isAwsK8s: boolean + isBranch: boolean + isNoticeVisible: boolean + isReadOnlyMode: boolean + usedPercentage: number + isWithinCooldownWindow: boolean + currentDiskSizeGb?: number + disableDiskSizeInput: boolean +} + +function getDiskNoticeDescription({ + isAwsK8s, + isBranch, +}: Pick) { + if (isAwsK8s) { + return 'Configuring your disk for AWS (Revamped) projects is unavailable for now.' + } + if (isBranch) { + return 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.' + } + return 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud provider to configure your disk.' +} + +export function DiskSection({ + form, + settingsRef, + showBillingBadge, + beforePrice, + afterPrice, + isAws, + isAwsK8s, + isBranch, + isNoticeVisible, + isReadOnlyMode, + usedPercentage, + isWithinCooldownWindow, + currentDiskSizeGb, + disableDiskSizeInput, +}: DiskSectionProps) { + const noticeDescription = getDiskNoticeDescription({ isAwsK8s, isBranch }) + + return ( + + + + Disk + + Configure provisioned storage for your primary database. + + + + + + + + + {isAws && } + + + + + + {isAws && ( + <> +
            + + {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( + + + + )} + {isReadOnlyMode && ( + + + + )} +
            + + + + + + + + )} +
            +
            + ) +} + +interface AdvancedSectionProps { + form: UseFormReturn + autoscaleSettingsRef: RefObject + storageSettingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + disableIopsThroughputConfig?: boolean + canUpdateDiskConfiguration: boolean + isDiskTooSmallForCustomIops: boolean + disableDiskInputs: boolean + disableDiskSizeInput: boolean + suggestedDiskSizeForCustomIops: number +} + +export function AdvancedSection({ + form, + autoscaleSettingsRef, + storageSettingsRef, + showBillingBadge, + beforePrice, + afterPrice, + disableIopsThroughputConfig, + canUpdateDiskConfiguration, + isDiskTooSmallForCustomIops, + disableDiskInputs, + disableDiskSizeInput, + suggestedDiskSizeForCustomIops, +}: AdvancedSectionProps) { + return ( + + + + Advanced + + Configure autoscaling, storage type, IOPS, and throughput. + + + + + + + + + + + + + + + + { + form.setValue('computeSize', 'ci_large', { + shouldDirty: true, + shouldValidate: true, + }) + form.trigger('provisionedIOPS') + form.trigger('throughput') + }} + > + Change to LARGE Compute + + ) : ( + + ) + } + /> + { + 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 778a790c325..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 @@ -346,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 @@ -358,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, @@ -67,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 ?? [] @@ -81,322 +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 lockedMicroDueToPITR = - compute.identifier === 'ci_micro' && !!subscriptionPitr - const lockedNanoDueToPlan = - org?.plan.id !== 'free' && - project?.infra_compute_size !== 'nano' && - compute.identifier === 'ci_nano' + {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 lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR + const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR - 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 + // 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 cpuLabel = (() => { - const cpuCores = compute.meta?.cpu_cores - if (typeof cpuCores === 'number') { - return `${cpuCores}-core CPU` - } - if (cpuCores) { - return `${cpuCores} CPU` - } - return 'CPU' - })() + const price = isNanoBilledAsMicro + ? availableOptions.find( + (option: ComputeAddonVariant) => option.identifier === 'ci_micro' + )?.price + : compute.price - return ( - - -
            - {showUpgradeBadge && compute.identifier === 'ci_micro' && ( -
            - No additional charge -
            - )} -
            -
            - -
            - {lockedOption ? ( -
            - -
            - ) : ( - showComputePrice && ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - - ) - )} -
            -
            + const cpuLabel = (() => { + const cpuCores = compute.meta?.cpu_cores + if (typeof cpuCores === 'number') { + return `${cpuCores}-core CPU` + } + if (cpuCores) { + return `${cpuCores} CPU` + } + return '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 - - )} - - } - /> - ) - })} - - {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/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/MarketplaceIntegrationSettingsTab.test.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx index ba0721575c0..56cab656c94 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { fireEvent, screen, waitFor } from '@testing-library/react' import { mockAnimationsApi } from 'jsdom-testing-mocks' import { HttpResponse } from 'msw' @@ -16,6 +17,9 @@ import { addAPIMock } from '@/tests/lib/msw' // the MSW resolver) is the raw OpenAPI `OAuthAppResponse`. Build fixtures against the API shape. type OAuthAppResponse = components['schemas']['OAuthAppResponse'] type PartnerIntegrationListResponse = components['schemas']['PartnerIntegrationListResponse'] +// Same story for permissions: the frontend `Permission` type narrows nullable wire fields, so build +// the fixture against the raw `AccessControlPermission` shape the MSW resolver actually returns. +type AccessControlPermission = components['schemas']['AccessControlPermission'] // `useIntegrationDetail` resolves the integration definition from the route, the marketplace query // and feature flags — none of which is the subject of this test. Mock it so each test can drive a @@ -31,6 +35,12 @@ vi.mock('@/components/interfaces/Integrations/Landing/useIntegrationDetail', () vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => ({ data: { slug: 'acme' } }), })) +// `useProjectOAuthIntegrationData` gates the authorized-apps query behind a permission check, which +// requires a logged-in user and a granted `oauth_apps` permission to ever fire. +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, useIsLoggedIn: () => true } +}) // The auth-config query (used to detect custom SMTP) only runs on the platform. vi.mock('@/lib/constants', async (importOriginal) => { const actual = await importOriginal>() @@ -74,6 +84,17 @@ const authorizedApp = (appId: string): OAuthAppResponse => ({ registration_type: 'manual', }) +const CAN_READ_OAUTH_APPS_PERMISSION: AccessControlPermission = { + actions: [PermissionAction.READ], + condition: null, + organization_id: null, + organization_slug: 'acme', + project_ids: null, + project_refs: [], + resources: ['oauth_apps'], + restrictive: false, +} + /** * Registers the five endpoints behind `useProjectOAuthIntegrationData`. Each test supplies only the * resources relevant to it; everything else defaults to "nothing connected". @@ -84,6 +105,11 @@ const mockProjectResources = ({ oauthApps = [] as OAuthAppResponse[], smtpHost = null as string | null, } = {}) => { + addAPIMock({ + method: 'get', + path: '/platform/profile/permissions', + response: () => HttpResponse.json([CAN_READ_OAUTH_APPS_PERMISSION]), + }) addAPIMock({ method: 'get', path: '/v1/projects/:ref/api-keys', diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx index 414eb7eb440..eaef1bf5bc5 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx @@ -1,6 +1,6 @@ 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 { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts index cd011a216db..f524a3ba675 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts @@ -1,4 +1,5 @@ import { parseSchemaComment } from '@stripe/sync-engine/supabase' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useMemo } from 'react' import { type WrapperMeta } from '../Wrappers/Wrappers.types' @@ -19,6 +20,7 @@ import { usePartnerIntegrationsQuery, } from '@/data/partners/integration-status-query' import { useSecretsQuery, type ProjectSecret } from '@/data/secrets/secrets-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { ResponseError } from '@/types' @@ -67,12 +69,32 @@ export const useProjectOAuthIntegrationData = ( isSuccess: boolean } => { const { data: org } = useSelectedOrganizationQuery({ enabled }) + + // Any error here has to be terminal on mount. An errored query holds no data, so it never counts + // as fresh and refetches on every consumer mount — and consumers that gate rendering on + // `isLoading` remount on each refetch, which loops. Transient failures still recover: the retry + // policy gives 5xx three attempts, and refetch-on-focus/reconnect are staleness-driven, so they + // are unaffected by this. + const { can: canReadOAuthApps } = useAsyncCheckPermissions( + PermissionAction.READ, + 'oauth_apps', + undefined, + { + organizationSlug: org?.slug, + projectRef: null, + } + ) + + const sharedOptions = { enabled, retryOnMount: false } const queries = { - apiKeys: useAPIKeysQuery({ projectRef, reveal: false }, { enabled }), - edgeFunctionSecrets: useSecretsQuery({ projectRef }, { enabled }), - authConfig: useAuthConfigQuery({ projectRef }, { enabled }), - partnerIntegrations: usePartnerIntegrationsQuery({ projectRef }, { enabled }), - oauthApps: useAuthorizedAppsQuery({ slug: org?.slug }, { enabled: enabled && !!org }), + apiKeys: useAPIKeysQuery({ projectRef, reveal: false }, sharedOptions), + edgeFunctionSecrets: useSecretsQuery({ projectRef }, sharedOptions), + authConfig: useAuthConfigQuery({ projectRef }, sharedOptions), + partnerIntegrations: usePartnerIntegrationsQuery({ projectRef }, sharedOptions), + oauthApps: useAuthorizedAppsQuery( + { slug: org?.slug }, + { ...sharedOptions, enabled: canReadOAuthApps && enabled && !!org } + ), } const data = useMemo(() => { @@ -169,7 +191,7 @@ export const isOAuthInstalled = ({ ) } - if (integration.id === 'grafana') { + if (integration.id === 'grafana' || integration.id === 'grafana-cloud') { // Grafana is not yet sending integration status, so just use presence of API key. return ( isOAuthAppAuthorized(projectData, integration) || diff --git a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx index 5ab944ae5b1..681f22bd809 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx @@ -48,6 +48,21 @@ const STABLE_INTEGRATIONS = [ navigate: () => null, navigation: [{ route: 'overview', label: 'Overview' }], }, + { + id: 'grafana-cloud', + name: 'Grafana', + type: 'oauth', + source: 'Partner', + description: 'Grafana', + content: 'Grafana overview content', + docsUrl: null, + siteUrl: null, + author: { name: 'Grafana Labs' }, + oauthAppId: 'grafana-app', + icon: () => null, + navigate: () => null, + navigation: [{ route: 'overview', label: 'Overview' }], + }, ] const STABLE_EMPTY_ARR = [] as unknown[] vi.mock('@/components/interfaces/Integrations/Landing/useAvailableIntegrations', () => ({ diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 32359359859..2f6ec849849 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useMemo } from 'react' import { @@ -11,11 +12,24 @@ import { useAvailableIntegrations } from './useAvailableIntegrations' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { useFDWsQuery } from '@/data/fdw/fdws-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { EMPTY_ARR } from '@/lib/void' export const useInstalledIntegrations = () => { const { data: project } = useSelectedProjectQuery() + const { data: org } = useSelectedOrganizationQuery() + + const { can: canReadOAuthApps } = useAsyncCheckPermissions( + PermissionAction.READ, + 'oauth_apps', + undefined, + { + organizationSlug: org?.slug, + projectRef: null, + } + ) const { data: allIntegrations = EMPTY_ARR, @@ -101,25 +115,25 @@ export const useInstalledIntegrations = () => { extensionsError || schemasError || availableIntegrationsError || - (hasOAuthIntegration ? oauthDataError : null) + (canReadOAuthApps && hasOAuthIntegration ? oauthDataError : null) const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading || isAvailableIntegrationsLoading || - (hasOAuthIntegration && isOAuthDataLoading) + (hasOAuthIntegration && canReadOAuthApps && isOAuthDataLoading) const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas || isErrorAvailableIntegrations || - (hasOAuthIntegration && isErrorOAuthData) + (hasOAuthIntegration && canReadOAuthApps && isErrorOAuthData) const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas && isSuccessAvailableIntegrations && - (!hasOAuthIntegration || isSuccessOAuthData) + (!hasOAuthIntegration || !canReadOAuthApps || isSuccessOAuthData) return { // show all integrations at once instead of showing partial results diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx index 5c8c8b097bd..2d64bb82d65 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx @@ -14,6 +14,7 @@ export type { MarketplaceSource } from '@/components/interfaces/Integrations/Lan // Defines featured integrations and their order in the featured hero export const FEATURED_INTEGRATION_IDS = [ 'grafana', + 'grafana-cloud', 'cron', 'queues', 'stripe_sync_engine', diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx index 15b18ce8871..9e03a5d9f5c 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx @@ -1,7 +1,7 @@ import { ArrowUpRight, BookOpen, Gauge, Settings } from 'lucide-react' import { useRouter } from 'next/router' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { MarketplaceDetailBreadrumbs } from './MarketplaceDetailBreadcrumbs' diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx index 8a0fe9880fe..1714b1f6e52 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx @@ -25,6 +25,10 @@ const FEATURED_INTEGRATION_IMAGES: Record }) { - const queueType = form.watch('values.type') + const queueType = useWatch({ control: form.control, name: 'values.type' }) if (queueType !== 'partitioned') return null diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx index eeeb99a4ad4..3d8db4d7475 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/PgPartmanCallout.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { usePgPartmanStatus } from '../usePgPartmanStatus' import { EnableExtensionModal } from '@/components/interfaces/Database/Extensions/EnableExtensionModal' @@ -14,7 +14,7 @@ export function PgPartmanCallout() { return (
            diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx index 8524e773ffc..8b0c04209db 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet/RlsSection.tsx @@ -1,6 +1,6 @@ import { UseFormReturn } from 'react-hook-form' import { Badge, FormControl, FormField, SheetSection, Switch } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateQueueForm } from './CreateQueueSheet.schema' diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx index 26fc8374fc8..27aa2fef5b2 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.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 { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { RequiredExtensionsSection } from '../Integration/RequiredExtensionsSection' diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx index 3f99d8f060e..05abb2f4947 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -2,10 +2,10 @@ import { zodResolver } from '@hookform/resolvers/zod' import { QUEUES_SCHEMA } from '@supabase/pg-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form, FormControl, FormField, FormItem, Switch } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' @@ -48,7 +48,7 @@ export const QueuesSettings = () => { defaultValues: { enable: false }, }) const { formState } = form - const { enable } = form.watch() + const enable = useWatch({ control: form.control, name: 'enable' }) const { data: queueTables } = useTablesQuery({ projectRef: project?.ref, diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx index 2b16556511d..b1496a1dc3c 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -25,7 +25,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { pgmqArchiveTable, pgmqQueueTable } from '../Queues.utils' diff --git a/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx b/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx index 7c6ffc398cb..e3ca55cb706 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/UpgradeDatabaseAlert.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' interface UpgradeDatabaseAlertProps { @@ -24,7 +25,7 @@ export const UpgradeDatabaseAlert = ({ minimumVersion = '15.6' }: UpgradeDatabas

            ) 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..884794f0ef0 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' @@ -124,7 +125,7 @@ const AddNewWrapperCTA = () => { diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx index e05b84a39ea..c2b4962f9c0 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx @@ -17,7 +17,7 @@ import { SheetSection, SheetTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx index 69d538bec14..d6f70d14cd6 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx @@ -3,7 +3,7 @@ import { formatRelative } from 'date-fns' import { BadgeCheck, RefreshCwIcon } from 'lucide-react' import Link from 'next/link' import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { PageSection, PageSectionContent, diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx index f6fe9c81b13..6184fcaffcd 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx @@ -21,8 +21,8 @@ import { TriangleAlert, } from 'lucide-react' import Link from 'next/link' -import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' -import { useForm, type SubmitHandler } from 'react-hook-form' +import { useState, type Dispatch, type SetStateAction } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -45,14 +45,11 @@ import { Form, FormControl, FormField, - FormInputGroupInput, - InputGroup, - InputGroupAddon, - InputGroupText, } 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 { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import * as z from 'zod' import { @@ -60,13 +57,10 @@ import { JWT_SECRET_UPDATE_PROGRESS_MESSAGES, } from './jwt.constants' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { FormActions } from '@/components/ui/Forms/FormActions' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' import { useLegacyAPIKeysStatusQuery } from '@/data/api-keys/legacy-api-keys-status-query' -import { useAuthConfigQuery } from '@/data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' import { useJwtSecretUpdateMutation } from '@/data/config/jwt-secret-update-mutation' import { useJwtSecretUpdatingStatusQuery } from '@/data/config/jwt-secret-updating-status-query' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' @@ -75,21 +69,6 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { DOCS_URL } from '@/lib/constants' import { uuidv4 } from '@/lib/helpers' -const MAX_JWT_EXP = 604800 -const formSchema = z.object({ - JWT_EXP: z.preprocess( - (val) => (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/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index 4bbcfbdd78a..88f761879cd 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) { diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx index 3c1ed319c43..f31ee12d219 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx @@ -1,81 +1,17 @@ -import dayjs from 'dayjs' import { isEqual } from 'lodash' -import { Minus, MoreVertical, StopCircle, X } from 'lucide-react' -import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates } from 'nuqs' -import { Fragment, useEffect, useState } from 'react' -import { toast } from 'sonner' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Badge, - Button, - Card, - cn, - copyToClipboard, - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - HoverCard, - HoverCardContent, - HoverCardTrigger, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' -import { CodeBlock } from 'ui-patterns/CodeBlock' +import { Search, X } from 'lucide-react' +import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' +import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ReportsSelectFilter } from '../../Reports/v2/ReportsSelectFilter' -import { - QUERY_STATE_TOOLTIP, - WARN_DURATION_ACTIVE_QUERY, - WARN_DURATION_IDLE_TXN, -} from './DatabaseConnections.constants' -import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' +import { GroupedActivityRow } from './ActivityRow' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -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 { useDatabaseActivityQuery } from '@/data/database/activity-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { formatSql } from '@/lib/formatSql' - -const getDuration = (activity: DatabaseActivity) => { - const { state } = activity - if (state === 'active' && activity.query_start) { - return dayjs().utc().diff(dayjs(activity.query_start).utc(), 'second') - } - if (state === 'idle' && activity.state_change) { - return dayjs().utc().diff(dayjs(activity.state_change).utc(), 'second') - } - if ( - (state === 'idle in transaction' || state === 'idle in transaction (aborted)') && - activity.transaction_start - ) { - return dayjs().utc().diff(dayjs(activity.transaction_start).utc(), 'second') - } - return null -} - -const getBadgeVariant = (activity: DatabaseActivity) => { - const { state } = activity - if (state === 'active') return 'success' - if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning' - return 'default' -} +import { useTrack } from '@/lib/telemetry/track' const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres'] @@ -84,25 +20,34 @@ interface ActivityProps { } export const Activity = ({ live }: ActivityProps) => { + const track = useTrack() const { data: project } = useSelectedProjectQuery() - const [selectedPid] = useQueryState('pid', parseAsInteger) - const [ - { states: statesFilter, applications: applicationsFilter, roles: rolesFilter }, + { + search: searchFilter, + states: statesFilter, + applications: applicationsFilter, + roles: rolesFilter, + view: viewFilter, + }, setQueryStates, ] = useQueryStates({ + search: parseAsString.withDefault(''), states: parseAsArrayOf(parseAsString, ',').withDefault([]), applications: parseAsArrayOf(parseAsString, ',').withDefault([]), roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER), + view: parseAsString.withDefault(''), }) const hasNoFiltersApplied = + searchFilter.length === 0 && statesFilter.length === 0 && applicationsFilter.length === 0 && - isEqual(rolesFilter, DEFAULT_ROLES_FILTER) + isEqual(rolesFilter, DEFAULT_ROLES_FILTER) && + viewFilter === '' - const { data, isPending, isSuccess } = useDatabaseActivityQuery( + const { data, isPending } = useDatabaseActivityQuery( { projectRef: project?.ref, connectionString: project?.connectionString, @@ -115,6 +60,12 @@ export const Activity = ({ live }: ActivityProps) => { connectionString: project?.connectionString, }) + const matchesSearch = (activity: { query: string | null }) => + !searchFilter || (activity.query?.toLowerCase().includes(searchFilter.toLowerCase()) ?? false) + + // Pids referenced in some other activity's blocked_by - i.e. they are blocking something + const blockingPids = new Set((data ?? []).flatMap((x) => x.blocked_by)) + const activities = data?.filter((activity) => { const matchesState = !statesFilter || @@ -123,8 +74,18 @@ export const Activity = ({ live }: ActivityProps) => { const matchesRole = rolesFilter.length === 0 || rolesFilter.includes(activity.role_name) const matchesApplication = applicationsFilter.length === 0 || applicationsFilter.includes(activity.application_name) - return matchesState && matchesRole && matchesApplication + // In the blocked view, only show root blockers - activities blocking others while not + // themselves blocked. Everything they block is shown nested under them instead. + const matchesView = + viewFilter !== 'blockers' || + (activity.blocked_by.length === 0 && blockingPids.has(activity.pid)) + return ( + matchesState && matchesRole && matchesApplication && matchesView && matchesSearch(activity) + ) }) + const rootBlockers = (data ?? []).filter( + (x) => x.blocked_by.length === 0 && blockingPids.has(x.pid) + ) const stateOptions = [ 'Idle', @@ -140,7 +101,9 @@ export const Activity = ({ live }: ActivityProps) => { (y) => y.state === x.toLowerCase() && (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && - (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) + (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && + matchesSearch(y) ).length, })) @@ -155,7 +118,9 @@ export const Activity = ({ live }: ActivityProps) => { (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && (!statesFilter || statesFilter.length === 0 || - (y.state !== null && statesFilter.includes(y.state))) + (y.state !== null && statesFilter.includes(y.state))) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && + matchesSearch(y) ).length, })) .filter((x) => !!x.value) @@ -172,7 +137,9 @@ export const Activity = ({ live }: ActivityProps) => { (!statesFilter || statesFilter.length === 0 || (y.state !== null && statesFilter.includes(y.state))) && - (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) + (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) && + (viewFilter !== 'blockers' || y.blocked_by.length > 0) && + matchesSearch(y) ).length, })) .sort((a, b) => { @@ -184,58 +151,95 @@ export const Activity = ({ live }: ActivityProps) => { return 0 }) - useEffect(() => { - if (selectedPid && isSuccess) { - document - .getElementById(selectedPid.toString()) - ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }, [selectedPid, isSuccess]) + const onResetFilters = () => { + track('database_connections_filter_updated', { type: 'reset' }) + setQueryStates({ + search: '', + states: [], + roles: DEFAULT_ROLES_FILTER, + applications: [], + view: '', + }) + } return (
        -
        -

        Sessions

        -
        - setQueryStates({ states })} - isLoading={isPending} - popoverClassName="w-60" +

        Sessions

        +
        + } + placeholder="Search query" + className="w-56" + value={searchFilter} + onChange={(e) => setQueryStates({ search: e.target.value })} + /> + { + if (isEqual(states, statesFilter)) return + track('database_connections_filter_updated', { type: 'state' }) + setQueryStates({ states }) + }} + isLoading={isPending} + popoverClassName="w-60" + /> + { + if (isEqual(roles, rolesFilter)) return + track('database_connections_filter_updated', { type: 'roles' }) + setQueryStates({ roles }) + }} + isLoading={isPending} + popoverClassName="w-72" + /> + { + if (isEqual(applications, applicationsFilter)) return + track('database_connections_filter_updated', { type: 'application' }) + setQueryStates({ applications }) + }} + isLoading={isPending} + popoverClassName="w-60" + /> + 0 ? {rootBlockers.length} : null} + onClick={() => { + track('database_connections_blocker_view_clicked', { + newState: viewFilter === 'blockers' ? 'disabled' : 'enabled', + }) + setQueryStates({ view: viewFilter === 'blockers' ? '' : 'blockers' }) + }} + tooltip={{ + content: { + side: 'bottom', + text: 'Shows queries currently blocking others', + }, + }} + > + Root blockers + + {!hasNoFiltersApplied && ( + } + onClick={onResetFilters} + aria-label="Reset filters" + tooltip={{ content: { side: 'bottom', text: 'Reset filters' } }} /> - setQueryStates({ roles })} - isLoading={isPending} - popoverClassName="w-72" - /> - setQueryStates({ applications })} - isLoading={isPending} - popoverClassName="w-60" - /> - {!hasNoFiltersApplied && ( - } - onClick={() => - setQueryStates({ states: [], roles: DEFAULT_ROLES_FILTER, applications: [] }) - } - aria-label="Reset filters" - tooltip={{ content: { side: 'bottom', text: 'Reset filters' } }} - /> - )} -
        + )}
        @@ -285,17 +289,7 @@ export const Activity = ({ live }: ActivityProps) => { There are no sessions that match the selected filters. Try adjusting or clearing them.

        - @@ -304,7 +298,7 @@ export const Activity = ({ live }: ActivityProps) => { ) : null} {activities?.map((activity) => ( - + ))}
        @@ -312,226 +306,3 @@ export const Activity = ({ live }: ActivityProps) => {
        ) } - -const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { - 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 () => { - try { - await abortQuery({ - pid: activity.pid, - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - } catch (error) {} - } - - return ( - <> - - - {selectedPid === activity.pid && ( -
        - )} - - - {activity.state} - - {activity.state && ( - {QUERY_STATE_TOOLTIP[activity.state]} - )} - -
        - - - -

        - {!!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 blockedProcess = data?.find((x) => x.pid === pid) - - return ( - - {index > 0 && ', '} - - setSelectedPid(pid)} - > - {pid} - - -

        - {blockedProcess?.query} -

        -

        - PID: {blockedProcess?.pid} - · - {blockedProcess?.role_name} - {blockedProcess?.application_name && ( - <> - · - {blockedProcess?.application_name} - - )} -

        -
        -
        -
        - ) - }) - ) : ( - - )} -
        - - - - -
      + + + + + +

      + {!!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/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..1dd3511cd3d 100644 --- a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboarding.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' 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 { 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/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' && (
    • The application may also have a Secret API key with access. - Go to the{' '} - - Integration's Settings - - , and remove any listed Secret API key to fully revoke its 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/ProjectClaim/confirm.tsx b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx index d85feaf490a..7d129542db9 100644 --- a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx +++ b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx @@ -5,7 +5,7 @@ import Image from 'next/image' import { useRouter } from 'next/router' import { toast } from 'sonner' import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ScopeSection } from '../OAuthApps/AuthorizeRequesterDetails' import { PERMISSIONS_DESCRIPTIONS } from '../OAuthApps/OAuthApps.constants' diff --git a/apps/studio/components/interfaces/Organization/SSO/JoinOrganizationOnSignup.tsx b/apps/studio/components/interfaces/Organization/SSO/JoinOrganizationOnSignup.tsx index bf794b9297d..28848039ddf 100644 --- a/apps/studio/components/interfaces/Organization/SSO/JoinOrganizationOnSignup.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/JoinOrganizationOnSignup.tsx @@ -1,4 +1,4 @@ -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { FormControl, FormField, @@ -19,7 +19,7 @@ export const JoinOrganizationOnSignup = ({ }: { form: ReturnType> }) => { - const joinOrgOnSignup = form.watch('joinOrgOnSignup') + const joinOrgOnSignup = useWatch({ control: form.control, name: 'joinOrgOnSignup' }) return (
      diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx index 8df56451a7d..6b0c41936ea 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -2,10 +2,10 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useFlag } from 'common' import { Trash } from 'lucide-react' import { useEffect, useEffectEvent, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import z from 'zod' @@ -129,8 +129,8 @@ export const SSOConfig = () => { defaultValues, }) - const isSSOEnabled = form.watch('enabled') - const enableSpInitiated = form.watch('enableSpInitiated') + const isSSOEnabled = useWatch({ control: form.control, name: 'enabled' }) + const enableSpInitiated = useWatch({ control: form.control, name: 'enableSpInitiated' }) const { mutate: createSSOConfig, isPending: isCreating } = useSSOConfigCreateMutation({ onSuccess: () => { @@ -314,7 +314,7 @@ export const SSOConfig = () => { )} /> - {form.watch('enableSpInitiated') && ( + {enableSpInitiated && ( { )} - {form.watch('enableSpInitiated') && ( + {enableSpInitiated && ( diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx index a6f01b4adee..61a52e643d2 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx @@ -6,7 +6,7 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, Switch } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { z } from 'zod' diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index c5d1fab5de4..fc5722bc5f0 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { UserPlus } from 'lucide-react' import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -30,7 +30,7 @@ import { SheetTrigger, Switch, } 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' @@ -151,7 +151,10 @@ export const InviteMemberButton = () => { defaultValues, }) - const { applyToOrg, projectRef, email } = form.watch() + const [applyToOrg, projectRef, email] = useWatch({ + control: form.control, + name: ['applyToOrg', 'projectRef', 'email'], + }) const emailCount = parseEmails(email ?? '').length diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index d5ee5abab4c..867e13c2e27 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -13,7 +13,7 @@ import { TableHeader, TableRow, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { MemberRow } from './MemberRow' diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 7acaeaaf284..afa81dc1426 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { Search } from 'lucide-react' import { useState } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { InviteMemberButton } from './InviteMemberButton' diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index 8d5eb804fce..6fccdd78045 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { USAGE_APPROACHING_THRESHOLD } from '@/components/interfaces/Billing/Billing.constants' import { EgressType, PricingMetric } from '@/data/analytics/org-daily-stats-query' @@ -413,10 +413,10 @@ export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[ anchor: 'pipeline-initial-sync-data', key: PricingMetric.ETL_COPY_BACKFILL_DATA, attributes: [{ key: PricingMetric.ETL_COPY_BACKFILL_DATA.toLowerCase(), color: 'white' }], - name: 'Pipeline Initial Sync Data', + name: 'Initial Sync Data Processed', unit: 'bytes', description: - 'Total amount of initial sync data processed across all projects.\nBilling is based on the total amount of initial sync data processed in gigabytes.', + 'Postgres row data accepted by pipeline destinations during initial sync or resynchronization across all projects.\nBilling is based on the total amount of initial sync data processed in gigabytes.', chartDescription: 'The data refreshes every hour.', links: [ { @@ -429,10 +429,10 @@ export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[ anchor: 'pipeline-ongoing-replication-data', key: PricingMetric.ETL_REPLICATED_DATA, attributes: [{ key: PricingMetric.ETL_REPLICATED_DATA.toLowerCase(), color: 'white' }], - name: 'Pipeline Ongoing Replication Data', + name: 'Ongoing Replication Data Processed', unit: 'bytes', description: - 'Total amount of ongoing replication data processed across all projects.\nBilling is based on the total amount of ongoing replication data processed in gigabytes.', + 'Postgres row data accepted by pipeline destinations during ongoing replication across all projects.\nBilling is based on the total amount of ongoing replication data processed in gigabytes.', chartDescription: 'The data refreshes every hour.', links: [ { diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx new file mode 100644 index 00000000000..9718945acbc --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx @@ -0,0 +1,90 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Usage } from './Usage' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUseAsyncCheckPermissions, + mockUseOrgDailyStatsQuery, + mockUseOrgSubscriptionQuery, + mockUseProjectDetailQuery, +} = vi.hoisted(() => ({ + mockUseAsyncCheckPermissions: vi.fn(), + mockUseOrgDailyStatsQuery: vi.fn(), + mockUseOrgSubscriptionQuery: vi.fn(), + mockUseProjectDetailQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ slug: 'test-org' }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/data/analytics/org-daily-stats-query', async (importOriginal) => ({ + ...(await importOriginal()), + useOrgDailyStatsQuery: mockUseOrgDailyStatsQuery, +})) + +vi.mock('@/data/projects/project-detail-query', () => ({ + useProjectDetailQuery: mockUseProjectDetailQuery, +})) + +vi.mock('@/data/subscriptions/org-subscription-query', () => ({ + useOrgSubscriptionQuery: mockUseOrgSubscriptionQuery, +})) + +describe('Usage', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isLoading: false }) + mockUseProjectDetailQuery.mockReturnValue({ + data: { + ref: 'ha-project', + name: 'HA project', + high_availability: true, + }, + isPending: false, + }) + mockUseOrgSubscriptionQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + isSuccess: false, + }) + mockUseOrgDailyStatsQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + }) + }) + + it('shows a coming-soon empty state for a High Availability project', () => { + customRender(, { + nuqs: { searchParams: { projectRef: 'ha-project' } }, + }) + + expect(screen.getByText('Usage unavailable on High Availability projects')).toBeInTheDocument() + expect( + screen.getByText('Usage insights for High Availability projects are coming soon.') + ).toBeInTheDocument() + expect(screen.queryByText('Usage filtered by project')).not.toBeInTheDocument() + + expect(mockUseProjectDetailQuery).toHaveBeenCalledWith({ ref: 'ha-project' }) + expect(mockUseOrgSubscriptionQuery).toHaveBeenCalledWith( + { orgSlug: 'test-org' }, + { enabled: false } + ) + expect(mockUseOrgDailyStatsQuery).toHaveBeenCalledWith( + expect.objectContaining({ orgSlug: 'test-org', projectRef: 'ha-project' }), + { enabled: false } + ) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index bad4f5d35cc..90138f2caec 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -1,12 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import dayjs from 'dayjs' -import { Check, ChevronDown } from 'lucide-react' +import { ChartArea, Check, ChevronDown } from 'lucide-react' import Link from 'next/link' import { useQueryState } from 'nuqs' import { useMemo, useState } from 'react' import { Button, cn, CommandGroup, CommandItem } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { Restriction } from '../BillingSettings/Restriction' @@ -26,12 +26,14 @@ import { } from '@/components/layouts/Scaffold' import { AlertError } from '@/components/ui/AlertError' import { DateRangePicker } from '@/components/ui/DateRangePicker' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { NoPermission } from '@/components/ui/NoPermission' import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' import { useOrgDailyStatsQuery } from '@/data/analytics/org-daily-stats-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { resolveHighAvailability } from '@/hooks/misc/useHighAvailability' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics' export const Usage = () => { @@ -42,6 +44,12 @@ export const Usage = () => { const [selectedProjectRef, setSelectedProjectRef] = useQueryState('projectRef') const [openProjectSelector, setOpenProjectSelector] = useState(false) + const { data: selectedProject, isPending: isLoadingSelectedProject } = useProjectDetailQuery({ + ref: selectedProjectRef ?? undefined, + }) + const isHighAvailability = resolveHighAvailability(selectedProject) + const canLoadUsage = !selectedProjectRef || (!isLoadingSelectedProject && !isHighAvailability) + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' @@ -53,11 +61,7 @@ export const Usage = () => { isPending: isLoadingSubscription, isError: isErrorSubscription, isSuccess: isSuccessSubscription, - } = useOrgSubscriptionQuery({ orgSlug: slug }) - - const { data: selectedProject } = useProjectDetailQuery({ - ref: selectedProjectRef ?? undefined, - }) + } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canLoadUsage }) const billingCycleStart = useMemo(() => { return dayjs.unix(subscription?.current_period_start ?? 0).utc() @@ -107,12 +111,27 @@ export const Usage = () => { error: orgDailyStatsError, isPending: isLoadingOrgDailyStats, isError: isErrorOrgDailyStats, - } = useOrgDailyStatsQuery({ - orgSlug: slug, - projectRef: selectedProjectRef ?? undefined, - startDate, - endDate, - }) + } = useOrgDailyStatsQuery( + { + orgSlug: slug, + projectRef: selectedProjectRef ?? undefined, + startDate, + endDate, + }, + { enabled: canLoadUsage } + ) + + if (isHighAvailability) { + return ( +
      + +
      + ) + } return ( <> diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index 6f30631ff4f..1f93a706ec9 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -6,6 +6,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { SectionContent } from '../SectionContent' import { CategoryAttribute } from '../Usage.constants' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { AlertError } from '@/components/ui/AlertError' import Panel from '@/components/ui/Panel' import { PricingMetric } from '@/data/analytics/org-daily-stats-query' @@ -59,7 +60,6 @@ export const DiskUsage = ({ }) .filter((it) => it.ref === projectRef || !projectRef) : [] - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, projects, projectRef]) const hasProjectsExceedingDiskSize = useMemo(() => { @@ -200,9 +200,7 @@ export const DiskUsage = ({
      ) 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..7e62eb43785 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 { @@ -25,9 +24,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 +69,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 +195,15 @@ export const OrganizationInvite = () => { Accept invite + {joinError && ( +
      +

      + Failed to join organization: {joinError.message} +

      +
      + )} ) 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/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx index 5ba618c8ccf..e9791b6b9c7 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { ChevronDown } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Accordion, AccordionContent, @@ -196,8 +196,8 @@ export const PlatformWebhooksEndpointSheet = ({ onClose, }) - const subscribeAll = form.watch('subscribeAll') - const selectedEventTypes = form.watch('eventTypes') + const subscribeAll = useWatch({ control: form.control, name: 'subscribeAll' }) + const selectedEventTypes = useWatch({ control: form.control, name: 'eventTypes' }) const groupedEventTypes = useMemo( () => 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/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx index c9a621a4d72..ef41eea090d 100644 --- a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -12,7 +12,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' diff --git a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx index e3b8389232c..1531bdae9ca 100644 --- a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import Panel from '@/components/ui/Panel' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' diff --git a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx index 5e24360fa55..93b8e7ad0f8 100644 --- a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx @@ -1,34 +1,127 @@ +import { useEffect, useRef } from 'react' import { UseFormReturn } from 'react-hook-form' -import { FormControl, FormField, Switch } from 'ui' +import { type CloudProvider } from 'shared-data' +import { Badge, FormControl, FormField, Switch, useWatch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' +import Panel from '@/components/ui/Panel' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' interface HighAvailabilityInputProps { form: UseFormReturn + // Derived from an org-wide regions query owned by the parent form, since fetching it + // here would mean duplicating that query's slug/cloud-provider/instance-size context. + highAvailabilityRegionName: string | undefined } -export const HighAvailabilityInput = ({ form }: HighAvailabilityInputProps) => { +export const HighAvailabilityInput = ({ + form, + highAvailabilityRegionName, +}: HighAvailabilityInputProps) => { + const { getValues, setValue } = form const { hasAccess } = useCheckEntitlements('instances.high_availability') + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + + // Fields to revert to when toggling off HA, so previously selected values aren't lost. + const beforeHighAvailability = useRef<{ + cloudProvider: CloudProvider | undefined + postgresVersionSelection: string | undefined + dbRegion: string | null + }>({ + cloudProvider: undefined, + postgresVersionSelection: undefined, + dbRegion: null, + }) + + const handleHighAvailabilityChange = (checked: boolean) => { + if (checked) { + const currentCloudProvider = getValues('cloudProvider') as CloudProvider + if (currentCloudProvider !== 'AWS_K8S') { + beforeHighAvailability.current.cloudProvider = currentCloudProvider + setValue('cloudProvider', 'AWS_K8S') + } + + beforeHighAvailability.current.postgresVersionSelection = getValues( + 'postgresVersionSelection' + ) + setValue('useOrioleDb', false) + + const currentRegion = getValues('dbRegion') + if ( + highAvailabilityRegionName !== undefined && + currentRegion !== highAvailabilityRegionName + ) { + beforeHighAvailability.current.dbRegion = currentRegion ?? null + setValue('dbRegion', highAvailabilityRegionName) + } + } else { + if (beforeHighAvailability.current.cloudProvider !== undefined) { + setValue('cloudProvider', beforeHighAvailability.current.cloudProvider) + beforeHighAvailability.current.cloudProvider = undefined + } + + if (beforeHighAvailability.current.postgresVersionSelection !== undefined) { + setValue( + 'postgresVersionSelection', + beforeHighAvailability.current.postgresVersionSelection + ) + beforeHighAvailability.current.postgresVersionSelection = undefined + } + + if (beforeHighAvailability.current.dbRegion !== null) { + setValue('dbRegion', beforeHighAvailability.current.dbRegion) + beforeHighAvailability.current.dbRegion = null + } + } + } + + // Catches the case where highAvailabilityRegionName wasn't loaded yet at the moment the + // toggle fired above (the org's available-regions query for AWS_K8S may still be in + // flight). The region auto-fill effect in the parent form skips dirty fields, so a + // manually chosen region would otherwise keep showing in the trigger — force it over + // explicitly once the HA region becomes known. + useEffect(() => { + if (!highAvailability || highAvailabilityRegionName === undefined) return + const currentRegion = getValues('dbRegion') + if (currentRegion === highAvailabilityRegionName) return + if (beforeHighAvailability.current.dbRegion === null) { + beforeHighAvailability.current.dbRegion = currentRegion ?? null + } + setValue('dbRegion', highAvailabilityRegionName) + }, [highAvailability, highAvailabilityRegionName, getValues, setValue]) if (!hasAccess) return null return ( - ( - - - - - - )} - /> + + ( + + High availability + Alpha + + } + description="Horizontally scalable Postgres for highly available deployments. Free during Alpha for up to 2 projects." + layout="horizontal" + > + + { + handleHighAvailabilityChange(checked) + field.onChange(checked) + }} + /> + + + )} + /> + ) } diff --git a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx index 12858f3df5f..3779c2912f7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx @@ -1,12 +1,12 @@ import { useParams } from 'common' +import { useRef } from 'react' import { UseFormReturn } from 'react-hook-form' import { type CloudProvider } from 'shared-data' -import { FormControl, FormField, Input } from 'ui' +import { FormControl, FormField, Input, useWatch } from 'ui' import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CloudProviderSelector } from './CloudProviderSelector' -import { HighAvailabilityInput } from './HighAvailabilityInput' import { PostgresVersionSelector } from './PostgresVersionSelector' import { CreateProjectForm } from './ProjectCreation.schema' import Panel from '@/components/ui/Panel' @@ -18,6 +18,10 @@ interface InternalOnlyConfigurationProps { export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationProps) => { const { slug } = useParams() const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + // Held here (outside the collapsible content) so the selector's last valid + // selection survives the section being collapsed and reopened. + const lastValidPostgresVersionSelection = useRef('') return ( @@ -37,11 +41,11 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro cloudProvider={form.getValues('cloudProvider') as CloudProvider} organizationSlug={slug} dbRegion={form.getValues('dbRegion')} + disabled={highAvailability} + lastValidSelectionRef={lastValidPostgresVersionSelection} /> )} /> - - {showNonProdFields && ( @@ -52,21 +56,23 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro
      - ( - - - - - - )} - /> + {!highAvailability && ( + ( + + + + + + )} + /> + )} form: UseFormReturn + /** + * Owned by the form owner (not this component) so the last valid selection + * survives this selector unmounting, e.g. when its collapsible section + * closes. Create it with useRef('') alongside the form. + */ + lastValidSelectionRef: { current: string } type?: 'create' | 'unpause' layout?: 'vertical' | 'horizontal' label?: string @@ -56,9 +63,11 @@ export const extractPostgresVersionDetails = (value: string): PostgresVersionDet export const PostgresVersionSelector = ({ cloudProvider, dbRegion, + disabled = false, organizationSlug, field, form, + lastValidSelectionRef, type = 'create', layout = 'horizontal', label = 'Postgres version', @@ -91,15 +100,37 @@ export const PostgresVersionSelector = ({ ? (createVersions?.available_versions ?? []) : (unpauseVersions?.available_versions ?? []) const availableVersions = versions.sort((a, b) => a.version.localeCompare(b.version)).reverse() - const { postgresVersionSelection } = form.watch() + const postgresVersionSelection = useWatch({ + control: form.control, + name: 'postgresVersionSelection', + }) + // react-hook-form intermittently drops this field's value when its Controller + // remounts, so a one-shot "set the default once versions load" effect leaves + // the select stuck empty. Instead this effect re-asserts off the watched value: + // a selection present in the current list is kept (and remembered in the + // owner-held lastValidSelectionRef), and when the value is missing or cleared + // out from under us it restores the last valid selection, falling back to the + // GA default. useEffect(() => { - if (availableVersions.length > 0) { - const gaVersion = availableVersions.find((x) => x.release_channel === 'ga') - const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0]) - form.setValue('postgresVersionSelection', defaultValue) + if (availableVersions.length === 0) return + const isSelectionAvailable = (selection: string) => + availableVersions.some((version) => formatValue(version) === selection) + + if (postgresVersionSelection && isSelectionAvailable(postgresVersionSelection)) { + lastValidSelectionRef.current = postgresVersionSelection + return } - }, [isSuccess, availableVersions, form]) + + if (isSelectionAvailable(lastValidSelectionRef.current)) { + form.setValue('postgresVersionSelection', lastValidSelectionRef.current) + return + } + + const gaVersion = availableVersions.find((x) => x.release_channel === 'ga') + const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0]) + form.setValue('postgresVersionSelection', defaultValue) + }, [isSuccess, availableVersions, postgresVersionSelection, lastValidSelectionRef, form]) return ( @@ -107,6 +138,7 @@ export const PostgresVersionSelector = ({ value={postgresVersionSelection} onValueChange={field.onChange} disabled={ + disabled || availableVersions.length === 0 || (type === 'create' && isLoadingProjectCreateVersions) || (type === 'unpause' && isLoadingProjectUnpauseVersions) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts index 2cdb36b8c86..fb6dcd1d258 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts @@ -1,4 +1,11 @@ -import { DesiredInstanceSize } from '@/data/projects/new-project.constants' +import type { + DesiredInstanceSize, + PostgresEngine, + ReleaseChannel, +} from '@/data/projects/new-project.constants' + +export const HIGH_AVAILABILITY_POSTGRES_ENGINE = '17' satisfies PostgresEngine +export const HIGH_AVAILABILITY_RELEASE_CHANNEL = 'ga' satisfies ReleaseChannel // [Joshen] Obtained from https://gist.github.com/tadast/8827699 export const COUNTRY_LAT_LON = { diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts new file mode 100644 index 00000000000..cb820643b13 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { + HIGH_AVAILABILITY_POSTGRES_ENGINE, + HIGH_AVAILABILITY_RELEASE_CHANNEL, +} from './ProjectCreation.constants' +import { + filterHighAvailabilityRegions, + getHighAvailabilityRegionCode, +} from './ProjectCreation.utils' + +describe('High Availability project creation constraints', () => { + it('pins the Alpha Postgres engine and release channel', () => { + expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17') + expect(HIGH_AVAILABILITY_RELEASE_CHANNEL).toBe('ga') + }) + + it.each([ + ['local', 'eu-central-1'], + ['staging', 'us-east-1'], + ['prod', undefined], + ])('resolves the %s region restriction', (environment, expectedRegion) => { + expect(getHighAvailabilityRegionCode(environment)).toBe(expectedRegion) + }) + + it.each([ + ['local', 'eu-central-1'], + ['staging', 'us-east-1'], + ['prod', undefined], + ])('limits %s projects to the required region', (environment, expectedRegion) => { + const regions = [{ code: 'us-east-1' }, { code: 'eu-central-1' }] + + expect(filterHighAvailabilityRegions(regions, true, environment)).toEqual( + expectedRegion === undefined ? regions : [{ code: expectedRegion }] + ) + expect(filterHighAvailabilityRegions(regions, false, environment)).toEqual(regions) + }) +}) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index 3b399505d9e..c77d48d680b 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -45,3 +45,22 @@ export const monthlyInstancePrice = (instance: string | undefined): number => { export const instanceLabel = (instance: string | undefined): string => { return instanceSizeSpecs[instance as DesiredInstanceSize]?.label || 'Micro' } + +export const getHighAvailabilityRegionCode = ( + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +) => { + if (environment === 'local') return 'eu-central-1' + if (environment === 'staging') return 'us-east-1' + return undefined +} + +export const filterHighAvailabilityRegions = ( + regions: T[], + highAvailability: boolean, + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +) => { + const regionCode = getHighAvailabilityRegionCode(environment) + return highAvailability && regionCode !== undefined + ? regions.filter((region) => region.code === regionCode) + : regions +} diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationFooter.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationFooter.tsx index e01c2be3dd6..22d0597a08a 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationFooter.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationFooter.tsx @@ -1,9 +1,11 @@ import { useFlag } from 'common' import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { Badge, Button, + cn, PopoverSeparator, Table, TableBody, @@ -22,6 +24,9 @@ import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganizat import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' +/** `close` = close the popup (Vercel interstitial); `studio` = navigate into Studio. */ +export type ProjectCreationCancelAction = 'studio' | 'close' | 'hidden' + interface ProjectCreationFooterProps { form: UseFormReturn canCreateProject: boolean @@ -29,7 +34,7 @@ interface ProjectCreationFooterProps { organizationProjects: OrgProject[] isCreatingNewProject: boolean isSuccessNewProject: boolean - hideCancelButton: boolean + cancelAction?: ProjectCreationCancelAction } export const ProjectCreationFooter = ({ @@ -39,12 +44,13 @@ export const ProjectCreationFooter = ({ organizationProjects, isCreatingNewProject, isSuccessNewProject, - hideCancelButton, + cancelAction = 'studio', }: ProjectCreationFooterProps) => { const router = useRouter() const { data: currentOrg } = useSelectedOrganizationQuery() const isFreePlan = currentOrg?.plan?.id === 'free' const { lastVisitedOrganization } = useLastVisitedOrganization() + const [showCloseWindowHint, setShowCloseWindowHint] = useState(false) const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') @@ -53,6 +59,9 @@ export const ProjectCreationFooter = ({ ? 0 : monthlyInstancePrice(instanceSize) - availableComputeCredits + const showAdditionalCosts = + !isFreePlan && !projectCreationDisabled && canCreateProject && additionalMonthlySpend > 0 + // [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now const monthlyComputeCosts = // current project costs @@ -66,121 +75,140 @@ export const ProjectCreationFooter = ({ // compute credits 10 + const onCancel = () => { + if (cancelAction === 'close') { + window.close() + return + } + + if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`) + else router.push('/organizations') + } + + useEffect(() => { + // Browsers only allow closing windows that were opened by script (i.e. have a live `opener`). + // Detect this upfront so we don't need to attempt-and-check after the user clicks cancel. + if (cancelAction === 'close' && !window.opener) setShowCloseWindowHint(true) + }, [cancelAction]) + return (
      -
      - {!isFreePlan && - !projectCreationDisabled && - canCreateProject && - additionalMonthlySpend > 0 && ( -
      - Additional costs -
      - ${additionalMonthlySpend}/m - -
      -

      - Each project includes a dedicated Postgres instance running on its own server. - You are charged for the{' '} - - Compute resource - {' '} - of that server, independent of your database usage. -

      - {monthlyComputeCosts > 0 && ( -

      Compute costs are applied on top of your subscription plan costs.

      - )} -
      + {showAdditionalCosts && ( +
      +
      + Additional costs +
      + ${additionalMonthlySpend}/m + +
      +

      + Each project includes a dedicated Postgres instance running on its own server. + You are charged for the{' '} + + Compute resource + {' '} + of that server, independent of your database usage. +

      + {monthlyComputeCosts > 0 && ( +

      Compute costs are applied on top of your subscription plan costs.

      + )} +
      -
      - - - 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} + +
      +
      +
      +
      - )} -
    +
    +
    + )} -
    - {!hideCancelButton && ( +
    + {cancelAction === 'hidden' ? null : showCloseWindowHint ? ( +

    + Close window to cancel +

    + ) : ( )} + ) : ( + + + +
    + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx new file mode 100644 index 00000000000..027ae76bd2c --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx @@ -0,0 +1,167 @@ +import { useParams } from 'common' +import dayjs from 'dayjs' +import { Check, ChevronDown, Database, ScrollText } from 'lucide-react' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +import { + datePickerValueToLogDateRange, + type QuerySource, + type SqlSnippetSource, +} from '../../querySource' +import { DatabaseSubMenu } from './DatabaseSubMenu' +import { LogsCustomRangeDialog } from './LogsCustomRangeDialog' +import { resolveSourceSwitch } from './QuerySourceMenu.utils' +import { RowLimitSubMenu } from './RowLimitSubMenu' +import { RunAsSubMenu } from './RunAsSubMenu' +import { TimeRangeSubMenu } from './TimeRangeSubMenu' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { IS_PLATFORM } from '@/lib/constants' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +const SOURCE_LABEL: Record = { + database: 'Database', + logs: 'Logs', +} + +const SourceIcon = ({ source, ...props }: { source: SqlSnippetSource; className?: string }) => + source === 'logs' ? : + +type QuerySourceMenuProps = { + id: string + runSource: QuerySource + /** Whether creating a logs snippet is available (feature-flagged). */ + canCreateLogsSnippet: boolean +} + +/** + * The consolidated SQL-editor source control: a single toolbar dropdown that + * both labels the snippet's source (Database / Logs) and hosts every + * source-specific control as a flyout submenu — database selector + role + * impersonation + row limit for a database snippet, time range for a logs + * snippet. The logs "Custom range…" calendar and the retention upgrade prompt are + * rendered as siblings of the dropdown (not inside it) so they survive the menu + * closing. + * + * A snippet's source is immutable, so the source rows aren't a plain toggle. + * Once a snippet exists — a saved query, or a new one the user has already typed + * into — switching backends opens a *fresh* tab with a new snippet of that + * source, leaving the existing query untouched (there's no sense running a query + * against the wrong backend). Only a brand-new, not-yet-materialized blank tab + * re-flavors in place, so we don't spawn a redundant snippet before the user has + * written anything. + */ +export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySourceMenuProps) => { + const { ref } = useParams() + const router = useRouter() + const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() + + const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const currentSource = runSource.type + const isLogs = currentSource === 'logs' + // A snippet materializes in the store on its first keystroke; until then a + // `/sql/new` tab is a blank scaffold with nothing to preserve. + const isBlankNewTab = snapV2.snippets[id] === undefined + + const switchSource = (target: SqlSnippetSource) => { + const next = resolveSourceSwitch({ ref, target, currentSource, isBlankNewTab }) + if (next === null) return + router[next.method](next.url) + } + + const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { + const fromIso = dayjs(from).startOf('day').toISOString() + if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { + setShowUpgradePrompt(true) + return + } + sessionSnap.setLogRange( + id, + datePickerValueToLogDateRange({ + from: fromIso, + to: dayjs(to).endOf('day').toISOString(), + isHelper: false, + }) + ) + } + + return ( + <> + + + + + + switchSource('database')}> + + + Database + + {!isLogs && } + + {(canCreateLogsSnippet || isLogs) && ( + switchSource('logs')}> + + + Logs + + {isLogs && } + + )} + + + + {runSource.type === 'logs' ? ( + setIsCustomRangeOpen(true)} + onShowUpgrade={() => setShowUpgradePrompt(true)} + /> + ) : ( + <> + {IS_PLATFORM && } + + + + )} + + + + {isLogs && ( + <> + + + + )} + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.test.ts new file mode 100644 index 00000000000..d005b5b44b4 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' + +import { resolveSourceSwitch } from './QuerySourceMenu.utils' + +describe('resolveSourceSwitch', () => { + it('opens a new tab (push) when switching source on an existing snippet', () => { + expect( + resolveSourceSwitch({ + ref: 'abc', + target: 'logs', + currentSource: 'database', + isBlankNewTab: false, + }) + ).toEqual({ method: 'push', url: '/project/abc/sql/new?skip=true&source=logs' }) + }) + + it('re-flavors in place (replace) on a brand-new blank tab', () => { + expect( + resolveSourceSwitch({ + ref: 'abc', + target: 'logs', + currentSource: 'database', + isBlankNewTab: true, + }) + ).toEqual({ method: 'replace', url: '/project/abc/sql/new?skip=true&source=logs' }) + }) + + it('omits the source param when switching to database (the default source)', () => { + expect( + resolveSourceSwitch({ + ref: 'abc', + target: 'database', + currentSource: 'logs', + isBlankNewTab: false, + }) + ).toEqual({ method: 'push', url: '/project/abc/sql/new?skip=true' }) + }) + + it('is a no-op when the snippet is already on the target source', () => { + expect( + resolveSourceSwitch({ + ref: 'abc', + target: 'database', + currentSource: 'database', + isBlankNewTab: false, + }) + ).toBeNull() + }) + + it('is a no-op without a project ref', () => { + expect( + resolveSourceSwitch({ + ref: undefined, + target: 'logs', + currentSource: 'database', + isBlankNewTab: true, + }) + ).toBeNull() + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.ts new file mode 100644 index 00000000000..0aba7a73b97 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.utils.ts @@ -0,0 +1,43 @@ +import { type SqlSnippetSource } from '../../querySource' + +export type SourceSwitchNavigation = { + /** + * `push` opens a new tab (leaving the current snippet intact); `replace` + * re-flavors the current blank tab in place without a history entry. + */ + method: 'push' | 'replace' + url: string +} + +/** + * Decide how switching a snippet's query source should navigate. A snippet's + * source is immutable, so switching never edits the current snippet: + * + * - An existing (materialized) snippet — a saved query, or a new one already + * typed into — opens a *fresh* `/sql/new` tab of the target source (`push`), so + * its query is never reinterpreted against the wrong backend. + * - A brand-new, not-yet-materialized blank tab re-flavors in place (`replace`), + * so we don't spawn a redundant snippet before anything has been written. + * + * Returns `null` when there's nothing to do (no project ref, or the snippet is + * already on the target source). + */ +export function resolveSourceSwitch({ + ref, + target, + currentSource, + isBlankNewTab, +}: { + ref: string | undefined + target: SqlSnippetSource + currentSource: SqlSnippetSource + isBlankNewTab: boolean +}): SourceSwitchNavigation | null { + if (!ref || target === currentSource) return null + const suffix = target === 'logs' ? '&source=logs' : '' + return { + method: isBlankNewTab ? 'replace' : 'push', + // skip=true bypasses the "load last visited snippet" redirect on /sql/new. + url: `/project/${ref}/sql/new?skip=true${suffix}`, + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx new file mode 100644 index 00000000000..9015dbada13 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx @@ -0,0 +1,38 @@ +import { + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from 'ui' + +import { ROWS_PER_PAGE_OPTIONS } from '../../SQLEditor.constants' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' + +export const RowLimitSubMenu = () => { + const sessionSnap = useSqlEditorSessionSnapshot() + const currentLabel = ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label + + return ( + + +
    + Row limit + {currentLabel} +
    +
    + + sessionSnap.setLimit(Number(val))} + > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
    + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx new file mode 100644 index 00000000000..a44a95d2d60 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx @@ -0,0 +1,31 @@ +import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from 'ui' + +import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector' +import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' + +const SERVICE_ROLE_LABEL = 'postgres' + +export const RunAsSubMenu = () => { + const state = useRoleImpersonationStateSnapshot() + const currentRole = state.role?.role ?? SERVICE_ROLE_LABEL + + return ( + + +
    + Run as + {currentRole} +
    +
    + {/* Stops propagation so the authenticated-user search input isn't swallowed by the + dropdown's typeahead. */} + e.stopPropagation()}> + + +
    + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx new file mode 100644 index 00000000000..39517f8c7b6 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/TimeRangeSubMenu.tsx @@ -0,0 +1,88 @@ +import { Check, Lock } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from 'ui' + +import { + datePickerValueToLogDateRange, + logDateRangesEqual, + type LogDateRange, +} from '../../querySource' +import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' + +export const TimeRangeSubMenu = ({ + id, + range, + onOpenCustomRange, + onShowUpgrade, +}: { + id: string + range: LogDateRange + onOpenCustomRange: () => void + onShowUpgrade: () => void +}) => { + const sessionSnap = useSqlEditorSessionSnapshot() + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const isCustomRange = range.kind === 'absolute' + const presets = EXPLORER_DATEPICKER_HELPERS.map((helper) => ({ + helper, + range: datePickerValueToLogDateRange({ + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, + }), + })) + const selectedPreset = presets.find((preset) => logDateRangesEqual(range, preset.range)) + + return ( + + +
    + Time range + + {isCustomRange ? 'Custom range' : (selectedPreset?.helper.text ?? 'Custom range')} + +
    +
    + + {presets.map(({ helper, range: presetRange }) => { + const isSelected = !isCustomRange && logDateRangesEqual(range, presetRange) + const isLocked = maybeShowUpgradePromptIfNotEntitled(helper.calcFrom(), entitledToLogDays) + + return ( + { + if (isLocked) return onShowUpgrade() + sessionSnap.setLogRange(id, presetRange) + }} + > + {helper.text} + {isLocked ? ( + + ) : ( + isSelected && + )} + + ) + })} + + + Custom range… + {isCustomRange && } + + +
    + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts index 020a9667b88..6006cde9feb 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -4,12 +4,56 @@ import { convertResultsToCSV, convertResultsToJSON, convertResultsToMarkdown, + formatCellValue, + formatClipboardValue, formatResults, getResultsHeaders, isLargeValue, } from './Results.utils' describe('Results.utils', () => { + describe('formatClipboardValue', () => { + it('returns empty string for null', () => { + expect(formatClipboardValue(null)).toBe('') + }) + + it('stringifies objects', () => { + expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies arrays', () => { + expect(formatClipboardValue([1, 2])).toBe('[1,2]') + }) + + it('converts primitives to string', () => { + expect(formatClipboardValue('hello')).toBe('hello') + expect(formatClipboardValue(42)).toBe('42') + expect(formatClipboardValue(false)).toBe('false') + }) + }) + + describe('formatCellValue', () => { + it('returns NULL for null', () => { + expect(formatCellValue(null)).toBe('NULL') + }) + + it('returns strings as-is', () => { + expect(formatCellValue('hello')).toBe('hello') + }) + + it('stringifies objects', () => { + expect(formatCellValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies numbers', () => { + expect(formatCellValue(42)).toBe('42') + }) + + it('stringifies booleans', () => { + expect(formatCellValue(true)).toBe('true') + }) + }) + describe('formatResults', () => { it('should stringify object values', () => { const results = [{ id: 1, data: { nested: true } }] diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx index 861481f457d..6fdaae2365e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx @@ -1,5 +1,7 @@ import { Loader2 } from 'lucide-react' -import { Button, KeyboardShortcut } from 'ui' +import { KeyboardShortcut } from 'ui' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' interface SqlRunButtonProps { isDisabled?: boolean @@ -7,6 +9,13 @@ interface SqlRunButtonProps { hasSelection?: boolean className?: string onClick: () => void + /** + * Explanation shown in a tooltip while the button is disabled — e.g. why a + * logs query can't run on this project. A disabled ` + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 4a6b55cf89b..a5c69be3d40 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -1,5 +1,5 @@ import { Hotkey } from '@tanstack/react-hotkeys' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { AlignLeft, Check, ChevronDown, Heart, Keyboard, MoreVertical } from 'lucide-react' import { toast } from 'sonner' import { @@ -18,8 +18,10 @@ import { TooltipTrigger, } from 'ui' +import { type QuerySource } from '../querySource' import { ROWS_PER_PAGE_OPTIONS } from '../SQLEditor.constants' import { AutosaveStatus } from './AutosaveStatus' +import { QuerySourceMenu } from './QuerySourceMenu/QuerySourceMenu' import { SqlRunButton } from './RunButton' import { SqlSaveButton } from './SaveButton' import SavingIndicator from './SavingIndicator' @@ -35,6 +37,7 @@ import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state export type UtilityActionsProps = { id: string + runSource: QuerySource isExecuting?: boolean isDisabled?: boolean hasSelection?: boolean @@ -45,6 +48,7 @@ export type UtilityActionsProps = { export const UtilityActions = ({ id, + runSource, isExecuting = false, isDisabled = false, hasSelection = false, @@ -57,6 +61,14 @@ export const UtilityActions = ({ const sessionSnap = useSqlEditorSessionSnapshot() const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled() + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + + const isLogs = runSource.type === 'logs' + const canCreateLogsSnippet = isLogsSourceEnabled && isOtelLogsEnabled + const canShowSourceIndicator = isLogs || canCreateLogsSnippet + const isLogsRunBlocked = isLogs && !isOtelLogsEnabled + const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, @@ -141,7 +153,7 @@ export const UtilityActions = ({ )} - + Prettify SQL @@ -208,6 +220,7 @@ export const UtilityActions = ({ -
    - - sessionSnap.setLimit(Number(val))} - > - {ROWS_PER_PAGE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - -
    + + + + + + sessionSnap.setLimit(Number(val))} + > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + + )}
    {isManualSaveEnabled && } diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts new file mode 100644 index 00000000000..5b79d3b7768 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -0,0 +1,285 @@ +import dayjs from 'dayjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + datePickerValueToLogDateRange, + DEFAULT_LOG_DATE_RANGE, + getSnippetSource, + isoDateTimeString, + logDateRangesEqual, + logDateRangeToDatePickerValue, + resolveLogRunRange, + type LogDateRange, +} from './querySource' +import { + EXPLORER_DATEPICKER_HELPERS, + getDefaultHelper, +} from '@/components/interfaces/Settings/Logs/Logs.constants' +import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' +import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types' + +/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */ +const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({ + from: helper.calcFrom(), + to: helper.calcTo(), + isHelper: true, + text: helper.text, +}) + +/** The single dynamic helper produced from typed input like "2h" / "30m". */ +const dynamicHelper = (input: string): DatetimeHelper => { + const generated = generateHelpersFromInput(input) + if (!generated || generated.length !== 1) { + throw new Error(`Expected a single dynamic helper for "${input}"`) + } + return generated[0] +} + +describe('querySource.ts:getSnippetSource', () => { + it('maps log_sql to the logs source', () => { + expect(getSnippetSource({ type: 'log_sql' })).toBe('logs') + }) + + it('maps sql to the database source', () => { + expect(getSnippetSource({ type: 'sql' })).toBe('database') + }) + + it('maps report to the database source', () => { + expect(getSnippetSource({ type: 'report' })).toBe('database') + }) +}) + +describe('querySource.ts:isoDateTimeString', () => { + it('accepts a valid ISO datetime', () => { + const raw = '2025-01-01T12:00:00.000Z' + expect(isoDateTimeString(raw)).toBe(raw) + }) + + it('rejects an empty string', () => { + expect(isoDateTimeString('')).toBeNull() + }) + + it('rejects junk', () => { + expect(isoDateTimeString('not-a-date')).toBeNull() + expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull() + }) +}) + +describe('querySource.ts:datePickerValueToLogDateRange', () => { + it('parses every static preset into a relative range', () => { + const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [ + ['Last hour', { amount: 1, unit: 'hour' }], + ['Last 3 hours', { amount: 3, unit: 'hour' }], + ['Last 24 hours', { amount: 24, unit: 'hour' }], + ['Last 3 days', { amount: 3, unit: 'day' }], + ['Last 7 days', { amount: 7, unit: 'day' }], + ] + + for (const [text, last] of cases) { + const helper = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === text) + expect(helper, `preset "${text}" exists`).toBeDefined() + expect(datePickerValueToLogDateRange(valueFromHelper(helper!))).toEqual({ + kind: 'relative', + last, + }) + } + }) + + it('parses dynamic helpers ("2h", "30m", "7d") into relative ranges', () => { + expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('2h')))).toEqual({ + kind: 'relative', + last: { amount: 2, unit: 'hour' }, + }) + expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('30m')))).toEqual({ + kind: 'relative', + last: { amount: 30, unit: 'minute' }, + }) + expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('7d')))).toEqual({ + kind: 'relative', + last: { amount: 7, unit: 'day' }, + }) + }) + + it('treats a preset (calcTo() === "") as relative — the empty end means "now"', () => { + const lastHour = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) + const value = valueFromHelper(lastHour) + expect(value.to).toBe('') + expect(datePickerValueToLogDateRange(value)).toEqual({ + kind: 'relative', + last: { amount: 1, unit: 'hour' }, + }) + }) + + it('degrades an unparseable helper to an absolute range using from/now (never empty)', () => { + vi.useFakeTimers() + const now = new Date('2025-01-01T12:00:00.000Z') + vi.setSystemTime(now) + + const from = '2024-12-31T00:00:00.000Z' + const result = datePickerValueToLogDateRange({ + from, + to: '', + isHelper: true, + text: 'Some custom label', + }) + + expect(result).toEqual({ + kind: 'absolute', + from, + to: dayjs(now).toISOString(), + }) + }) + + it('maps a custom (non-helper) pick to an absolute range with both endpoints', () => { + const from = '2025-01-01T00:00:00.000Z' + const to = '2025-01-02T00:00:00.000Z' + expect(datePickerValueToLogDateRange({ from, to, isHelper: false })).toEqual({ + kind: 'absolute', + from, + to, + }) + }) + + it('falls back to the default range when the value has no usable from', () => { + expect(datePickerValueToLogDateRange({ from: '', to: '', isHelper: false })).toEqual( + DEFAULT_LOG_DATE_RANGE + ) + }) +}) + +describe('querySource.ts:logDateRangeToDatePickerValue', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')) + }) + + it('renders a relative range exactly as the picker would emit the helper', () => { + const value = logDateRangeToDatePickerValue({ + kind: 'relative', + last: { amount: 1, unit: 'hour' }, + }) + expect(value).toEqual({ + from: dayjs().subtract(1, 'hour').toISOString(), + to: dayjs().toISOString(), + isHelper: true, + text: 'Last 1 hour', + }) + }) + + it('pluralizes the label for amounts greater than one', () => { + expect( + logDateRangeToDatePickerValue({ kind: 'relative', last: { amount: 3, unit: 'day' } }).text + ).toBe('Last 3 days') + }) + + it('round-trips through datePickerValueToLogDateRange', () => { + const range: LogDateRange = { kind: 'relative', last: { amount: 30, unit: 'minute' } } + expect(datePickerValueToLogDateRange(logDateRangeToDatePickerValue(range))).toEqual(range) + }) + + it('passes an absolute range through as a non-helper value', () => { + const range: LogDateRange = { + kind: 'absolute', + from: isoDateTimeString('2025-01-01T00:00:00.000Z')!, + to: isoDateTimeString('2025-01-02T00:00:00.000Z')!, + } + expect(logDateRangeToDatePickerValue(range)).toEqual({ + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + isHelper: false, + }) + }) +}) + +describe('querySource.ts:resolveLogRunRange', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('re-resolves a relative range against the current time', () => { + vi.useFakeTimers() + const now = new Date('2025-06-15T08:30:00.000Z') + vi.setSystemTime(now) + + expect(resolveLogRunRange({ kind: 'relative', last: { amount: 2, unit: 'hour' } })).toEqual({ + from: dayjs(now).subtract(2, 'hour').toISOString(), + to: dayjs(now).toISOString(), + }) + }) + + it('re-resolves the same relative range differently as time advances', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-06-15T08:00:00.000Z')) + const first = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) + + vi.setSystemTime(new Date('2025-06-15T10:00:00.000Z')) + const second = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } }) + + expect(first).not.toEqual(second) + expect(second.to).toBe(dayjs('2025-06-15T10:00:00.000Z').toISOString()) + }) + + it('passes an absolute range through unchanged', () => { + const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! + const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! + expect(resolveLogRunRange({ kind: 'absolute', from, to })).toEqual({ + from: '2025-01-01T00:00:00.000Z', + to: '2025-01-02T00:00:00.000Z', + }) + }) +}) + +describe('querySource.ts:logDateRangesEqual', () => { + it('matches relative ranges on amount + unit regardless of label formatting', () => { + const lastHourPreset = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === 'Last hour')! + const presetRange = datePickerValueToLogDateRange({ + from: lastHourPreset.calcFrom(), + to: lastHourPreset.calcTo(), + isHelper: true, + text: lastHourPreset.text, + }) + + expect( + logDateRangesEqual(presetRange, { kind: 'relative', last: { amount: 1, unit: 'hour' } }) + ).toBe(true) + }) + + it('does not match relative ranges with a different amount or unit', () => { + expect( + logDateRangesEqual( + { kind: 'relative', last: { amount: 1, unit: 'hour' } }, + { kind: 'relative', last: { amount: 3, unit: 'hour' } } + ) + ).toBe(false) + expect( + logDateRangesEqual( + { kind: 'relative', last: { amount: 1, unit: 'hour' } }, + { kind: 'relative', last: { amount: 1, unit: 'day' } } + ) + ).toBe(false) + }) + + it('matches absolute ranges on their ISO endpoints', () => { + const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! + const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! + expect(logDateRangesEqual({ kind: 'absolute', from, to }, { kind: 'absolute', from, to })).toBe( + true + ) + }) + + it('never matches a relative range against an absolute one', () => { + const from = isoDateTimeString('2025-01-01T00:00:00.000Z')! + const to = isoDateTimeString('2025-01-02T00:00:00.000Z')! + expect( + logDateRangesEqual( + { kind: 'relative', last: { amount: 1, unit: 'hour' } }, + { + kind: 'absolute', + from, + to, + } + ) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts new file mode 100644 index 00000000000..1ba1c4fd1ea --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -0,0 +1,178 @@ +import dayjs from 'dayjs' + +import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' +import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' +import type { Snippet } from '@/data/content/sql-folders-query' + +/** + * Domain view of where a snippet's query runs. Derived from the content TYPE: + * a `log_sql` snippet always targets the logs backend and a `sql` (or `report`) + * snippet always targets the user's Postgres database. A snippet's source is + * immutable — switching backends means creating a new snippet, not toggling this + * value. + */ +export type SqlSnippetSource = 'database' | 'logs' + +/** + * The single reader every surface (AI, reports, tabs, nav, execution) uses to + * decide where a snippet runs. `'log_sql'` → `'logs'`; everything else (`'sql'`, + * `'report'`) → `'database'`. Accepts the raw `Snippet['type']` so a snippet of + * any content type maps to a source without narrowing first. + */ +export function getSnippetSource(snippet: Pick): SqlSnippetSource { + return snippet.type === 'log_sql' ? 'logs' : 'database' +} + +/** + * Parse a raw `source` value (e.g. the `?source=` query param a creation entry + * threads through `/sql/new`) into a `SqlSnippetSource`. Only the explicit + * `'logs'` opts a new snippet into the logs backend; anything else — including an + * absent param — is a database snippet, keeping database the safe default. + */ +export function parseSqlSnippetSource(raw: string | undefined): SqlSnippetSource { + return raw === 'logs' ? 'logs' : 'database' +} + +/** + * An ISO-8601 datetime proven valid at construction via a dayjs parse. Absolute + * log ranges carry these instead of raw strings so an unvalidated datetime can + * never reach execution. + */ +export type IsoDateTimeString = string & { readonly __isoDateTimeBrand: unique symbol } + +/** + * Validate a raw string as an ISO datetime, returning the branded value or null. + * The sole construction site for `IsoDateTimeString` outside `now`. + */ +export function isoDateTimeString(raw: string): IsoDateTimeString | null { + if (!raw) return null + return dayjs(raw).isValid() ? (raw as IsoDateTimeString) : null +} + +/** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */ +function nowIsoDateTime(): IsoDateTimeString { + return dayjs().toISOString() as IsoDateTimeString +} + +/** + * The units a relative log range is expressed in. Aliases the Logs date picker's + * `Unit` so the two stay in lockstep rather than drifting as parallel unions. + */ +export type RelativeTimeUnit = Unit + +/** + * A log query's time range. Relative ranges are structural (amount + unit) and + * re-resolve against `now` at every run — so a saved "last hour" always means the + * hour before the run, not the hour before the snippet was opened. Absolute ranges + * carry validated ISO datetimes and pass through unchanged. + */ +export type LogDateRange = + | { kind: 'relative'; last: { amount: number; unit: RelativeTimeUnit } } + | { kind: 'absolute'; from: IsoDateTimeString; to: IsoDateTimeString } + +/** The range a freshly opened logs snippet starts with: the last hour. */ +export const DEFAULT_LOG_DATE_RANGE: LogDateRange = { + kind: 'relative', + last: { amount: 1, unit: 'hour' }, +} + +/** + * The runtime query source for a snippet, pairing the database/logs discriminant + * with the extra state each backend needs to run. A logs run carries the active + * time range (session state, re-resolved at every run); a database run needs + * nothing beyond the connection the execution pipeline already resolves. + */ +export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogDateRange } + +/** + * Parse a date-picker helper's label (e.g. "Last hour", "Last 3 hours", "Last 30 + * minutes") into a relative amount/unit. Covers both the static presets in + * `EXPLORER_DATEPICKER_HELPERS` and the dynamic helpers `generateHelpersFromInput` + * produces from typed input like "2h"/"30m". A label with no number means one unit + * ("Last hour"). Returns null for any other label. + */ +function parseRelativeHelperLabel( + text: string | undefined +): { amount: number; unit: RelativeTimeUnit } | null { + if (!text) return null + const match = text + .trim() + .toLowerCase() + .match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/) + if (!match) return null + const amount = match[1] ? parseInt(match[1], 10) : 1 + if (!Number.isFinite(amount) || amount <= 0) return null + const unit = match[2] + if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null + return { amount, unit } +} + +/** + * Convert a Logs date-picker value into a `LogDateRange`. Helper picks (presets and + * dynamic "2h"/"30m" helpers) become relative ranges by parsing the helper label; + * a preset's `calcTo()` resolves to `''` (meaning "now"), which the relative variant + * models implicitly. Everything else — custom calendar picks, or a helper whose label + * we can't parse — becomes an absolute range with validated ISO datetimes, degrading + * via `from`/now rather than rejecting an empty string. A value with no usable `from` + * falls back to the default range. + */ +export function datePickerValueToLogDateRange(value: DatePickerValue): LogDateRange { + if (value.isHelper) { + const relative = parseRelativeHelperLabel(value.text) + if (relative) return { kind: 'relative', last: relative } + } + + const from = isoDateTimeString(value.from) + if (from === null) return DEFAULT_LOG_DATE_RANGE + const to = isoDateTimeString(value.to) ?? nowIsoDateTime() + return { kind: 'absolute', from, to } +} + +/** + * Render a `LogDateRange` back into a Logs date-picker value for display. Relative + * ranges reuse the picker's own `generateDynamicHelper` to derive the resolved + * `from`/`to` and matching "Last N unit(s)" label, so the value is byte-for-byte + * what the picker itself would emit for that helper. Absolute ranges pass their + * datetimes through. + */ +export function logDateRangeToDatePickerValue(range: LogDateRange): DatePickerValue { + if (range.kind === 'relative') { + const helper = generateDynamicHelper(range.last.amount, range.last.unit) + return { from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text } + } + return { from: range.from, to: range.to, isHelper: false } +} + +/** + * Structural equality for two log date ranges. Relative ranges match on amount + + * unit, NOT display text — "Last hour" and "Last 1 hour" render differently but + * are the same range, so comparing labels is unreliable. Absolute ranges match on + * their (validated) ISO endpoints. + */ +export function logDateRangesEqual(a: LogDateRange, b: LogDateRange): boolean { + if (a.kind === 'relative' && b.kind === 'relative') { + return a.last.amount === b.last.amount && a.last.unit === b.last.unit + } + if (a.kind === 'absolute' && b.kind === 'absolute') { + return a.from === b.from && a.to === b.to + } + return false +} + +/** + * Resolve a `LogDateRange` to concrete ISO endpoints for a run. Relative ranges + * re-resolve against `now` (so "last hour" is always the hour before the run); + * absolute ranges pass through. Reuses the Logs `ResolvedLogDateRange` shape. + */ +export function resolveLogRunRange(range: LogDateRange): ResolvedLogDateRange { + if (range.kind === 'relative') { + const now = dayjs() + return { + from: now.subtract(range.last.amount, range.last.unit).toISOString(), + to: now.toISOString(), + } + } + return { from: range.from, to: range.to } +} diff --git a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts index a55817590c8..490c9b3a86a 100644 --- a/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts +++ b/apps/studio/components/interfaces/SQLEditor/useAddDefinitions.ts @@ -15,7 +15,11 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' -export const useAddDefinitions = (id: string, monaco: Monaco | null) => { +export const useAddDefinitions = ( + id: string, + monaco: Monaco | null, + { enabled = true }: { enabled?: boolean } = {} +) => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -29,28 +33,28 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: functions, isSuccess: isFunctionsSuccess } = useDatabaseFunctionsQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: schemas, isSuccess: isSchemasSuccess } = useSchemasQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: tableColumns, isSuccess: isTableColumnsSuccess } = useTableColumnsQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const pgInfoRef = useRef(null) @@ -58,6 +62,7 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => { const filteredSchemas = useSchemasFilteredForHighAvailability(schemas) const isPgInfoReady = + enabled && intellisenseEnabled && isTableColumnsSuccess && isSchemasSuccess && diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx new file mode 100644 index 00000000000..38e3fb8634b --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx @@ -0,0 +1,136 @@ +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useLogsSqlExecution } from './useLogsSqlExecution' +import { + acceptUntrustedLogsSql, + untrustedLogSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { addAPIMock } from '@/tests/lib/msw' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'logs-execution-snippet' + +/** Promote raw text to the `SafeLogSqlFragment` the run pipeline expects, exactly + * as the toolbar/editor-panel promote it right at the user action. */ +const logsSql = (text: string): SafeLogSqlFragment => acceptUntrustedLogsSql(untrustedLogSql(text)) + +type CapturedBody = { sql: string; iso_timestamp_start: string; iso_timestamp_end: string } + +function mockLogsAllOtel(rows: unknown[] = []) { + const captured: CapturedBody[] = [] + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel', + response: async ({ request }) => { + const body = (await request.json()) as CapturedBody + captured.push(body) + return HttpResponse.json({ result: rows }) + }, + }) + return captured +} + +/** Renders the hook with ClickHouse logs enabled by default; pass + * `{ otelLegacyLogs: false }` to exercise the not-available-yet guard. */ +function renderLogsExecution(flags: Record = { otelLegacyLogs: true }) { + return renderSqlEditorHook(() => useLogsSqlExecution({ id: SNIPPET_ID }), { flags }) +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, source: 'logs' }) +}) + +describe('useLogsSqlExecution', () => { + it('runs a logs query and writes the result to the session store', async () => { + const rows = [{ event_message: 'hello' }] + const captured = mockLogsAllOtel(rows) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select event_message from edge_logs')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows) + + expect(captured).toHaveLength(1) + expect(captured[0].sql).toContain('select event_message from edge_logs') + expect(captured[0].iso_timestamp_start.length).toBeGreaterThan(0) + expect(captured[0].iso_timestamp_end.length).toBeGreaterThan(0) + }) + + it('records a structured 200-body error on the result instead of throwing', async () => { + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel', + response: async () => + HttpResponse.json({ + error: { + code: 400, + errors: [{ domain: 'global', message: 'Missing column', reason: 'invalid' }], + message: 'Missing column', + status: 'INVALID_ARGUMENT', + }, + }), + }) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select does_not_exist from edge_logs')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe('Missing column') + }) + + it('resolves a relative session range to a from/to window around now', async () => { + const captured = mockLogsAllOtel([]) + sqlEditorSessionState.setLogRange(SNIPPET_ID, { + kind: 'relative', + last: { amount: 2, unit: 'hour' }, + }) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select 1')) + }) + + await waitFor(() => expect(captured).toHaveLength(1)) + const from = Date.parse(captured[0].iso_timestamp_start) + const to = Date.parse(captured[0].iso_timestamp_end) + expect(Number.isNaN(from)).toBe(false) + expect(Number.isNaN(to)).toBe(false) + expect(from).toBeLessThan(to) + }) + + it('records an unavailable message and fires no request when ClickHouse logs are off', async () => { + const captured = mockLogsAllOtel([]) + + const { result } = renderLogsExecution({ otelLegacyLogs: false }) + + act(() => { + result.current.executeLogsQuery(logsSql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe( + "Querying logs from the SQL editor isn't available for this project yet." + ) + // The doomed request never left the client. + expect(captured).toHaveLength(0) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts new file mode 100644 index 00000000000..4c1c006bfd7 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts @@ -0,0 +1,61 @@ +import { useFlag, useParams } from 'common' +import { useCallback } from 'react' + +import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource' +import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' +import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' +import { useTrack } from '@/lib/telemetry/track' +import { + getSqlEditorSessionSnapshot, + useSqlEditorSessionSnapshot, +} from '@/state/sql-editor/sql-editor-session-state' + +type UseLogsSqlExecutionArgs = { id: string } + +/** + * Logs counterpart to `useSqlEditorExecution`. Runs a promoted + * `SafeLogSqlFragment` against the analytics OTEL endpoint with the snippet's + * active time range attached as request params. + */ +export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) { + const { ref: projectRef } = useParams() + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const track = useTrack() + const sessionSnap = useSqlEditorSessionSnapshot() + + const { mutate, isPending: isExecuting } = useExecuteLogsSqlMutation({ + onSuccess: (data) => { + sessionSnap.addResult(id, data.rows) + }, + onError: (error) => { + sessionSnap.addResultError(id, error) + }, + }) + + const executeLogsQuery = useCallback( + (sql: SafeLogSqlFragment) => { + if (isExecuting || projectRef === undefined) return + + if (!isOtelLogsEnabled) { + getSqlEditorSessionSnapshot().addResultError(id, { + message: "Querying logs from the SQL editor isn't available for this project yet.", + }) + return + } + + // Re-read imperatively so a range picked immediately before the run is + // honored; relative ranges re-resolve against `now` here. + const range = resolveLogRunRange( + getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE + ) + + mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) }) + + track('sql_editor_query_run_button_clicked', { source: 'logs' }) + }, + [id, isExecuting, isOtelLogsEnabled, mutate, projectRef, track] + ) + + return { executeLogsQuery, isExecuting } +} diff --git a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts index 60c415e79d3..8e287272600 100644 --- a/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts +++ b/apps/studio/components/interfaces/SQLEditor/usePrettifyQuery.ts @@ -24,8 +24,13 @@ export function usePrettifyQuery({ id, isDiffOpen }: { id: string; isDiffOpen: b const state = getSqlEditorV2StateSnapshot() const snippet = state.snippets[id] + // pg formatting can mangle ClickHouse syntax (backtick identifiers), so + // Prettify is a no-op for logs snippets — the UI also hides the affordance. + if (snippet?.snippet.type === 'log_sql') return + if (editor.isReady() && project) { - const sql = editor.getSql(snippet?.snippet.content?.unchecked_sql) + const fallback = snippet?.snippet.content?.unchecked_sql + const sql = editor.getSql(fallback) if (sql === undefined) return const formattedSql = formatSql(sql) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx new file mode 100644 index 00000000000..820caa97c00 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { DEFAULT_LOG_DATE_RANGE } from './querySource' +import { useRunSource } from './useRunSource' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() +}) + +describe('useRunSource', () => { + it('resolves a database snippet to a database source', () => { + const id = 'database-snippet' + seedSnippet({ id, source: 'database' }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ type: 'database' }) + }) + + it('resolves a logs snippet with no session range to the default range', () => { + const id = 'logs-snippet-default-range' + seedSnippet({ id, source: 'logs' }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE }) + }) + + it('resolves a logs snippet to its session-stored range when one is set', () => { + const id = 'logs-snippet-custom-range' + seedSnippet({ id, source: 'logs' }) + sqlEditorSessionState.setLogRange(id, { + kind: 'relative', + last: { amount: 2, unit: 'hour' }, + }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ + type: 'logs', + dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } }, + }) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts new file mode 100644 index 00000000000..aa7cbf05b6f --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -0,0 +1,42 @@ +import { useParams } from 'common' +import { useMemo } from 'react' + +import { + DEFAULT_LOG_DATE_RANGE, + getSnippetSource, + parseSqlSnippetSource, + type QuerySource, + type SqlSnippetSource, +} from './querySource' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +/** + * Resolves where a snippet's query runs. A `log_sql` snippet targets the logs + * backend and carries its session time range (falling back to the default when + * the user hasn't picked one); every other snippet targets the database. Source + * is derived from the snippet's content type and is NOT flag-gated here — a + * URL-opened logs snippet routes correctly even with the feature flag off. + * + * Before the snippet exists in the store (a fresh `/sql/new` tab, materialized + * lazily on the first keystroke) the source falls back to the `?source=` URL + * param, so a "New logs query" tab shows the logs toolbar and range picker + * immediately rather than flashing the database controls until the user types. + */ +export function useRunSource(id: string): QuerySource { + const { source: sourceParam } = useParams() + const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() + + const snippet = snapV2.snippets[id]?.snippet + const source: SqlSnippetSource = + snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam) + const logRange = sessionSnap.logRange[id] + + return useMemo(() => { + if (source === 'logs') { + return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } + } + return { type: 'database' } + }, [source, logRange]) +} diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts index 76c5a139296..f7bfd296d9c 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetEditor.ts @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useEffect, useEffectEvent } from 'react' +import { parseSqlSnippetSource } from './querySource' import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useProfile } from '@/lib/profile' @@ -25,7 +26,7 @@ import { useTabsStateSnapshot } from '@/state/tabs' export function useSnippetEditor({ id, snippetName }: { id: string; snippetName: string }) { const router = useRouter() const { profile } = useProfile() - const { ref, content } = useParams() + const { ref, content, source } = useParams() const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -45,6 +46,9 @@ export function useSnippetEditor({ id, snippetName }: { id: string; snippetName: sql: value, owner_id: profile?.id, project_id: project?.id, + // A creation entry (e.g. "New logs query") threads the target backend + // through `/sql/new?source=`; default to a database snippet otherwise. + source: parseSqlSnippetSource(source), }) snapV2.addSnippet({ projectRef: ref, snippet: newSnippet }) // When the editor was seeded from a `content` deep-link, replace rather diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 7ded54949db..5d9c7f45a8d 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -139,7 +139,7 @@ export function useSqlEditorExecution({ }, }) - track('sql_editor_query_run_button_clicked') + track('sql_editor_query_run_button_clicked', { source: 'database' }) }, [ editor, diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx index 79c0752f081..7568f35e4d6 100644 --- a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx @@ -1,4 +1,4 @@ -import type { UseFormReturn } from 'react-hook-form' +import { useWatch, type UseFormReturn } from 'react-hook-form' import { CardContent, CardFooter, Form, FormControl, FormField, FormItem, Switch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -23,7 +23,7 @@ export const DataApiEnableSwitchForm = ({ onSubmit: (values: DataApiFormValues) => void handleReset: () => void }) => { - const watchedEnabled = form.watch('enableDataApi') + const watchedEnabled = useWatch({ control: form.control, name: 'enableDataApi' }) return ( diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index c1a1c146c32..a6801719455 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -23,7 +23,7 @@ import { Switch, useWatch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector, diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 22b304adde1..3f2090f4edc 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -13,7 +13,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection } from 'ui-patterns/PageSection' diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index b27ec4e139d..e2800d7d2fd 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { cn, RadioGroup, RadioGroupLargeItem, SidePanel } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx new file mode 100644 index 00000000000..491aaf978fe --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx @@ -0,0 +1,154 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ConnectionPooling } from './ConnectionPooling' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUpdatePoolerConfig, + mockUseAsyncCheckPermissions, + mockUseCheckEntitlements, + mockUseHighAvailability, + mockUseMaxConnectionsQuery, + mockUsePgbouncerConfigQuery, + mockUsePgbouncerConfigurationUpdateMutation, + mockUseProjectAddonsQuery, + mockUseSelectedProjectQuery, +} = vi.hoisted(() => ({ + mockUpdatePoolerConfig: vi.fn(), + mockUseAsyncCheckPermissions: vi.fn(), + mockUseCheckEntitlements: vi.fn(), + mockUseHighAvailability: vi.fn(), + mockUseMaxConnectionsQuery: vi.fn(), + mockUsePgbouncerConfigQuery: vi.fn(), + mockUsePgbouncerConfigurationUpdateMutation: vi.fn(), + mockUseProjectAddonsQuery: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ ref: 'ha-project' }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ + useCheckEntitlements: mockUseCheckEntitlements, +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +vi.mock('@/data/database/max-connections-query', () => ({ + useMaxConnectionsQuery: mockUseMaxConnectionsQuery, +})) + +vi.mock('@/data/database/pgbouncer-config-query', () => ({ + usePgbouncerConfigQuery: mockUsePgbouncerConfigQuery, +})) + +vi.mock('@/data/database/pgbouncer-config-update-mutation', () => ({ + usePgbouncerConfigurationUpdateMutation: mockUsePgbouncerConfigurationUpdateMutation, +})) + +vi.mock('@/data/subscriptions/project-addons-query', () => ({ + useProjectAddonsQuery: mockUseProjectAddonsQuery, +})) + +const expectEveryQueryCall = (queryMock: ReturnType, enabled: boolean) => { + expect(queryMock).toHaveBeenCalled() + for (const [, options] of queryMock.mock.calls) { + expect(options).toEqual({ enabled }) + } +} + +describe('ConnectionPooling', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseSelectedProjectQuery.mockReturnValue({ + data: { + id: 1, + ref: 'ha-project', + infra_compute_size: 'small', + connectionString: 'postgresql://example', + }, + }) + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + mockUseAsyncCheckPermissions.mockReturnValue({ can: true }) + mockUseCheckEntitlements.mockReturnValue({ hasAccess: true }) + mockUsePgbouncerConfigQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + isSuccess: false, + }) + mockUseMaxConnectionsQuery.mockReturnValue({ data: undefined }) + mockUseProjectAddonsQuery.mockReturnValue({ + data: { + selected_addons: [ + { + type: 'compute_instance', + variant: { name: 'Small', identifier: 'ci_small' }, + }, + ], + }, + isSuccess: true, + }) + mockUsePgbouncerConfigurationUpdateMutation.mockReturnValue({ + mutate: mockUpdatePoolerConfig, + isPending: false, + }) + }) + + it('renders High Availability pooling settings as read-only', () => { + customRender() + + expect( + screen.getAllByText( + 'Connection pooling settings are managed automatically on High Availability projects' + ) + ).toHaveLength(1) + expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute( + 'href', + 'https://multigres.com/blog/pooling-without-choosing-a-mode' + ) + + expect(screen.queryByText('Enable IPv4 add-on')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('Managed automatically')).toBeDisabled() + expect(screen.getByDisplayValue('100000')).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + expect(mockUpdatePoolerConfig).not.toHaveBeenCalled() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, false) + expectEveryQueryCall(mockUseMaxConnectionsQuery, false) + }) + + it('does not fetch pooling config while the high availability state is pending', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: true }) + + customRender() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, false) + expectEveryQueryCall(mockUseMaxConnectionsQuery, false) + }) + + it('fetches pooling config for non high availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) + + customRender() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, true) + expectEveryQueryCall(mockUseMaxConnectionsQuery, true) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 1d89f386db3..d6d3a31638b 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -4,7 +4,7 @@ import { useParams } from 'common' import { capitalize } from 'lodash' import Link from 'next/link' import { Fragment, useEffect } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Alert, @@ -21,7 +21,7 @@ import { InputGroupText, Separator, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, @@ -38,6 +38,7 @@ import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FormActions } from '@/components/ui/Forms/FormActions' +import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' @@ -46,10 +47,14 @@ import { usePgbouncerConfigurationUpdateMutation } from '@/data/database/pgbounc import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' const formId = 'pooling-configuration-form' +const HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS = 100_000 +const HA_DISABLED_TITLE = + 'Connection pooling settings are managed automatically on High Availability projects' const PoolingConfigurationFormSchema = z.object({ default_pool_size: z.preprocess( @@ -68,6 +73,8 @@ const PoolingConfigurationFormSchema = z.object({ export const ConnectionPooling = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability() + const canLoadPoolingConfig = !isHighAvailability && !isHighAvailabilityPending const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', @@ -80,15 +87,18 @@ export const ConnectionPooling = () => { isPending: isLoadingPgbouncerConfig, isError: isErrorPgbouncerConfig, isSuccess: isSuccessPgbouncerConfig, - } = usePgbouncerConfigQuery({ projectRef }) + } = usePgbouncerConfigQuery({ projectRef }, { enabled: canLoadPoolingConfig }) const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') const disablePoolModeSelection = !hasDedicatedPooler - const { data: maxConnData } = useMaxConnectionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) + const { data: maxConnData } = useMaxConnectionsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: canLoadPoolingConfig } + ) const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery({ projectRef }) const { mutate: updatePoolerConfig, isPending: isUpdatingPoolerConfig } = @@ -113,14 +123,14 @@ export const ConnectionPooling = () => { max_client_conn: undefined, }, }) - const { default_pool_size } = form.watch() + const default_pool_size = useWatch({ control: form.control, name: 'default_pool_size' }) const connectionPoolingUnavailable = pgbouncerConfig?.pool_mode === null const ignoreStartupParameters = pgbouncerConfig?.ignore_startup_parameters const onSubmit: SubmitHandler> = async (data) => { const { default_pool_size } = data - if (!projectRef) return console.error('Project ref is required') + if (!projectRef || isHighAvailability) return updatePoolerConfig( { @@ -165,7 +175,24 @@ export const ConnectionPooling = () => { - {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && ( + {isHighAvailability && ( + + High Availability projects run one pooler per Postgres pod, each supporting up to{' '} + {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active or passive + connections, with pooling behavior selected automatically.{' '} + + Learn more + + . + + } + /> + )} + + {isSuccessAddons && !isHighAvailability && !disablePoolModeSelection && !hasIpv4Addon && ( { resetForm()} - helper={ - !canUpdateConnectionPoolingConfiguration - ? 'You need additional permissions to update connection pooling settings' - : undefined - } - /> + isHighAvailability ? undefined : ( + resetForm()} + helper={ + !canUpdateConnectionPoolingConfiguration + ? 'You need additional permissions to update connection pooling settings' + : undefined + } + /> + ) } > - {isLoadingPgbouncerConfig && ( + {!isHighAvailability && isLoadingPgbouncerConfig && (
    {Array.from({ length: 4 }).map((_, i) => ( @@ -213,31 +242,40 @@ export const ConnectionPooling = () => {
    )} - {isErrorPgbouncerConfig && ( + {!isHighAvailability && isErrorPgbouncerConfig && ( )} - {connectionPoolingUnavailable && ( + {!isHighAvailability && connectionPoolingUnavailable && ( )} - {isSuccessPgbouncerConfig && !connectionPoolingUnavailable && ( + {(isHighAvailability || + (isSuccessPgbouncerConfig && !connectionPoolingUnavailable)) && ( <>
    Connection poolers

    - Configuration is shared across all connection poolers. + {isHighAvailability + ? 'One pooler runs for each Postgres pod in the cluster.' + : 'Configuration is shared across all connection poolers.'}

    - Shared - {!disablePoolModeSelection && Dedicated} + {isHighAvailability ? ( + High Availability + ) : ( + <> + Shared + {!disablePoolModeSelection && Dedicated} + + )}
    @@ -255,11 +293,18 @@ export const ConnectionPooling = () => { layout="flex-row-reverse" label="Connection pool size" description={ -

    - The maximum number of connections made to the underlying Postgres - cluster, per user+db combination. Pool size has a default of{' '} - {defaultPoolSize} based on your compute size of {computeSize}. -

    + isHighAvailability ? ( +

    + Pool size is managed automatically for each Postgres pod and cannot + be changed. +

    + ) : ( +

    + The maximum number of connections made to the underlying Postgres + cluster, per user+db combination. Pool size has a default of{' '} + {defaultPoolSize} based on your compute size of {computeSize}. +

    + ) } className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full" > @@ -267,10 +312,15 @@ export const ConnectionPooling = () => { field.onChange( isNaN(event.target.valueAsNumber) @@ -279,12 +329,15 @@ export const ConnectionPooling = () => { ) } /> - - connections - + {!isHighAvailability && ( + + connections + + )} - {!!maxConnData && + {!isHighAvailability && + !!maxConnData && (default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && ( @@ -313,7 +366,14 @@ export const ConnectionPooling = () => { label="Max client connections" className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full" description={ - <> + isHighAvailability ? ( +

    + Each pooler can support up to{' '} + {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active + or passive client connections. This value is managed automatically + and cannot be changed. +

    + ) : (

    The maximum number of concurrent client connections allowed. This value is fixed at {defaultMaxClientConn} based on your compute size @@ -324,7 +384,7 @@ export const ConnectionPooling = () => { Learn more

    - + ) } > @@ -333,8 +393,16 @@ export const ConnectionPooling = () => { {...field} type="number" className="w-full" - value={pgbouncerConfig?.max_client_conn ?? ''} - placeholder={defaultMaxClientConn.toString()} + value={ + isHighAvailability + ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS + : (pgbouncerConfig?.max_client_conn ?? '') + } + placeholder={ + isHighAvailability + ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toString() + : defaultMaxClientConn.toString() + } onChange={(event) => field.onChange( isNaN(event.target.valueAsNumber) diff --git a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx index 6c4cb135cf3..bfd7d6a522e 100644 --- a/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx @@ -23,7 +23,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import { PageSection, @@ -42,6 +42,7 @@ import { import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' @@ -379,7 +380,7 @@ export const JitDbAccessConfiguration = () => { actions={ unavailableReason === 'postgres_upgrade_required' && ref ? ( ) : unavailableReason === 'ssl_enforcement_required' && ref ? ( -
    - )} - - - - - - ) - })} - - ) -} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index f8f898ad978..b59b45ceee6 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -36,6 +36,7 @@ import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceCo import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { AlertError } from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query' @@ -301,9 +302,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU - - Resize databases - + Resize databases setShowDeleteAllModal(true)}> diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx index db1ca7378d6..a5204a403db 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.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 { InlineLink } from '@/components/ui/InlineLink' import { diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx index 62d1dea190e..2dc4c347376 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx @@ -17,7 +17,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 { InlineLink } from '@/components/ui/InlineLink' diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index eb672742b98..d56634f0111 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence, motion } from 'framer-motion' import { Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -17,7 +17,7 @@ import { Input, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' @@ -174,9 +174,18 @@ export const GitHubIntegrationConnectionForm = ({ }, }) - const enableProductionSync = githubSettingsForm.watch('enableProductionSync') - const newBranchPerPr = githubSettingsForm.watch('new_branch_per_pr') - const currentRepositoryId = githubSettingsForm.watch('repositoryId') + const enableProductionSync = useWatch({ + control: githubSettingsForm.control, + name: 'enableProductionSync', + }) + const newBranchPerPr = useWatch({ + control: githubSettingsForm.control, + name: 'new_branch_per_pr', + }) + const currentRepositoryId = useWatch({ + control: githubSettingsForm.control, + name: 'repositoryId', + }) const handleCreateOrUpdateConnection = async (data: z.infer) => { if (!selectedProject?.ref || !selectedOrganization?.id) return diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx index 7ec922a48da..c1149f96f0c 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField.tsx @@ -1,6 +1,6 @@ import { ChevronDown, PlusIcon, RefreshCw } from 'lucide-react' import { useMemo, useState, type ComponentProps, type ReactNode } from 'react' -import type { FieldValues, Path, UseFormReturn } from 'react-hook-form' +import { useWatch, type FieldValues, type Path, type UseFormReturn } from 'react-hook-form' import { Button, Command, @@ -125,7 +125,7 @@ export const GitHubRepositoryField = ({ }: GitHubRepositoryFieldProps) => { const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false) - const currentRepositoryId = form.watch(name) as string | undefined + const currentRepositoryId = useWatch({ control: form.control, name }) as string | undefined const selectedRepository = repositories.find((repo) => repo.id === currentRepositoryId) return ( diff --git a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx index 5422d72ba50..80405ab50b3 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx @@ -1,5 +1,5 @@ import { Card } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { AWSPrivateLinkSection } from './AWSPrivateLink/AWSPrivateLinkSection' import { GitHubSection } from './GithubIntegration/GithubSection' diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx index a968219bc5b..b1b656f579e 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelIntegrationConnectionForm.tsx @@ -17,7 +17,7 @@ import { Input, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index 344ed4c94f9..b0ef9ed89a0 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -92,7 +92,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
    - + Details diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index 1c1c6975f52..811191d918a 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -17,6 +17,7 @@ import { } from 'ui' import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants' +import { generateHelpersFromInput } from './Logs.datePickerHelpers' import type { DatetimeHelper } from './Logs.types' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { TimeSplitInput } from '@/components/ui/DatePicker/TimeSplitInput' @@ -24,65 +25,6 @@ import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import type { ShortcutId } from '@/state/shortcuts/registry' -type Unit = 'minute' | 'hour' | 'day' - -export type ParsedCustomInput = - | { type: 'number'; value: number } - | { type: 'unit'; value: number; unit: Unit } - | { type: 'invalid' } - -export const parseCustomInput = (input: string): ParsedCustomInput => { - const trimmed = input.trim().toLowerCase() - if (!trimmed) return { type: 'invalid' } - - // Try to match "number + optional space + unit prefix" - const match = trimmed.match(/^(\d+)\s*([a-z]*)$/) - if (!match) return { type: 'invalid' } - - const [, numStr, unitStr] = match - const value = parseInt(numStr, 10) - - if (isNaN(value) || value <= 0) return { type: 'invalid' } - - if (!unitStr) { - return { type: 'number', value } - } - - // Match if unitStr is a prefix of any unit name or its first letter - const units: Unit[] = ['minute', 'hour', 'day'] - const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr) - - if (!matchedUnit) return { type: 'invalid' } - - return { type: 'unit', value, unit: matchedUnit } -} - -export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => { - return { - text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`, - calcFrom: () => dayjs().subtract(value, unit).toISOString(), - calcTo: () => dayjs().toISOString(), - } -} - -export const generateDynamicHelpers = (value: number): DatetimeHelper[] => { - const units: Unit[] = ['minute', 'hour', 'day'] - return units.map((unit) => generateDynamicHelper(value, unit)) -} - -export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => { - const parsed = parseCustomInput(input) - - switch (parsed.type) { - case 'number': - return generateDynamicHelpers(parsed.value) - case 'unit': - return [generateDynamicHelper(parsed.value, parsed.unit)] - case 'invalid': - return null - } -} - export type DatePickerValue = { to: string from: string diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.test.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.test.tsx new file mode 100644 index 00000000000..1adbd15c8e5 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' + +import { SavedQueriesItem } from './Logs.SavedQueriesItem' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +// SavedQueriesItem renders a Radix dropdown + dialog, both of which use Web Animations. +mockAnimationsApi() + +const ITEM_ID = 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22' +const LOGS_SQL = + "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10" + +const item = { + id: ITEM_ID, + name: 'My logs query', + description: 'A saved logs query', + owner_id: 1, + content: { unchecked_sql: untrustedLogSql(LOGS_SQL) }, +} + +describe('SavedQueriesItem', () => { + it('preserves content.sql (as the raw wire field) and keeps type log_sql when editing a saved logs query', async () => { + const requests: Array<{ type: string; name: string; content: Record }> = [] + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + requests.push( + (await request.json()) as { type: string; name: string; content: Record } + ) + return HttpResponse.json({ + id: ITEM_ID, + type: 'log_sql', + name: 'My logs query (edited)', + description: 'A saved logs query', + visibility: 'user', + owner_id: 1, + project_id: 1, + folder_id: null, + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + content: { sql: LOGS_SQL, content_id: ITEM_ID, schema_version: '1' }, + }) + }, + }) + + customRender() + + // Open the actions dropdown, then the edit modal. + await userEvent.click(screen.getByRole('button', { name: 'Actions' })) + await userEvent.click(await screen.findByText('Edit query')) + + // Dirty the form so the submit button enables, then submit. + await userEvent.type(await screen.findByPlaceholderText('Enter text'), ' (edited)') + fireEvent.click(screen.getByRole('button', { name: 'Save query' })) + + await waitFor(() => expect(requests).toHaveLength(1)) + + expect(requests[0].type).toBe('log_sql') + expect(requests[0].name).toBe('My logs query (edited)') + expect(requests[0].content.sql).toBe(LOGS_SQL) + expect(requests[0].content).not.toHaveProperty('unchecked_sql') + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx index ac160baa964..97831f24449 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx @@ -12,6 +12,7 @@ import { UpdateSavedQueryModal } from './Logs.UpdateSavedQueryModal' import { LogsSidebarItem } from './SidebarV2/SidebarItem' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' +import type { UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql' interface SavedQueriesItemProps { item: { @@ -20,7 +21,7 @@ interface SavedQueriesItemProps { description?: string owner_id: number content: { - sql: string + unchecked_sql: UntrustedLogSqlFragment } } } @@ -87,7 +88,7 @@ export const SavedQueriesItem = ({ item }: SavedQueriesItemProps) => { } - href={`/project/${ref}/logs/explorer?queryId=${encodeURIComponent(item.id)}&q=${encodeURIComponent(item.content.sql)}`} + href={`/project/${ref}/logs/explorer?queryId=${encodeURIComponent(item.id)}&q=${encodeURIComponent(item.content.unchecked_sql)}`} isActive={isActive} dropdownItems={ <> diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts new file mode 100644 index 00000000000..422a2f04033 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.datePickerHelpers.ts @@ -0,0 +1,73 @@ +import dayjs from 'dayjs' + +import type { DatetimeHelper } from './Logs.types' + +/** + * Framework-free date-range helpers shared by the Logs date picker + * (`Logs.DatePickers.tsx`) and the SQL editor's logs query-source domain + * (`SQLEditor/querySource.ts`). Kept in a pure `.ts` module — depending only on + * dayjs — so both a React component and a valtio-free domain module can import it + * without pulling in the picker's UI. + */ + +/** The relative-range units the picker and the logs domain both speak. */ +export type Unit = 'minute' | 'hour' | 'day' + +export type ParsedCustomInput = + | { type: 'number'; value: number } + | { type: 'unit'; value: number; unit: Unit } + | { type: 'invalid' } + +export const parseCustomInput = (input: string): ParsedCustomInput => { + const trimmed = input.trim().toLowerCase() + if (!trimmed) return { type: 'invalid' } + + // Try to match "number + optional space + unit prefix" + const match = trimmed.match(/^(\d+)\s*([a-z]*)$/) + if (!match) return { type: 'invalid' } + + const [, numStr, unitStr] = match + const value = Number.parseInt(numStr, 10) + + // Only finite positive values may reach generateDynamicHelper(): Number.isFinite + // rejects NaN and Infinity outright, and the <= 0 guard keeps out non-positive. + if (!Number.isFinite(value) || value <= 0) return { type: 'invalid' } + + if (!unitStr) { + return { type: 'number', value } + } + + // Match if unitStr is a prefix of any unit name or its first letter + const units: Unit[] = ['minute', 'hour', 'day'] + const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr) + + if (!matchedUnit) return { type: 'invalid' } + + return { type: 'unit', value, unit: matchedUnit } +} + +export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => { + return { + text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`, + calcFrom: () => dayjs().subtract(value, unit).toISOString(), + calcTo: () => dayjs().toISOString(), + } +} + +export const generateDynamicHelpers = (value: number): DatetimeHelper[] => { + const units: Unit[] = ['minute', 'hour', 'day'] + return units.map((unit) => generateDynamicHelper(value, unit)) +} + +export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => { + const parsed = parseCustomInput(input) + + switch (parsed.type) { + case 'number': + return generateDynamicHelpers(parsed.value) + case 'unit': + return [generateDynamicHelper(parsed.value, parsed.unit)] + case 'invalid': + return null + } +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx index 1c52f2f5265..4f9bac9cd34 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx @@ -1,5 +1,5 @@ import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' interface LogsExplorerOtelBannerProps { isRewriting: boolean diff --git a/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx b/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx index d0fdb2505ed..62c93b2d8d7 100644 --- a/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/RecentQueriesItem.tsx @@ -15,18 +15,18 @@ const RecentQueriesItem: React.FC = ({ item }) => { const { ref } = router.query return ( - + - {item.sql} + {item.unchecked_sql}
    diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 88198f6808d..3e9d040ae21 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -7,7 +7,7 @@ import { Lock } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { Button, Form, FormControl, FormField, Input } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' @@ -41,7 +41,7 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => { defaultValues: { code: '' }, }) - const { code } = form.watch() + const code = useWatch({ control: form.control, name: 'code' }) const { data: factors, diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index 26cd71e1fcb..f4640f9ad34 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -5,7 +5,7 @@ import { CheckCircle, Eye, EyeOff } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' import { 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, @@ -67,6 +67,7 @@ export const SignUpForm = () => { const [searchParams] = useQueryStates({ auth_id: parseAsString.withDefault(''), token: parseAsString.withDefault(''), + organization_slug: parseAsString.withDefault(''), }) const trackFunnelError = useTrackFunnelError() @@ -102,7 +103,12 @@ export const SignUpForm = () => { let redirectTo: string if (isInsideOAuthFlow) { - redirectTo = `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}` + const authorizeParams = new URLSearchParams({ auth_id: searchParams.auth_id }) + if (searchParams.token) authorizeParams.set('token', searchParams.token) + if (searchParams.organization_slug) { + authorizeParams.set('organization_slug', searchParams.organization_slug) + } + redirectTo = `${redirectUrlBase}/authorize?${authorizeParams.toString()}` } else { // Use getRedirectToPath to handle redirect_to parameter and other query params const { returnTo } = router.query @@ -120,7 +126,7 @@ export const SignUpForm = () => { }) } - const password = form.watch('password') + const password = useWatch({ control: form.control, name: 'password' }) const isSubmitting = form.formState.isSubmitting || isSigningUp return ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx index 3876dbf344b..98a0070380e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/BucketCallouts.tsx @@ -1,9 +1,10 @@ import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { ScaffoldSection } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' @@ -48,7 +49,7 @@ export const ExtensionNotInstalled = ({ @@ -97,7 +98,7 @@ export const ExtensionNeedsUpgrade = ({ diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.constants.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.constants.ts index 63389dbc20d..7a8b4ada080 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.constants.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.constants.ts @@ -15,9 +15,16 @@ export const COLUMN_TYPES = [ 'binary', 'decimal', 'fixed', -] +] as const -export const COLUMN_TYPE_FIELDS = { +export type ColumnType = (typeof COLUMN_TYPES)[number] + +interface AdditionalColumnField { + name: 'precision' | 'scale' | 'length' + type: 'number' +} + +export const COLUMN_TYPE_FIELDS: Partial> = { decimal: [ { name: 'precision', type: 'number' }, { name: 'scale', type: 'number' }, diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.schema.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.schema.ts index 05f0edee7ba..a5896e5066d 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.schema.ts +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -import { NEW_NAMESPACE_MARKER } from './CreateTableSheet.constants' +import { COLUMN_TYPES, NEW_NAMESPACE_MARKER } from './CreateTableSheet.constants' const getValidRegex = (type: 'namespace' | 'table') => type === 'namespace' @@ -37,7 +37,7 @@ export const createFormSchema = () => columns: z .object({ name: z.string().min(1, 'Provide a name for your column'), - type: z.string().min(1, 'Select a type for your column'), + type: z.enum(COLUMN_TYPES, { message: 'Select a type for your column' }), // For decimal type precision: z.number().optional(), scale: z.number().int().optional(), diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx index c93911b7aa6..baa8f39d07a 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CreateTable/CreateTableSheet.tsx @@ -1,8 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { Plus, X } from 'lucide-react' -import { Fragment, useState } from 'react' -import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' +import { useState } from 'react' +import { Control, SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -43,6 +43,8 @@ import { useIcebergNamespacesQuery } from '@/data/storage/iceberg-namespaces-que const formId = 'create-namespace-table' const NEW_NAMESPACE_MARKER = 'new-namespace' +type CreateTableFormValues = z.infer> + interface CreateTableSheetProps { open: boolean onOpenChange: (value: boolean) => void @@ -53,18 +55,18 @@ export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps) const [isCreating, setIsCreating] = useState(false) const FormSchema = createFormSchema() - const defaultValues = { + const defaultValues: CreateTableFormValues = { namespace: '', newNamespace: undefined, name: '', - columns: [{ name: '', type: 'string' as any }], + columns: [{ name: '', type: 'string' }], } const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues, mode: 'onChange', }) - const { namespace } = form.watch() + const namespace = useWatch({ control: form.control, name: 'namespace' }) const { fields: columns, append: appendColumn, @@ -238,102 +240,15 @@ export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps)

    Name

    Type

    - {columns.map((_, idx) => { - const columnType = form.watch(`columns.${idx}.type`) - const additionalFields = - COLUMN_TYPE_FIELDS[columnType as keyof typeof COLUMN_TYPE_FIELDS] ?? [] - - return ( - -
    - ( - - - - - - )} - /> - ( - - - - )} - /> -
    -
    - - {additionalFields.length > 0 && ( -
    -
    - {additionalFields.map((x) => ( - ( - - - - - {x.name} - - - field.onChange( - isNaN(event.target.valueAsNumber) - ? null - : event.target.valueAsNumber - ) - } - /> - - - - )} - /> - ))} -
    -
    -
    - )} -
    - - ) - })} + {columns.map((column, idx) => ( + removeColumn(idx)} + /> + ))} )}
    @@ -362,3 +277,101 @@ export const CreateTableSheet = ({ open, onOpenChange }: CreateTableSheetProps) ) } + +interface ColumnRowProps { + control: Control + idx: number + isCreating: boolean + onRemove: () => void +} + +const ColumnRow = ({ control, idx, isCreating, onRemove }: ColumnRowProps) => { + const columnType = useWatch({ control, name: `columns.${idx}.type` }) + const additionalFields = COLUMN_TYPE_FIELDS[columnType] ?? [] + + return ( +
    + ( + + + + + + )} + /> + ( + + + + )} + /> +
    +
    + + {additionalFields.length > 0 && ( +
    +
    + {additionalFields.map((x) => ( + ( + + + + {x.name} + + field.onChange( + isNaN(event.target.valueAsNumber) ? null : event.target.valueAsNumber + ) + } + /> + + + + )} + /> + ))} +
    +
    +
    + )} +
    + ) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx index a4e936a0d5d..7b68b016af4 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -124,9 +124,9 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen setIsUpdatingReplication(true) // [Joshen ALPHA] Assumption here is that all the namespace tables have _changelog as suffix // May need to update if that assumption falls short (e.g for those dealing with iceberg APIs directly) - const updatedTables = publication.tables.filter( - (x) => table.name !== getNamespaceTableNameFromPostgresTableName(x) - ) + const updatedTables = publication.tables + .filter((x) => table.name !== getNamespaceTableNameFromPostgresTableName(x)) + .map(({ schema, name }) => ({ schema, name })) await updatePublication({ projectRef, sourceId, @@ -158,9 +158,9 @@ export const TableRowComponent = ({ table, schema, namespace }: TableRowComponen try { setIsUpdatingReplication(true) - const updatedTables = publication.tables.concat([ - { schema: pgTable.schema, name: pgTable.name }, - ]) + const updatedTables = publication.tables + .map(({ schema, name }) => ({ schema, name })) + .concat([{ schema: pgTable.schema, name: pgTable.name }]) await updatePublication({ projectRef, sourceId, diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx index 6000042a6bf..357ed760bcd 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/SimpleConfigurationDetails.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Card } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './AnalyticsBucketDetails.constants' import { CopyEnvButton } from './CopyEnvButton' diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx index 21dc8212a49..ed73ce414f9 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/UpdateCatalogTokenDialog.tsx @@ -20,7 +20,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' diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx index d9327afd527..25446fb5eb1 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketForm.tsx @@ -14,7 +14,7 @@ import { SheetFooter, SheetSection, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' @@ -24,6 +24,7 @@ import { reservedSuffixes, validBucketNameRegex, } from './CreateAnalyticsBucketForm.utils' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { InlineLink } from '@/components/ui/InlineLink' import { useDatabaseExtensionEnableMutation } from '@/data/database-extensions/database-extension-enable-mutation' import { useAnalyticsBucketCreateMutation } from '@/data/storage/analytics-bucket-create-mutation' @@ -226,9 +227,7 @@ export const CreateAnalyticsBucketForm = ({

    Update the wrappers extension by upgrading your project from your{' '} - - project settings - {' '} + project settings{' '} before creating an Analytics bucket.{' '} Learn more diff --git a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx index b527971903e..595cb64317e 100644 --- a/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx +++ b/apps/studio/components/interfaces/Storage/BucketsPickerDialog/BucketsPicker.tsx @@ -11,7 +11,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 411204e7e6d..38c9cc76269 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -24,7 +24,7 @@ import { SelectValue, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' @@ -108,8 +108,8 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps }, }) const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors - const isPublicBucket = form.watch('public') - const hasFileSizeLimit = form.watch('has_file_size_limit') + const isPublicBucket = useWatch({ control: form.control, name: 'public' }) + const hasFileSizeLimit = useWatch({ control: form.control, name: 'has_file_size_limit' }) const onSubmit: SubmitHandler = async (values) => { if (!ref) return console.error('Project ref is required') diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index d0a3a8f047b..125d45e2d09 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { useEffect, useRef, 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, @@ -24,7 +24,7 @@ import { SelectValue, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' @@ -121,8 +121,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro }) const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors - const isPublicBucket = form.watch('public') - const hasFileSizeLimit = form.watch('has_file_size_limit') + const isPublicBucket = useWatch({ control: form.control, name: 'public' }) + const hasFileSizeLimit = useWatch({ control: form.control, name: 'has_file_size_limit' }) const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState( Boolean(bucket?.allowed_mime_types?.length) ) diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx index edbb38a5c85..15d5646f10b 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx @@ -10,7 +10,7 @@ import { DialogSectionSeparator, DialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useBucketEmptyMutation } from '@/data/storage/bucket-empty-mutation' import type { Bucket } from '@/data/storage/buckets-query' diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx index db17240ed84..da0f7aea3a5 100644 --- a/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx +++ b/apps/studio/components/interfaces/Storage/FilesBuckets/index.tsx @@ -12,7 +12,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } 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/Storage/PublicBucketWarning.tsx b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx index 555d5cc5d7e..1626a35fd76 100644 --- a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx +++ b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx @@ -4,7 +4,7 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { useEffect, useState, type ReactNode } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' diff --git a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx index 1ae65ef4d8d..4af7100ec2e 100644 --- a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx +++ b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx @@ -1,7 +1,7 @@ import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { SupportLink } from '../Support/SupportLink' import type { ResponseError } from '@/types' diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index 245ef423660..d93c413405b 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM, useParams } from 'common' import { useEffect, useMemo, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, @@ -21,7 +21,7 @@ import { SelectValue, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' @@ -158,7 +158,7 @@ export const StorageSettings = () => { reValidateMode: 'onSubmit', }) - const { unit: storageUnit } = form.watch() + const storageUnit = useWatch({ control: form.control, name: 'unit' }) const fileSizeLimitError = form.formState.errors.fileSizeLimit const { mutate: updateStorageConfig } = useProjectStorageConfigUpdateUpdateMutation({ diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx index f35171dd771..9746240bb6a 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -17,7 +17,7 @@ import { FormField, Input, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx index 8bf96c3df3e..7ce70529753 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx @@ -23,7 +23,7 @@ import { SheetTitle, SheetTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx deleted file mode 100644 index d8b133dc5eb..00000000000 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/RegionLimitation.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { VectorBucket } from 'icons' -import { AWS_REGIONS } from 'shared-data' -import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' -import { PageContainer } from 'ui-patterns/PageContainer' -import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' - -import { AVAILABLE_REPLICA_REGIONS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' -import { AlphaNotice } from '@/components/ui/AlphaNotice' -import { InlineLinkClassName } from '@/components/ui/InlineLink' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' - -export const VECTOR_BUCKETS_AVAILABLE_REGIONS = [ - 'us-east-1', - 'us-east-2', - 'us-west-2', - 'eu-central-1', - 'ap-southeast-2', -] - -const getRegionNameFromCode = (code: string) => - Object.values(AWS_REGIONS).find((x) => x.code === code)?.displayName - -export const RegionLimitation = () => { - const { data: project } = useSelectedProjectQuery() - - const regionLabel = AVAILABLE_REPLICA_REGIONS.find((region) => - project?.region?.includes(region.region) - ) - - return ( - - - - - - Your project is in{' '} - - - {regionLabel?.name} - - {regionLabel?.region} - - , but Vector buckets are only available for{' '} - - certain regions - -

      - {VECTOR_BUCKETS_AVAILABLE_REGIONS.map((x) => ( -
    • - {getRegionNameFromCode(x)} - {x} -
    • - ))} -
    - - - . We're actively looking to expand that soon. - - } - /> - - - - ) -} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx index 05b68cda245..acadd747eec 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketCallouts.tsx @@ -1,9 +1,10 @@ import Link from 'next/link' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types' +import { getServiceVersionsPath } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersions.utils' import { ScaffoldSection } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' @@ -40,7 +41,7 @@ export const ExtensionNotInstalled = ({ @@ -84,7 +85,7 @@ export const ExtensionNeedsUpgrade = ({ diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx index d50be9764c4..75b8469bc02 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails/VectorBucketTableExamplesSheet.tsx @@ -22,7 +22,7 @@ import { SheetTitle, SheetTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { useS3VectorsWrapperExtension } from '../useS3VectorsWrapper' diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx index b58bd8fd8fb..abc33955152 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' const CONFIG_SNIPPET = `[storage.vector] diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx index 34e243e3226..b1806a24497 100644 --- a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -12,7 +12,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 { @@ -186,14 +186,11 @@ const IssueSuggestion = ({ category, projectRef }: { category: string; projectRe High memory or low disk IO bandwidth may be slowing down your database. Verify by checking - the infrastructure activity of your project{' '} - - here - - . + your project's database observability reports{' '} + here. ) } @@ -205,12 +202,9 @@ const IssueSuggestion = ({ category, projectRef }: { category: string; projectRe className={className} title="Have you checked the Query Performance Advisor?" > - Identify slow running queries and get actionable insights on how to optimize them with the - Query Performance Advisor{' '} - - here - - . + Identify slow running queries and get actionable insights on how to optimize them with Query + Performance{' '} + here. ) } diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx index a0e59404a07..4680a9ea453 100644 --- a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx +++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { processIncidentData } from '@/data/platform/incident-status-utils' diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx index 1d104342edb..813ba728648 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Link2 } from 'lucide-react' import { useEffect } from 'react' import type { SubmitHandler } from 'react-hook-form' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, DialogSectionSeparator, Form, FormControl, FormField, Input } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -51,7 +51,10 @@ export const LinkSupportTicketForm = ({ reValidateMode: 'onBlur', }) - const { category, organizationSlug, projectRef } = form.watch() + const [category, organizationSlug, projectRef] = useWatch({ + control: form.control, + name: ['category', 'organizationSlug', 'projectRef'], + }) const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug const selectedProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx index b41c76a478b..014111b314b 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' import { Button, cn, DialogSectionSeparator } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { LinkSupportTicketForm } from './LinkSupportTicketForm' diff --git a/apps/studio/components/interfaces/Support/MessageField.tsx b/apps/studio/components/interfaces/Support/MessageField.tsx index 41f13db835a..522d9efc257 100644 --- a/apps/studio/components/interfaces/Support/MessageField.tsx +++ b/apps/studio/components/interfaces/Support/MessageField.tsx @@ -2,7 +2,7 @@ import type { UseFormReturn } from 'react-hook-form' // End of third-party imports import { FormControl, FormField, TextArea } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx index f587e28a2a5..5f0fa748e0b 100644 --- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -7,7 +7,7 @@ import Link from 'next/link' import type { UseFormReturn } from 'react-hook-form' import { toast } from 'sonner' import { Button, cn, CommandGroup, CommandItem, 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 { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index b7ed6b91e26..3962d3fbf35 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -6,7 +6,7 @@ import type { UseFormReturn } from 'react-hook-form' import SVG from 'react-inlinesvg' import { toast } from 'sonner' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { AIAssistantOption } from './AIAssistantOption' import { DiscordCTACard } from './DiscordCTACard' diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index eeb9a7d5bfb..adf6400dd4a 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -4,6 +4,7 @@ import { useConstant, useFlag } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' import { type Dispatch, type MouseEventHandler } from 'react' import type { SubmitHandler, UseFormReturn } from 'react-hook-form' +import { useWatch } from 'react-hook-form' import { DialogSectionSeparator, Form } from 'ui' import { @@ -71,7 +72,10 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' - const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() + const [organizationSlug, projectRef, category, severity, subject, library] = useWatch({ + control: form.control, + name: ['organizationSlug', 'projectRef', 'category', 'severity', 'subject', 'library'], + }) const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug const selectedProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx index d8629c4c5f4..764dc5959eb 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV3.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -4,6 +4,7 @@ import { useConstant, useFlag } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' import { type Dispatch, type MouseEventHandler } from 'react' import type { SubmitHandler, UseFormReturn } from 'react-hook-form' +import { useWatch } from 'react-hook-form' import { Form, Separator } from 'ui' import { v4 as uuidv4 } from 'uuid' @@ -80,7 +81,10 @@ export const SupportFormV3 = ({ const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' - const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() + const [organizationSlug, projectRef, category, severity, subject, library] = useWatch({ + control: form.control, + name: ['organizationSlug', 'projectRef', 'category', 'severity', 'subject', 'library'], + }) const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug const currentProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef diff --git a/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx b/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx index 76c46d62b13..013b23ab711 100644 --- a/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx +++ b/apps/studio/components/interfaces/SupportEmailVerification/SupportEmailVerification.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { useEffect } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout' diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 0e8f86367dc..58b7588cab0 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -332,14 +332,19 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp - - + + + onSelectDownloadBackup()} + tooltip={{ + content: { + side: 'right', + text: 'No backups available, please reach out via support for assistance', + }, + }} + > + + Database backup (PG: {dbVersion}) + + onSelectDownloadStorageArchive()}> + + Storage objects + + + +
    + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx index 25dcf86ec2b..ab60e5398e8 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/PauseDisabledState.tsx @@ -1,121 +1,23 @@ import { useParams } from 'common' -import { Database, Storage } from 'icons' -import { ChevronDown, Download, ExternalLink } from 'lucide-react' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { ExternalLink } from 'lucide-react' +import { Admonition } from 'ui-patterns/Admonition' import { TimestampInfo } from 'ui-patterns/TimestampInfo' -import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' +import { DownloadBackupsSection } from './DownloadBackupsSection' import { InlineLink } from '@/components/ui/InlineLink' -import { useBackupDownloadMutation } from '@/data/database/backup-download-mutation' import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query' -import { useStorageArchiveCreateMutation } from '@/data/storage/storage-archive-create-mutation' -import { useStorageArchiveQuery } from '@/data/storage/storage-archive-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants' +import { formatRestoreWindow } from '@/lib/helpers' export const PauseDisabledState = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const [toastId, setToastId] = useState() - const [refetchInterval, setRefetchInterval] = useState(false) - - const dbVersion = project?.dbVersion?.replace('supabase-postgres-', '') const { data: pauseStatus } = useProjectPauseStatusQuery( { ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE } ) - const latestBackup = pauseStatus?.latest_downloadable_backup_id - - const { data: storageArchive, isSuccess: isStorageArchiveSuccess } = useStorageArchiveQuery( - { projectRef: ref }, - { - refetchInterval, - refetchOnWindowFocus: false, - } - ) - - useEffect(() => { - if (!isStorageArchiveSuccess) return - if (storageArchive.fileUrl && refetchInterval !== false) { - toast.success('Downloading storage objects', { id: toastId }) - setToastId(undefined) - setRefetchInterval(false) - downloadStorageArchive(storageArchive.fileUrl) - } - }, [isStorageArchiveSuccess, storageArchive, refetchInterval]) - - const storageArchiveUrl = storageArchive?.fileUrl - - const { mutate: downloadBackup } = useBackupDownloadMutation({ - onSuccess: (res) => { - const { fileUrl } = res - - // Trigger browser download by create,trigger and remove tempLink - const tempLink = document.createElement('a') - tempLink.href = fileUrl - document.body.appendChild(tempLink) - tempLink.click() - document.body.removeChild(tempLink) - }, - }) - - const { mutate: createStorageArchive } = useStorageArchiveCreateMutation({ - onSuccess: () => { - const toastId = toast.loading( - 'Retrieving storage archive. This may take a few minutes depending on the size of your storage objects.' - ) - setToastId(toastId) - setRefetchInterval(5000) - }, - }) - - const onSelectDownloadBackup = () => { - if (ref === undefined) return console.error('Project ref is required') - if (!latestBackup) return toast.error('No backups available for download') - - const toastId = toast.loading('Fetching database backup') - - downloadBackup( - { - ref, - backup: { - id: latestBackup, - }, - }, - { - onSuccess: () => { - toast.success('Downloading database backup', { id: toastId }) - }, - } - ) - } - - const downloadStorageArchive = (url: string) => { - const tempLink = document.createElement('a') - tempLink.href = url - document.body.appendChild(tempLink) - tempLink.click() - document.body.removeChild(tempLink) - } - - const onSelectDownloadStorageArchive = () => { - if (!storageArchiveUrl) { - createStorageArchive({ projectRef: ref }) - } else { - toast.success('Downloading storage objects') - downloadStorageArchive(storageArchiveUrl) - } - } return ( <> @@ -128,7 +30,7 @@ export const PauseDisabledState = () => {

    This project has been paused for over{' '} - {pauseStatus?.max_days_till_restore_disabled ?? 90} days + {formatRestoreWindow(pauseStatus?.max_days_till_restore_disabled ?? 365)} {' '} and cannot be restored through the dashboard. However, your data remains intact and can be downloaded as a backup. @@ -165,46 +67,7 @@ export const PauseDisabledState = () => {

    -
    -
    -

    Export your data

    -

    - Download backups for your database and storage objects -

    -
    - - - - - - onSelectDownloadBackup()} - tooltip={{ - content: { - side: 'right', - text: 'No backups available, please reach out via support for assistance', - }, - }} - > - - Database backup (PG: {dbVersion}) - - onSelectDownloadStorageArchive()}> - - Storage objects - - {/* [Joshen] Once storage object download is supported, can just use the below component */} - {/* onSelectDownloadStorageArchive()}> - - Download storage objects - */} - - -
    + ) } diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 3a0ea112408..cda99e5774f 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -2,23 +2,14 @@ import { useParams } from 'common' import dayjs from 'dayjs' import { PauseCircle } from 'lucide-react' import Link from 'next/link' -import { - Button, - Card, - CardContent, - CardFooter, - cn, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Button, Card, CardContent, CardFooter } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' +import { DownloadBackupsSection } from './DownloadBackupsSection' import { PauseDisabledState } from './PauseDisabledState' import { ResumeProjectButton } from '@/components/interfaces/Project/ResumeProjectButton' import { AlertError } from '@/components/ui/AlertError' -import { InlineLinkClassName } from '@/components/ui/InlineLink' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' @@ -45,10 +36,11 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { isPending: isLoading, } = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE }) - const finalDaysRemainingBeforeRestoreDisabled = - pauseStatus?.remaining_days_till_restore_disabled ?? - pauseStatus?.max_days_till_restore_disabled ?? - 0 + // null when there is no expiry (e.g. paid plans can always restore) + const restoreExpiresAt = + pauseStatus?.remaining_days_till_restore_disabled != null + ? dayjs().utc().add(pauseStatus.remaining_days_till_restore_disabled, 'day').toISOString() + : null const isFreePlan = selectedOrganization?.plan?.id === 'free' const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore @@ -67,36 +59,26 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { isFreePlan ? (
    • All data, including backups and storage objects, remains safe.
    • -
    • - You can resume this project from the dashboard within{' '} - - - - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - {' '} - - - Free projects cannot be restored through the dashboard if they are paused - for more than {pauseStatus.max_days_till_restore_disabled} days - - {' '} - (until{' '} - - ). -
    • -
    • - After that, this project will not be resumable, but data will still be - available for download. -
    • + {restoreExpiresAt ? ( + <> +
    • + You can resume this project from the dashboard until{' '} + + . +
    • +
    • + After that, this project will not be resumable, but data will still be + available for download. +
    • + + ) : ( +
    • You can resume this project from the dashboard.
    • + )}
    • {enableProBenefitWording === 'variant-a' ? 'Upgrade to Pro to prevent pauses and unlock features like branching, compute upgrades, and daily backups.' @@ -106,22 +88,20 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { ) : ( <>

      - Your project data is safe and available for{' '} - - {finalDaysRemainingBeforeRestoreDisabled} day - {finalDaysRemainingBeforeRestoreDisabled > 1 ? 's' : ''} - {' '} - (until{' '} - - ), but inaccessible while paused. + Your project data is safe + {restoreExpiresAt ? ( + <> + {' '} + and available until{' '} + + + ) : null} + , but inaccessible while paused.

      Once resumed, usage will be billed by compute size and hours active. @@ -156,17 +136,20 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { )} {isPauseStatusSuccess && !isRestoreDisabled && ( - - + <> + + - {isFreePlan ? ( - - ) : ( - - )} - + {isFreePlan ? ( + + ) : ( + + )} + + + )} {isPauseStatusSuccess && isRestoreDisabled && } diff --git a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx index b9de7da6826..a223931bb4b 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx @@ -3,7 +3,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { CheckCircle, Download, Loader } from 'lucide-react' import { useEffect, useState } from 'react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.selfhosted.test.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.selfhosted.test.tsx index 1b5d40e4037..b4cb5ee9b07 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.selfhosted.test.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.selfhosted.test.tsx @@ -106,7 +106,6 @@ describe('useGenerateSettingsMenu (self-hosted)', () => { const { result } = renderHook(() => useGenerateSettingsMenu()) const configGroup = result.current.find((group) => group.title === 'Configuration') - expect(configGroup?.items.some((item) => item.key === 'compute-and-disk')).toBe(false) expect(configGroup?.items.some((item) => item.key === 'infrastructure')).toBe(false) expect(configGroup?.items.some((item) => item.key === 'addons')).toBe(false) }) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx index 1c6a6b17f16..1ea61eb5e70 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx @@ -80,6 +80,26 @@ describe('useGenerateSettingsMenu', () => { expect(hasMembers).toBe(false) }) + it('uses Infrastructure as the canonical compute and disk destination', () => { + const { result } = renderHook(() => useGenerateSettingsMenu()) + const configurationGroup = result.current.find((group) => group.title === 'Configuration') + + expect(configurationGroup?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'infrastructure', + name: 'Infrastructure', + url: '/project/project-ref/settings/infrastructure', + }), + ]) + ) + expect( + configurationGroup?.items.some( + (item) => item.key === 'compute-and-disk' || item.name === 'Compute and Disk' + ) + ).toBe(false) + }) + it('includes dashboard in configuration when flag is enabled', () => { vi.mocked(useFlag).mockReturnValue(true) @@ -147,9 +167,6 @@ describe('useGenerateSettingsMenu', () => { ) expect(shortcutByKey.get('general')).toBe(SHORTCUT_IDS.NAV_PROJECT_SETTINGS_GENERAL) - expect(shortcutByKey.get('compute-and-disk')).toBe( - SHORTCUT_IDS.NAV_PROJECT_SETTINGS_COMPUTE_AND_DISK - ) expect(shortcutByKey.get('infrastructure')).toBe( SHORTCUT_IDS.NAV_PROJECT_SETTINGS_INFRASTRUCTURE ) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 354a4b603f8..30e693dcf53 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -2,6 +2,7 @@ import { useFlag, useParams } from 'common' import { ArrowUpRight } from 'lucide-react' import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -106,19 +107,10 @@ export const useGenerateSettingsMenu = () => { items: [], shortcutId: SHORTCUT_IDS.NAV_PROJECT_SETTINGS_GENERAL, }, - { - name: 'Compute and Disk', - key: 'compute-and-disk', - url: `/project/${ref}/settings/compute-and-disk`, - items: [], - disabled: !isProjectActive, - isLoading: isPending, - shortcutId: SHORTCUT_IDS.NAV_PROJECT_SETTINGS_COMPUTE_AND_DISK, - }, { name: 'Infrastructure', key: 'infrastructure', - url: `/project/${ref}/settings/infrastructure`, + url: getInfrastructurePath(ref), items: [], disabled: !isProjectActive, isLoading: isPending, diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index a27febc9d52..0fad5971029 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' -import { FilePlus, FolderPlus, Plus, X } from 'lucide-react' +import { FilePlus, FolderPlus, Plus, ScrollText, X } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -24,6 +24,7 @@ import { import { SearchList } from './SQLEditorNavV2/SearchList' import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav' +import { type SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -39,6 +40,9 @@ export const SQLEditorMenu = () => { const snapV2 = useSqlEditorV2StateSnapshot() const topForPostgres = useFlag('topForPostgres') + const sqlEditorLogsSource = useFlag('sqlEditorLogsSource') + const otelLegacyLogs = useFlag('otelLegacyLogs') + const canCreateLogsSnippet = sqlEditorLogsSource && otelLegacyLogs const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) @@ -66,7 +70,7 @@ export const SQLEditorMenu = () => { snapV2.addNewFolder({ projectRef: ref }) } - const handleNewQuery = async () => { + const handleNewQuery = async (source: SqlSnippetSource = 'database') => { if (!ref) return console.error('Project ref is required') if (!project) return console.error('Project is required') if (!profile) return console.error('Profile is required') @@ -74,7 +78,8 @@ export const SQLEditorMenu = () => { return toast('Your queries will not be saved as you do not have sufficient permissions') } try { - router.push(`/project/${ref}/sql/new?skip=true`) + const suffix = source === 'logs' ? '&source=logs' : '' + router.push(`/project/${ref}/sql/new?skip=true${suffix}`) setSearch('') setShowSearch(false) } catch (error: any) { @@ -156,6 +161,12 @@ export const SQLEditorMenu = () => { Create a new snippet + {canCreateLogsSnippet && ( + handleNewQuery('logs')}> + + Create a new logs query + + )} createNewFolder()}> Create a new folder diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 828f99b7687..21ae2a5e129 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -28,6 +28,7 @@ import { TreeViewItem, } from 'ui' +import { getSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { getContentById, getSqlSnippetById } from '@/data/content/content-id-query' import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query' @@ -225,6 +226,7 @@ export const SQLEditorTreeViewItem = ({ sql, owner_id: profile?.id, project_id: project?.id, + source: getSnippetSource(snippet), }) snapV2.addSnippet({ projectRef, snippet: snippetCopy }) @@ -420,16 +422,18 @@ export const SQLEditorTreeViewItem = ({ {isFavorite ? 'Remove from' : 'Add to'} favorites )} - {onSelectDownload !== undefined && IS_PLATFORM && ( - onSelectDownload()} - onFocusCapture={(e) => e.stopPropagation()} - > - - Export query - - )} + {onSelectDownload !== undefined && + IS_PLATFORM && + getSnippetSource(element.metadata) !== 'logs' && ( + onSelectDownload()} + onFocusCapture={(e) => e.stopPropagation()} + > + + Export query + + )} {onSelectDelete !== undefined && isOwner && ( <> diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index eeffd9a142f..e3e000216d6 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -234,17 +234,23 @@ export const EntityListItem = ({ {canEdit && ( - - diff --git a/apps/studio/components/ui/ComputeBadgeWrapper.tsx b/apps/studio/components/ui/ComputeBadgeWrapper.tsx index 66218618298..9f8924a3b59 100644 --- a/apps/studio/components/ui/ComputeBadgeWrapper.tsx +++ b/apps/studio/components/ui/ComputeBadgeWrapper.tsx @@ -5,6 +5,7 @@ import { ComputeBadge } from 'ui-patterns/ComputeBadge' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getAvailableComputeOptions } from '@/components/interfaces/DiskManagement/DiskManagement.utils' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { ProjectDetail } from '@/data/projects/project-detail-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' @@ -195,9 +196,7 @@ export const ComputeBadgeWrapper = ({ }) }} > - - Upgrade compute - + Upgrade compute diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index fbf10834285..29f276be2e8 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -57,7 +57,7 @@ const CopyButton = forwardRef( }} {...props} variant={variant} - className={cn({ 'px-1': iconOnly }, className)} + className={cn({ 'px-1.5': iconOnly }, className)} icon={ showCopied ? ( { let timeoutId: NodeJS.Timeout diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 0543f8f87dd..bea0ab15770 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -23,6 +23,7 @@ import { } from 'ui' import { Markdown } from '@/components/interfaces/Markdown' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' @@ -177,7 +178,7 @@ export const DatabaseSelector = ({ diff --git a/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx b/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx index 8591de05fc8..ae71ae1b38c 100644 --- a/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx +++ b/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx @@ -2,7 +2,7 @@ import { Code } from 'lucide-react' import Link from 'next/link' import type { DragEvent, ReactNode } from 'react' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock' import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer' diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 036c73653d3..9df6699fcd0 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -34,7 +34,7 @@ import { PopoverTrigger, SQL_ICON, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { CodeBlock } from 'ui-patterns/CodeBlock' import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils' @@ -640,7 +640,7 @@ export const EditorPanel = () => { owner_id: profile.id, content: { ...activeSnippet.content, - sql: currentValue, + unchecked_sql: untrustedSql(currentValue), }, }, }) diff --git a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx index f5e37a2d175..1b4534fa392 100644 --- a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx +++ b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx @@ -3,7 +3,7 @@ import { safeLocalStorage, safeSessionStorage } from 'common' import { ExternalLink } from 'lucide-react' import { useRouter } from 'next/router' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import CopyButton from '../CopyButton' import { InlineLinkClassName } from '../InlineLink' diff --git a/apps/studio/components/ui/HelpPanel/HelpPanel.tsx b/apps/studio/components/ui/HelpPanel/HelpPanel.tsx index 211639e3e9b..c5f0a90da1b 100644 --- a/apps/studio/components/ui/HelpPanel/HelpPanel.tsx +++ b/apps/studio/components/ui/HelpPanel/HelpPanel.tsx @@ -2,9 +2,10 @@ import { IS_PLATFORM } from 'common' import { ChevronLeft, X } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import SVG from 'react-inlinesvg' import { Button } from 'ui' +import { useSnapshot } from 'valtio' import { ASSISTANT_SUGGESTIONS } from './HelpPanel.constants' import { HelpSection } from './HelpSection' @@ -16,6 +17,7 @@ import { import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { helpPanelState } from '@/state/help-panel-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' export const HelpPanel = ({ @@ -30,7 +32,16 @@ export const HelpPanel = ({ const snap = useAiAssistantStateSnapshot() const { openSidebar, closeSidebar } = useSidebarManagerSnapshot() const router = useRouter() - const [view, setView] = useState<'home' | 'support'>('home') + const helpSnap = useSnapshot(helpPanelState) + const [view, setView] = useState<'home' | 'support'>(() => helpPanelState.requestedView) + + useEffect(() => { + if (helpSnap.requestedView !== 'home') { + setView(helpSnap.requestedView) + helpPanelState.requestedView = 'home' + } + }, [helpSnap.requestedView]) + const isSupportView = view === 'support' const openAssistant = () => { onClose() diff --git a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx index 50188ff9856..29e7c1f7eee 100644 --- a/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx +++ b/apps/studio/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { useHighAvailability } from '@/hooks/misc/useHighAvailability' diff --git a/apps/studio/components/ui/HighQueryCost.tsx b/apps/studio/components/ui/HighQueryCost.tsx index 4d9432f08ef..eb78467da38 100644 --- a/apps/studio/components/ui/HighQueryCost.tsx +++ b/apps/studio/components/ui/HighQueryCost.tsx @@ -14,7 +14,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DocsButton } from './DocsButton' import { InlineLinkClassName } from './InlineLink' diff --git a/apps/studio/components/ui/NoPermission.tsx b/apps/studio/components/ui/NoPermission.tsx index 90d8c3c32bd..e736079d890 100644 --- a/apps/studio/components/ui/NoPermission.tsx +++ b/apps/studio/components/ui/NoPermission.tsx @@ -1,4 +1,4 @@ -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' interface NoPermissionProps { resourceText: string diff --git a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx index e80fc755f57..510c2c8fcaa 100644 --- a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx +++ b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx @@ -1,13 +1,16 @@ import { SupportCategories } from '@supabase/shared-types/out/constants' import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' -import { useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import dayjs from 'dayjs' +import { X } from 'lucide-react' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' +import { ButtonTooltip } from './ButtonTooltip' import { InlineLink } from './InlineLink' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { useProjectUpgradingStatusQuery } from '@/data/config/project-upgrade-status-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useShowPostgresUpgradeLogs } from '@/hooks/misc/useShowPostgresUpgradeLogs' import { IS_PLATFORM } from '@/lib/constants' import { guessLocalTimezone } from '@/lib/dayjs' @@ -20,7 +23,12 @@ export const ProjectUpgradeFailedBanner = () => { const { status, initiated_at, latest_status_at, error } = data?.databaseUpgradeStatus ?? {} const showPostgresUpgradeLogs = useShowPostgresUpgradeLogs() - const isFailed = status === DatabaseUpgradeStatus.Failed + const [dismissedAt, setDismissedAt] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.PROJECT_UPGRADE_FAILED_BANNER_DISMISSED_AT(ref ?? 'unknown'), + null + ) + + const isFailed = status === DatabaseUpgradeStatus.Failed && initiated_at !== dismissedAt const initiatedAt = dayjs .utc(initiated_at ?? 0) .tz(guessLocalTimezone()) @@ -48,18 +56,28 @@ export const ProjectUpgradeFailedBanner = () => { type="warning" title={`Postgres version upgrade was not successful (Initiated at ${initiatedAt})`} actions={ - + <> + + } + variant="text" + className="w-6" + tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} + aria-label="Dismiss upgrade failed banner" + onClick={() => setDismissedAt(initiated_at ?? null)} + /> + } >

      diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx index 9a2acba857a..9f665a1d0e5 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx @@ -16,7 +16,11 @@ import { } from 'ui' import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants' -import { getWarningContent } from './ResourceExhaustionWarningBanner.utils' +import { + getResourceWarningCorrectionUrl, + getWarningContent, + isComputeUpgradeWarning, +} from './ResourceExhaustionWarningBanner.utils' import { mapComputeSizeNameToAddonVariantId } from '@/components/interfaces/DiskManagement/DiskManagement.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' @@ -26,13 +30,6 @@ import { useTrack } from '@/lib/telemetry/track' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' -const COMPUTE_UPGRADE_METRICS = ['disk_io', 'cpu', 'ram'] -const COMPUTE_UPGRADE_WARNING_TYPES = [ - 'disk_io_exhaustion', - 'cpu_exhaustion', - 'memory_and_swap_exhaustion', -] - export const ResourceExhaustionWarningBanner = () => { const { ref } = useParams() const router = useRouter() @@ -112,46 +109,18 @@ export const ResourceExhaustionWarningBanner = () => { : RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES] ?.metric - const correctionUrlVariants = { - undefined: undefined, - null: '/project/[ref]/settings/[infra-path]', - disk_space: '/project/[ref]/settings/compute-and-disk', - read_only: '/project/[ref]/settings/compute-and-disk', - disk_io: '/project/[ref]/settings/compute-and-disk', - cpu: '/project/[ref]/settings/compute-and-disk', - ram: '/project/[ref]/settings/compute-and-disk', - auth_email_rate_limit: '/project/[ref]/auth/rate-limits', - auth_restricted_email_sending: '/project/[ref]/auth/smtp', - default: (metric: string) => `/project/[ref]/settings/[infra-path]#${metric}`, - } - - const getCorrectionUrl = (metric: string | undefined | null) => { - const variant = metric === undefined ? 'undefined' : metric === null ? 'null' : metric - const url = - correctionUrlVariants[variant as keyof typeof correctionUrlVariants] || - correctionUrlVariants.default(metric as string) - return typeof url === 'function' ? url(metric as string) : url - } - const isFreePlan = organization?.plan?.id === 'free' // True for a single compute warning, or when all active warnings are compute-related - const isComputeUpgradeMetric = - (metric !== null && metric !== undefined && COMPUTE_UPGRADE_METRICS.includes(metric)) || - (activeWarnings.length > 1 && - activeWarnings.every((w) => COMPUTE_UPGRADE_WARNING_TYPES.includes(w))) + const isComputeUpgradeMetric = isComputeUpgradeWarning(metric, activeWarnings) - const correctionUrl = (() => { - if (isComputeUpgradeMetric && isFreePlan) { - return `/org/${organization?.slug ?? '_'}/billing?panel=subscriptionPlan&source=resource_exhaustion_banner` - } - if (isComputeUpgradeMetric && activeWarnings.length > 1) { - return `/project/${ref ?? 'default'}/settings/compute-and-disk` - } - return getCorrectionUrl(metric) - ?.replace('[ref]', ref ?? 'default') - ?.replace('[infra-path]', 'infrastructure') - })() + const correctionUrl = getResourceWarningCorrectionUrl({ + metric, + activeWarnings, + projectRef: ref, + isFreePlan, + organizationSlug: organization?.slug, + }) const buttonText = (() => { if (isComputeUpgradeMetric) return 'Upgrade compute' @@ -182,15 +151,15 @@ export const ResourceExhaustionWarningBanner = () => { warningContent === undefined || (!warningContent?.title && !warningContent?.description) const isUsageOrInfraPage = router.pathname.endsWith('/usage') || router.pathname.endsWith('/infrastructure') - // Compute warnings now link to compute-and-disk, so they should remain visible on infrastructure + // Compute warnings now link to infrastructure, so they should remain visible on usage. const onUsageOrInfraAndNotInReadOnlyMode = isUsageOrInfraPage && !activeWarnings.includes('is_readonly_mode_enabled') && !isComputeUpgradeMetric // Suppress when already on the target page (no-op CTA). Paid-plan compute warnings link to - // compute-and-disk; free-plan links to billing instead, so we keep the banner visible for them. - const onDatabaseSettingsAndInReadOnlyMode = - router.pathname.endsWith('settings/compute-and-disk') && + // infrastructure; free-plan links to billing instead, so we keep the banner visible for them. + const shouldSuppressOnInfrastructurePage = + router.pathname.endsWith('settings/infrastructure') && (activeWarnings.includes('is_readonly_mode_enabled') || (isComputeUpgradeMetric && !isFreePlan)) // these take precedence over each other, so there's only one active warning to check @@ -217,7 +186,7 @@ export const ResourceExhaustionWarningBanner = () => { hasNoWarnings || hasNoWarningContent || onUsageOrInfraAndNotInReadOnlyMode || - onDatabaseSettingsAndInReadOnlyMode || + shouldSuppressOnInfrastructurePage || !isVisible ) { return null diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.test.ts b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.test.ts new file mode 100644 index 00000000000..0f91e4fb947 --- /dev/null +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { + getResourceWarningCorrectionUrl, + isComputeUpgradeWarning, +} from './ResourceExhaustionWarningBanner.utils' + +describe('resource warning correction routes', () => { + it.each(['cpu', 'ram', 'disk_io'])('sends paid-plan %s warnings to Infrastructure', (metric) => { + expect( + getResourceWarningCorrectionUrl({ + metric, + activeWarnings: [`${metric}_warning`], + projectRef: 'project-ref', + isFreePlan: false, + organizationSlug: 'org-slug', + }) + ).toBe('/project/project-ref/settings/infrastructure') + }) + + it('sends free-plan compute warnings to the plan upgrade panel', () => { + expect( + getResourceWarningCorrectionUrl({ + metric: 'cpu', + activeWarnings: ['cpu_exhaustion'], + projectRef: 'project-ref', + isFreePlan: true, + organizationSlug: 'org-slug', + }) + ).toBe('/org/org-slug/billing?panel=subscriptionPlan&source=resource_exhaustion_banner') + }) + + it('treats multiple compute-resource warnings as a compute upgrade', () => { + const activeWarnings = ['cpu_exhaustion', 'memory_and_swap_exhaustion'] + + expect(isComputeUpgradeWarning(null, activeWarnings)).toBe(true) + expect( + getResourceWarningCorrectionUrl({ + metric: null, + activeWarnings, + projectRef: 'project-ref', + isFreePlan: false, + }) + ).toBe('/project/project-ref/settings/infrastructure') + }) + + it.each(['disk_space', 'read_only'])('sends %s warnings to Infrastructure', (metric) => { + expect( + getResourceWarningCorrectionUrl({ + metric, + activeWarnings: ['disk_space_exhaustion'], + projectRef: 'project-ref', + isFreePlan: false, + }) + ).toBe('/project/project-ref/settings/infrastructure') + }) + + it('preserves non-infrastructure correction destinations', () => { + expect( + getResourceWarningCorrectionUrl({ + metric: 'auth_email_rate_limit', + activeWarnings: ['auth_rate_limit_exhaustion'], + projectRef: 'project-ref', + isFreePlan: false, + }) + ).toBe('/project/project-ref/auth/rate-limits') + }) + + it('keeps the fallback metric anchor on Infrastructure', () => { + expect( + getResourceWarningCorrectionUrl({ + metric: 'custom_metric', + activeWarnings: ['custom_warning'], + projectRef: 'project-ref', + isFreePlan: false, + }) + ).toBe('/project/project-ref/settings/infrastructure#custom_metric') + }) +}) diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts index 671d61616e3..eee6e258a31 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts @@ -1,6 +1,14 @@ import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import type { ResourceWarning } from '@/data/usage/resource-warnings-query' +const COMPUTE_UPGRADE_METRICS = ['disk_io', 'cpu', 'ram'] +const COMPUTE_UPGRADE_WARNING_TYPES = [ + 'disk_io_exhaustion', + 'cpu_exhaustion', + 'memory_and_swap_exhaustion', +] + export const getWarningContent = ( resourceWarnings: ResourceWarning, metric: string, @@ -17,3 +25,52 @@ export const getWarningContent = ( contentType ]?.[severity as 'warning' | 'critical'] } + +export const isComputeUpgradeWarning = ( + metric: string | null | undefined, + activeWarnings: string[] +) => + (metric !== null && metric !== undefined && COMPUTE_UPGRADE_METRICS.includes(metric)) || + (activeWarnings.length > 1 && + activeWarnings.every((warning) => COMPUTE_UPGRADE_WARNING_TYPES.includes(warning))) + +export const getResourceWarningCorrectionUrl = ({ + metric, + activeWarnings, + projectRef, + isFreePlan, + organizationSlug, +}: { + metric: string | null | undefined + activeWarnings: string[] + projectRef?: string + isFreePlan: boolean + organizationSlug?: string +}) => { + const isComputeUpgradeMetric = isComputeUpgradeWarning(metric, activeWarnings) + + if (isComputeUpgradeMetric && isFreePlan) { + return `/org/${organizationSlug ?? '_'}/billing?panel=subscriptionPlan&source=resource_exhaustion_banner` + } + + const ref = projectRef ?? 'default' + const infrastructurePath = getInfrastructurePath(ref) + + if (isComputeUpgradeMetric && activeWarnings.length > 1) { + return infrastructurePath + } + + const correctionUrlVariants: Record = { + disk_space: infrastructurePath, + read_only: infrastructurePath, + disk_io: infrastructurePath, + cpu: infrastructurePath, + ram: infrastructurePath, + auth_email_rate_limit: `/project/${ref}/auth/rate-limits`, + auth_restricted_email_sending: `/project/${ref}/auth/smtp`, + } + + if (metric === undefined) return undefined + if (metric === null) return infrastructurePath + return correctionUrlVariants[metric] ?? `${infrastructurePath}#${metric}` +} diff --git a/apps/studio/components/ui/SqlWarningAdmonition.tsx b/apps/studio/components/ui/SqlWarningAdmonition.tsx index d8cb41f5324..18f4e0a47db 100644 --- a/apps/studio/components/ui/SqlWarningAdmonition.tsx +++ b/apps/studio/components/ui/SqlWarningAdmonition.tsx @@ -1,5 +1,5 @@ import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' export interface SqlWarningAdmonitionProps { warningType: 'hasWriteOperation' | 'hasUnknownFunctions' diff --git a/apps/studio/components/ui/TwoOptionToggle.tsx b/apps/studio/components/ui/TwoOptionToggle.tsx index 923b776260d..00fae332a90 100644 --- a/apps/studio/components/ui/TwoOptionToggle.tsx +++ b/apps/studio/components/ui/TwoOptionToggle.tsx @@ -30,6 +30,7 @@ export const TwoOptionToggle = ({
      {options.map((option, index: number) => { const isDisabled = disabledOptions.includes(option) + const isActive = activeOption === option const optionButton = ( - { @@ -58,13 +65,13 @@ export const TwoOptionToggle = ({ {option} - + ) if (!isDisabled || !disabledOptionTooltip) return optionButton diff --git a/apps/studio/components/ui/UnknownInterface.tsx b/apps/studio/components/ui/UnknownInterface.tsx index fcbf30d68f8..92b565f0ab2 100644 --- a/apps/studio/components/ui/UnknownInterface.tsx +++ b/apps/studio/components/ui/UnknownInterface.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' export const UnknownInterface = ({ urlBack, diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 0b1a2bacc92..2967b1b0d7f 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -6,6 +6,7 @@ import { Button } from 'ui' import { ButtonTooltip } from './ButtonTooltip' import { RequestUpgradeToBillingOwners } from './RequestUpgradeToBillingOwners' +import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -71,7 +72,7 @@ export const UpgradePlanButton = ({ ? `/org/${slug ?? '_'}/billing?panel=costControl&source=${source}` : isOnPaidPlanAndRequestingToPurchaseAddon ? addon === 'computeSize' - ? `/project/${ref ?? '_'}/settings/compute-and-disk` + ? getInfrastructurePath(ref) : `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}` : `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}` diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index b8e6b0f81a0..4c8ddac9616 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react' import { cn } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { DocsButton } from './DocsButton' import { UpgradePlanButton } from './UpgradePlanButton' diff --git a/apps/studio/data/content/content-id-query.ts b/apps/studio/data/content/content-id-query.ts index 681e7c645ca..37547ab58fe 100644 --- a/apps/studio/data/content/content-id-query.ts +++ b/apps/studio/data/content/content-id-query.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { components } from 'api-types' import type { Content } from './content-query' -import { remapSqlContentField } from './content-remap' +import { remapSqlContentField, remapWireSnippet } from './content-remap' import { contentKeys } from './keys' import type { SnippetWithContent } from './sql-folders-query' import { get, handleError } from '@/data/fetchers' @@ -51,8 +51,7 @@ export async function getSqlSnippetById( }) if (error) throw handleError(error) - const snippet = remapSqlContentField(data as unknown as Omit) - return { ...snippet, status: 'saved' } + return remapWireSnippet(data, 'saved') } export type SqlSnippetByIdData = Awaited> diff --git a/apps/studio/data/content/content-remap.test.ts b/apps/studio/data/content/content-remap.test.ts index c2fa305e6e2..d14e5e283a8 100644 --- a/apps/studio/data/content/content-remap.test.ts +++ b/apps/studio/data/content/content-remap.test.ts @@ -1,7 +1,8 @@ import { untrustedSql } from '@supabase/pg-meta' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { remapSqlContentField, remapSqlContentFields, unmapSqlContentField } from './content-remap' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' const SQL_SNIPPET = { id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', @@ -14,6 +15,21 @@ const SQL_SNIPPET = { }, } +const LOG_SQL_SNIPPET = { + id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + type: 'log_sql' as const, + name: 'My logs query', + content: { + sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10", + content_id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22', + schema_version: '1', + }, +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + describe('remapSqlContentField', () => { it('remaps content.sql to content.unchecked_sql for SQL snippets', () => { const result = remapSqlContentField(SQL_SNIPPET) @@ -26,6 +42,17 @@ describe('remapSqlContentField', () => { expect(result.content).not.toHaveProperty('sql') }) + it('remaps content.sql to content.unchecked_sql for log_sql snippets using the logs brand', () => { + const result = remapSqlContentField(LOG_SQL_SNIPPET) + + expect(result.content).toEqual({ + content_id: LOG_SQL_SNIPPET.content.content_id, + schema_version: '1', + unchecked_sql: untrustedLogSql(LOG_SQL_SNIPPET.content.sql), + }) + expect(result.content).not.toHaveProperty('sql') + }) + it('leaves non-SQL content types unchanged', () => { const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } } @@ -77,27 +104,75 @@ describe('unmapSqlContentField', () => { expect(result.content).not.toHaveProperty('unchecked_sql') }) + it('remaps content.unchecked_sql back to content.sql for log_sql snippets', () => { + const snippet = remapSqlContentField(LOG_SQL_SNIPPET) + + const result = unmapSqlContentField(snippet) + + expect(result.content).toEqual({ + content_id: LOG_SQL_SNIPPET.content.content_id, + schema_version: '1', + sql: LOG_SQL_SNIPPET.content.sql, + }) + expect(result.content).not.toHaveProperty('unchecked_sql') + }) + it('leaves non-SQL content types unchanged', () => { const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } } expect(unmapSqlContentField(report)).toBe(report) }) - it('leaves SQL snippets whose content has no unchecked_sql field unchanged', () => { + it('leaves content without a sql or unchecked_sql field unchanged (never fabricates sql)', () => { const snippet = { id: '1', type: 'sql' as const, - content: { content_id: '1', schema_version: '1.0', sql: 'SELECT 1' }, + content: { content_id: '1', schema_version: '1.0' }, } - expect(unmapSqlContentField(snippet)).toBe(snippet) + const result = unmapSqlContentField(snippet) + + expect(result).toBe(snippet) + expect(result.content).not.toHaveProperty('sql') + }) + + it('throws in development when content still carries a raw sql field (missed rename)', () => { + const snippet = { + id: '1', + type: 'log_sql' as const, + content: { content_id: '1', schema_version: '1', sql: 'select 1' }, + } + + expect(() => unmapSqlContentField(snippet)).toThrow(/not migrated/) + }) + + it('never clobbers a raw sql field in production (defensive no-op)', () => { + vi.stubEnv('NODE_ENV', 'production') + const snippet = { + id: '1', + type: 'log_sql' as const, + content: { content_id: '1', schema_version: '1', sql: 'select 1' }, + } + + const result = unmapSqlContentField(snippet) + + // The pre-rename `{ sql }` shape is already the wire shape, so passing it through + // untouched is correct — and crucially avoids writing `sql: undefined`. + expect(result).toBe(snippet) + expect(result.content.sql).toBe('select 1') }) }) describe('remap/unmap round-trip', () => { - it('returns the original content shape after remap then unmap', () => { + it('returns the original content shape after remap then unmap for sql snippets', () => { const result = unmapSqlContentField(remapSqlContentField(SQL_SNIPPET)) expect(result.content).toEqual(SQL_SNIPPET.content) }) + + it('returns the original content shape after remap then unmap for log_sql snippets', () => { + const result = unmapSqlContentField(remapSqlContentField(LOG_SQL_SNIPPET)) + + expect(result.content).toEqual(LOG_SQL_SNIPPET.content) + }) }) diff --git a/apps/studio/data/content/content-remap.ts b/apps/studio/data/content/content-remap.ts index 9d7864da0fd..e3f4b397ef1 100644 --- a/apps/studio/data/content/content-remap.ts +++ b/apps/studio/data/content/content-remap.ts @@ -1,27 +1,64 @@ // Remap `sql` → `unchecked_sql` on SQL snippet content objects as they cross the API boundary. // The API stores and returns the field as `sql`; the frontend type uses `unchecked_sql` to make // it explicit that this value must never be executed without user confirmation. +// +// Both database (`type: 'sql'`) and logs (`type: 'log_sql'`) snippets carry user-authored SQL +// under the same wire field, but each is branded with its own untrusted brand so Postgres SQL +// and logs SQL can never cross execution paths. Branding is per-type and never mixed. import { untrustedSql } from '@supabase/pg-meta' +import type { SnippetStatus } from './snippet-status' +import type { SnippetWithContent } from './sql-folders-query' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' + +function isSqlContentType(type: string): type is 'sql' | 'log_sql' { + return type === 'sql' || type === 'log_sql' +} + export function remapSqlContentField(item: T): T { - if (item.type !== 'sql') return item + if (!isSqlContentType(item.type)) return item if (!('content' in item)) return item const content = item.content as Record if (!('sql' in content)) return item const { sql, ...rest } = content - return { ...item, content: { ...rest, unchecked_sql: untrustedSql(sql as string) } } as T + const unchecked_sql = + item.type === 'log_sql' ? untrustedLogSql(sql as string) : untrustedSql(sql as string) + return { ...item, content: { ...rest, unchecked_sql } } as T } export function remapSqlContentFields(items: Array): Array { return items.map(remapSqlContentField) } +// Wire→domain boundary for a single SQL-editor snippet. The platform API types a +// content row's `content` as an opaque `{ [key: string]: unknown }` map (and its +// `type` as the full `'sql' | 'report' | 'log_sql'`), so turning a fetched row +// into a branded, discriminated `SnippetWithContent` needs exactly one assertion. +// Concentrating it here — the single place that already owns the sql↔unchecked_sql +// rename — keeps the query/mutation call sites free of `as unknown as` casts. +export function remapWireSnippet(row: unknown, status: SnippetStatus): SnippetWithContent { + const snippet = remapSqlContentField(row as SnippetWithContent) + return { ...snippet, status } +} + // Reverse remap: `unchecked_sql` → `sql` before sending to the API. export function unmapSqlContentField(item: T): T { - if (item.type !== 'sql') return item + if (!isSqlContentType(item.type)) return item if (!('content' in item)) return item const content = item.content as Record - if (!('unchecked_sql' in content)) return item + if (!('unchecked_sql' in content)) { + // Defensive guard against a writer that still submits the pre-rename `{ sql }` shape. + // Such a payload happens to reach the wire correctly (the API stores `sql`), but it + // means a save path was missed during the rename — surface it loudly in development. + // Crucially, we NEVER fabricate `sql: undefined` here: that would clobber the user's + // saved query text. The no-op below preserves whatever the content already holds. + if (process.env.NODE_ENV !== 'production' && 'sql' in content) { + throw new Error( + `unmapSqlContentField: ${item.type} content is missing 'unchecked_sql' but still carries a raw 'sql' field — a save path was not migrated to the branded field.` + ) + } + return item + } const { unchecked_sql, ...rest } = content return { ...item, content: { ...rest, sql: unchecked_sql } } as T } diff --git a/apps/studio/data/content/content-upsert-mutation.test.ts b/apps/studio/data/content/content-upsert-mutation.test.ts index 6c632d57e2c..be1649bd682 100644 --- a/apps/studio/data/content/content-upsert-mutation.test.ts +++ b/apps/studio/data/content/content-upsert-mutation.test.ts @@ -3,6 +3,7 @@ import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' import { upsertContent } from './content-upsert-mutation' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { addAPIMock } from '@/tests/lib/msw' const SNIPPET_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' @@ -23,6 +24,23 @@ const payload = { }, } +const LOGS_SQL = + "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10" + +const logSqlPayload = { + id: SNIPPET_ID, + type: 'log_sql' as const, + name: 'Saved logs query', + visibility: 'user' as const, + project_id: 1, + owner_id: 1, + content: { + content_id: SNIPPET_ID, + schema_version: '1', + unchecked_sql: untrustedLogSql(LOGS_SQL), + }, +} + describe('upsertContent', () => { it('remaps content.sql to unchecked_sql in the upsert response', async () => { // Self-hosted (and the management API) persist + return the SQL body under `content.sql`; @@ -61,6 +79,50 @@ describe('upsertContent', () => { expect(result?.content).not.toHaveProperty('sql') }) + it('sends log_sql content as a raw content.sql on the wire and remaps the response back', async () => { + let sentBody: { type: string; content: Record } | undefined + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + sentBody = (await request.json()) as { + type: string + content: Record + } + return HttpResponse.json({ + id: SNIPPET_ID, + type: 'log_sql', + name: logSqlPayload.name, + description: '', + favorite: false, + folder_id: null, + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + visibility: 'user', + owner_id: 1, + project_id: 1, + content: { + sql: LOGS_SQL, + content_id: SNIPPET_ID, + schema_version: '1', + }, + }) + }, + }) + + const result = await upsertContent({ projectRef: 'default', payload: logSqlPayload }) + + // Wire body must carry the plain `sql` field, never the branded `unchecked_sql`. + expect(sentBody?.type).toBe('log_sql') + expect(sentBody?.content.sql).toBe(LOGS_SQL) + expect(sentBody?.content).not.toHaveProperty('unchecked_sql') + + // Response is remapped back to the branded field for the editor. + expect(result?.status).toBe('saved') + expect(result?.content?.unchecked_sql).toEqual(untrustedLogSql(LOGS_SQL)) + expect(result?.content).not.toHaveProperty('sql') + }) + it('returns null when the API responds with no snippet', async () => { addAPIMock({ method: 'put', diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index 26640dbb066..8ce336b7684 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -2,9 +2,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import type { Content } from './content-query' -import { remapSqlContentField, unmapSqlContentField } from './content-remap' +import { remapWireSnippet, unmapSqlContentField } from './content-remap' import { contentKeys } from './keys' -import type { Snippet, SnippetWithContent } from './sql-folders-query' +import type { SnippetWithContent } from './sql-folders-query' import type { components } from '@/data/api' import { handleError, put } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -31,9 +31,9 @@ export async function upsertContent( signal, }) if (error) handleError(error) + if (!data) return null - const snippet = data as Snippet | null - return snippet === null ? null : { ...remapSqlContentField(snippet), status: 'saved' } + return remapWireSnippet(data, 'saved') } export type UpsertContentData = Awaited> diff --git a/apps/studio/data/content/sql-folders-query.ts b/apps/studio/data/content/sql-folders-query.ts index 36d31c6a505..85d2ec4b5b1 100644 --- a/apps/studio/data/content/sql-folders-query.ts +++ b/apps/studio/data/content/sql-folders-query.ts @@ -4,7 +4,12 @@ import { components } from 'api-types' import { contentKeys } from './keys' import type { SnippetStatus } from './snippet-status' import { get, handleError } from '@/data/fetchers' -import type { ResponseError, SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types' +import type { + LogSqlSnippets, + ResponseError, + SqlSnippets, + UseCustomInfiniteQueryOptions, +} from '@/types' export type SnippetFolderResponse = components['schemas']['GetUserContentFolderResponse']['data'] export type SnippetFolder = @@ -12,10 +17,25 @@ export type SnippetFolder = export type Snippet = components['schemas']['GetUserContentFolderResponse']['data']['contents'][number] -export interface SnippetWithContent extends Snippet { - content?: SqlSnippets.Content - status: SnippetStatus -} +// The SQL editor's loaded-snippet type. Discriminated on `type` so database SQL +// (`SqlSnippets.Content`, Postgres brand) and logs SQL (`LogSqlSnippets.Content`, +// logs brand) can never cross execution paths: reading `snippet.content.unchecked_sql` +// on the union yields the widened brand, forcing callers that need one specific +// brand to narrow on `type` first. The shared field names (`content_id`, +// `unchecked_sql`, `schema_version`) keep the many read sites that only need the +// SQL text compiling unchanged. +// +// `report` is included because the content endpoints' wire type carries it, but +// it deliberately has NO SQL content (`content?: never`): report bodies are +// `Dashboards.Content` and are loaded through the separate `Content` union +// (data/content/content-query.ts), never with SQL here. Modelling it as `never` +// (rather than reusing `SqlSnippets.Content`) keeps that honest while letting the +// broadly-typed rows the content/folder queries return assign without a cast. +export type SnippetWithContent = Omit & { status: SnippetStatus } & ( + | { type: 'sql'; content?: SqlSnippets.Content } + | { type: 'log_sql'; content?: LogSqlSnippets.Content } + | { type: 'report'; content?: never } + ) // Attaches the 'saved' lifecycle status to a snippet as it crosses from the // database into the app. Generic so it preserves any loaded content on the diff --git a/apps/studio/data/content/sql-snippets-query.ts b/apps/studio/data/content/sql-snippets-query.ts index afd112f9535..ef4aac6673c 100644 --- a/apps/studio/data/content/sql-snippets-query.ts +++ b/apps/studio/data/content/sql-snippets-query.ts @@ -3,9 +3,9 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { Content } from './content-query' import { remapSqlContentFields } from './content-remap' import { contentKeys } from './keys' -import { SNIPPET_PAGE_LIMIT, withSavedStatus, type Snippet } from './sql-folders-query' +import { SNIPPET_PAGE_LIMIT, withSavedStatus, type SnippetWithContent } from './sql-folders-query' import { get } from '@/data/fetchers' -import type { SqlSnippets, UseCustomInfiniteQueryOptions } from '@/types' +import type { UseCustomInfiniteQueryOptions } from '@/types' export type SqlSnippet = Extract @@ -49,9 +49,7 @@ export async function getSqlSnippets( throw error } - const snippets = remapSqlContentFields( - data.data as unknown as Array - ) + const snippets = remapSqlContentFields(data.data as unknown as SnippetWithContent[]) return { cursor: data.cursor, contents: snippets.map(withSavedStatus), diff --git a/apps/studio/data/custom-domains/custom-domains-query.ts b/apps/studio/data/custom-domains/custom-domains-query.ts index b3fe24ba2fd..f10416f63b7 100644 --- a/apps/studio/data/custom-domains/custom-domains-query.ts +++ b/apps/studio/data/custom-domains/custom-domains-query.ts @@ -110,7 +110,7 @@ export const useCustomDomainsQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const { data } = useProjectAddonsQuery({ projectRef }) + const { data } = useProjectAddonsQuery({ projectRef }, { enabled: enabled !== false }) const hasCustomDomainsAddon = !!data?.selected_addons.find((x) => x.type === 'custom_domain') return useQuery({ diff --git a/apps/studio/data/database-columns/database-column-delete-mutation.ts b/apps/studio/data/database-columns/database-column-delete-mutation.ts index dfe2745cd2f..2ad154eb8ba 100644 --- a/apps/studio/data/database-columns/database-column-delete-mutation.ts +++ b/apps/studio/data/database-columns/database-column-delete-mutation.ts @@ -8,6 +8,7 @@ import { entityTypeKeys } from '@/data/entity-types/keys' import { executeSql } from '@/data/sql/execute-sql-mutation' import { tableEditorKeys } from '@/data/table-editor/keys' import { tableRowKeys } from '@/data/table-rows/keys' +import { tableKeys } from '@/data/tables/keys' import { viewKeys } from '@/data/views/keys' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -68,6 +69,11 @@ export const useDatabaseColumnDeleteMutation = ({ queryClient.invalidateQueries({ queryKey: viewKeys.listBySchema(projectRef, [column.schema]), }), + // useTableQuery (FK selectors/formatters) has a 5min staleTime -- without this, + // it can keep serving the deleted column to any FK target cell pointing at this table. + queryClient.invalidateQueries({ + queryKey: tableKeys.retrieve(projectRef, column.table, column.schema), + }), ]) // We need to invalidate tableRowsAndCount after tableEditor diff --git a/apps/studio/data/database-cron-jobs/database-cron-job-query.ts b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts index 983f21bfdc9..9093e0ceffd 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-job-query.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-job-query.ts @@ -27,13 +27,13 @@ export async function getDatabaseCronJob({ sql: !!id ? safeSql`SELECT * FROM cron.job where jobid = ${literal(id)};` : safeSql`SELECT * FROM cron.job where jobname = ${literal(name)};`, - queryKey: ['cron-job', id], + queryKey: ['cron-job', id ?? name], }) - return result[0] + return (result[0] ?? null) as CronJob | null } -export type DatabaseCronJobData = CronJob +export type DatabaseCronJobData = CronJob | null export type DatabaseCronJobError = ResponseError export const useCronJobQuery = ( @@ -45,7 +45,7 @@ export const useCronJobQuery = ( ) => useQuery({ queryKey: databaseCronJobsKeys.job(projectRef, id ?? name), - queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id }), + queryFn: () => getDatabaseCronJob({ projectRef, connectionString, id, name }), enabled: enabled && typeof projectRef !== 'undefined' && diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts index ff052c32e11..62dbd83141e 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts @@ -48,19 +48,13 @@ export const useDatabaseCronJobCreateMutation = ({ return useMutation({ mutationFn: (vars) => createDatabaseCronJob(vars), async onSuccess(data, variables, context) { - const { projectRef, searchTerm, identifier } = variables + const { projectRef, searchTerm } = variables await Promise.all([ + queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }), queryClient.invalidateQueries({ - queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm), + queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm), }), - ...(!!identifier - ? [ - queryClient.invalidateQueries({ - queryKey: databaseCronJobsKeys.job(projectRef, identifier), - }), - ] - : []), ]) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts index 686806ecd29..191b199a8ce 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-delete-mutation.ts @@ -48,9 +48,12 @@ export const useDatabaseCronJobDeleteMutation = ({ mutationFn: (vars) => deleteDatabaseCronJob(vars), async onSuccess(data, variables, context) { const { projectRef, searchTerm } = variables - await queryClient.invalidateQueries({ - queryKey: databaseCronJobsKeys.listInfinite(projectRef, searchTerm), - }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: databaseCronJobsKeys.jobs(projectRef) }), + queryClient.invalidateQueries({ + queryKey: databaseCronJobsKeys.listInfiniteMinimal(projectRef, searchTerm), + }), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/database-cron-jobs/keys.ts b/apps/studio/data/database-cron-jobs/keys.ts index 835fd1fa5a2..720f30e3c19 100644 --- a/apps/studio/data/database-cron-jobs/keys.ts +++ b/apps/studio/data/database-cron-jobs/keys.ts @@ -4,6 +4,8 @@ export const databaseCronJobsKeys = { create: () => ['cron-jobs', 'create'] as const, delete: () => ['cron-jobs', 'delete'] as const, alter: () => ['cronjobs', 'alter'] as const, + /** Prefix of job, listInfinite, count, run, and runsInfinite — but not listInfiniteMinimal */ + jobs: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const, job: (projectRef: string | undefined, identifier: number | string | undefined) => ['projects', projectRef, 'cron-jobs', identifier] as const, listInfinite: (projectRef: string | undefined, searchTerm: string | undefined) => diff --git a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts index b7dc3453f3d..d912f70f2ba 100644 --- a/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts +++ b/apps/studio/data/database-cron-jobs/schedule-clean-up-mutation.ts @@ -1,8 +1,8 @@ -import { getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta' -import { useMutation } from '@tanstack/react-query' +import { CRON_CLEANUP_JOB_NAME, getScheduleDeleteCronJobRunDetailsSql } from '@supabase/pg-meta' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { getScheduleDeleteCronJobRunDetailsKey } from './keys' +import { databaseCronJobsKeys, getScheduleDeleteCronJobRunDetailsKey } from './keys' import { executeSql } from '@/data/sql/execute-sql-mutation' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -43,6 +43,8 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({ >, 'mutationFn' > = {}) => { + const queryClient = useQueryClient() + return useMutation< ScheduleCronJobRunDetailsCleanupData, ResponseError, @@ -50,6 +52,18 @@ export const useScheduleCronJobRunDetailsCleanupMutation = ({ >({ mutationFn: (vars) => scheduleCronJobRunDetailsCleanup(vars), async onSuccess(data, variables, context) { + // Deliberately narrower than the jobs() prefix: invalidating the jobs list here would + // refetch it while it sits in the cost-threshold error state, which briefly resets it + // to pending and unmounts the overflow notice (closing its dialog mid-flow). Both + // callers refetch their own grid via callbacks instead. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: databaseCronJobsKeys.job(variables.projectRef, CRON_CLEANUP_JOB_NAME), + }), + queryClient.invalidateQueries({ + queryKey: databaseCronJobsKeys.count(variables.projectRef), + }), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/enumerated-types/enumerated-types-query.ts b/apps/studio/data/enumerated-types/enumerated-types-query.ts index b494ac83e3f..71d2b43e968 100644 --- a/apps/studio/data/enumerated-types/enumerated-types-query.ts +++ b/apps/studio/data/enumerated-types/enumerated-types-query.ts @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { executeSql } from '../sql/execute-sql-mutation' import { enumeratedTypesKeys } from './keys' import type { components } from '@/data/api' +import { isScopedIntrospection, scopedIntrospectionReady } from '@/data/scoped-introspection' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type EnumeratedTypesVariables = { @@ -20,7 +21,9 @@ export async function getEnumeratedTypes( ) { if (!projectRef) throw new Error('projectRef is required') - const { sql } = pgMeta.types.list({ includedSchemas: schemas }) + // Cold-load race guard -- see the module comment on scoped-introspection.ts. + await scopedIntrospectionReady() + const { sql } = pgMeta.types.list({ includedSchemas: schemas, scoped: isScopedIntrospection() }) const { result } = await executeSql( { projectRef, diff --git a/apps/studio/data/logs/execute-logs-sql-mutation.test.ts b/apps/studio/data/logs/execute-logs-sql-mutation.test.ts new file mode 100644 index 00000000000..6505ba9781e --- /dev/null +++ b/apps/studio/data/logs/execute-logs-sql-mutation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { mapLogsError } from './execute-logs-sql-mutation' + +describe('mapLogsError', () => { + it('returns undefined when there is no error', () => { + expect(mapLogsError(undefined)).toBeUndefined() + expect(mapLogsError(null)).toBeUndefined() + }) + + it('extracts the top-level message from the structured backend error body', () => { + const structured = { + code: 400, + errors: [{ domain: 'global', message: 'nested detail', reason: 'invalidQuery' }], + message: 'Syntax error near LIMIT', + status: 'INVALID_ARGUMENT', + } + expect(mapLogsError(structured)).toEqual({ message: 'Syntax error near LIMIT' }) + }) + + it('falls back to the first nested errors[].message when top-level message is missing', () => { + const structured = { + code: 400, + errors: [ + { domain: 'global', message: '', reason: 'invalidQuery' }, + { domain: 'global', message: 'first usable detail', reason: 'invalidQuery' }, + ], + status: 'INVALID_ARGUMENT', + } + expect(mapLogsError(structured)).toEqual({ message: 'first usable detail' }) + }) + + it('does not throw and falls back when errors is a malformed non-array value', () => { + expect(mapLogsError({ code: 400, errors: {}, status: 'INVALID_ARGUMENT' })).toEqual({ + message: 'An unexpected error occurred while running the logs query.', + }) + expect(mapLogsError({ message: 'top-level wins', errors: 'nope' })).toEqual({ + message: 'top-level wins', + }) + }) + + it('normalizes a plain string error', () => { + expect(mapLogsError('boom')).toEqual({ message: 'boom' }) + }) + + it('returns a generic fallback when an error is present but carries no usable text', () => { + expect(mapLogsError({ code: 500, errors: [], status: 'INTERNAL' })).toEqual({ + message: 'An unexpected error occurred while running the logs query.', + }) + expect(mapLogsError('')).toEqual({ + message: 'An unexpected error occurred while running the logs query.', + }) + expect(mapLogsError({ message: '' })).toEqual({ + message: 'An unexpected error occurred while running the logs query.', + }) + }) +}) diff --git a/apps/studio/data/logs/execute-logs-sql-mutation.ts b/apps/studio/data/logs/execute-logs-sql-mutation.ts new file mode 100644 index 00000000000..cb241ea64df --- /dev/null +++ b/apps/studio/data/logs/execute-logs-sql-mutation.ts @@ -0,0 +1,176 @@ +/** + * Execution data layer for user-authored logs SQL run from the SQL editor. + * + * This is the logs-path analog of `data/sql/execute-sql-mutation.ts`. It wraps + * the analytics wire boundary (`executeAnalyticsSql`) and: + * - accepts only a `SafeLogSqlFragment` (promoted from `UntrustedLogSqlFragment` + * at an explicit user run gesture — see `safe-analytics-sql.ts`), + * - attaches the resolved time range as request params + * (`iso_timestamp_start`/`iso_timestamp_end`), never spliced into the SQL, + * - normalizes the response to `{ rows, error? }`. + * + * There are two ways a logs query can fail, and the layer keeps them distinct + * only at the pure-function level: + * - Transport failures (non-2xx) throw out of `executeAnalyticsSql` via + * `handleError`. + * - The analytics backend also returns a *200 body* carrying a structured error + * (`{ code, errors, message, status }`) — the same shape `useLogsQuery` + * reads. `mapLogsError` normalizes that into the `{ message }` shape the SQL + * editor's result pane renders, returned in `executeLogsSql`'s `error` field. + * + * `useExecuteLogsSqlMutation` then collapses both into React Query's single + * `onError` path so consumers handle one normalized error shape in one place. + */ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { executeAnalyticsSql, type AnalyticsSqlEndpoint } from './execute-analytics-sql' +import type { SafeLogSqlFragment } from './safe-analytics-sql' +import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange' +import type { UseCustomMutationOptions } from '@/types' + +/** + * Normalized logs query error in the shape the SQL editor result pane reads + * (`{ message }`). Produced by `mapLogsError` from either a transport error or + * the analytics backend's structured 200-body error. + */ +export interface LogsQueryError { + message: string +} + +/** + * Structured error the analytics backend returns inside a 200 response body. + * Mirrors `LFResponse['error']` from the Logs types; every field is optional + * here because the value crosses the wire and must be treated defensively. + */ +interface RawLogsResponseError { + code?: number + errors?: Array<{ domain?: string; message?: string; reason?: string }> + message?: string + status?: string +} + +const UNKNOWN_LOGS_ERROR_MESSAGE = 'An unexpected error occurred while running the logs query.' + +/** + * Normalizes an analytics backend error into the `{ message }` shape the SQL + * editor result pane renders. Follows `useLogsQuery`'s extraction: prefer the + * top-level `message`, then the first nested `errors[].message`. Returns + * `undefined` when there is no error, and a generic fallback message when an + * error is present but carries no usable text. + */ +export function mapLogsError(error: unknown): LogsQueryError | undefined { + if (error === undefined || error === null) return undefined + + if (typeof error === 'string') { + return { message: error.length > 0 ? error : UNKNOWN_LOGS_ERROR_MESSAGE } + } + + if (typeof error === 'object') { + const structured = error as RawLogsResponseError + const nestedMessage = Array.isArray(structured.errors) + ? structured.errors.find( + (entry) => typeof entry?.message === 'string' && entry.message.length > 0 + )?.message + : undefined + const message = structured.message || nestedMessage + if (typeof message === 'string' && message.length > 0) { + return { message } + } + } + + return { message: UNKNOWN_LOGS_ERROR_MESSAGE } +} + +export interface ExecuteLogsSqlVariables { + projectRef: string + /** Must carry the `SafeLogSqlFragment` brand — promote at the run gesture. */ + sql: SafeLogSqlFragment + /** Resolved (absolute) time range; relative ranges re-resolve at each run. */ + range: ResolvedLogDateRange + /** + * Analytics endpoint to run against. The SQL editor pins this to the OTEL + * (ClickHouse) endpoint; kept as a param so callers stay explicit. + */ + endpoint: AnalyticsSqlEndpoint + signal?: AbortSignal +} + +export interface ExecuteLogsSqlResult { + rows: unknown[] + /** Present when the backend returned a 200 body carrying a structured error. */ + error?: LogsQueryError +} + +/** + * Runs a logs SQL query against the analytics backend and normalizes the + * response. Transport failures throw; a structured 200-body error is returned + * in `error` alongside empty `rows`. + * + * @throws {ResponseError} on transport failure (via `executeAnalyticsSql`). + */ +export async function executeLogsSql({ + projectRef, + sql, + range, + endpoint, + signal, +}: ExecuteLogsSqlVariables): Promise { + const data = await executeAnalyticsSql({ + projectRef, + endpoint, + sql, + iso_timestamp_start: range.from, + iso_timestamp_end: range.to, + key: 'sql-editor', + signal, + }) + + const body = (data ?? {}) as { result?: unknown[]; error?: unknown } + const error = mapLogsError(body.error) + + return { rows: body.result ?? [], ...(error ? { error } : {}) } +} + +/** + * React Query mutation wrapping `executeLogsSql`. + * + * Collapses both failure modes into React Query's single `onError` path: a + * transport failure (thrown by `executeAnalyticsSql`) and the analytics + * backend's structured 200-body error are both normalized to `LogsQueryError` + * before they reach `onError`, so callers only ever handle one error shape in + * one place. `onSuccess` therefore always receives a genuinely successful + * result. + */ +export const useExecuteLogsSqlMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + async mutationFn(variables) { + let result: ExecuteLogsSqlResult + try { + result = await executeLogsSql(variables) + } catch (error) { + // Transport failure — normalize to the same shape as a query error. + throw mapLogsError(error) ?? { message: UNKNOWN_LOGS_ERROR_MESSAGE } + } + // 200-body query error — route through onError alongside transport errors. + if (result.error) throw result.error + return result + }, + onSuccess, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(error.message) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/logs/safe-analytics-sql.test.ts b/apps/studio/data/logs/safe-analytics-sql.test.ts index 376d721e03d..dd6a92fcb92 100644 --- a/apps/studio/data/logs/safe-analytics-sql.test.ts +++ b/apps/studio/data/logs/safe-analytics-sql.test.ts @@ -1,7 +1,19 @@ +import { + acceptUntrustedSql as pgAcceptUntrustedSql, + safeSql as pgSafeSql, + untrustedSql as pgUntrustedSql, +} from '@supabase/pg-meta' import { describe, expect, it } from 'vitest' import { executeAnalyticsSql } from './execute-analytics-sql' -import { analyticsLiteral, keyword, quotedIdent, safeSql } from './safe-analytics-sql' +import { + acceptUntrustedLogsSql, + analyticsLiteral, + keyword, + quotedIdent, + safeSql, + untrustedLogSql, +} from './safe-analytics-sql' describe('keyword', () => { it('returns the matching SafeLogSqlFragment from the allow-list', () => { @@ -101,6 +113,44 @@ describe('executeAnalyticsSql — compile-time boundary', () => { }) }) +describe('untrusted logs SQL brands', () => { + it('untrustedLogSql preserves the raw text', () => { + expect(untrustedLogSql('SELECT 1 FROM logs')).toBe('SELECT 1 FROM logs') + }) + + it('acceptUntrustedLogsSql promotes untrusted text to a runnable fragment unchanged', () => { + const untrusted = untrustedLogSql('SELECT 1 FROM logs') + const safe = acceptUntrustedLogsSql(untrusted) + expect(safe).toBe('SELECT 1 FROM logs') + }) + + it('a promoted logs fragment composes via safeSql', () => { + const safe = acceptUntrustedLogsSql(untrustedLogSql('SELECT 1')) + expect(safeSql`${safe}`).toBe('SELECT 1') + }) + + it('rejects an unbranded string at the acceptUntrustedLogsSql boundary', () => { + const plain: string = 'SELECT 1' + // @ts-expect-error — only UntrustedLogSqlFragment may be promoted + const _check = () => acceptUntrustedLogsSql(plain) + expect(_check).toBeDefined() + }) + + it('rejects Postgres-branded fragments at the logs boundary (brands are disjoint)', () => { + // The logs boundary must not accept Postgres SQL, whether it is still untrusted or + // already promoted. + const pgUntrusted = pgUntrustedSql('SELECT 1') + const pgSafe = pgAcceptUntrustedSql(pgSafeSql`SELECT 1`) + const _check = () => [ + // @ts-expect-error — pg untrusted brand is not assignable to UntrustedLogSqlFragment + acceptUntrustedLogsSql(pgUntrusted), + // @ts-expect-error — pg safe brand is not assignable to UntrustedLogSqlFragment + acceptUntrustedLogsSql(pgSafe), + ] + expect(_check).toBeDefined() + }) +}) + describe('safeSql template tag — existing helpers still compose', () => { it('analyticsLiteral output is composable via safeSql', () => { const val = analyticsLiteral('hello') diff --git a/apps/studio/data/logs/safe-analytics-sql.ts b/apps/studio/data/logs/safe-analytics-sql.ts index 7f2b24787f2..e8f401b774f 100644 --- a/apps/studio/data/logs/safe-analytics-sql.ts +++ b/apps/studio/data/logs/safe-analytics-sql.ts @@ -51,6 +51,41 @@ */ export type SafeLogSqlFragment = string & { readonly __safeLogSqlFragmentBrand: never } +/** + * User-authored logs SQL that has NOT yet been promoted to a runnable + * `SafeLogSqlFragment`. Mirrors pg-meta's `UntrustedSqlFragment`, but is an + * intentionally distinct brand so that Postgres SQL and logs SQL can never + * cross paths: neither can be promoted through the other's boundary, and + * neither can be composed into the other's queries. + * + * Safe to display and to store as the editor's working text; must never be + * executed without an explicit user run gesture. Promote via + * `acceptUntrustedLogsSql` — only inside a user-action event handler. + */ +export type UntrustedLogSqlFragment = string & { readonly __untrustedLogSqlBrand: never } + +/** + * Marks a raw string as user-authored logs SQL awaiting an explicit run + * gesture. Use at the editor boundary where the user's logs SQL text enters + * the type system; the value stays untrusted until `acceptUntrustedLogsSql` + * promotes it. + */ +export function untrustedLogSql(sql: string): UntrustedLogSqlFragment { + return sql as UntrustedLogSqlFragment +} + +/** + * SECURITY BOUNDARY — promotes user-authored logs SQL to a runnable + * `SafeLogSqlFragment`. + * + * ONLY call from an event handler tied to a deliberate user action (Run button + * onClick, Cmd+Enter keydown). Never call from render, useEffect, or any path + * that runs without a user gesture. + */ +export function acceptUntrustedLogsSql(sql: UntrustedLogSqlFragment): SafeLogSqlFragment { + return sql as unknown as SafeLogSqlFragment +} + type LogSqlFragmentSeparator = | ',' | ', ' diff --git a/apps/studio/data/privileges/table-api-access-query.ts b/apps/studio/data/privileges/table-api-access-query.ts index 43e289f68b9..84a24b72e5d 100644 --- a/apps/studio/data/privileges/table-api-access-query.ts +++ b/apps/studio/data/privileges/table-api-access-query.ts @@ -150,7 +150,7 @@ export const useTableApiAccessQuery = ( const enablePrivilegesQuery = enabled && hasTables const privilegeStatus = useTablePrivilegesQuery( - { projectRef, connectionString }, + { projectRef, connectionString, includedSchemas: [schemaName] }, { enabled: enablePrivilegesQuery, ...options } ) diff --git a/apps/studio/data/privileges/table-privileges-query.ts b/apps/studio/data/privileges/table-privileges-query.ts index 4c9c6bf7f14..a09611363b2 100644 --- a/apps/studio/data/privileges/table-privileges-query.ts +++ b/apps/studio/data/privileges/table-privileges-query.ts @@ -3,6 +3,7 @@ import { QueryClient, useQuery } from '@tanstack/react-query' import { z } from 'zod' import { privilegeKeys } from './keys' +import { isScopedIntrospection, scopedIntrospectionReady } from '@/data/scoped-introspection' import { executeSql } from '@/data/sql/execute-sql-mutation' import { ResponseError, UseCustomQueryOptions } from '@/types' @@ -22,7 +23,12 @@ async function getTablePrivileges( { projectRef, connectionString, includedSchemas }: TablePrivilegesVariables, signal?: AbortSignal ) { - const sql = pgMeta.tablePrivileges.list({ includedSchemas }).sql + // Cold-load race guard -- see the module comment on scoped-introspection.ts. + await scopedIntrospectionReady() + const sql = pgMeta.tablePrivileges.list({ + includedSchemas, + scoped: isScopedIntrospection(), + }).sql const queryKey = ['table-privileges', includedSchemas?.join(',')] const { result } = await executeSql({ projectRef, connectionString, sql, queryKey }, signal) diff --git a/apps/studio/data/projects/project-pause-status-override.ts b/apps/studio/data/projects/project-pause-status-override.ts index 0277594c14f..9e617afe208 100644 --- a/apps/studio/data/projects/project-pause-status-override.ts +++ b/apps/studio/data/projects/project-pause-status-override.ts @@ -52,7 +52,7 @@ export function buildPauseStatus(override: PauseStateOverride): PauseStatusRespo can_restore: isRestorable, last_paused_on: null, latest_downloadable_backup_id: null, - max_days_till_restore_disabled: 90, - remaining_days_till_restore_disabled: isRestorable ? 78 : 0, + max_days_till_restore_disabled: 400, + remaining_days_till_restore_disabled: isRestorable ? 350 : 0, } } diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts index 66004344a18..5264a361700 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from 'vitest' -import { buildDucklakeApiConfig } from './create-destination-pipeline-mutation' +import { + buildDucklakeApiConfig, + buildPipelineApiConfig, +} from './create-destination-pipeline-mutation' + +describe('buildPipelineApiConfig', () => { + it('maps selective initial-copy configuration to the ETL API shape', () => { + expect( + buildPipelineApiConfig({ + publicationName: 'analytics', + batch: { maxFillMs: 500, maxBytes: 8_388_608, memoryBudgetRatio: 0.2 }, + maxTableSyncWorkers: 4, + maxCopyConnectionsPerTable: 2, + invalidatedSlotBehavior: 'recreate', + tableSyncCopy: { type: 'skip_tables', table_ids: [101, 202] }, + }) + ).toEqual({ + publication_name: 'analytics', + batch: { max_fill_ms: 500, max_bytes: 8_388_608, memory_budget_ratio: 0.2 }, + max_table_sync_workers: 4, + max_copy_connections_per_table: 2, + invalidated_slot_behavior: 'recreate', + table_sync_copy: { type: 'skip_tables', table_ids: [101, 202] }, + }) + }) +}) describe('buildDucklakeApiConfig', () => { it('maps a "Use Supabase" config with catalog-level pool size + metadata schema', () => { diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index 30db38dc6ca..4ada0784bd8 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -4,9 +4,12 @@ import { toast } from 'sonner' import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' +import type { TableSyncCopyConfig } from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' +export type { TableSyncCopyConfig } from '@/components/interfaces/Database/Replication/TableSyncCopy.utils' + export type DestinationConfig = | { bigQuery: BigQueryDestinationConfig } | { iceberg: IcebergDestinationConfig } @@ -139,20 +142,47 @@ export type ClickHouseDestinationConfig = { export type BatchConfig = { maxFillMs?: number + maxBytes?: number + memoryBudgetRatio?: number } +export type PipelineConfig = { + publicationName: string + batch?: BatchConfig + maxTableSyncWorkers?: number + maxCopyConnectionsPerTable?: number + invalidatedSlotBehavior?: 'error' | 'recreate' + tableSyncCopy: TableSyncCopyConfig +} + +export const buildPipelineApiConfig = ({ + publicationName, + batch, + maxTableSyncWorkers, + maxCopyConnectionsPerTable, + invalidatedSlotBehavior, + tableSyncCopy, +}: PipelineConfig) => ({ + publication_name: publicationName, + max_table_sync_workers: maxTableSyncWorkers, + max_copy_connections_per_table: maxCopyConnectionsPerTable, + invalidated_slot_behavior: invalidatedSlotBehavior, + table_sync_copy: tableSyncCopy, + batch: batch + ? { + max_fill_ms: batch.maxFillMs, + max_bytes: batch.maxBytes, + memory_budget_ratio: batch.memoryBudgetRatio, + } + : undefined, +}) + export type CreateDestinationPipelineParams = { projectRef: string destinationName: string destinationConfig: DestinationConfig sourceId: number - pipelineConfig: { - publicationName: string - batch?: BatchConfig - maxTableSyncWorkers?: number - maxCopyConnectionsPerTable?: number - invalidatedSlotBehavior?: 'error' | 'recreate' - } + pipelineConfig: PipelineConfig } async function createDestinationPipeline( @@ -160,13 +190,7 @@ async function createDestinationPipeline( projectRef, destinationName: destinationName, destinationConfig, - pipelineConfig: { - publicationName, - batch, - maxTableSyncWorkers, - maxCopyConnectionsPerTable, - invalidatedSlotBehavior, - }, + pipelineConfig, sourceId, }: CreateDestinationPipelineParams, signal?: AbortSignal @@ -250,13 +274,7 @@ async function createDestinationPipeline( ) } - const pipeline_config = { - publication_name: publicationName, - max_table_sync_workers: maxTableSyncWorkers, - max_copy_connections_per_table: maxCopyConnectionsPerTable, - invalidated_slot_behavior: invalidatedSlotBehavior, - batch: batch ? { max_fill_ms: batch.maxFillMs } : undefined, - } + const pipeline_config = buildPipelineApiConfig(pipelineConfig) const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', { params: { path: { ref: projectRef } }, diff --git a/apps/studio/data/replication/sources-query.ts b/apps/studio/data/replication/sources-query.ts index 1be9a53a848..c10c1344a1e 100644 --- a/apps/studio/data/replication/sources-query.ts +++ b/apps/studio/data/replication/sources-query.ts @@ -42,3 +42,8 @@ export const useReplicationSourcesQuery = ( retry: checkReplicationFeatureFlagRetry, ...options, }) + +export const useReplicationSourceId = ({ projectRef }: { projectRef?: string }) => { + const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) + return sourcesData?.sources.find((s) => s.name === projectRef)?.id +} diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index 587baa777e4..8356324f701 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -3,9 +3,10 @@ import type { components } from 'api-types' import { toast } from 'sonner' import { - BatchConfig, buildDucklakeApiConfig, + buildPipelineApiConfig, DestinationConfig, + PipelineConfig, } from './create-destination-pipeline-mutation' import { optionalSecret } from './destination-secret-utils' import { replicationKeys } from './keys' @@ -19,13 +20,7 @@ export type UpdateDestinationPipelineParams = { destinationName: string destinationConfig: DestinationConfig sourceId: number - pipelineConfig: { - publicationName: string - batch?: BatchConfig - maxTableSyncWorkers?: number - maxCopyConnectionsPerTable?: number - invalidatedSlotBehavior?: 'error' | 'recreate' - } + pipelineConfig: PipelineConfig } type UpdateDestinationPipelineBody = @@ -40,13 +35,7 @@ async function updateDestinationPipeline( projectRef, destinationName: destinationName, destinationConfig, - pipelineConfig: { - publicationName, - batch, - maxTableSyncWorkers, - maxCopyConnectionsPerTable, - invalidatedSlotBehavior, - }, + pipelineConfig, sourceId, }: UpdateDestinationPipelineParams, signal?: AbortSignal @@ -126,13 +115,7 @@ async function updateDestinationPipeline( ) } - const pipeline_config = { - publication_name: publicationName, - max_table_sync_workers: maxTableSyncWorkers, - max_copy_connections_per_table: maxCopyConnectionsPerTable, - invalidated_slot_behavior: invalidatedSlotBehavior, - batch: batch ? { max_fill_ms: batch.maxFillMs } : undefined, - } + const pipeline_config = buildPipelineApiConfig(pipelineConfig) const { data, error } = await post( '/platform/replication/{ref}/destinations-pipelines/{destination_id}/{pipeline_id}', diff --git a/apps/studio/data/replication/validate-destination-mutation.ts b/apps/studio/data/replication/validate-destination-mutation.ts index 327d108cea1..3e263ac79b3 100644 --- a/apps/studio/data/replication/validate-destination-mutation.ts +++ b/apps/studio/data/replication/validate-destination-mutation.ts @@ -1,7 +1,11 @@ import { useMutation } from '@tanstack/react-query' import type { components } from 'api-types' -import { buildDucklakeApiConfig, DestinationConfig } from './create-destination-pipeline-mutation' +import { + buildDucklakeApiConfig, + DestinationConfig, + TableSyncCopyConfig, +} from './create-destination-pipeline-mutation' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -14,6 +18,7 @@ type ValidateDestinationParams = { maxTableSyncWorkers?: number maxCopyConnectionsPerTable?: number invalidatedSlotBehavior?: 'error' | 'recreate' + tableSyncCopy?: TableSyncCopyConfig } type ValidateDestinationResponse = components['schemas']['ValidateDestinationResponse'] @@ -29,6 +34,7 @@ async function validateDestination( maxTableSyncWorkers, maxCopyConnectionsPerTable, invalidatedSlotBehavior, + tableSyncCopy, }: ValidateDestinationParams, signal?: AbortSignal ): Promise { @@ -120,6 +126,7 @@ async function validateDestination( max_table_sync_workers: maxTableSyncWorkers, max_copy_connections_per_table: maxCopyConnectionsPerTable, invalidated_slot_behavior: invalidatedSlotBehavior, + table_sync_copy: tableSyncCopy, batch: batchConfig, } diff --git a/apps/studio/data/replication/validate-pipeline-mutation.ts b/apps/studio/data/replication/validate-pipeline-mutation.ts index 1c90216766b..81fc461046a 100644 --- a/apps/studio/data/replication/validate-pipeline-mutation.ts +++ b/apps/studio/data/replication/validate-pipeline-mutation.ts @@ -1,6 +1,7 @@ import { useMutation } from '@tanstack/react-query' import { components } from 'api-types' +import type { TableSyncCopyConfig } from './create-destination-pipeline-mutation' import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomMutationOptions } from '@/types' @@ -12,6 +13,7 @@ type ValidatePipelineParams = { maxTableSyncWorkers?: number maxCopyConnectionsPerTable?: number invalidatedSlotBehavior?: 'error' | 'recreate' + tableSyncCopy: TableSyncCopyConfig } type ValidatePipelineResponse = components['schemas']['ValidatePipelineResponse'] @@ -24,6 +26,7 @@ async function validatePipeline( maxTableSyncWorkers, maxCopyConnectionsPerTable, invalidatedSlotBehavior, + tableSyncCopy, }: ValidatePipelineParams, signal?: AbortSignal ): Promise { @@ -37,6 +40,7 @@ async function validatePipeline( max_table_sync_workers: maxTableSyncWorkers, max_copy_connections_per_table: maxCopyConnectionsPerTable, invalidated_slot_behavior: invalidatedSlotBehavior, + table_sync_copy: tableSyncCopy, batch: batchConfig, } diff --git a/apps/studio/data/scoped-introspection.test.tsx b/apps/studio/data/scoped-introspection.test.tsx new file mode 100644 index 00000000000..89081efeb52 --- /dev/null +++ b/apps/studio/data/scoped-introspection.test.tsx @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react' +import { FeatureFlagContext } from 'common' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { PG_META_SCOPED_INTROSPECTION_FLAG } from '@/data/table-editor/table-editor-query' + +const { mockIsPlatform } = vi.hoisted(() => ({ mockIsPlatform: { value: true } })) + +vi.mock('@/lib/constants', async () => { + const actual = await vi.importActual>('@/lib/constants') + return { + ...actual, + get IS_PLATFORM() { + return mockIsPlatform.value + }, + } +}) + +// Mirrors the real provider: `configcat` and `hasLoaded` flip together once +// `processFlags()` resolves -- an empty store (the pre-load default) is what +// makes `useFlag` read `false` before that. +function flagProvider(hasLoaded: boolean, flagValue = true) { + const configcat: Record = hasLoaded + ? { [PG_META_SCOPED_INTROSPECTION_FLAG]: flagValue } + : {} + return function FlagProviderWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) + } +} + +describe('scoped-introspection', () => { + // Module-level singleton state -- start every test from a fresh module + // instance so the ready-promise isn't already settled from a prior test. + let mod: typeof import('./scoped-introspection') + + beforeEach(async () => { + mockIsPlatform.value = true + vi.resetModules() + mod = await import('./scoped-introspection') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('isScopedIntrospection defaults to false', () => { + expect(mod.isScopedIntrospection()).toBe(false) + }) + + test('setScopedIntrospection updates the accessor', () => { + mod.setScopedIntrospection(true) + expect(mod.isScopedIntrospection()).toBe(true) + }) + + test('scopedIntrospectionReady stays pending until a loaded flag store hydrates it', async () => { + let settled = false + mod.scopedIntrospectionReady().then(() => { + settled = true + }) + + const { unmount } = renderHook(() => mod.useSyncScopedIntrospection(), { + wrapper: flagProvider(false), + }) + await Promise.resolve() + expect(settled).toBe(false) + expect(mod.isScopedIntrospection()).toBe(false) // useFlag reads false pre-load + unmount() + + renderHook(() => mod.useSyncScopedIntrospection(), { wrapper: flagProvider(true, true) }) + await Promise.resolve() + + expect(settled).toBe(true) + expect(mod.isScopedIntrospection()).toBe(true) + }) + + test('resolves immediately when self-hosted (flags disabled)', async () => { + mockIsPlatform.value = false + + let settled = false + mod.scopedIntrospectionReady().then(() => { + settled = true + }) + + renderHook(() => mod.useSyncScopedIntrospection(), { wrapper: flagProvider(false, false) }) + await Promise.resolve() + + expect(settled).toBe(true) + }) + + test('falls back to resolving 5s after the first scopedIntrospectionReady() call if hydration never happens', async () => { + vi.useFakeTimers() + + let settled = false + mod.scopedIntrospectionReady().then(() => { + settled = true + }) + + vi.advanceTimersByTime(4_999) + await Promise.resolve() + expect(settled).toBe(false) + + vi.advanceTimersByTime(1) + await Promise.resolve() + expect(settled).toBe(true) + // Hydration never ran -- accessor stays at its default. + expect(mod.isScopedIntrospection()).toBe(false) + }) +}) diff --git a/apps/studio/data/scoped-introspection.ts b/apps/studio/data/scoped-introspection.ts new file mode 100644 index 00000000000..00d801a089f --- /dev/null +++ b/apps/studio/data/scoped-introspection.ts @@ -0,0 +1,72 @@ +import { useFeatureFlags, useFlag } from 'common' +import { useEffect } from 'react' + +import { PG_META_SCOPED_INTROSPECTION_FLAG } from '@/data/table-editor/table-editor-query' +import { IS_PLATFORM } from '@/lib/constants' + +/** + * Imperative accessor for the `pgMetaScopedIntrospection` flag, read by query + * fns that opt into scoped pg-meta SQL without threading `scoped` through + * their React Query key (accepted tradeoff: a mid-session flag flip can serve + * stale-keyed cached data until the query's next natural refetch). Hydrated + * by `useSyncScopedIntrospection`, called from `DefaultLayout` -- the first + * component both of Studio's root trees (pages/_app.tsx, routes/__root.tsx) + * render inside `FeatureFlagProviderWithOrgContext`. + */ +let scopedIntrospection = false + +export const setScopedIntrospection = (value: boolean) => { + scopedIntrospection = value +} + +export const isScopedIntrospection = () => scopedIntrospection + +const READY_TIMEOUT_MS = 5_000 + +let isReadySettled = false +let isTimeoutArmed = false +let resolveReady: () => void = () => {} +const readyPromise = new Promise((resolve) => { + resolveReady = resolve +}) + +const markScopedIntrospectionReady = () => { + if (isReadySettled) return + isReadySettled = true + resolveReady() +} + +/** + * Resolves once `useSyncScopedIntrospection` has hydrated the accessor from a + * loaded flag store (or immediately if self-hosted) -- never before, by + * construction (see that hook). Arms a 5s fallback timer on its *first call* + * rather than at module load, so a query firing long after boot isn't bound + * by a timer that already expired before hydration got a chance to run. + */ +export const scopedIntrospectionReady = (): Promise => { + if (!isTimeoutArmed) { + isTimeoutArmed = true + setTimeout(markScopedIntrospectionReady, READY_TIMEOUT_MS) + } + return readyPromise +} + +/** + * Syncs the flag's current value into `isScopedIntrospection()`. Call once + * near the root, under `FeatureFlagProviderWithOrgContext` (currently + * `DefaultLayout`). + */ +export const useSyncScopedIntrospection = () => { + const { hasLoaded } = useFeatureFlags() + const scoped = !!useFlag(PG_META_SCOPED_INTROSPECTION_FLAG) + + useEffect(() => { + setScopedIntrospection(scoped) + + // Self-hosted disables the flag provider (enabled={IS_PLATFORM}), so + // hasLoaded never flips true -- there's nothing to wait for there. + if (hasLoaded || !IS_PLATFORM) { + markScopedIntrospectionReady() + } + }, [scoped, hasLoaded]) +} diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index 636d4ef1406..d002da4aef5 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -74,6 +74,7 @@ export async function getTableRowsCount( filters: formattedFilters, enforceExactCount, isReadOnlyContext, + scoped, }), roleImpersonationState ) @@ -111,7 +112,7 @@ export const useTableRowsCountQuery = ( identifier: readReplicaIdentifier, type, } = useConnectionStringForReadOps() - const { can: canSQLAdminWrite } = useAsyncCheckPermissions( + const { can: canSQLAdminWrite, isLoading: isPermissionsLoading } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) @@ -141,7 +142,15 @@ export const useTableRowsCountQuery = ( enabled && typeof projectRef !== 'undefined' && typeof tableId !== 'undefined' && - (!IS_PLATFORM || typeof connectionString !== 'undefined'), + (!IS_PLATFORM || typeof connectionString !== 'undefined') && + // isReadOnlyContext resolves to `type === 'replica' || !canSQLAdminWrite`: for + // read replicas it's already known synchronously, but otherwise it depends on + // canSQLAdminWrite, which starts out `false` while permissions are loading. + // Firing while that's still in flight would cache a transient + // isReadOnlyContext:true (and, on a never-analyzed table, a scoped + // count:-1/is_estimate:true) for what may actually be a writable user. Wait + // for the permission check to settle before firing in that case. + (type === 'replica' || !isPermissionsLoading), ...options, }) } diff --git a/apps/studio/data/tables/table-retrieve-query.ts b/apps/studio/data/tables/table-retrieve-query.ts index 7df19581b23..55e855238f3 100644 --- a/apps/studio/data/tables/table-retrieve-query.ts +++ b/apps/studio/data/tables/table-retrieve-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { tableKeys } from './keys' import { getQueryClient } from '@/data/query-client' +import { isScopedIntrospection, scopedIntrospectionReady } from '@/data/scoped-introspection' import { executeSql } from '@/data/sql/execute-sql-mutation' import type { SafePostgresTable } from '@/lib/postgres-types' import type { ResponseError, UseCustomQueryOptions } from '@/types' @@ -18,7 +19,9 @@ export async function getTable( { projectRef, connectionString, name, schema }: TablesVariables, signal?: AbortSignal ): Promise { - const { sql, zod } = pgMeta.tables.retrieve({ name, schema }) + // Cold-load race guard -- see the module comment on scoped-introspection.ts. + await scopedIntrospectionReady() + const { sql, zod } = pgMeta.tables.retrieve({ name, schema, scoped: isScopedIntrospection() }) const { result } = await executeSql( { @@ -48,6 +51,9 @@ export const useTableQuery = ( queryKey: tableKeys.retrieve(projectRef, name, schema), queryFn: ({ signal }) => getTable({ projectRef, connectionString, name, schema }, signal), enabled: enabled && typeof projectRef !== 'undefined', + refetchOnWindowFocus: false, + retryOnMount: false, + staleTime: 5 * 60 * 1000, ...options, }) } diff --git a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts index b82728b4227..38ef4892467 100644 --- a/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts +++ b/apps/studio/hooks/misc/__tests__/useEnabledIdentityProviders.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useEnabledIdentityProviders } from '../useEnabledIdentityProviders' import { @@ -9,6 +9,7 @@ import { const mockIsFeatureEnabled = vi.hoisted(() => vi.fn()) const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) +const mockUseFlag = vi.hoisted(() => vi.fn()) vi.mock('../useIsFeatureEnabled', () => ({ useIsFeatureEnabled: mockIsFeatureEnabled, @@ -18,13 +19,22 @@ vi.mock('../useLocalStorage', () => ({ useLocalStorageQuery: mockUseLocalStorageQuery, })) +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useFlag: mockUseFlag, +})) + describe('useEnabledIdentityProviders', () => { + beforeEach(() => { + mockUseFlag.mockReset() + }) + it('returns every provider when all flags are enabled', () => { mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: true, - dashboardAuthSignInWithChatgpt: true, }) mockUseLocalStorageQuery.mockReturnValue([true]) + mockUseFlag.mockReturnValue(true) const { result } = renderHook(() => useEnabledIdentityProviders()) @@ -34,45 +44,69 @@ describe('useEnabledIdentityProviders', () => { it('returns no providers when all flags are disabled', () => { mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false, - dashboardAuthSignInWithChatgpt: false, }) mockUseLocalStorageQuery.mockReturnValue([false]) + mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) expect(result.current).toEqual([]) }) - it('includes ChatGPT when its flag is enabled and the local storage switch is truthy', () => { - mockIsFeatureEnabled.mockReturnValue({ - dashboardAuthSignInWithGithub: false, - dashboardAuthSignInWithChatgpt: true, - }) + it('includes ChatGPT when localStorage is true and configcat is true', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false }) mockUseLocalStorageQuery.mockReturnValue([true]) + mockUseFlag.mockReturnValue(true) const { result } = renderHook(() => useEnabledIdentityProviders()) expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) }) - it('excludes ChatGPT when its flag is enabled but the local storage switch is unset', () => { - mockIsFeatureEnabled.mockReturnValue({ - dashboardAuthSignInWithGithub: false, - dashboardAuthSignInWithChatgpt: true, - }) + it('includes ChatGPT when localStorage is true and configcat is false', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false }) + mockUseLocalStorageQuery.mockReturnValue([true]) + mockUseFlag.mockReturnValue(false) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) + }) + + it('includes ChatGPT when localStorage is false and configcat is true', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false }) mockUseLocalStorageQuery.mockReturnValue([false]) + mockUseFlag.mockReturnValue(true) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([CHATGPT_IDENTITY_PROVIDER]) + }) + + it('excludes ChatGPT when localStorage is false and configcat is false', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false }) + mockUseLocalStorageQuery.mockReturnValue([false]) + mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) expect(result.current).toEqual([]) }) - it('excludes ChatGPT when the local storage switch is truthy but its flag is disabled', () => { - mockIsFeatureEnabled.mockReturnValue({ - dashboardAuthSignInWithGithub: false, - dashboardAuthSignInWithChatgpt: false, - }) - mockUseLocalStorageQuery.mockReturnValue([true]) + it('includes GitHub when its feature flag is enabled', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: true }) + mockUseLocalStorageQuery.mockReturnValue([false]) + mockUseFlag.mockReturnValue(false) + + const { result } = renderHook(() => useEnabledIdentityProviders()) + + expect(result.current).toEqual([GITHUB_IDENTITY_PROVIDER]) + }) + + it('excludes GitHub when its feature flag is disabled', () => { + mockIsFeatureEnabled.mockReturnValue({ dashboardAuthSignInWithGithub: false }) + mockUseLocalStorageQuery.mockReturnValue([false]) + mockUseFlag.mockReturnValue(false) const { result } = renderHook(() => useEnabledIdentityProviders()) diff --git a/apps/studio/hooks/misc/__tests__/useReportDateRange.test.ts b/apps/studio/hooks/misc/__tests__/useReportDateRange.test.ts new file mode 100644 index 00000000000..400c0c7094d --- /dev/null +++ b/apps/studio/hooks/misc/__tests__/useReportDateRange.test.ts @@ -0,0 +1,35 @@ +import dayjs from 'dayjs' +import { describe, expect, test } from 'vitest' + +import { analyticsIntervalToGranularity } from '@/data/reports/report.utils' +import { getIntervalGranularity } from '@/hooks/misc/useReportDateRange' + +const rangeOf = (value: number, unit: dayjs.ManipulateType) => { + const to = dayjs() + return { from: to.subtract(value, unit).toISOString(), to: to.toISOString() } +} + +describe('getIntervalGranularity', () => { + test.each([ + [24, 'hour' as dayjs.ManipulateType, 24], + [7, 'day' as dayjs.ManipulateType, 168], + ])('a %s-%s range buckets hourly (FE-4023)', (value, unit, expectedBuckets) => { + const { from, to } = rangeOf(value, unit) + const interval = getIntervalGranularity(from, to) + + expect(interval).toBe('1h') + expect(analyticsIntervalToGranularity(interval)).toBe('hour') + expect(dayjs(to).diff(from, 'hour')).toBe(expectedBuckets) + }) + + test.each([ + [10, 'minute' as dayjs.ManipulateType, '1m'], + [1, 'hour' as dayjs.ManipulateType, '1m'], + [3, 'hour' as dayjs.ManipulateType, '2m'], + [14, 'day' as dayjs.ManipulateType, '1d'], + [28, 'day' as dayjs.ManipulateType, '1d'], + ])('leaves a %s-%s range on the %s interval', (value, unit, expected) => { + const { from, to } = rangeOf(value, unit) + expect(getIntervalGranularity(from, to)).toBe(expected) + }) +}) diff --git a/apps/studio/hooks/misc/useCheckPermissions.ts b/apps/studio/hooks/misc/useCheckPermissions.ts index 5d77713a96a..68868edae3c 100644 --- a/apps/studio/hooks/misc/useCheckPermissions.ts +++ b/apps/studio/hooks/misc/useCheckPermissions.ts @@ -34,7 +34,7 @@ export function doPermissionsCheck( resource: string, data?: object, organizationSlug?: string, - projectRef?: string + projectRef?: string | null ) { if (!permissions || !Array.isArray(permissions)) { return false @@ -76,7 +76,7 @@ export function useGetPermissions( function useGetProjectPermissions( permissionsOverride?: Permission[], organizationSlugOverride?: string, - projectRefOverride?: string, + projectRefOverride?: string | null, enabled = true ) { const { @@ -114,7 +114,12 @@ function useGetProjectPermissions( ? projectData : { ref: projectRefOverride, parent_project_ref: undefined } - const projectRef = project?.parent_project_ref ? project.parent_project_ref : project?.ref + const projectRef = + projectRefOverride === null + ? null + : project?.parent_project_ref + ? project.parent_project_ref + : project?.ref const isLoading = isLoadingPermissions || @@ -142,7 +147,7 @@ export function useAsyncCheckPermissions( data?: object, overrides?: { organizationSlug?: string - projectRef?: string + projectRef?: string | null permissions?: Permission[] } ) { diff --git a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts index 83aa7c1b18d..939a7e0e9ff 100644 --- a/apps/studio/hooks/misc/useEnabledIdentityProviders.ts +++ b/apps/studio/hooks/misc/useEnabledIdentityProviders.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useMemo } from 'react' import { useIsFeatureEnabled } from './useIsFeatureEnabled' @@ -14,28 +14,28 @@ import { * To add a provider: declare its config in `lib/external-identity-providers.ts`, add a * `dashboard_auth:sign_in_with_*` flag, and gate it here. * - * ChatGPT is a deliberate exception: it's also gated behind a manual, localStorage-only rollout - * switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`) on top of its feature flag, since it's WIP. + * ChatGPT is a deliberate exception: it's rolled out via the `ShowSignInWithChatGptButton` + * ConfigCat flag OR'd with a manual, localStorage-only opt-in switch + * (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, flippable via the `?siwc-enabled=1` query param — + * see `useSiwcQueryParamOptIn`), instead of the static `dashboard_auth:sign_in_with_*` pattern. */ export function useEnabledIdentityProviders(): ExternalIdentityProviderConfig[] { const { dashboardAuthSignInWithGithub: githubEnabled } = useIsFeatureEnabled([ 'dashboard_auth:sign_in_with_github', ]) - const { dashboardAuthSignInWithChatgpt: chatgptEnabled } = useIsFeatureEnabled([ - 'dashboard_auth:sign_in_with_chatgpt', - ]) const [chatgptLocalStorageEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, false ) + const chatGptConfigCatFlagEnabled = useFlag('ShowSignInWithChatGptButton') return useMemo( () => [ githubEnabled && GITHUB_IDENTITY_PROVIDER, - chatgptEnabled && chatgptLocalStorageEnabled && CHATGPT_IDENTITY_PROVIDER, + (chatgptLocalStorageEnabled || chatGptConfigCatFlagEnabled) && CHATGPT_IDENTITY_PROVIDER, ].filter((p): p is ExternalIdentityProviderConfig => Boolean(p)), - [githubEnabled, chatgptEnabled, chatgptLocalStorageEnabled] + [githubEnabled, chatgptLocalStorageEnabled, chatGptConfigCatFlagEnabled] ) } diff --git a/apps/studio/hooks/misc/useReportDateRange.ts b/apps/studio/hooks/misc/useReportDateRange.ts index 73b4690f029..24417c3990b 100644 --- a/apps/studio/hooks/misc/useReportDateRange.ts +++ b/apps/studio/hooks/misc/useReportDateRange.ts @@ -19,8 +19,7 @@ export const getIntervalGranularity = (from: string, to: string): AnalyticsInter if (diffInHours <= 1) return '1m' if (diffInHours <= 12) return '2m' - if (diffInHours <= 24) return '10m' - if (diffInDays <= 7) return '30m' + if (diffInDays <= 7) return '1h' return '1d' } diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index b5bfd570443..2f77b957d97 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -8,6 +8,7 @@ import { extractUrls, formatBytes, formatCurrency, + formatRestoreWindow, getDatabaseMajorVersion, getDistanceLatLonKM, getSemanticVersion, @@ -702,3 +703,15 @@ describe('tablesToSQL', () => { expect(result).not.toContain('CREATE TABLE') }) }) + +describe('formatRestoreWindow', () => { + it('renders windows under a year in days', () => { + expect(formatRestoreWindow(90)).toBe('90 days') + expect(formatRestoreWindow(364)).toBe('364 days') + }) + + it('renders windows of a year or more as 1 year', () => { + expect(formatRestoreWindow(365)).toBe('1 year') + expect(formatRestoreWindow(400)).toBe('1 year') + }) +}) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 558a5dbf46f..2206be48975 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -351,6 +351,9 @@ const formatSemver = (version: string) => { return formattedSemver } +// Windows of a year or more read better as years than day counts +export const formatRestoreWindow = (days: number) => (days >= 365 ? '1 year' : `${days} days`) + export const getSemanticVersion = (version: string) => { if (!version) return 0 diff --git a/apps/studio/lib/integrations/vercel-install.utils.ts b/apps/studio/lib/integrations/vercel-install.utils.ts index cdc30c48cd3..8eaac03c080 100644 --- a/apps/studio/lib/integrations/vercel-install.utils.ts +++ b/apps/studio/lib/integrations/vercel-install.utils.ts @@ -13,9 +13,33 @@ export function getErrorMessage(error: unknown): string | undefined { export type VercelInstallSource = 'deploy-button' | 'marketplace' | 'external' -export function getVercelInstallSource( +/** + * Vercel sometimes sends source=marketplace with Deploy Button params + * (currentProjectId + external-id). Treat that as deploy-button for routing. + */ +export function hasVercelDeployButtonSignals({ + currentProjectId, + externalId, +}: { + currentProjectId?: string + externalId?: string +}): boolean { + return Boolean(currentProjectId && externalId) +} + +export function resolveVercelInstallSource({ + source, + currentProjectId, + externalId, +}: { source: string | undefined -): VercelInstallSource | undefined { + currentProjectId?: string + externalId?: string +}): VercelInstallSource | undefined { + if (hasVercelDeployButtonSignals({ currentProjectId, externalId })) { + return 'deploy-button' + } + switch (source) { case 'deploy-button': case 'marketplace': @@ -59,9 +83,12 @@ export function buildVercelInstallRouteQuery({ }) case 'marketplace': case 'external': + // Keep Deploy Button ids when present so choose-project → create can seed + link. return removeUndefinedValues({ organizationSlug, configurationId, + currentProjectId, + externalId, next, }) default: diff --git a/apps/studio/package.json b/apps/studio/package.json index 082bcecfc09..2f3d947c9ed 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -61,12 +61,15 @@ "@monaco-editor/react": "catalog:", "@next/bundle-analyzer": "16.2.3", "@number-flow/react": "^0.3.2", + "@radix-ui/react-use-escape-keydown": "^1.1.1", "@sentry/nextjs": "catalog:", "@sentry/react": "^10.27.0", "@sentry/tanstackstart-react": "catalog:", + "@smithy/property-provider": "^4.0.4", "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "6.1.0", "@stripe/stripe-js": "9.1.0", + "@stripe/sync-engine": "1.0.32", "@supabase/auth-js": "catalog:", "@supabase/mcp-server-supabase": "^0.7.0", "@supabase/pg-meta": "workspace:*", @@ -91,6 +94,8 @@ "ai": "^6.0.174", "ai-commands": "workspace:*", "awesome-debounce-promise": "^2.1.0", + "braintrust": "^3.9.0", + "class-variance-authority": "^0.7.1", "common": "workspace:*", "common-tags": "^1.8.2", "config": "workspace:*", @@ -111,6 +116,7 @@ "idb": "^8.0.2", "ip-num": "^1.5.1", "json-logic-js": "^2.0.2", + "libpg-query": "17.6.0", "lodash": "catalog:", "lodash-es": "catalog:", "lucide-react": "^0.436.0", @@ -145,7 +151,6 @@ "sonner": "^1.5.0", "sql-formatter": "^15.0.0", "streamdown": "^1.3.0", - "@stripe/sync-engine": "1.0.32", "swiper": "^12.1.2", "tus-js-client": "^4.1.0", "ui": "workspace:*", @@ -163,8 +168,7 @@ "@faker-js/faker": "^9.9.0", "@graphql-codegen/cli": "5.0.5", "@graphql-typed-document-node/core": "^3.2.0", - "@radix-ui/react-use-escape-keydown": "^1.1.1", - "@smithy/property-provider": "^4.0.4", + "@jridgewell/remapping": "^2.3.5", "@tailwindcss/vite": "4.2.4", "@tanstack/devtools-vite": "^0.6.0", "@testing-library/dom": "^10.0.0", @@ -185,14 +189,13 @@ "@types/react-grid-layout": "^1.3.0", "@types/react-simple-maps": "^3.0.1", "@types/zxcvbn": "^4.4.1", - "@jridgewell/remapping": "^2.3.5", + "@typescript/native": "catalog:", "@vercel/config": "^0.2.1", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "@vitest/ui": "catalog:", "api-types": "workspace:*", "autoevals": "^0.0.132", - "braintrust": "^3.9.0", "common": "workspace:*", "config": "workspace:*", "date-fns": "^2.30.0", @@ -204,7 +207,6 @@ "graphql-ws": "5.14.1", "import-in-the-middle": "^2.0.0", "jsdom-testing-mocks": "^1.13.1", - "libpg-query": "17.6.0", "magic-string": "^0.30.21", "msw": "^2.3.0", "next-router-mock": "^0.9.13", @@ -214,7 +216,6 @@ "require-in-the-middle": "^8.0.0", "tsconfig": "workspace:*", "tsx": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "catalog:", diff --git a/apps/studio/pages/account/security.tsx b/apps/studio/pages/account/security.tsx index c52cf454728..db7ec4be0b7 100644 --- a/apps/studio/pages/account/security.tsx +++ b/apps/studio/pages/account/security.tsx @@ -1,6 +1,6 @@ import { Lock } from 'lucide-react' import { Badge, Card, CardContent, CardHeader } 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/pages/claim-project.tsx b/apps/studio/pages/claim-project.tsx index 25cc7aa8e7e..1ba8c0bfd3d 100644 --- a/apps/studio/pages/claim-project.tsx +++ b/apps/studio/pages/claim-project.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import Head from 'next/head' import { PropsWithChildren, useMemo, useState } from 'react' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectClaimBenefits } from '@/components/interfaces/Organization/ProjectClaim/benefits' diff --git a/apps/studio/pages/cli/login.tsx b/apps/studio/pages/cli/login.tsx index 3ef79b48dd9..e663e16ca98 100644 --- a/apps/studio/pages/cli/login.tsx +++ b/apps/studio/pages/cli/login.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useEffectEvent, useRef, useState, 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 { diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 239ec8e56e9..7febfc533af 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -17,10 +17,8 @@ import { VercelIntegrationInterstitialErrorState, VercelIntegrationLogo, } from '@/components/interfaces/Integrations/Vercel/VercelIntegrationInterstitial' -import { - ProjectLinker, - type ForeignProject, -} from '@/components/interfaces/Integrations/VercelGithub/ProjectLinker' +import { ProjectLinker } from '@/components/interfaces/Integrations/VercelGithub/ProjectLinker' +import type { ForeignProject } from '@/components/interfaces/Integrations/VercelGithub/VercelGithub.types' import { InterstitialAccountRow, InterstitialLayout } from '@/components/layouts/InterstitialLayout' import { vercelIcon } from '@/components/to-be-cleaned/ListIcons' import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' @@ -30,6 +28,7 @@ import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { withAuth } from '@/hooks/misc/withAuth' import { BASE_PATH } from '@/lib/constants' import { getErrorMessage } from '@/lib/get-error-message' +import { hasVercelDeployButtonSignals } from '@/lib/integrations/vercel-install.utils' import { buildStudioPageTitle } from '@/lib/page-title' import { useProfileNameAndPicture } from '@/lib/profile' import { EMPTY_ARR } from '@/lib/void' @@ -42,9 +41,10 @@ const PAGE_TITLE = buildStudioPageTitle({ }) const VercelChooseProjectPage: NextPageWithLayout = () => { - const { slug, configurationId, next } = useParams() + const { slug, configurationId, next, currentProjectId, externalId } = useParams() const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture() const displayName = primaryEmail ?? username ?? '' + const isDeployButtonFlow = hasVercelDeployButtonSignals({ currentProjectId, externalId }) const { data: integrationData, @@ -200,11 +200,16 @@ const VercelChooseProjectPage: NextPageWithLayout = () => { integrationIcon={VERCEL_INTEGRATION_ICON} getForeignProjectIcon={getForeignProjectIcon} choosePrompt="Choose Vercel project" - onSkip={() => { - if (next && isVercelUrl(next)) { - window.location.href = next - } - }} + defaultForeignProjectId={currentProjectId} + onSkip={ + isDeployButtonFlow + ? undefined + : () => { + if (next && isVercelUrl(next)) { + window.location.href = next + } + } + } loadingForeignProjects={isLoadingVercelProjectsData} mode="Vercel" /> diff --git a/apps/studio/pages/integrations/vercel/install.tsx b/apps/studio/pages/integrations/vercel/install.tsx index ea4d72d9bd3..fb2365a9730 100644 --- a/apps/studio/pages/integrations/vercel/install.tsx +++ b/apps/studio/pages/integrations/vercel/install.tsx @@ -15,7 +15,7 @@ import { SelectTrigger, SelectValue, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { @@ -32,7 +32,7 @@ import { withAuth } from '@/hooks/misc/withAuth' import { buildVercelInstallRouteQuery, getErrorMessage, - getVercelInstallSource, + resolveVercelInstallSource, } from '@/lib/integrations/vercel-install.utils' import { buildStudioPageTitle } from '@/lib/page-title' import { useProfileNameAndPicture } from '@/lib/profile' @@ -56,13 +56,14 @@ const PAGE_TITLE = buildStudioPageTitle({ export type VercelIntegrationFlow = 'deploy-button' | 'marketing' const VercelIntegration: NextPageWithLayout = () => { - const router = useRouter() - const { code, configurationId, currentProjectId, externalId, next, teamId, source } = useParams() - const [selectedOrg, setSelectedOrg] = useState(null) - const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture() const track = useTrack() - + const router = useRouter() const snapshot = useIntegrationInstallationSnapshot() + const { code, configurationId, currentProjectId, externalId, next, teamId, source } = useParams() + + const [selectedOrg, setSelectedOrg] = useState(null) + + const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture() const displayName = primaryEmail ?? username ?? '' /** @@ -85,16 +86,6 @@ const VercelIntegration: NextPageWithLayout = () => { error: organizationsError, } = useOrganizationsQuery() - useEffect(() => { - if (organizationsData !== undefined && integrationData !== undefined) { - const firstOrg = organizationsData[0] - - if (firstOrg && selectedOrg === null) { - setSelectedOrg(firstOrg) - } - } - }, [organizationsData, integrationData, selectedOrg]) - /** * Organizations with extra `installationInstalled` attribute * @@ -124,7 +115,11 @@ const VercelIntegration: NextPageWithLayout = () => { */ function handleRouteChange() { const orgSlug = selectedOrg?.slug - const vercelInstallSource = getVercelInstallSource(source) + const vercelInstallSource = resolveVercelInstallSource({ + source, + currentProjectId, + externalId, + }) const query = buildVercelInstallRouteQuery({ source: vercelInstallSource, organizationSlug: orgSlug, @@ -224,12 +219,6 @@ const VercelIntegration: NextPageWithLayout = () => { return isOrganizationsDataSuccess && organizationsData?.length === 0 ? true : false }, [isOrganizationsDataSuccess, organizationsData]) - const alreadyInstalled = useMemo(() => { - return selectedOrg && installed[selectedOrg.slug] && source === 'marketplace' && !dataLoading - ? true - : false - }, [dataLoading, installed, selectedOrg, source]) - const missingParams = [ !code ? 'code' : undefined, !configurationId ? 'configurationId' : undefined, @@ -241,13 +230,18 @@ const VercelIntegration: NextPageWithLayout = () => { const showLoadingState = isLoadingOrganizationsQuery || isLoadingIntegrationsQuery const disableInstallationForm = - dataLoading || - // disables installation button if integration is already installed and it is Marketplace flow - alreadyInstalled || - noOrganizations || - !selectedOrg || - missingParams.length > 0 || - isError + dataLoading || noOrganizations || !selectedOrg || missingParams.length > 0 || isError + + useEffect(() => { + if (organizationsData !== undefined && integrationData !== undefined) { + const firstOrg = organizationsData[0] + + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + } + } + }, [organizationsData, integrationData, selectedOrg]) + return ( <> @@ -256,7 +250,7 @@ const VercelIntegration: NextPageWithLayout = () => { } - title="Install Vercel Integration" + title="Install Vercel integration" description="Choose the Supabase organization Vercel can connect to" footer={} > @@ -290,14 +284,6 @@ const VercelIntegration: NextPageWithLayout = () => { /> )} - {alreadyInstalled && ( - - )} - {noOrganizations && ( { loading={dataLoading} onClick={onInstall} > - Install integration + {selectedOrg && installed[selectedOrg.slug] ? 'Continue' : 'Install integration'}
      diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 4673219bb6c..26fbcb40c3a 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useIsMFAEnabled } from 'common' import Link from 'next/link' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ProjectList } from '@/components/interfaces/Home/ProjectList/ProjectList' import { HomePageActions } from '@/components/interfaces/HomePageActions' diff --git a/apps/studio/pages/partners/stripe/projects/login.tsx b/apps/studio/pages/partners/stripe/projects/login.tsx index bb1afc2a829..4f1d396404e 100644 --- a/apps/studio/pages/partners/stripe/projects/login.tsx +++ b/apps/studio/pages/partners/stripe/projects/login.tsx @@ -5,7 +5,7 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { useEffect } 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 { @@ -140,7 +140,7 @@ const StripeProjectsLoginPage: NextPageWithLayout = () => { {showAuthorizationState && emailMatches && linkedOrg && (
      {linkedOrg.name} is already diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx index 1ef04b4557f..0fe62065225 100644 --- a/apps/studio/pages/project/[ref]/auth/sessions.tsx +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -34,7 +34,7 @@ const SessionsPage: NextPageWithLayout = () => { User Sessions - Configure settings for user sessions and refresh tokens + Configure settings for access tokens, refresh tokens, and user sessions diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx index 93a4c95abb0..4f5254f12f8 100644 --- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx +++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx @@ -21,7 +21,7 @@ import { FormField, Switch, } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageContainer } from 'ui-patterns/PageContainer' import { diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 1c7d72cbf30..f62c4ffa277 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AlertCircle, DatabaseBackup } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } 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/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index dd77bf41bda..547e0018607 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { Info } from 'lucide-react' -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/pages/project/[ref]/database/migrations.tsx b/apps/studio/pages/project/[ref]/database/migrations.tsx index e57b15dd6fd..425a6ec8762 100644 --- a/apps/studio/pages/project/[ref]/database/migrations.tsx +++ b/apps/studio/pages/project/[ref]/database/migrations.tsx @@ -9,7 +9,7 @@ import { } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' -import Migrations from '@/components/interfaces/Database/Migrations/Migrations' +import { Migrations } from '@/components/interfaces/Database/Migrations/Migrations' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { DocsButton } from '@/components/ui/DocsButton' diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 965c22faa2a..8ae7129b577 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -4,6 +4,7 @@ import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsTables } from '@/components/interfaces/Database/Publications/PublicationsTables' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -14,7 +15,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import type { NextPageWithLayout } from '@/types' -const DatabasePublications: NextPageWithLayout = () => { +const DatabasePublicationsContent = () => { const { ref, id } = useParams() const { data: project } = useSelectedProjectQuery() const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( @@ -54,6 +55,12 @@ const DatabasePublications: NextPageWithLayout = () => { ) } +const DatabasePublications: NextPageWithLayout = () => ( + + + +) + DatabasePublications.getLayout = (page) => ( {page} diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx index ded564a278a..59cd351d97b 100644 --- a/apps/studio/pages/project/[ref]/database/publications/index.tsx +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection } from 'ui-patterns/PageSection' +import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsList } from '@/components/interfaces/Database/Publications/PublicationsList' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -10,7 +11,7 @@ import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' -const DatabasePublications: NextPageWithLayout = () => { +const DatabasePublicationsContent = () => { const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'publications' @@ -31,6 +32,12 @@ const DatabasePublications: NextPageWithLayout = () => { ) } +const DatabasePublications: NextPageWithLayout = () => ( + + + +) + DatabasePublications.getLayout = (page) => ( {page} diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx index 2d48e5b2721..98097e6481a 100644 --- a/apps/studio/pages/project/[ref]/database/replication/index.tsx +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -1,10 +1,18 @@ +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Destinations } from '@/components/interfaces/Database/Replication/Destinations' import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' -import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useHighAvailability } from '@/hooks/misc/useHighAvailability' @@ -35,34 +43,27 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { return ( - - -
      -
      -

      Replication

      -
      -

      - Deploy Read Replicas across multiple regions, or use Pipelines to replicate database - changes to analytics destinations. -

      -
      -
      -
      + + + + Replication + Read replicas and analytics pipelines + + + - {isPending ? ( - + + {isPending ? ( - - ) : ( - <> - - - + ) : ( + + + - - - - )} + + + )} +
      ) } diff --git a/apps/studio/pages/project/[ref]/database/settings.tsx b/apps/studio/pages/project/[ref]/database/settings.tsx index 2a99513ac6f..fda7409aa70 100644 --- a/apps/studio/pages/project/[ref]/database/settings.tsx +++ b/apps/studio/pages/project/[ref]/database/settings.tsx @@ -58,7 +58,7 @@ const DatabaseSettings: NextPageWithLayout = () => { {showNewDiskManagementUI ? ( - // This form is hidden if Disk and Compute form is enabled, new form is on ./settings/compute-and-disk + // This form is hidden if Disk and Compute form is enabled, new form is on ./settings/infrastructure ) : ( diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 9f6d74bc2fa..b0a026a30a4 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router' import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import React, { useMemo, useRef, type PropsWithChildren } from 'react' import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } 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 { diff --git a/apps/studio/pages/project/[ref]/functions/secrets.tsx b/apps/studio/pages/project/[ref]/functions/secrets.tsx index de49ba0b3c9..9f1afd21894 100644 --- a/apps/studio/pages/project/[ref]/functions/secrets.tsx +++ b/apps/studio/pages/project/[ref]/functions/secrets.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react' -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/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 2e88ba89ded..ffd3ad8e318 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { useEffect, useMemo } from 'react' -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/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index e25431adb4a..4d736497fc4 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -40,6 +40,7 @@ import { LogsExplorerOtelBanner } from '@/components/interfaces/Settings/Logs/Lo import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' +import { useRecentLogSqlSnippets } from '@/components/interfaces/Settings/Logs/useRecentLogSqlSnippets' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' @@ -53,6 +54,7 @@ import { } from '@/data/content/content-upsert-mutation' import { constructHeaders } from '@/data/fetchers' import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' import { useLogsQuery } from '@/hooks/analytics/useLogsQuery' import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' @@ -150,10 +152,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { false ) - const [recentLogs, setRecentLogs] = useLocalStorage( - `project-content-${projectRef}-recent-log-sql`, - [] - ) + const [recentLogs, setRecentLogs] = useRecentLogSqlSnippets(projectRef) const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') const entitledToAuditLogDays = getEntitlementNumericValue() @@ -216,7 +215,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const addRecentLogSqlSnippet = (snippet: Partial) => { const defaults: LogSqlSnippets.Content = { schema_version: '1', - sql: '', + unchecked_sql: untrustedLogSql(''), content_id: '', } setRecentLogs([...recentLogs, { ...defaults, ...snippet }]) @@ -241,7 +240,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { editorRef.current.focus() } - addRecentLogSqlSnippet({ sql: template.searchString }) + addRecentLogSqlSnippet({ unchecked_sql: untrustedLogSql(template.searchString) }) } const handleRewrite = async () => { @@ -321,7 +320,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { setTimeRange('', '') } setSearch(query) - addRecentLogSqlSnippet({ sql: query }) + addRecentLogSqlSnippet({ unchecked_sql: untrustedLogSql(query) }) } const handleInsertSource = (source: string) => { @@ -367,7 +366,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { type: 'log_sql' as const, content: { content_id: editorId, - sql: editorValue, + unchecked_sql: untrustedLogSql(editorValue), schema_version: '1', favorite: false, } as LogSqlSnippets.Content, @@ -393,7 +392,10 @@ export const LogsExplorerPage: NextPageWithLayout = () => { projectRef: projectRef!, payload: { ...query, - content: { ...(query.content as LogSqlSnippets.Content), sql: currentSql }, + content: { + ...(query.content as LogSqlSnippets.Content), + unchecked_sql: untrustedLogSql(currentSql), + }, }, }) @@ -429,7 +431,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { })) } - const querySql = (query?.content as LogSqlSnippets.Content | undefined)?.sql + const querySql = (query?.content as LogSqlSnippets.Content | undefined)?.unchecked_sql useEffect(() => { if (search) { setEditorValue(search) diff --git a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx index dd8d11afd9c..a1d26255e0e 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx @@ -4,20 +4,17 @@ import Link from 'next/link' import { Button } from 'ui' import RecentQueriesItem from '@/components/interfaces/Settings/Logs/RecentQueriesItem' +import { useRecentLogSqlSnippets } from '@/components/interfaces/Settings/Logs/useRecentLogSqlSnippets' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import Table from '@/components/to-be-cleaned/Table' import LogsExplorerHeader from '@/components/ui/Logs/LogsExplorerHeader' -import { useLocalStorage } from '@/hooks/misc/useLocalStorage' -import type { LogSqlSnippets, NextPageWithLayout } from '@/types' +import type { NextPageWithLayout } from '@/types' export const LogsSavedPage: NextPageWithLayout = () => { const { ref } = useParams() - const [recentLogSnippets, setRecentLogSnippets] = useLocalStorage( - `project-content-${ref}-recent-log-sql`, - [] - ) + const [recentLogSnippets, setRecentLogSnippets] = useRecentLogSqlSnippets(ref) const recent = recentLogSnippets.slice().reverse() return ( @@ -35,8 +32,8 @@ export const LogsSavedPage: NextPageWithLayout = () => { } - body={recent.map((item: LogSqlSnippets.Content) => ( - + body={recent.map((item) => ( + ))} /> )} diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx index 3200df9cf70..f662734cac2 100644 --- a/apps/studio/pages/project/[ref]/merge.tsx +++ b/apps/studio/pages/project/[ref]/merge.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, cn, NavMenu, NavMenuItem } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { useIsPgDeltaDiffEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' diff --git a/apps/studio/pages/project/[ref]/observability/connections.tsx b/apps/studio/pages/project/[ref]/observability/connections.tsx index da80b8b844f..e6102a2ae03 100644 --- a/apps/studio/pages/project/[ref]/observability/connections.tsx +++ b/apps/studio/pages/project/[ref]/observability/connections.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs' -import { Pause, Play } from 'lucide-react' +import { CirclePause, CirclePlay } from 'lucide-react' import { useEffect, useState } from 'react' import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -11,13 +11,18 @@ import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useDatabaseActivityQuery } from '@/data/database/activity-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useTrack } from '@/lib/telemetry/track' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import type { NextPageWithLayout } from '@/types' export const DatabaseConnections: NextPageWithLayout = () => { + const track = useTrack() const { data: project } = useSelectedProjectQuery() const { openSidebar } = useSidebarManagerSnapshot() const aiSnap = useAiAssistantStateSnapshot() @@ -25,6 +30,10 @@ export const DatabaseConnections: NextPageWithLayout = () => { const [live, setLive] = useState(true) const [now, setNow] = useState(() => dayjs.utc()) + useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE, handleToggleLive, { + registerInCommandMenu: false, + }) + const { data, isPending: isLoadingActivity, @@ -37,6 +46,20 @@ export const DatabaseConnections: NextPageWithLayout = () => { { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } ) + function handleToggleLive() { + const nextLive = !live + + track('database_connections_live_mode_clicked', { + newState: nextLive ? 'enabled' : 'disabled', + }) + + setLive(nextLive) + if (nextLive) { + setNow(dayjs.utc()) + refetchActivity() + } + } + const buildPrompt = () => { return buildDatabaseConnectionsSummaryPrompt({ activities: data ?? [], @@ -62,9 +85,9 @@ export const DatabaseConnections: NextPageWithLayout = () => { return ( -
      +
      -

      Database Connections

      +

      Database Connections

      {live && ( @@ -73,34 +96,30 @@ export const DatabaseConnections: NextPageWithLayout = () => { Live - - Data on this page is refreshed every 3 seconds - + Refreshes data every 3 seconds )}
      - + + {
      {project?.cloud_provider === 'AWS' ? ( ) : ( {

      {REPORT_TITLE}

      - + {IS_PLATFORM && }
      { let redirectUrl if (['cpu', 'ram', 'disk_io'].includes(hash)) { - redirectUrl = `/project/${ref}/settings/infrastructure#${hash}` + redirectUrl = `${getInfrastructurePath(ref)}#${hash}` } else { redirectUrl = `/org/${organization.slug}/usage?projectRef=${ref}` } diff --git a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx b/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx deleted file mode 100644 index f87ba5397fe..00000000000 --- a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { DiskManagementForm } from '@/components/interfaces/DiskManagement/DiskManagementForm' -import { DefaultLayout } from '@/components/layouts/DefaultLayout' -import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from '@/components/layouts/Scaffold' -import type { NextPageWithLayout } from '@/types' - -const AuthSettings: NextPageWithLayout = () => { - return ( - <> - - - Compute and Disk - - Configure the compute and disk settings for your project. - - - - - - ) -} - -AuthSettings.getLayout = (page) => ( - - {page} - -) -export default AuthSettings diff --git a/apps/studio/pages/project/[ref]/settings/general.tsx b/apps/studio/pages/project/[ref]/settings/general.tsx index 92bd8b01719..5c4af074c56 100644 --- a/apps/studio/pages/project/[ref]/settings/general.tsx +++ b/apps/studio/pages/project/[ref]/settings/general.tsx @@ -15,6 +15,7 @@ import { DeleteBranchPanel } from '@/components/interfaces/Settings/General/Dele import { DeleteProjectPanel } from '@/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectPanel' import { General } from '@/components/interfaces/Settings/General/General' import { Project } from '@/components/interfaces/Settings/General/Project' +import { ServiceVersionsSection } from '@/components/interfaces/Settings/General/ServiceVersions/ServiceVersionsSection' import { TransferProjectPanel } from '@/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectPanel' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' @@ -55,6 +56,7 @@ const ProjectSettings: NextPageWithLayout = () => { {IS_PLATFORM && ( <> + {/* this is only settable on compliance orgs, currently that means HIPAA orgs */} {!isBranch && hasHipaaAddon && } {projectSettingsCustomDomains && } diff --git a/apps/studio/pages/project/[ref]/settings/infrastructure.tsx b/apps/studio/pages/project/[ref]/settings/infrastructure.tsx index 775a02425b8..4883b228591 100644 --- a/apps/studio/pages/project/[ref]/settings/infrastructure.tsx +++ b/apps/studio/pages/project/[ref]/settings/infrastructure.tsx @@ -1,38 +1,37 @@ -import { InfrastructureActivity } from '@/components/interfaces/Settings/Infrastructure/InfrastructureActivity' -import { InfrastructureInfo } from '@/components/interfaces/Settings/Infrastructure/InfrastructureInfo' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' + +import { DiskManagementForm } from '@/components/interfaces/DiskManagement/DiskManagementForm' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldDivider, - ScaffoldHeader, - ScaffoldTitle, -} from '@/components/layouts/Scaffold' import type { NextPageWithLayout } from '@/types' -const ProjectInfrastructure: NextPageWithLayout = () => { +const InfrastructureSettings: NextPageWithLayout = () => { return ( <> - - - Infrastructure - - General information regarding your server instance - - - - - - + + + + Infrastructure + + View and configure compute and disk for your project. + + + + + ) } -ProjectInfrastructure.getLayout = (page) => ( +InfrastructureSettings.getLayout = (page) => ( {page} ) - -export default ProjectInfrastructure +export default InfrastructureSettings diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 9657ec98a0b..f40ea989fbf 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import { toast } from 'sonner' import { Button } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition } from 'ui-patterns/Admonition' import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' diff --git a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx index 8a277b627ae..afc598aeda4 100644 --- a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx +++ b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx @@ -2,30 +2,17 @@ import { IS_PLATFORM, useParams } from 'common' import { BucketsUpgradePlan } from '@/components/interfaces/Storage/BucketsUpgradePlan' import { VectorsBuckets } from '@/components/interfaces/Storage/VectorBuckets' -import { - RegionLimitation, - VECTOR_BUCKETS_AVAILABLE_REGIONS, -} from '@/components/interfaces/Storage/VectorBuckets/RegionLimitation' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { StorageBucketsLayout } from '@/components/layouts/StorageLayout/StorageBucketsLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import { useIsVectorBucketsEnabled } from '@/data/config/project-storage-config-query' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import type { NextPageWithLayout } from '@/types' const StorageVectorsPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef }) - // [Joshen] We're actively looking into lifting this restriction so can remove once done - const isAvailableInProjectRegion = VECTOR_BUCKETS_AVAILABLE_REGIONS.includes( - project?.region ?? '' - ) - - if (IS_PLATFORM && !isAvailableInProjectRegion) { - return - } else if (IS_PLATFORM && !isVectorBucketsEnabled) { + if (IS_PLATFORM && !isVectorBucketsEnabled) { return } else if (!isVectorBucketsEnabled) { return null diff --git a/apps/studio/public/img/integrations/covers/grafana-cover-light.webp b/apps/studio/public/img/integrations/covers/grafana-cover-light.webp index 8911e2befb2..59244abbd77 100644 Binary files a/apps/studio/public/img/integrations/covers/grafana-cover-light.webp and b/apps/studio/public/img/integrations/covers/grafana-cover-light.webp differ diff --git a/apps/studio/redirects.shared.test.ts b/apps/studio/redirects.shared.test.ts index 6136ae80955..1aa495d6e0e 100644 --- a/apps/studio/redirects.shared.test.ts +++ b/apps/studio/redirects.shared.test.ts @@ -52,6 +52,33 @@ describe('preserveQueryAndHash', () => { }) describe('matchRedirect query/hash preservation', () => { + it('redirects the legacy compute and disk route while preserving query and hash', () => { + expect( + matchRedirect({ + pathname: '/project/abc/settings/compute-and-disk', + search: { upgrade: 'micro' }, + isPlatform: true, + hash: 'disk', + }) + ).toEqual({ + destination: '/project/abc/settings/infrastructure?upgrade=micro#disk', + permanent: true, + }) + }) + + it('redirects the legacy compute billing panel to the CPU section', () => { + expect( + matchRedirect({ + pathname: '/project/abc/settings/billing/subscription', + search: { panel: 'computeInstance', source: 'banner' }, + isPlatform: true, + }) + ).toEqual({ + destination: '/project/abc/settings/infrastructure?source=banner#cpu', + permanent: true, + }) + }) + it('carries the incoming query and hash through a plain rule', () => { expect( matchRedirect({ diff --git a/apps/studio/redirects.shared.ts b/apps/studio/redirects.shared.ts index 9a22b174a1b..845f11cb68a 100644 --- a/apps/studio/redirects.shared.ts +++ b/apps/studio/redirects.shared.ts @@ -105,7 +105,7 @@ export const SHARED_REDIRECTS: StudioRedirect[] = [ }, { source: '/project/:ref/auth/settings', - destination: '/project/:ref/auth/users', + destination: '/project/:ref/auth/providers', permanent: true, }, { @@ -114,6 +114,11 @@ export const SHARED_REDIRECTS: StudioRedirect[] = [ destination: '/org/_/billing?panel=subscriptionPlan', permanent: true, }, + { + source: '/project/:ref/settings/compute-and-disk', + destination: '/project/:ref/settings/infrastructure', + permanent: true, + }, { source: '/project/:ref/settings/billing/subscription', has: [{ type: 'query', key: 'panel', value: 'pitr' }], @@ -123,7 +128,7 @@ export const SHARED_REDIRECTS: StudioRedirect[] = [ { source: '/project/:ref/settings/billing/subscription', has: [{ type: 'query', key: 'panel', value: 'computeInstance' }], - destination: '/project/:ref/settings/compute-and-disk', + destination: '/project/:ref/settings/infrastructure#cpu', permanent: true, }, { diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 8f8e52c4d4d..3e6b5c721d8 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -104,7 +104,6 @@ import { Route as ProjectRefSettingsIntegrationsRouteImport } from './routes/pro import { Route as ProjectRefSettingsInfrastructureRouteImport } from './routes/project/$ref/settings/infrastructure' import { Route as ProjectRefSettingsGeneralRouteImport } from './routes/project/$ref/settings/general' import { Route as ProjectRefSettingsDashboardRouteImport } from './routes/project/$ref/settings/dashboard' -import { Route as ProjectRefSettingsComputeAndDiskRouteImport } from './routes/project/$ref/settings/compute-and-disk' import { Route as ProjectRefSettingsApiKeysRouteImport } from './routes/project/$ref/settings/api-keys' import { Route as ProjectRefSettingsApiRouteImport } from './routes/project/$ref/settings/api' import { Route as ProjectRefSettingsAddonsRouteImport } from './routes/project/$ref/settings/addons' @@ -808,12 +807,6 @@ const ProjectRefSettingsDashboardRoute = path: '/dashboard', getParentRoute: () => ProjectRefSettingsRoute, } as any) -const ProjectRefSettingsComputeAndDiskRoute = - ProjectRefSettingsComputeAndDiskRouteImport.update({ - id: '/compute-and-disk', - path: '/compute-and-disk', - getParentRoute: () => ProjectRefSettingsRoute, - } as any) const ProjectRefSettingsApiKeysRoute = ProjectRefSettingsApiKeysRouteImport.update({ id: '/api-keys', @@ -2199,7 +2192,6 @@ export interface FileRoutesByFullPath { '/project/$ref/settings/addons': typeof ProjectRefSettingsAddonsRoute '/project/$ref/settings/api': typeof ProjectRefSettingsApiRoute '/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren - '/project/$ref/settings/compute-and-disk': typeof ProjectRefSettingsComputeAndDiskRoute '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureRoute @@ -2494,7 +2486,6 @@ export interface FileRoutesByTo { '/project/$ref/realtime/settings': typeof ProjectRefRealtimeSettingsRoute '/project/$ref/settings/addons': typeof ProjectRefSettingsAddonsRoute '/project/$ref/settings/api': typeof ProjectRefSettingsApiRoute - '/project/$ref/settings/compute-and-disk': typeof ProjectRefSettingsComputeAndDiskRoute '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureRoute @@ -2804,7 +2795,6 @@ export interface FileRoutesById { '/project/$ref/settings/addons': typeof ProjectRefSettingsAddonsRoute '/project/$ref/settings/api': typeof ProjectRefSettingsApiRoute '/project/$ref/settings/api-keys': typeof ProjectRefSettingsApiKeysRouteWithChildren - '/project/$ref/settings/compute-and-disk': typeof ProjectRefSettingsComputeAndDiskRoute '/project/$ref/settings/dashboard': typeof ProjectRefSettingsDashboardRoute '/project/$ref/settings/general': typeof ProjectRefSettingsGeneralRoute '/project/$ref/settings/infrastructure': typeof ProjectRefSettingsInfrastructureRoute @@ -3113,7 +3103,6 @@ export interface FileRouteTypes { | '/project/$ref/settings/addons' | '/project/$ref/settings/api' | '/project/$ref/settings/api-keys' - | '/project/$ref/settings/compute-and-disk' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' | '/project/$ref/settings/infrastructure' @@ -3408,7 +3397,6 @@ export interface FileRouteTypes { | '/project/$ref/realtime/settings' | '/project/$ref/settings/addons' | '/project/$ref/settings/api' - | '/project/$ref/settings/compute-and-disk' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' | '/project/$ref/settings/infrastructure' @@ -3717,7 +3705,6 @@ export interface FileRouteTypes { | '/project/$ref/settings/addons' | '/project/$ref/settings/api' | '/project/$ref/settings/api-keys' - | '/project/$ref/settings/compute-and-disk' | '/project/$ref/settings/dashboard' | '/project/$ref/settings/general' | '/project/$ref/settings/infrastructure' @@ -4650,13 +4637,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectRefSettingsDashboardRouteImport parentRoute: typeof ProjectRefSettingsRoute } - '/project/$ref/settings/compute-and-disk': { - id: '/project/$ref/settings/compute-and-disk' - path: '/compute-and-disk' - fullPath: '/project/$ref/settings/compute-and-disk' - preLoaderRoute: typeof ProjectRefSettingsComputeAndDiskRouteImport - parentRoute: typeof ProjectRefSettingsRoute - } '/project/$ref/settings/api-keys': { id: '/project/$ref/settings/api-keys' path: '/api-keys' @@ -6650,7 +6630,6 @@ interface ProjectRefSettingsRouteChildren { ProjectRefSettingsAddonsRoute: typeof ProjectRefSettingsAddonsRoute ProjectRefSettingsApiRoute: typeof ProjectRefSettingsApiRoute ProjectRefSettingsApiKeysRoute: typeof ProjectRefSettingsApiKeysRouteWithChildren - ProjectRefSettingsComputeAndDiskRoute: typeof ProjectRefSettingsComputeAndDiskRoute ProjectRefSettingsDashboardRoute: typeof ProjectRefSettingsDashboardRoute ProjectRefSettingsGeneralRoute: typeof ProjectRefSettingsGeneralRoute ProjectRefSettingsInfrastructureRoute: typeof ProjectRefSettingsInfrastructureRoute @@ -6667,7 +6646,6 @@ const ProjectRefSettingsRouteChildren: ProjectRefSettingsRouteChildren = { ProjectRefSettingsAddonsRoute: ProjectRefSettingsAddonsRoute, ProjectRefSettingsApiRoute: ProjectRefSettingsApiRoute, ProjectRefSettingsApiKeysRoute: ProjectRefSettingsApiKeysRouteWithChildren, - ProjectRefSettingsComputeAndDiskRoute: ProjectRefSettingsComputeAndDiskRoute, ProjectRefSettingsDashboardRoute: ProjectRefSettingsDashboardRoute, ProjectRefSettingsGeneralRoute: ProjectRefSettingsGeneralRoute, ProjectRefSettingsInfrastructureRoute: ProjectRefSettingsInfrastructureRoute, diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 1e5542d5c7f..4d1c42e7c45 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -30,6 +30,7 @@ import { Outlet, redirect, Scripts, + type AnyRouter, } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { @@ -76,6 +77,7 @@ import { AuthProvider } from '@/lib/auth' import { configureMonacoLoader } from '@/lib/configure-monaco-loader' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants' import { TimezoneProvider, useTimezone } from '@/lib/datetime' +import { splitInternalUrl } from '@/lib/internal-url' // Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one // injects a trailing slash before the query on every nuqs write (see module). import { NuqsAdapter } from '@/lib/nuqs-tanstack-adapter' @@ -330,8 +332,21 @@ export const Route = createRootRouteWithContext()({ hash: location.hash, }) if (!match) return - const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination - throw redirect({ href, statusCode: match.permanent ? 308 : 307 }) + // `to`/`search`/`hash`, never `href`: the router treats `href` as an + // opaque (external) target, and preloading a Link whose beforeLoad + // throws `redirect({ href })` recurses forever — the preload retry + // rebuilds the origin location and re-runs this beforeLoad + // (https://github.com/TanStack/router/issues/7141). `to` is also + // basepath-relative, so no manual BASE_PATH prefix. The explicit + // `search`/`hash` fallbacks clear the incoming values rather than + // inherit them — `preserveQueryAndHash` already merged what carries over. + const { to, search, hash } = splitInternalUrl(match.destination) + throw redirect({ + to, + search: search ?? {}, + hash: hash ?? '', + statusCode: match.permanent ? 308 : 307, + }) }, component: RootComponent, shellComponent: RootDocument, diff --git a/apps/studio/routes/index.tsx b/apps/studio/routes/index.tsx index bafddebe8e4..e4211538c8b 100644 --- a/apps/studio/routes/index.tsx +++ b/apps/studio/routes/index.tsx @@ -1,7 +1,6 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, redirect, type AnyRouter } from '@tanstack/react-router' import { IS_PLATFORM } from '@/lib/constants' -import { stringifySearch } from '@/lib/router-search-params' // `/` is never rendered — it always redirects. Mirrors the Next.js // `redirects()` rules in next.config.ts: platform sends users to `/org` @@ -16,21 +15,30 @@ export const Route = createFileRoute('/')({ // destination — deep links like `/?next=new-project&projectName=x` must // keep `projectName`. Only the consumed `next` param is dropped (and only // when it matched); everything else passes through. - const suffix = (shouldConsumeNext: boolean) => { - const carried = { ...location.search } as Record - if (shouldConsumeNext) delete carried.next - return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}` + const carried = (shouldConsumeNext: boolean) => { + const carriedSearch: Record = { ...location.search } + if (shouldConsumeNext) delete carriedSearch.next + return carriedSearch } - // `href` instead of `to` because these targets aren't in the TanStack - // routeTree yet — they're still on the Next.js pages side during the - // migration. Swap to `to` once `/org`, `/new/new-project`, and - // `/project/default` are migrated. + // `to`, never `href`: preloading a Link whose beforeLoad throws + // `redirect({ href })` recurses forever + // (https://github.com/TanStack/router/issues/7141). The `` type arguments opt out of the registered route tree's strict + // typing so the carried free-form search record is accepted. if (IS_PLATFORM) { if (search.next === 'new-project') { - throw redirect({ href: `/new/new-project${suffix(true)}` }) + throw redirect({ + to: '/new/new-project', + search: carried(true), + hash: location.hash, + }) } - throw redirect({ href: `/org${suffix(false)}` }) + throw redirect({ to: '/org', search: carried(false), hash: location.hash }) } - throw redirect({ href: `/project/default${suffix(false)}` }) + throw redirect({ + to: '/project/default', + search: carried(false), + hash: location.hash, + }) }, }) diff --git a/apps/studio/routes/project/$ref/settings/compute-and-disk.tsx b/apps/studio/routes/project/$ref/settings/compute-and-disk.tsx deleted file mode 100644 index d52452e83d8..00000000000 --- a/apps/studio/routes/project/$ref/settings/compute-and-disk.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' - -import ComputeAndDiskPage from '@/pages/project/[ref]/settings/compute-and-disk' - -export const Route = createFileRoute('/project/$ref/settings/compute-and-disk')({ - component: SettingsComputeAndDiskRoute, - staticData: { settingsLayoutTitle: 'Compute and Disk' }, -}) - -function SettingsComputeAndDiskRoute() { - return -} diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index 9f9599b32f9..fc9d188f969 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -511,37 +511,14 @@ export const createAiAssistantState = (): AiAssistantState => { chat.updatedAt = new Date() }, - saveMessage: (message: MessageType | MessageType[]) => { - const chat = state.activeChat - if (!chat) return - - const incomingMessages = Array.isArray(message) ? message : [message] - - const messagesToAdd: AssistantMessageType[] = [] - - incomingMessages.forEach((msg) => { - const index = chat.messages.findIndex((existing) => existing.id === msg.id) - - if (index !== -1) { - state.updateMessage(msg) - } else { - messagesToAdd.push(msg) - } - }) - - if (messagesToAdd.length > 0) { - chat.messages.push(...messagesToAdd) - chat.updatedAt = new Date() - } - }, - updateMessage: (updatedMessage: MessageType) => { const chat = state.activeChat if (!chat) return const messageIndex = chat.messages.findIndex((msg) => msg.id === updatedMessage.id) if (messageIndex !== -1) { - chat.messages[messageIndex] = updatedMessage + // Clone first — valtio's proxy() mutates nested properties in place and would corrupt the SDK's live array + chat.messages[messageIndex] = sanitizeForCloning(updatedMessage) chat.updatedAt = new Date() } }, @@ -637,7 +614,6 @@ export type AiAssistantState = AiAssistantData & { renameChat: (id: string, name: string) => void clearMessages: () => void deleteMessagesAfter: (id: string, options?: { includeSelf?: boolean }) => void - saveMessage: (message: MessageType | MessageType[]) => void updateMessage: (message: MessageType) => void setSqlSnippets: (snippets: SqlSnippet[]) => void clearSqlSnippets: () => void diff --git a/apps/studio/state/help-panel-state.ts b/apps/studio/state/help-panel-state.ts new file mode 100644 index 00000000000..100959fa74b --- /dev/null +++ b/apps/studio/state/help-panel-state.ts @@ -0,0 +1,7 @@ +import { proxy } from 'valtio' + +export const helpPanelState = proxy<{ + requestedView: 'home' | 'support' +}>({ + requestedView: 'home', +}) diff --git a/apps/studio/state/shortcuts/registry/project-settings-nav.ts b/apps/studio/state/shortcuts/registry/project-settings-nav.ts index eb941ed35e1..eeefb857bfa 100644 --- a/apps/studio/state/shortcuts/registry/project-settings-nav.ts +++ b/apps/studio/state/shortcuts/registry/project-settings-nav.ts @@ -10,7 +10,6 @@ import { RegistryDefinations } from '../types' */ export const PROJECT_SETTINGS_NAV_SHORTCUT_IDS = { NAV_PROJECT_SETTINGS_GENERAL: 'nav.project-settings-general', - NAV_PROJECT_SETTINGS_COMPUTE_AND_DISK: 'nav.project-settings-compute-and-disk', NAV_PROJECT_SETTINGS_INFRASTRUCTURE: 'nav.project-settings-infrastructure', NAV_PROJECT_SETTINGS_INTEGRATIONS: 'nav.project-settings-integrations', NAV_PROJECT_SETTINGS_WEBHOOKS: 'nav.project-settings-webhooks', @@ -32,13 +31,6 @@ export const projectSettingsNavRegistry: RegistryDefinations, 'content'> = {}): LoadedSnippet { +function makeLogSnippet(): LoadedSnippet { + return { + id: 'log-snippet-1', + name: 'My Logs Query', + description: '', + visibility: 'user', + project_id: 42, + owner_id: 7, + folder_id: null, + favorite: false, + status: 'saved', + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + type: 'log_sql', + content: { + content_id: 'log-snippet-1', + schema_version: '1', + unchecked_sql: untrustedLogSql('select * from logs'), + }, + } +} + +function makeSnippet( + overrides: Omit, 'content' | 'type'> = {} +): LoadedSnippet { return { id: 'snippet-1', - type: 'sql', name: 'My Query', description: 'A description', visibility: 'user', @@ -26,6 +50,7 @@ function makeSnippet(overrides: Omit, 'content'> = { inserted_at: '2024-01-01T00:00:00.000Z', updated_at: '2024-01-01T00:00:00.000Z', ...overrides, + type: 'sql', content: { content_id: 'snippet-1', schema_version: '1', @@ -96,6 +121,17 @@ describe('validateMoveToFolder', () => { }) describe('buildUpsertPayload', () => { + it('emits the snippet type — sql for a database snippet', () => { + const payload = buildUpsertPayload(makeSnippet(), 'snippet-1') + expect(payload.type).toBe('sql') + }) + + it('emits the snippet type — log_sql for a logs snippet (not silently rewritten to sql)', () => { + const payload = buildUpsertPayload(makeLogSnippet(), 'log-snippet-1') + expect(payload.type).toBe('log_sql') + expect((payload.content as { content_id: string }).content_id).toBe('log-snippet-1') + }) + it('passes through provided values', () => { const snippet = makeSnippet() const payload = buildUpsertPayload(snippet, 'snippet-1') diff --git a/apps/studio/state/sql-editor/sql-editor-rules.ts b/apps/studio/state/sql-editor/sql-editor-rules.ts index b1c52682d28..2702e251b5d 100644 --- a/apps/studio/state/sql-editor/sql-editor-rules.ts +++ b/apps/studio/state/sql-editor/sql-editor-rules.ts @@ -51,7 +51,7 @@ export function buildUpsertPayload(snippet: LoadedSnippet, id: string): UpsertCo snippet return { id, - type: 'sql', + type: snippet.type, name: name ?? 'Untitled', description: description ?? '', visibility: visibility ?? 'user', diff --git a/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts b/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts index 00dc272a804..ba39dbda6d4 100644 --- a/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-save-scheduler.test.ts @@ -5,11 +5,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createSaveScheduler, type SaveMode } from './sql-editor-save-scheduler' import type { StateSnippet, StateSnippetFolder } from './types' +import type { SqlSnippets } from '@/types' // Valtio notifies subscribers on a microtask; flush past it before asserting. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) -function makeStateSnippet(overrides: Partial = {}): StateSnippet { +function makeStateSnippet( + overrides: Omit, 'type' | 'content'> & { + content?: SqlSnippets.Content + } = {} +): StateSnippet { return { projectRef: 'ref', splitSizes: [50, 50], diff --git a/apps/studio/state/sql-editor/sql-editor-save.test.ts b/apps/studio/state/sql-editor/sql-editor-save.test.ts index 506dbfcc7ed..30f5a6da77f 100644 --- a/apps/studio/state/sql-editor/sql-editor-save.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-save.test.ts @@ -4,8 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSaveMechanism, type SaveMechanismStore } from './sql-editor-save' import type { StateSnippet } from './types' import type { SnippetFolder } from '@/data/content/sql-folders-query' +import type { SqlSnippets } from '@/types' -function makeStateSnippet(overrides: Partial = {}): StateSnippet { +function makeStateSnippet( + overrides: Omit, 'type' | 'content'> & { + content?: SqlSnippets.Content + } = {} +): StateSnippet { return { projectRef: 'ref', splitSizes: [50, 50], diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 338e7eb3651..38aeafc030c 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -1,10 +1,12 @@ import { proxy, ref, snapshot, useSnapshot } from 'valtio' +import type { LogDateRange } from '@/components/interfaces/SQLEditor/querySource' + /** - * Ephemeral, per-session SQL editor state that is NOT persisted: query results - * and the row limit. Kept separate from the snippet/folder store (which deals - * with persistence) because none of this is saved — it lives only for the - * current editing session. + * Ephemeral, per-session SQL editor state that is NOT persisted: query results, + * the row limit, and the per-snippet logs time range. Kept separate from the + * snippet/folder store (which deals with persistence) because none of this is + * saved — it lives only for the current editing session. */ export const sqlEditorSessionState = proxy({ /** @@ -29,6 +31,18 @@ export const sqlEditorSessionState = proxy({ setLimit: (value: number) => (sqlEditorSessionState.limit = value), + /** + * The logs time range for a logs snippet, keyed by snippet id. Session state — + * never written to snippet content — so it works on read-only shared snippets + * and resets on reload. An unset snippet has no entry; read sites fall back to + * `DEFAULT_LOG_DATE_RANGE`. + */ + logRange: {} as { [snippetId: string]: LogDateRange }, + + setLogRange: (id: string, range: LogDateRange) => { + sqlEditorSessionState.logRange[id] = range + }, + addResult: (id: string, results: any[], autoLimit?: number) => { // Use ref() to prevent Valtio from creating proxies for each row object. // This is critical for large result sets - without ref(), Valtio wraps every @@ -49,6 +63,7 @@ export const sqlEditorSessionState = proxy({ /** Drop all session state for a snippet (called when the snippet is removed). */ clearForSnippet: (id: string) => { delete sqlEditorSessionState.results[id] + delete sqlEditorSessionState.logRange[id] }, }) diff --git a/apps/studio/state/sql-editor/sql-editor-state.test.ts b/apps/studio/state/sql-editor/sql-editor-state.test.ts index ba59013f006..3bceae2105a 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.test.ts @@ -3,14 +3,36 @@ import { beforeEach, describe, expect, it } from 'vitest' import { sqlEditorState } from './sql-editor-state' import type { SnippetWithContent } from '@/data/content/sql-folders-query' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' + +function makeLogSnippet(id: string): SnippetWithContent { + return { + id, + name: 'My Logs Query', + description: '', + visibility: 'user', + project_id: 42, + owner_id: 7, + folder_id: null, + favorite: false, + status: 'saved', + inserted_at: '2024-01-01T00:00:00.000Z', + updated_at: '2024-01-01T00:00:00.000Z', + type: 'log_sql', + content: { + content_id: id, + schema_version: '1', + unchecked_sql: untrustedLogSql('select * from logs'), + }, + } +} function makeSnippet( id: string, - overrides: Omit, 'content'> = {} + overrides: Omit, 'content' | 'type'> = {} ): SnippetWithContent { return { id, - type: 'sql', name: 'My Query', description: 'A description', visibility: 'user', @@ -22,6 +44,7 @@ function makeSnippet( inserted_at: '2024-01-01T00:00:00.000Z', updated_at: '2024-01-01T00:00:00.000Z', ...overrides, + type: 'sql', content: { content_id: id, schema_version: '1', @@ -70,3 +93,32 @@ describe('addFavorite / removeFavorite', () => { expect(sqlEditorState.needsSaving.has('missing')).toBe(false) }) }) + +describe('setSql — source-aware branding', () => { + beforeEach(() => { + for (const id of Object.keys(sqlEditorState.snippets)) { + delete sqlEditorState.snippets[id] + } + sqlEditorState.needsSaving.clear() + }) + + it('updates the SQL of a database snippet and marks it for saving', () => { + sqlEditorState.addSnippet({ projectRef: 'ref', snippet: makeSnippet('db-1') }) + + sqlEditorState.setSql({ id: 'db-1', sql: 'select 2' }) + + expect(sqlEditorState.snippets['db-1'].snippet.content?.unchecked_sql).toBe('select 2') + expect(sqlEditorState.needsSaving.has('db-1')).toBe(true) + }) + + it('updates the SQL of a logs snippet and marks it for saving', () => { + sqlEditorState.addSnippet({ projectRef: 'ref', snippet: makeLogSnippet('logs-1') }) + + sqlEditorState.setSql({ id: 'logs-1', sql: 'select count(*) from logs' }) + + expect(sqlEditorState.snippets['logs-1'].snippet.content?.unchecked_sql).toBe( + 'select count(*) from logs' + ) + expect(sqlEditorState.needsSaving.has('logs-1')).toBe(true) + }) +}) diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts index c0073c6df9f..c3519a75ea8 100644 --- a/apps/studio/state/sql-editor/sql-editor-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-state.ts @@ -14,6 +14,7 @@ import { sqlEditorSessionState } from './sql-editor-session-state' import type { StateSnippet, StateSnippetFolder } from './types' import type { SnippetWithContent } from '@/data/content/sql-folders-query' import { Snippet, SnippetFolder } from '@/data/content/sql-folders-query' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' export const sqlEditorState = proxy({ // ======================================================================== @@ -84,7 +85,7 @@ export const sqlEditorState = proxy({ skipSave = false, }: { id: string - snippet: Partial + snippet: Omit, 'type'> skipSave?: boolean }) => { if (sqlEditorState.snippets[id]) { @@ -127,7 +128,11 @@ export const sqlEditorState = proxy({ }) => { let snippet = sqlEditorState.snippets[id]?.snippet if (snippet?.content) { - snippet.content.unchecked_sql = untrustedSql(sql) + if (snippet.type === 'log_sql') { + snippet.content.unchecked_sql = untrustedLogSql(sql) + } else { + snippet.content.unchecked_sql = untrustedSql(sql) + } // Mark dirty immediately so `status` (not the transient needsSaving queue) // is the durable source of truth for unsaved changes, even before the // debounced save begins. diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 010e6c67259..2c0f0b9eb50 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -93,8 +93,10 @@ export const createTableEditorTableState = ({ state.table = supaTable state.gridColumns = gridColumns - state.originalTable = table + // ref() must run before the assignment below — otherwise valtio's proxy() would wrap + // and mutate `table`'s nested properties in place, corrupting the shared react-query cache object state._originalTableRef = ref(table) + state.originalTable = table }, /* Rows */ diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index b2640fe0075..44e8f55b3c1 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -208,13 +208,24 @@ body, box-sizing: border-box; } +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-heading; + } +} + h1, h2, h3, h4, h5, h6 { - @apply font-heading font-semibold; + @apply font-semibold; } .form-group .form-text, @@ -246,19 +257,12 @@ input.form-control, /* @apply w-full ; */ @apply rounded-md; @apply shadow-xs; - @apply transition-all; @apply text-foreground; @apply border; - @apply focus:shadow-md; - - @apply focus:border-stronger; - @apply focus:ring-border-overlay; - @apply bg-studio; @apply border-strong border; - - @apply outline-hidden; - @apply focus:ring-2 focus:ring-current; + /* Prefer shared focus-ring over legacy green box-shadow / ring-current stacks */ + @apply focus-ring; } .form-group input, @@ -267,26 +271,6 @@ input.form-control, @apply px-4 py-2; } -.form-group input:focus, -.form-group input[type='text']:focus, -.form-group input[type='email']:focus, -.form-group input[type='url']:focus, -.form-group input[type='password']:focus, -.form-group input[type='number']:focus, -.form-group input[type='date']:focus, -.form-group input[type='datetime-local']:focus, -.form-group input[type='month']:focus, -.form-group input[type='search']:focus, -.form-group input[type='tel']:focus, -.form-group input[type='time']:focus, -.form-group input[type='week']:focus, -.form-group input[multiple]:focus, -.form-group textarea:focus, -.form-group select:focus, -.form-group input:focus .form-control:focus { - box-shadow: 0 0 0 2px rgba(62, 207, 142, 0.1); -} - /* icons in date / time inputs */ .dark input[type='date']::-webkit-calendar-picker-indicator, .dark input[type='datetime-local']::-webkit-calendar-picker-indicator, @@ -297,7 +281,7 @@ input.form-control, input.is-invalid { @apply bg-red-100; @apply border border-red-700; - @apply focus:ring-red-500; + @apply focus-ring focus-visible:ring-destructive; @apply placeholder:text-red-600; } diff --git a/apps/studio/tests/components/SQLEditor/Results.utils.test.ts b/apps/studio/tests/components/SQLEditor/Results.utils.test.ts deleted file mode 100644 index 1b2e0300ed6..00000000000 --- a/apps/studio/tests/components/SQLEditor/Results.utils.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { - formatCellValue, - formatClipboardValue, -} from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils' - -describe('formatClipboardValue', () => { - test('returns empty string for null', () => { - expect(formatClipboardValue(null)).toBe('') - }) - - test('stringifies objects', () => { - expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') - }) - - test('stringifies arrays', () => { - expect(formatClipboardValue([1, 2])).toBe('[1,2]') - }) - - test('converts primitives to string', () => { - expect(formatClipboardValue('hello')).toBe('hello') - expect(formatClipboardValue(42)).toBe('42') - expect(formatClipboardValue(false)).toBe('false') - }) -}) - -describe('formatCellValue', () => { - test('returns NULL for null', () => { - expect(formatCellValue(null)).toBe('NULL') - }) - - test('returns strings as-is', () => { - expect(formatCellValue('hello')).toBe('hello') - }) - - test('stringifies objects', () => { - expect(formatCellValue({ a: 1 })).toBe('{"a":1}') - }) - - test('stringifies numbers', () => { - expect(formatCellValue(42)).toBe('42') - }) - - test('stringifies booleans', () => { - expect(formatCellValue(true)).toBe('true') - }) -}) diff --git a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx b/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx deleted file mode 100644 index 7d828dbf9f2..00000000000 --- a/apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' -import { HttpResponse } from 'msw' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' - -import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' -import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types' -import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request' -import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' -import { sqlEditorState } from '@/state/sql-editor/sql-editor-state' -import { customRender } from '@/tests/lib/custom-render' -import { addAPIMock } from '@/tests/lib/msw' - -/** - * Characterization tests for the (pre-decomposition) `SQLEditor` monolith. - * - * These tests are not intended to be a model of best practices; they - * over-mock and assert on implementation details. They are temporary to - * capture regressions while the editor is being decomposed into smaller, more - * testable components. - */ - -const SNIPPET_ID = 'test-snippet-id' - -// A single mutable fake-editor state so tests can drive selection / value / -// decoration ids, shared into the hoisted module mocks below. -const mocks = vi.hoisted(() => { - const state = { - value: 'select 1;', - selection: null as null | { startLineNumber: number }, - selectionValue: undefined as string | undefined, - decorations: ['decoration-1'] as string[], - scrollHandler: null as null | ((e: { scrollTop: number }) => void), - diffModifiedValue: 'select 2;', - } - - const editor = { - getValue: () => state.value, - getSelection: () => state.selection, - getModel: () => ({ - getValueInRange: (_selection: unknown) => state.selectionValue, - getFullModelRange: () => ({ __fullRange: true }), - }), - executeEdits: vi.fn(), - deltaDecorations: vi.fn( - (_oldDecorations: string[], _newDecorations: any[]) => state.decorations - ), - revealLineInCenter: vi.fn(), - onDidScrollChange: vi.fn((cb: (e: { scrollTop: number }) => void) => { - state.scrollHandler = cb - }), - setScrollTop: vi.fn(), - focus: vi.fn(), - } - - class FakeRange { - startLineNumber: number - startColumn: number - endLineNumber: number - endColumn: number - constructor(a: number, b: number, c: number, d: number) { - this.startLineNumber = a - this.startColumn = b - this.endLineNumber = c - this.endColumn = d - } - } - const monaco = { Range: FakeRange } - - const diffEditor = { - getModel: () => ({ - original: { setValue: vi.fn(), getValue: () => '' }, - modified: { setValue: vi.fn(), getValue: () => state.diffModifiedValue }, - }), - getModifiedEditor: () => ({ revealLineInCenter: vi.fn() }), - } - - return { state, editor, monaco, diffEditor } -}) - -// --- Editor fakes ----------------------------------------------------------- - -vi.mock('@/components/interfaces/SQLEditor/MonacoEditor', async () => { - const { useEffect } = await vi.importActual('react') - return { - MonacoEditor: (props: any) => { - useEffect(() => { - props.editorRef.current = mocks.editor - props.monacoRef.current = mocks.monaco - props.onMount?.(mocks.editor) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( -
      -
      {props.placeholder}
      - - -
      - ) - }, - } -}) - -vi.mock('@/components/ui/DiffEditor', async () => { - const { useEffect } = await vi.importActual('react') - return { - DiffEditor: (props: any) => { - useEffect(() => { - props.onMount?.(mocks.diffEditor) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return
      - }, - } -}) - -vi.mock('@/components/ui/AIEditor/ResizableAIWidget', () => ({ - default: (props: any) => ( -
      - - - -
      - ), -})) - -// --- Child panel stubs (not under test) ------------------------------------- - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityActions', () => ({ - UtilityActions: (props: any) => ( -
      - - - {String(props.isExecuting)} -
      - ), -})) - -vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel', () => ({ - UtilityPanel: (props: any) => ( -
      - {props.activeTab} - -
      - ), -})) - -vi.mock('@/components/interfaces/SQLEditor/RunQueryWarningModal', () => ({ - RunQueryWarningModal: (props: any) => - props.visible ? ( -
      - - - -
      - ) : null, -})) - -// --- Context / selection hook stubs (orthogonal infra, not under test) ------ - -vi.mock('@/components/interfaces/SQLEditor/useAddDefinitions', () => ({ - useAddDefinitions: () => {}, -})) - -vi.mock('@/hooks/misc/useSelectedProject', () => ({ - useSelectedProjectQuery: () => ({ - data: { - id: 1, - ref: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - }), -})) - -vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ - useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }), -})) - -vi.mock('@/hooks/misc/useOrgOptedIntoAi', () => ({ - useOrgAiOptInLevel: () => ({ - aiOptInLevel: 'disabled', - includeSchemaMetadata: false, - isHipaaProjectDisallowed: false, - }), -})) - -vi.mock('@/data/read-replicas/replicas-query', () => ({ - useReadReplicasQuery: () => ({ - data: [ - { - identifier: 'default', - connectionString: 'postgresql://postgres@localhost:5432/postgres', - }, - ], - isSuccess: true, - }), -})) - -vi.mock('@/data/database-event-triggers/database-event-triggers-query', () => ({ - useDatabaseEventTriggersQuery: () => ({ data: undefined }), -})) - -// `common` is globally mocked in vitestSetup to `{ ref: 'default' }`. Re-mock it -// here to also provide a snippet id (so `id` is stable and points at our seeded -// snippet) and a stable `useFlag`. -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useParams: () => ({ ref: 'default', id: SNIPPET_ID }), - useFlag: () => false, - } -}) - -// --- Helpers ---------------------------------------------------------------- - -function seedSnippet(sql: string, name = 'My query') { - ;(sqlEditorState.snippets as any)[SNIPPET_ID] = { - projectRef: 'default', - splitSizes: [50, 50], - snippet: { - id: SNIPPET_ID, - name, - project_id: 1, - owner_id: 1, - content: { sql, unchecked_sql: sql, schema_version: '1.0', favorite: false }, - }, - } -} - -function resetStores() { - for (const key of Object.keys(sqlEditorState.snippets)) { - delete (sqlEditorState.snippets as any)[key] - } - for (const key of Object.keys(sqlEditorSessionState.results)) { - delete (sqlEditorSessionState.results as any)[key] - } - sqlEditorDiffRequestState.pending = undefined -} - -const NON_EXPLAIN_ROWS = [{ id: 1, name: 'row-1' }] - -function mockQuerySuccess(rows: unknown[] = NON_EXPLAIN_ROWS) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(rows), - }) -} - -function mockQueryError(body: Record) { - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: () => HttpResponse.json(body, { status: 400 }), - }) -} - -beforeEach(() => { - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - return setTimeout(() => cb(0), 0) as unknown as number - }) - resetStores() - seedSnippet('select 1;') - mocks.state.value = 'select 1;' - mocks.state.selection = null - mocks.state.selectionValue = undefined - mocks.state.decorations = ['decoration-1'] - mocks.editor.executeEdits.mockClear() - mocks.editor.deltaDecorations.mockClear() - mocks.editor.revealLineInCenter.mockClear() - mocks.editor.focus.mockClear() -}) - -afterEach(() => { - cleanup() - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -async function renderEditor() { - const utils = customRender() - // The Monaco editor is loaded via next/dynamic; wait for the fake to mount. - await screen.findByTestId('monaco-editor') - return utils -} - -describe('SQLEditor characterization', () => { - test('1a. successful run adds result and keeps Results tab active', async () => { - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - mockQuerySuccess(NON_EXPLAIN_ROWS) - - await renderEditor() - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(addResult).toHaveBeenCalled()) - // The snippet id and rows are the load-bearing part; autoLimit is derived - // from the (SELECT) query + configured row limit and is asserted loosely. - expect(addResult.mock.calls[0][0]).toBe(SNIPPET_ID) - expect(addResult.mock.calls[0][1]).toEqual(NON_EXPLAIN_ROWS) - expect(screen.getByTestId('active-tab')).toHaveTextContent('results') - }) - - test('2. run error with position highlights the computed line and reveals it', async () => { - const addResultError = vi.spyOn(sqlEditorSessionState, 'addResultError') - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^', - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalled()) - - // With no selection, startLineNumber is 0 → highlighted line === parsed LINE (3). - const [oldDecorations, newDecorations] = mocks.editor.deltaDecorations.mock.calls[0] - expect(oldDecorations).toEqual([]) - expect(newDecorations[0].range.startLineNumber).toBe(3) - expect(newDecorations[0].range.endLineNumber).toBe(3) - expect(mocks.editor.revealLineInCenter).toHaveBeenCalledWith(3) - await waitFor(() => expect(addResultError).toHaveBeenCalled()) - }) - - test('2b. the next run clears the previously-set line highlights', async () => { - // First: produce a highlight. - mockQueryError({ - message: 'syntax error', - position: '8', - formattedError: 'ERROR: syntax error\nLINE 2: foo\n', - }) - await renderEditor() - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalledTimes(1)) - - // Second run should clear the stored decorations before running again. - mockQuerySuccess(NON_EXPLAIN_ROWS) - fireEvent.click(screen.getByTestId('editor-run')) - await waitFor(() => - expect(mocks.editor.deltaDecorations).toHaveBeenCalledWith(['decoration-1'], []) - ) - }) - - test('3. running via the run button refocuses the editor', async () => { - mockQuerySuccess(NON_EXPLAIN_ROWS) - await renderEditor() - - fireEvent.click(screen.getByTestId('run-button')) - - await waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled()) - }) - - test('3b. run button is disabled and short-circuits while a diff is open', async () => { - // Queue a diff request against a non-empty editor so a diff opens on mount. - mocks.state.value = 'select 1;' - sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification) - - await renderEditor() - // The queued request drains on mount and opens a diff, which disables Run. - // (Assert via the run button rather than the dynamically-imported diff editor.) - await waitFor(() => expect(screen.getByTestId('run-button')).toBeDisabled()) - - const addResult = vi.spyOn(sqlEditorSessionState, 'addResult') - // The run button is disabled while diffing; clicking should not execute. - fireEvent.click(screen.getByTestId('run-button')) - // Give any async work a chance to (not) happen. - await new Promise((r) => setTimeout(r, 20)) - expect(addResult).not.toHaveBeenCalled() - }) - - test('4. a diff request queued before mount drains exactly once', async () => { - // Empty editor → drain copies the SQL in via executeEdits('apply-ai-message'). - mocks.state.value = '' - sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification) - - const { unmount } = await renderEditor() - - await waitFor(() => - expect(mocks.editor.executeEdits).toHaveBeenCalledWith('apply-ai-message', expect.any(Array)) - ) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - // Request was consumed (drained), so it cannot re-apply. - expect(sqlEditorDiffRequestState.pending).toBeUndefined() - - // A fresh mount must NOT re-apply the already-consumed request. - unmount() - await renderEditor() - await new Promise((r) => setTimeout(r, 20)) - expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1) - }) - - test('5. the ask-ai widget renders only while the prompt is open', async () => { - mocks.state.value = 'select 1;' - await renderEditor() - - expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('editor-prompt')) - expect(await screen.findByTestId('ask-ai')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('ask-ai-cancel')) - await waitFor(() => expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument()) - }) - - test('6. a destructive query opens the warning modal; confirm re-runs forced', async () => { - mocks.state.value = 'drop table foo;' - let forcedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - forcedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - - // Destructive query → confirmation modal, and no query is sent yet. - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - expect(forcedQuery).toBe('') - - // Confirming forces the (same) destructive query to actually run. - fireEvent.click(screen.getByTestId('warn-confirm')) - await waitFor(() => expect(forcedQuery).toMatch(/drop table foo/i)) - }) - - test('6b. confirm-with-RLS re-runs with appended enable-RLS statements', async () => { - mocks.state.value = 'create table foo (id int);' - let capturedQuery = '' - addAPIMock({ - method: 'post', - path: '/platform/pg-meta/:ref/query', - response: async ({ request }) => { - const body = (await request.json()) as { query: string } - capturedQuery = body.query - return HttpResponse.json(NON_EXPLAIN_ROWS) - }, - }) - - await renderEditor() - - fireEvent.click(screen.getByTestId('editor-run')) - expect(await screen.findByTestId('warning-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('warn-confirm-rls')) - await waitFor(() => expect(capturedQuery).toMatch(/enable row level security/i)) - }) -}) diff --git a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx index 654a4d52a8c..09e328d5f4e 100644 --- a/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/features/logs/Logs.Datepickers.test.tsx @@ -11,9 +11,9 @@ import { generateDynamicHelper, generateDynamicHelpers, generateHelpersFromInput, - LogsDatePicker, parseCustomInput, -} from '@/components/interfaces/Settings/Logs/Logs.DatePickers' +} from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers' +import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types' dayjs.extend(timezone) diff --git a/apps/studio/tests/lib/sql-editor-test-utils.tsx b/apps/studio/tests/lib/sql-editor-test-utils.tsx index 3be4957d80b..d2e30d49bab 100644 --- a/apps/studio/tests/lib/sql-editor-test-utils.tsx +++ b/apps/studio/tests/lib/sql-editor-test-utils.tsx @@ -1,10 +1,12 @@ import { untrustedSql } from '@supabase/pg-meta' import type { QueryClient } from '@tanstack/react-query' import { renderHook, type RenderHookOptions } from '@testing-library/react' +import { FeatureFlagContext } from 'common' import { http, HttpResponse } from 'msw' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import type { ReactNode } from 'react' +import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import type { ContentDiff, DiffController, @@ -147,6 +149,7 @@ export function seedSnippet({ sql = '', ownerId = 1, projectId = 1, + source = 'database', }: { id: string projectRef?: string @@ -154,6 +157,7 @@ export function seedSnippet({ sql?: string ownerId?: number projectId?: number + source?: SqlSnippetSource }) { const snippet = createSqlSnippetSkeletonV2({ name, @@ -161,6 +165,7 @@ export function seedSnippet({ owner_id: ownerId, project_id: projectId, idOverride: id, + source, }) sqlEditorState.addSnippet({ projectRef, snippet }) return snippet @@ -319,6 +324,8 @@ type RenderSqlEditorHookOptions = { aiAssistantState?: ReturnType databaseSelectorState?: ReturnType roleImpersonationState?: ReturnType + /** ConfigCat flags to expose via FeatureFlagContext (e.g. `{ otelLegacyLogs: true }`). */ + flags?: Record } export function renderSqlEditorHook( @@ -332,12 +339,10 @@ export function renderSqlEditorHook( options?.roleImpersonationState ?? createRoleImpersonationState('default', { current: async () => ({}) }) - const wrapper = ({ children }: { children: ReactNode }) => ( - + const flags = options?.flags + + const wrapper = ({ children }: { children: ReactNode }) => { + const tree = ( @@ -347,8 +352,24 @@ export function renderSqlEditorHook( - - ) + ) + + return ( + + {flags ? ( + + {tree} + + ) : ( + tree + )} + + ) + } const result = renderHook(hook, { initialProps: options?.initialProps, diff --git a/apps/studio/tests/pages/integrations/vercel/install.utils.test.ts b/apps/studio/tests/pages/integrations/vercel/install.utils.test.ts index 3c88df74c09..7d96716d14d 100644 --- a/apps/studio/tests/pages/integrations/vercel/install.utils.test.ts +++ b/apps/studio/tests/pages/integrations/vercel/install.utils.test.ts @@ -3,7 +3,8 @@ import { describe, expect, test } from 'vitest' import { buildVercelInstallRouteQuery, getErrorMessage, - getVercelInstallSource, + hasVercelDeployButtonSignals, + resolveVercelInstallSource, } from '@/lib/integrations/vercel-install.utils' describe('getErrorMessage', () => { @@ -45,7 +46,7 @@ describe('buildVercelInstallRouteQuery', () => { }) }) - test('only keeps marketplace destination params', () => { + test('keeps marketplace params and passes through deploy-button ids when present', () => { expect( buildVercelInstallRouteQuery({ source: 'marketplace', @@ -58,6 +59,8 @@ describe('buildVercelInstallRouteQuery', () => { ).toStrictEqual({ organizationSlug: 'acme', configurationId: 'configuration-id', + currentProjectId: 'vercel-project', + externalId: 'github-repo', next: 'https://vercel.com/callback', }) }) @@ -87,15 +90,46 @@ describe('buildVercelInstallRouteQuery', () => { }) }) -describe('getVercelInstallSource', () => { - test('returns supported Vercel install sources', () => { - expect(getVercelInstallSource('deploy-button')).toBe('deploy-button') - expect(getVercelInstallSource('marketplace')).toBe('marketplace') - expect(getVercelInstallSource('external')).toBe('external') +describe('hasVercelDeployButtonSignals', () => { + test('requires both currentProjectId and externalId', () => { + expect( + hasVercelDeployButtonSignals({ + currentProjectId: 'prj_123', + externalId: 'https://github.com/org/repo', + }) + ).toBe(true) + expect(hasVercelDeployButtonSignals({ currentProjectId: 'prj_123' })).toBe(false) + expect(hasVercelDeployButtonSignals({ externalId: 'https://github.com/org/repo' })).toBe(false) + expect(hasVercelDeployButtonSignals({})).toBe(false) }) +}) - test('returns undefined for unsupported sources', () => { - expect(getVercelInstallSource('deploybutton')).toBeUndefined() - expect(getVercelInstallSource(undefined)).toBeUndefined() +describe('resolveVercelInstallSource', () => { + test('overrides marketplace and external when deploy-button signals are present', () => { + expect( + resolveVercelInstallSource({ + source: 'marketplace', + currentProjectId: 'prj_123', + externalId: 'https://github.com/org/repo', + }) + ).toBe('deploy-button') + expect( + resolveVercelInstallSource({ + source: 'external', + currentProjectId: 'prj_123', + externalId: 'https://github.com/org/repo', + }) + ).toBe('deploy-button') + }) + + test('keeps the declared source when deploy-button signals are incomplete', () => { + expect( + resolveVercelInstallSource({ + source: 'marketplace', + currentProjectId: 'prj_123', + }) + ).toBe('marketplace') + expect(resolveVercelInstallSource({ source: 'deploy-button' })).toBe('deploy-button') + expect(resolveVercelInstallSource({ source: undefined })).toBeUndefined() }) }) diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx index 3c77f3cfc76..652b1f59c87 100644 --- a/apps/studio/tests/pages/new/[slug].test.tsx +++ b/apps/studio/tests/pages/new/[slug].test.tsx @@ -102,6 +102,19 @@ const DEFAULT_AVAILABLE_REGIONS: RegionsInfo = { }, } +const FRANKFURT = 'Central EU (Frankfurt)' + +const AVAILABLE_REGIONS_WITH_FRANKFURT: RegionsInfo = { + ...DEFAULT_AVAILABLE_REGIONS, + all: { + ...DEFAULT_AVAILABLE_REGIONS.all, + specific: [ + ...DEFAULT_AVAILABLE_REGIONS.all.specific, + { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' }, + ], + }, +} + const DEFAULT_AVAILABLE_VERSIONS: { available_versions: AvailableVersion[] } = { available_versions: [ { postgres_engine: '15', release_channel: 'ga', version: 'supabase-postgres-15.6.1.139' }, @@ -496,6 +509,28 @@ describe('project creation wizard', () => { }) describe('postgres version and orioledb', () => { + test('hides advanced configuration when orioledb is unavailable', async () => { + mockWizardEndpoints() + const onAvailableVersionsRequest = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: () => { + onAvailableVersionsRequest() + return HttpResponse.json<{ available_versions: AvailableVersion[] }>({ + available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1), + }) + }, + }) + + await renderWizard() + + await waitFor(() => expect(onAvailableVersionsRequest).toHaveBeenCalled()) + expect( + screen.queryByRole('button', { name: 'Advanced Configuration' }) + ).not.toBeInTheDocument() + }) + test('selecting orioledb shows the alpha warning and submits the oriole engine/channel', async () => { mockWizardEndpoints() const onRequest = vi.fn() @@ -520,40 +555,109 @@ describe('project creation wizard', () => { expect(body.release_channel).toBe('alpha') }) - test('rejects high availability combined with orioledb', async () => { + test('hides advanced configuration only while high availability is enabled', async () => { mockWizardEndpoints() const onRequest = vi.fn() mockCreateProject(onRequest) - await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + await renderWizard() await fillProjectName('HA Oriole Project') + await generateAndWaitForStrongPassword() await selectRegion(/Americas/) - fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) - await user.click(await screen.findByRole('switch')) - fireEvent.click(await screen.findByRole('button', { name: 'Advanced Configuration' })) await user.click(await screen.findByRole('radio', { name: /Postgres with OrioleDB/ })) - await screen.findByText('High availability is not supported with OrioleDB images') - expect(onRequest).not.toHaveBeenCalled() + const highAvailabilitySwitch = await screen.findByRole('switch') + await user.click(highAvailabilitySwitch) + + expect( + screen.queryByRole('button', { name: 'Advanced Configuration' }) + ).not.toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + expect( + await screen.findByRole('button', { name: 'Advanced Configuration' }) + ).toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + expect(onRequest.mock.calls[0][0].postgres_engine).toBe('17') + expect(onRequest.mock.calls[0][0].release_channel).toBe('ga') + }) + + test('keeps a manually selected Postgres version when available versions refetch', async () => { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) + const onAvailableVersionsRequest = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: () => { + onAvailableVersionsRequest() + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + DEFAULT_AVAILABLE_VERSIONS + ) + }, + }) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('Sticky Version Project') + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + // Auto-selects the GA default once versions load + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + const requestsBeforeRegionChange = onAvailableVersionsRequest.mock.calls.length + await selectRegion(/Frankfurt/) + await waitFor(() => + expect(onAvailableVersionsRequest.mock.calls.length).toBeGreaterThan( + requestsBeforeRegionChange + ) + ) + + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + }) + + test('keeps a non-default Postgres version when the internal panel is collapsed and reopened', async () => { + mockWizardEndpoints() + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await screen.findByPlaceholderText('Project name') + await selectRegion(/Americas/) + const panelToggle = await screen.findByRole('button', { + name: 'Internal-only Configuration', + }) + fireEvent.click(panelToggle) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + fireEvent.click(panelToggle) + await waitFor(() => expect(screen.queryByLabelText('Postgres version')).toBeNull()) + fireEvent.click(panelToggle) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + ) }) test('blocks submission with a toast when orioledb becomes unavailable after selection', async () => { - const FRANKFURT = 'Central EU (Frankfurt)' - mockWizardEndpoints({ - availableRegions: { - ...DEFAULT_AVAILABLE_REGIONS, - all: { - ...DEFAULT_AVAILABLE_REGIONS.all, - specific: [ - ...DEFAULT_AVAILABLE_REGIONS.all.specific, - { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' }, - ], - }, - }, - }) + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) const onRequest = vi.fn() mockCreateProject(onRequest) @@ -592,6 +696,245 @@ describe('project creation wizard', () => { }) }) + describe('high availability', () => { + test('shows the high availability toggle when the entitlement is enabled', async () => { + mockWizardEndpoints() + + await renderWizard() + + expect(await screen.findByText('High availability')).toBeInTheDocument() + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText(/Free during Alpha for up to 2 projects/)).toBeInTheDocument() + expect( + screen + .getByText('High availability') + .compareDocumentPosition(screen.getByText('Compute size')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect( + screen.queryByRole('button', { name: 'Internal-only Configuration' }) + ).not.toBeInTheDocument() + }) + + test('hides the high availability toggle when the org lacks the entitlement', async () => { + mockWizardEndpoints({ + entitlements: [ + { + feature: { key: 'instances.high_availability', type: 'boolean' }, + hasAccess: false, + type: 'boolean', + config: { enabled: false }, + } as Entitlement, + ], + }) + + await renderWizard() + + await screen.findByPlaceholderText('Project name') + expect(screen.queryByText('High availability')).not.toBeInTheDocument() + }) + + test('removes the custom Postgres version field while high availability is enabled', async () => { + mockWizardEndpoints() + + await renderWizard({ + flags: { + newProjectInternalOnlyConfiguration: true, + }, + }) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + expect(screen.getByLabelText('Postgres version')).toBeDisabled() + expect(screen.queryByPlaceholderText('e.g 17.6.1.104')).not.toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + + expect(await screen.findByPlaceholderText('e.g 17.6.1.104')).toBeInTheDocument() + }) + + test('shows high availability regions in a dedicated group', async () => { + mockWizardEndpoints() + + await renderWizard() + + await user.click(await screen.findByRole('switch', { name: 'Enable high availability' })) + await user.click(getSelectTriggerByLabel('Region')) + + expect(await screen.findByText('High Availability Regions')).toBeInTheDocument() + expect(screen.queryByText('General regions')).not.toBeInTheDocument() + expect(screen.queryByText('Specific regions')).not.toBeInTheDocument() + }) + + test('enabling high availability submits the fixed engine and AWS_K8S provider without a custom version', async () => { + mockWizardEndpoints() + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard() + + await fillProjectName('HA Project') + await generateAndWaitForStrongPassword() + await user.click(await screen.findByRole('switch')) + await selectRegion(/East US/) + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + const body = onRequest.mock.calls[0][0] + expect(body.high_availability).toBe(true) + expect(body.cloud_provider).toBe('AWS_K8S') + expect(body.postgres_engine).toBe('17') + expect(body.release_channel).toBe('ga') + expect(body.custom_supabase_internal_requests).toBeUndefined() + }) + + test('forces the high availability region over a manually selected region and restores it', async () => { + vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'staging') + try { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) + + await renderWizard() + + await screen.findByPlaceholderText('Project name') + await selectRegion(/Frankfurt/) + expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(getSelectTriggerByLabel('Region')).toHaveTextContent('East US (North Virginia)') + ) + + await user.click(highAvailabilitySwitch) + + await waitFor(() => expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT)) + } finally { + vi.unstubAllEnvs() + } + }) + + test('restores the Postgres version selection when high availability is toggled off', async () => { + mockWizardEndpoints() + // Mirror staging: no versions offered for the standard provider, so the + // selection only ever gets populated by the AWS_K8S list while HA is on. + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: async ({ request }) => { + const { provider } = (await request.json()) as { provider: string } + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + provider === 'AWS_K8S' ? DEFAULT_AVAILABLE_VERSIONS : { available_versions: [] } + ) + }, + }) + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('HA Selection Restore Project') + await generateAndWaitForStrongPassword() + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + + // The AWS_K8S versions load and auto-select a default while HA is on + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent( + 'Select a Postgres version for your project' + ) + ) + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + const body = onRequest.mock.calls[0][0] + expect(body.high_availability).toBe(false) + expect(body.postgres_engine).toBeUndefined() + expect(body.release_channel).toBeUndefined() + expect(body.custom_supabase_internal_requests).toBeUndefined() + }) + + test('restores a manually selected Postgres version selection after HA toggle', async () => { + mockWizardEndpoints() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: async ({ request }) => { + const { provider } = (await request.json()) as { provider: string } + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + provider === 'AWS_K8S' + ? { available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1) } + : DEFAULT_AVAILABLE_VERSIONS + ) + }, + }) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('Manual Selection Restore Project') + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + ) + }) + + test('restores the standard Postgres configuration when high availability is disabled', async () => { + mockWizardEndpoints() + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard() + + await fillProjectName('Standard Project') + await generateAndWaitForStrongPassword() + const highAvailabilitySwitch = await screen.findByRole('switch') + await user.click(highAvailabilitySwitch) + await user.click(highAvailabilitySwitch) + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + const body = onRequest.mock.calls[0][0] + expect(body.high_availability).toBe(false) + expect(body.cloud_provider).toBe('AWS') + expect(body.postgres_engine).toBeUndefined() + expect(body.custom_supabase_internal_requests).toBeUndefined() + }) + }) + describe('security options', () => { test('disabling the Data API sends an empty exposed-schemas list', async () => { mockWizardEndpoints() diff --git a/apps/studio/tests/pages/project/[ref]/settings/infrastructure.test.tsx b/apps/studio/tests/pages/project/[ref]/settings/infrastructure.test.tsx new file mode 100644 index 00000000000..4db3ac56f8c --- /dev/null +++ b/apps/studio/tests/pages/project/[ref]/settings/infrastructure.test.tsx @@ -0,0 +1,437 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { platformComponents as components } from 'api-types' +import { HttpResponse } from 'msw' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import type { ProfileContextType } from '@/lib/profile' +import InfrastructurePage from '@/pages/project/[ref]/settings/infrastructure' +import { createMockOrganizationResponse } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type ProjectDetailResponse = components['schemas']['ProjectDetailResponse'] +type ProjectAddonsResponse = components['schemas']['ProjectAddonsResponse'] +type PermissionResponse = components['schemas']['AccessControlPermission'] + +const PROJECT_REF = 'project-ref' +const ORGANIZATION_SLUG = 'acme' + +const PROFILE_CONTEXT: ProfileContextType = { + profile: { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'testuser', + primary_email: 'test@example.com', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, + }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, +} + +const PROJECT: ProjectDetailResponse = { + cloud_provider: 'AWS', + connectionString: 'postgresql://postgres:password@db.project-ref.supabase.co:5432/postgres', + db_host: 'db.project-ref.supabase.co', + high_availability: false, + id: 1, + infra_compute_size: 'micro', + inserted_at: '2026-01-01T00:00:00.000Z', + integration_source: null, + is_branch_enabled: false, + is_physical_backups_enabled: false, + name: 'Production', + organization_id: 1, + ref: PROJECT_REF, + region: 'us-east-1', + restUrl: `https://${PROJECT_REF}.supabase.co`, + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00.000Z', +} + +const COMPUTE_VARIANTS: ProjectAddonsResponse['available_addons'][number]['variants'] = [ + { + identifier: 'ci_micro', + name: 'Micro', + price: 0.01344, + price_description: '$0.01344/hour (~$10/month)', + price_interval: 'hourly', + price_type: 'usage', + meta: { cpu_cores: 2, memory_gb: 1 }, + }, + { + identifier: 'ci_small', + name: 'Small', + price: 0.0206, + price_description: '$0.0206/hour (~$15/month)', + price_interval: 'hourly', + price_type: 'usage', + meta: { cpu_cores: 2, memory_gb: 2 }, + }, + { + identifier: 'ci_large', + name: 'Large', + price: 0.1517, + price_description: '$0.1517/hour (~$110/month)', + price_interval: 'hourly', + price_type: 'usage', + meta: { cpu_cores: 2, memory_gb: 8 }, + }, +] + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + useIsLoggedIn: () => true, + useParams: () => ({ ref: PROJECT_REF }), + } +}) + +vi.mock('@/lib/constants', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + } +}) + +vi.mock('ui-patterns/Admonition', () => ({ + Admonition: ({ + children, + description, + title, + }: { + children?: ReactNode + description?: ReactNode + title?: ReactNode + }) => ( +
      + {title} + {description} + {children} +
      + ), +})) + +vi.mock('ui-patterns/Chart', () => ({ + Chart: ({ children }: { children: ReactNode }) =>
      {children}
      , + ChartCard: ({ children }: { children: ReactNode }) =>
      {children}
      , + ChartContent: ({ children }: { children: ReactNode }) =>
      {children}
      , + ChartEmptyState: ({ title }: { title: ReactNode }) =>
      {title}
      , + ChartHeader: ({ children }: { children: ReactNode }) =>
      {children}
      , + ChartLine: () =>
      , + ChartLoadingState: () =>
      Loading chart
      , + ChartMetric: ({ label, value }: { label: ReactNode; value: ReactNode }) => ( +
      + {label}: {value} +
      + ), +})) + +function mockInfrastructureEndpoints() { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: PROJECT, + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [ + createMockOrganizationResponse({ + id: 1, + slug: ORGANIZATION_SLUG, + plan: { id: 'pro', name: 'Pro' }, + usage_billing_enabled: true, + }), + ], + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/entitlements', + response: { + entitlements: [ + { + config: { enabled: true }, + feature: { + key: 'instances.compute_update_available_sizes', + type: 'boolean', + }, + hasAccess: true, + type: 'boolean', + }, + ], + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/profile/permissions', + response: [ + { + actions: [PermissionAction.UPDATE], + condition: null, + organization_id: 1, + organization_slug: ORGANIZATION_SLUG, + project_ids: [PROJECT.id], + project_refs: [PROJECT_REF], + resources: ['projects'], + restrictive: false, + } satisfies PermissionResponse, + ], + }) + addAPIMock({ + method: 'get', + path: '/platform/projects-resource-warnings', + response: [], + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/databases', + response: [ + { + cloud_provider: 'AWS', + connection_string_read_only: PROJECT.connectionString, + connectionString: PROJECT.connectionString, + db_host: PROJECT.db_host, + db_name: 'postgres', + db_port: 5432, + db_user: 'postgres', + identifier: PROJECT_REF, + inserted_at: PROJECT.inserted_at, + region: PROJECT.region, + restUrl: PROJECT.restUrl, + size: 't4g.small', + status: 'ACTIVE_HEALTHY', + }, + ], + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/disk', + response: { + attributes: { + iops: 3000, + size_gb: 100, + throughput_mbps: 125, + throughput_mibps: 125, + type: 'gp3', + }, + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/disk/util', + response: { + metrics: { + fs_avail_bytes: 60 * 1024 * 1024 * 1024, + fs_size_bytes: 100 * 1024 * 1024 * 1024, + fs_used_bytes: 40 * 1024 * 1024 * 1024, + }, + timestamp: '2026-07-20T00:00:00.000Z', + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/disk/custom-config', + response: { + growth_percent: 20, + max_size_gb: 1000, + min_increment_gb: 10, + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/billing/addons', + response: { + available_addons: [ + { + name: 'Compute Instance', + type: 'compute_instance', + variants: COMPUTE_VARIANTS, + }, + ], + ref: PROJECT_REF, + selected_addons: [ + { + type: 'compute_instance', + variant: COMPUTE_VARIANTS[0], + }, + ], + }, + }) + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/infra-monitoring', + response: () => + HttpResponse.json({ + series: {}, + data: [ + { + period_start: '2026-07-20T00:00:00.000Z', + values: { + max_cpu_usage: '40', + ram_usage: '50', + disk_io_consumption: '60', + pg_database_size: String(40 * 1024 * 1024 * 1024), + disk_fs_used_wal: String(5 * 1024 * 1024 * 1024), + disk_fs_used_system: String(5 * 1024 * 1024 * 1024), + disk_fs_size: String(100 * 1024 * 1024 * 1024), + }, + }, + ], + }), + }) +} + +function renderInfrastructurePage() { + return customRender(, { + profileContext: PROFILE_CONTEXT, + }) +} + +describe('/project/[ref]/settings/infrastructure', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInfrastructureEndpoints() + window.HTMLElement.prototype.scrollIntoView = vi.fn() + }) + + test('loads the complete infrastructure page through MSW and resets an interaction', async () => { + const user = userEvent.setup() + renderInfrastructurePage() + + expect(screen.getByRole('heading', { name: 'Infrastructure', level: 1 })).toBeInTheDocument() + expect(screen.getByText('Scaling')).toBeInTheDocument() + expect(screen.getByText('Compute size')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Disk', level: 2 })).toBeInTheDocument() + // The Advanced section renders only once the project query resolves (isAws) + expect(await screen.findByText('Advanced')).toBeInTheDocument() + + const diskSize = await screen.findByRole('spinbutton', { name: 'Disk size' }) + await waitFor(() => expect(diskSize).toHaveValue(100)) + expect(screen.getByRole('spinbutton', { name: 'IOPS' })).toHaveValue(3000) + expect(screen.getByRole('spinbutton', { name: 'Throughput' })).toHaveValue(125) + expect(screen.getByTestId('metric-CPU')).toHaveTextContent('40%') + expect(screen.getByTestId('metric-Memory')).toHaveTextContent('50%') + expect(screen.getByTestId('metric-Disk')).toHaveTextContent('50%') + + await user.click(screen.getByText('Small')) + expect(screen.getByRole('button', { name: 'Review changes' })).toBeEnabled() + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Review changes' })).not.toBeInTheDocument() + }) + }) + + test('reviews and confirms a compute resize through the add-on API', async () => { + const user = userEvent.setup() + let addonRequest: unknown + + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/billing/addons', + response: async ({ request }) => { + addonRequest = await request.json() + return new HttpResponse(null, { status: 201 }) + }, + }) + + renderInfrastructurePage() + + await user.click(await screen.findByText('Small')) + await user.click(screen.getByRole('button', { name: 'Review changes' })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Compute size')).toBeInTheDocument() + expect(within(dialog).getByText('Micro')).toBeInTheDocument() + expect(within(dialog).getByText('Small')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Confirm changes' })) + + await waitFor(() => { + expect(addonRequest).toEqual({ + addon_type: 'compute_instance', + addon_variant: 'ci_small', + }) + }) + }) + + test('submits disk and autoscaling changes to both infrastructure APIs', async () => { + const user = userEvent.setup() + let diskRequest: unknown + let autoscaleRequest: unknown + + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/disk', + response: async ({ request }) => { + diskRequest = await request.json() + return new HttpResponse(null, { status: 201 }) + }, + }) + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/disk/custom-config', + response: async ({ request }) => { + autoscaleRequest = await request.json() + return HttpResponse.json( + { + growth_percent: 20, + max_size_gb: 1200, + min_increment_gb: 10, + }, + { status: 201 } + ) + }, + }) + + renderInfrastructurePage() + + const diskSize = await screen.findByRole('spinbutton', { name: 'Disk size' }) + await waitFor(() => expect(diskSize).toHaveValue(100)) + await user.clear(diskSize) + await user.type(diskSize, '120') + + const maxDiskSize = screen.getByRole('spinbutton', { name: 'Maximum disk size' }) + await user.clear(maxDiskSize) + await user.type(maxDiskSize, '1200') + + await user.click(screen.getByRole('button', { name: 'Review changes' })) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Disk size')).toBeInTheDocument() + expect(within(dialog).getByText('Max disk size')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Confirm changes' })) + + await waitFor(() => { + expect(diskRequest).toEqual({ + attributes: { + iops: 3000, + size_gb: 120, + throughput_mbps: 125, + type: 'gp3', + }, + }) + expect(autoscaleRequest).toEqual({ + growth_percent: 20, + max_size_gb: 1200, + min_increment_gb: 10, + }) + }) + }) +}) diff --git a/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx b/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx index f5c0cd53e3e..49005e81ee2 100644 --- a/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx +++ b/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx @@ -3,7 +3,6 @@ import { http, HttpResponse } from 'msw' import { beforeEach, describe, expect, test, vi } from 'vitest' import type { ProjectStorageConfigData } from '@/data/config/project-storage-config-query' -import type { ProjectDetail } from '@/data/projects/project-detail-query' import { API_URL } from '@/lib/constants' import StorageVectorsPage from '@/pages/project/[ref]/storage/vectors' import { customRender } from '@/tests/lib/custom-render' @@ -39,16 +38,7 @@ vi.mock('@/lib/constants', async (importOriginal) => { }) // Stub the heavy leaf children — each fires its own queries and renders a large -// tree. We only assert which branch the page picks. Keep the real -// `VECTOR_BUCKETS_AVAILABLE_REGIONS` so the region gate runs for real. -vi.mock( - '@/components/interfaces/Storage/VectorBuckets/RegionLimitation', - async (importOriginal) => ({ - ...(await importOriginal>()), - RegionLimitation: () =>
      region-limitation
      , - }) -) - +// tree. We only assert which branch the page picks. vi.mock('@/components/interfaces/Storage/VectorBuckets', () => ({ VectorsBuckets: () =>
      vectors-buckets
      , })) @@ -57,18 +47,6 @@ vi.mock('@/components/interfaces/Storage/BucketsUpgradePlan', () => ({ BucketsUpgradePlan: ({ type }: { type: string }) =>
      buckets-upgrade-plan-{type}
      , })) -const AVAILABLE_REGION = 'us-east-1' -const UNAVAILABLE_REGION = 'ap-south-1' - -const mockProject = (region: string) => { - addAPIMock({ - method: 'get', - path: '/platform/projects/:ref', - // The page only reads `region` off the project - response: () => HttpResponse.json({ region } as unknown as ProjectDetail), - }) -} - const mockStorageConfig = (vectorBucketsEnabled: boolean) => { addAPIMock({ method: 'get', @@ -99,20 +77,9 @@ const mockDeploymentMode = (isCli: boolean) => { describe('StorageVectorsPage', () => { beforeEach(() => { mockIsPlatform.value = true - mockProject(AVAILABLE_REGION) }) - test('platform + region not supported: shows region limitation', async () => { - mockProject(UNAVAILABLE_REGION) - mockStorageConfig(true) - - customRender() - - expect(await screen.findByText('region-limitation')).toBeInTheDocument() - expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument() - }) - - test('platform + supported region + not enabled: shows upgrade plan', async () => { + test('platform + not enabled: shows upgrade plan', async () => { mockStorageConfig(false) customRender() @@ -121,7 +88,7 @@ describe('StorageVectorsPage', () => { expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument() }) - test('platform + supported region + enabled: shows vector buckets', async () => { + test('platform + enabled: shows vector buckets', async () => { mockStorageConfig(true) customRender() @@ -129,16 +96,13 @@ describe('StorageVectorsPage', () => { expect(await screen.findByText('vectors-buckets')).toBeInTheDocument() }) - test('CLI (non-platform, enabled): shows vector buckets, skips region/upgrade gates', async () => { + test('CLI (non-platform, enabled): shows vector buckets, skips upgrade gate', async () => { mockIsPlatform.value = false - // Even an unsupported region shouldn't gate off-platform - mockProject(UNAVAILABLE_REGION) mockDeploymentMode(true) customRender() expect(await screen.findByText('vectors-buckets')).toBeInTheDocument() - expect(screen.queryByText('region-limitation')).not.toBeInTheDocument() }) test('self-hosted (non-platform): renders nothing', async () => { diff --git a/apps/studio/types/userContent.ts b/apps/studio/types/userContent.ts index 03f926eeb17..0910808bb82 100644 --- a/apps/studio/types/userContent.ts +++ b/apps/studio/types/userContent.ts @@ -1,6 +1,7 @@ import type { UntrustedSqlFragment } from '@supabase/pg-meta' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import type { UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql' export interface UserContent< T = Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content, @@ -123,8 +124,13 @@ export namespace LogSqlSnippets { // unique id of the sql snippet, possibly to used so snippets can support versioning content_id: string - // A full SQL query - this will be hashed on the /content endpoint - sql: string + // A full SQL query - this will be hashed on the /content endpoint. + // Named unchecked_sql (mirroring SqlSnippets.Content) to highlight that this SQL must + // never be run automatically without a user run gesture. The API stores/returns it as + // `sql`; the remap boundary in data/content/content-remap.ts brands it on the way in + // (untrustedLogSql) and strips the brand on the way out. Distinct from the Postgres + // brand so logs SQL and database SQL can never cross execution paths. + unchecked_sql: UntrustedLogSqlFragment // we can add some versioning to this schema in case we need to change the format. schema_version: string diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 7300e86046c..29998d6d8a2 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -670,6 +670,15 @@ export default defineConfig(({ command, mode }) => { server: { port: 3000, }, + preview: { + // The prerender step (@tanstack/start-plugin-core) boots `vite preview` + // on an ephemeral port and crawls the first resolved URL. With the + // default host (`localhost`) the server can bind the IPv6 loopback + // while the crawler's fetch connects to 127.0.0.1 — split name + // resolution that ECONNREFUSEDs the whole prerender inside docker + // build containers. Pin both sides to IPv4 loopback. + host: '127.0.0.1', + }, resolve: { tsconfigPaths: true, alias: [ diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index dfbcb6c3de8..62abc5c3b9e 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -80,6 +80,7 @@ "@types/lodash": "^4.17.16", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native": "catalog:", "config": "workspace:^", "lodash": "catalog:", "mdast-util-toc": "^6.1.1", @@ -92,7 +93,6 @@ "tailwindcss": "catalog:", "tsconfig": "workspace:*", "tsx": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "vite": "catalog:" } diff --git a/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx b/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx index 9ddfb9d672a..e4e518a2873 100644 --- a/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx +++ b/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx @@ -544,9 +544,7 @@ create trigger on_auth_user_created Also, Supabase has email confirmation turned on by default, meaning that every time someone signs up, they have to click the confirmation link they receive in their email. This is ideal for a production app, but for our sample app, we can turn it off since we want to get up and running with building a functioning chat app. We will cover secure authentications using Supabase in later articles. -Go to authentication → settings and turn off the switch of `Enable email confirmations`. - -![turn off email confirmation](/images/blog/flutter-chat/turn-off-email-confirmation.png) +Go to [Authentication → Sign In / Providers](https://supabase.com/dashboard/project/_/auth/providers) and turn off the **Confirm email** toggle under **User Signups**. ### Step 6: Create login page diff --git a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx index fe0c4ee9e35..15ff6346dc1 100644 --- a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx +++ b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx @@ -52,9 +52,7 @@ First of all we need a new Supabase project. If you don't have a Supabase accoun In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy o your Database password! -The only thing we will change manually for now is disabling the email confirmation step. By doing this, users will be directly able to sign in when using the magic link, so go to the **Authentication** tab of your project, select **Settings** and scroll down to your **Auth Providers** where you can disable it. - -![Disable Email confirm](/images/blog/simon-grimm-angular-realtime/disable-email-confirm.png) +The only thing we will change manually for now is disabling the email confirmation step. By doing this, users will be directly able to sign in when using the magic link, so go to [**Authentication → Sign In / Providers**](https://supabase.com/dashboard/project/_/auth/providers) in your project and turn off the **Confirm email** toggle under **User Signups**. Everything else regarding authentication[https://supabase.com/docs/guides/auth] is handled by Supabase and we don't need to worry about it at the moment! diff --git a/apps/www/_blog/2024-01-31-nosql-mongodb-compatibility-with-ferretdb-and-flydotio.mdx b/apps/www/_blog/2024-01-31-nosql-mongodb-compatibility-with-ferretdb-and-flydotio.mdx index 7d62d103765..5c098310c28 100644 --- a/apps/www/_blog/2024-01-31-nosql-mongodb-compatibility-with-ferretdb-and-flydotio.mdx +++ b/apps/www/_blog/2024-01-31-nosql-mongodb-compatibility-with-ferretdb-and-flydotio.mdx @@ -45,7 +45,7 @@ FerretDB only requires the Postgres database URI to be provided as the `FERRETDB Make sure **Use connection pooling** is checked and **Session mode** is selected. Then copy the URI. Replace the password placeholder with your saved database password. - + If your network supports IPv6 connections, you can also use the direct connection string. Uncheck **Use connection pooling** and copy the new URI. diff --git a/apps/www/_blog/2024-12-04-cli-v2-config-as-code.mdx b/apps/www/_blog/2024-12-04-cli-v2-config-as-code.mdx index e74b3a8daf7..b70f75ae6ad 100644 --- a/apps/www/_blog/2024-12-04-cli-v2-config-as-code.mdx +++ b/apps/www/_blog/2024-12-04-cli-v2-config-as-code.mdx @@ -41,7 +41,7 @@ You can make deployments consistent and repeatable by promoting Edge Functions, To demonstrate this workflow, let’s use the `supabase.com` website as an example. It’s hosted on Vercel with [Supabase Branching](/docs/guides/deployment/branching) enabled for development. If you are not using Branching, a similar setup can be achieved using GitHub Actions. - + Before changing any project configuration, it’s a good idea to verify that your remote config has not drifted. @@ -75,7 +75,7 @@ verify_jwt = false If you are using a monorepo (like the [@supabase/supabase](https://github.com/supabase/supabase) GitHub repository), you may also want to customize the paths to your function’s entrypoint and import map files. This is especially useful for code sharing between your frontend application and Edge Functions. - + Edge Function secrets must be manually added to branches as of this release. We plan to support setting Function secrets at deploy time in the near future. diff --git a/apps/www/_blog/2025-10-08-triplit-joins-supabase.mdx b/apps/www/_blog/2025-10-08-triplit-joins-supabase.mdx index 35740c54ca3..9f20d82d8c5 100644 --- a/apps/www/_blog/2025-10-08-triplit-joins-supabase.mdx +++ b/apps/www/_blog/2025-10-08-triplit-joins-supabase.mdx @@ -10,7 +10,7 @@ date: '2025-10-08T10:00:00' toc_depth: 2 --- -We're excited to welcome Matt Linkous, co-founder of [Triplit](https://www.triplit.dev/), to the Supabase team. +We're excited to welcome Matt Linkous, co-founder of Triplit, to the Supabase team. With this acquisition, we're bringing Matt's deep expertise in the offline-first domain into our ecosystem. Our focus isn't to directly integrate Triplit into our platform. Instead, Matt will be working to expand third-party integrations at Supabase. Part of this effort will be making Supabase an excellent partner to other syncing systems such as [ElectricSQL](https://electric-sql.com/), [Zero](https://zero.rocicorp.dev/), and [PowerSync](https://www.powersync.com/). Creating a great self-serve integration system is a growing priority at Supabase, so we're excited for Matt to bring his experience as Y Combinator founder and open-source maintainer to build an integration experience that's seamless for both developers using Supabase and third-party providers. diff --git a/apps/www/_blog/2025-12-02-introducing-supabase-pipelines.mdx b/apps/www/_blog/2025-12-02-introducing-supabase-pipelines.mdx index eaea807b1a2..0ab739d6940 100644 --- a/apps/www/_blog/2025-12-02-introducing-supabase-pipelines.mdx +++ b/apps/www/_blog/2025-12-02-introducing-supabase-pipelines.mdx @@ -128,7 +128,7 @@ BigQuery is currently available. You can [request early access to ClickHouse, Sn ## Pricing -Pipelines is billed for configured pipeline hours, initial sync data, and ongoing replication data. See [Pipelines pricing](/docs/guides/platform/manage-your-usage/pipelines) for current rates and billing examples. Destination-provider charges, such as BigQuery charges from Google Cloud, are separate. +Pipelines is billed for configured pipeline hours and Postgres row data processed during initial sync and ongoing replication. See [Pipelines pricing](/docs/guides/platform/manage-your-usage/pipelines) for the measurement definition, current rates, and billing examples. Destination-provider charges, such as BigQuery charges from Google Cloud, are separate. ## Get started diff --git a/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx b/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx index a3dd01b0ea1..82541195ed9 100644 --- a/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx +++ b/apps/www/_blog/2026-07-21-supabase-pipelines-public-alpha.mdx @@ -108,10 +108,10 @@ We're always looking to add more destinations. If there is another warehouse, la Supabase Pipelines uses a pipeline-based and usage-based pricing model: - **$0.053 per hour** per configured pipeline -- **$0.60 per GB** of initial sync data -- **$3 per GB** of ongoing replication data +- **$0.60 per GB** of data processed during initial sync +- **$3 per GB** of data processed during ongoing replication -A pipeline becomes billable as soon as it is created and is charged hourly, including while replication is paused or inactive. Initial sync data covers the first load of existing data, while ongoing replication data covers changes processed after that initial sync. Pricing may change during the public alpha, and we will provide advance notice before any updates take effect. +A pipeline becomes billable as soon as it is created and is charged hourly, including while replication is paused or inactive. Data processed is Postgres row data accepted by the destination, measured consistently across destinations. The initial sync rate covers the first load of existing data and later resynchronizations, while the ongoing replication rate covers subsequent changes. Pricing may change during the public alpha, and we will provide advance notice before any updates take effect. ## Things to know during public alpha diff --git a/apps/www/_blog/2026-07-23-observability-for-every-supabase-project-with-grafana-cloud.mdx b/apps/www/_blog/2026-07-23-observability-for-every-supabase-project-with-grafana-cloud.mdx new file mode 100644 index 00000000000..f5e32a66ea8 --- /dev/null +++ b/apps/www/_blog/2026-07-23-observability-for-every-supabase-project-with-grafana-cloud.mdx @@ -0,0 +1,60 @@ +--- +title: 'Observability for every Supabase project with Grafana Cloud' +description: 'Connect your Supabase project to Grafana Cloud in one click. A pre-built dashboard, alerting, and metrics: available on every plan, including free.' +author: alex_hall, matt_linkous, raminder_singh +date: '2026-07-23' +categories: + - product +tags: + - integrations + - observability + - monitoring + - postgres +imgSocial: observability-for-every-supabase-project-with-grafana-cloud/grafana-og.png +imgThumb: observability-for-every-supabase-project-with-grafana-cloud/grafana-thumb.png +toc_depth: 2 +--- + +The [Grafana Cloud integration](https://supabase.com/dashboard/project/_/integrations/grafana-cloud/overview?utm_source=supabase-announcement&utm_medium=blog&utm_campaign=supabase_grafana_cloud_one_click_integration_launch) is available in Supabase. You've been able to connect Supabase to Grafana Cloud for years, and now you can connect to it instantly with the push of a button. + +From your Supabase Dashboard, in a single click, you get a fully-configured Grafana Cloud instance with authentication, metric scraping, and a pre-built observability dashboard already set up on any plan, including free. + +[Grafana Cloud](https://grafana.com/products/cloud/) is the AI-powered open observability cloud built on open source and open standards. Used by developers everywhere, the full-stack observability platform helps you improve the reliability of your applications, resolve incidents quickly, and optimize your telemetry to reduce noise and costs. + +
      +