diff --git a/.github/workflows/trigger-nimbus-sync.yml b/.github/workflows/trigger-nimbus-sync.yml
new file mode 100644
index 00000000000..603360eb170
--- /dev/null
+++ b/.github/workflows/trigger-nimbus-sync.yml
@@ -0,0 +1,17 @@
+name: Trigger Nimbus Sync
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch: # Allow manual triggering
+
+jobs:
+ trigger-nimbus-sync:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Trigger sync in supabase-nimbus
+ uses: peter-evans/repository-dispatch@v4
+ with:
+ token: ${{ secrets.NIMBUS_SYNC_TOKEN }}
+ repository: supabase/supabase-nimbus
+ event-type: sync_from_upstream
diff --git a/apps/docs/content/_partials/mcp_supabase_config.mdx b/apps/docs/content/_partials/mcp_supabase_config.mdx
deleted file mode 100644
index 871a25462b2..00000000000
--- a/apps/docs/content/_partials/mcp_supabase_config.mdx
+++ /dev/null
@@ -1,156 +0,0 @@
-
-
-
-
- ```json
- {
- "mcpServers": {
- "supabase": {
- "command": "npx",
- "args": [
- "-y",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref, and `` with your personal access token.
-
-
-
-
-
- ```json
- {
- "mcpServers": {
- "supabase": {
- "command": "cmd",
- "args": [
- "/c",
- "npx",
- "-y",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
-Or, if using `pnpm` instead of `npm`
-
- ```json
- {
- "mcpServers": {
- "supabase": {
- "command": "cmd",
- "args": [
- "/c",
- "pnpm",
- "dlx",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
- Replace `` with your project ref, and `` with your personal access token.
-
-
-
- Make sure that `node` and `npx` are available in your system `PATH`. Assuming `node` is installed, you can get the path by running:
-
- ```shell
- npm config get prefix
- ```
-
- Then add it to your system `PATH` by running:
-
- ```shell
- setx PATH "%PATH%;"
- ```
-
- Replacing `` with the path you got from the previous command.
-
- Finally restart {{ .app }} for the changes to take effect.
-
-
-
-
-
-
-
- ```json
- {
- "mcpServers": {
- "supabase": {
- "command": "wsl",
- "args": [
- "npx",
- "-y",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref, and `` with your personal access token.
-
- This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment.
-
-
-
-
-
- ```json
- {
- "mcpServers": {
- "supabase": {
- "command": "npx",
- "args": [
- "-y",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref, and `` with your personal access token.
-
-
-
-
diff --git a/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx b/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx
deleted file mode 100644
index 0117cb7d3e3..00000000000
--- a/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx
+++ /dev/null
@@ -1,143 +0,0 @@
-
-
-
-
- ```json
- {
- "inputs": [
- {
- "type": "promptString",
- "id": "supabase-access-token",
- "description": "Supabase personal access token",
- "password": true
- }
- ],
- "servers": {
- "supabase": {
- "command": "npx",
- "args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="],
- "env": {
- "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref.
-
-
-
-
-
- ```json
- {
- "inputs": [
- {
- "type": "promptString",
- "id": "supabase-access-token",
- "description": "Supabase personal access token",
- "password": true
- }
- ],
- "servers": {
- "supabase": {
- "command": "cmd",
- "args": ["/c", "npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="],
- "env": {
- "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref.
-
-
-
- Make sure that `node` and `npx` are available in your system `PATH`. Assuming `node` is installed, you can get the path by running:
-
- ```shell
- npm config get prefix
- ```
-
- Then add it to your system `PATH` by running:
-
- ```shell
- setx PATH "%PATH%;"
- ```
-
- Replacing `` with the path you got from the previous command.
-
- Finally restart VS Code for the changes to take effect.
-
-
-
-
-
-
-
- ```json
- {
- "inputs": [
- {
- "type": "promptString",
- "id": "supabase-access-token",
- "description": "Supabase personal access token",
- "password": true
- }
- ],
- "servers": {
- "supabase": {
- "command": "wsl",
- "args": ["npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="],
- "env": {
- "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref.
-
- This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment.
-
-
-
-
-
- ```json
- {
- "inputs": [
- {
- "type": "promptString",
- "id": "supabase-access-token",
- "description": "Supabase personal access token",
- "password": true
- }
- ],
- "servers": {
- "supabase": {
- "command": "npx",
- "args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="],
- "env": {
- "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
- }
- }
- }
- }
- ```
-
- Replace `` with your project ref.
-
-
-
-
diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx
index b5667aba713..e3dbd33fba9 100644
--- a/apps/docs/content/guides/getting-started/mcp.mdx
+++ b/apps/docs/content/guides/getting-started/mcp.mdx
@@ -6,211 +6,31 @@ description: 'Connect your AI tools to Supabase using MCP'
sidebar_label: 'Model context protocol (MCP)'
---
-The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. This guide covers how to connect Supabase to the following AI tools using MCP:
+The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
-- [Cursor](#cursor)
-- [Windsurf](#windsurf) (Codium)
-- [Visual Studio Code](#visual-studio-code-copilot) (Copilot)
-- [Cline](#cline) (VS Code extension)
-- [Claude desktop](#claude-desktop)
-- [Claude code](#claude-code)
-- [Amp](#amp)
+## Remote MCP installation
-Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
-
-## Step 1: Create an access token
-
-First, go to your [Supabase settings](/dashboard/account/tokens) and create an access token to authenticate the MCP server with your Supabase account. Give it a name that describes its purpose, like "Cursor MCP Server".
-
-## Step 2: Follow our security best practices
+### Step 1: Follow our security best practices
Before running the MCP server, we recommend you read our [security best practices](#security-risks) to understand the risks of connecting an LLM to your Supabase projects and how to mitigate them.
-## Step 3: Configure your AI tool
+### Step 2: Configure your AI tool
-MCP compatible tools connect to Supabase using the [Supabase MCP server](https://github.com/supabase-community/supabase-mcp).
+Choose your Supabase platform, project, and MCP client and follow the installation instructions:
-Follow the instructions for your AI tool to connect the Supabase MCP server. The configuration below uses read-only, project-scoped mode by default. We recommend these settings to prevent the agent from making unintended changes to your database.
+
-
+
-Read-only mode applies only to database operations. Write operations on project-management tools,
-such as `create_project`, are still available.
+Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions.
+
+Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.
-### Cursor
-
-1. Open [Cursor](https://www.cursor.com/) and create a `.cursor` directory in your project root if it doesn't exist.
-2. Create a `.cursor/mcp.json` file if it doesn't exist and open it.
-3. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Cursor" }} />
-
-4. Save the configuration file.
-5. Open Cursor and navigate to **Settings > Cursor Settings > MCP & Integrations**. You should see a green active status after the server is successfully connected.
-
-### Windsurf
-
-1. Open [Windsurf](https://docs.codeium.com/windsurf) and open the Cascade assistant.
-2. Tap on the box (**Customizations**) icon, then the **Configure** icon in the top right of the panel to open the configuration file.
-3. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Windsurf" }} />
-
-4. Save the configuration file and reload by tapping **Refresh** in the Cascade assistant.
-5. You should see a green active status after the server is successfully connected.
-
-### Visual Studio Code (Copilot)
-
-
- [](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D)
- [](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D&quality=insiders)
-
-
-1. Open [VS Code](https://code.visualstudio.com/) and create a `.vscode` directory in your project root if it doesn't exist.
-2. Create a `.vscode/mcp.json` file if it doesn't exist and open it.
-3. Add the following configuration:
-
- <$Partial path="mcp_supabase_vscode_config.mdx" />
-
-4. Save the configuration file and click the **Start** button that appears inline above the Supabase server definition. VS Code prompts you to enter your personal access token. Enter the token that you created earlier.
-5. Open Copilot chat and switch to "Agent" mode. You should see a tool icon that you can tap to confirm the MCP tools are available.
-
-
-
-For more info on using MCP in VS Code, read the [Copilot documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers).
-
-
-
-### Cline
-
-1. Open the [Cline](https://github.com/cline/cline) extension in VS Code and tap the **MCP Servers** icon.
-2. Tap **MCP Servers**, open the **Installed** tab, then click "Configure MCP Servers" to open the configuration file.
-3. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "VS Code" }} />
-
-4. Save the configuration file. Cline should automatically reload the configuration.
-5. You should see a green active status after the server is successfully connected.
-
-### Claude desktop
-
-1. Open [Claude desktop](https://claude.ai/download) and navigate to **Settings**.
-2. Under the **Developer** tab, tap **Edit Config** to open the configuration file.
-3. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Claude desktop" }} />
-
-4. Save the configuration file and restart Claude desktop.
-5. From the new chat screen, you should see a settings (Search and tools) icon appear with the new MCP server available.
-
-### Claude code
-
-You can add the Supabase MCP server to Claude Code in two ways:
-
-#### Option 1: Project-scoped server (via .mcp.json file)
-
-1. Create a `.mcp.json` file in your project root if it doesn't exist.
-2. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Claude code" }} />
-
-3. Save the configuration file.
-4. Restart [Claude code](https://claude.ai/code) to apply the new configuration.
-
-#### Option 2: Locally-scoped server (via CLI command)
-
-You can also add the Supabase MCP server as a locally-scoped server, which is only available to you in the current project:
-
-1. Run the following command in your terminal:
-
- ```bash
- claude mcp add supabase -s local -e SUPABASE_ACCESS_TOKEN=your_token_here -- npx -y @supabase/mcp-server-supabase@latest
- ```
-
-Locally-scoped servers take precedence over project-scoped servers with the same name and are stored in your project-specific user settings.
-
-### Amp
-
-You can add the Supabase MCP server to [Amp](https://ampcode.com) in two ways:
-
-#### Option 1: VSCode settings.json
-
-1. Open VSCode's `settings.json` file.
-2. Add the following configuration:
-
- ```json
- {
- "amp.mcpServers": {
- "supabase": {
- "command": "npx",
- "args": [
- "-y",
- "@supabase/mcp-server-supabase@latest",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
- Replace `project-ref` and `personal-access-token` with your project ref and personal access token.
-
-3. Save the configuration file.
-4. Restart VS Code to apply the new configuration.
-
-#### Option 2: Amp CLI
-
-1. Edit `~/.config/amp/settings.json`
-2. Add the following configuration:
-
- ```json
- {
- "amp.mcpServers": {
- "supabase": {
- "command": "npx",
- "args": [
- "-y",
- "@supabase/mcp-server-supabase",
- "--read-only",
- "--project-ref="
- ],
- "env": {
- "SUPABASE_ACCESS_TOKEN": ""
- }
- }
- }
- }
- ```
-
- Replace `project-ref` and `personal-access-token` with your project ref and personal access token.
-
-3. Save the configuration file.
-4. Restart Amp to apply the new configuration.
-
-### Qodo Gen
-
-1. Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ.
-2. Click **Connect more tools**.
-3. Click **+ Add new MCP**.
-4. Add the following configuration:
-
- <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Qodo Gen" }} />
-
-5. Click **Save**.
-
### Next steps
-Your AI tool is now connected to Supabase using MCP. Try asking your AI assistant to create a new project, create a table, or fetch project config.
-
-For a full list of tools available, see the [GitHub README](https://github.com/supabase-community/supabase-mcp#tools). If you experience any issues, [submit an bug report](https://github.com/supabase-community/supabase-mcp/issues/new?template=1.Bug_report.md).
+Your AI tool is now connected to your Supabase project or account using remote MCP. Try asking the AI tool to query your database using natural language commands.
## Security risks
diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx
index 7a5bb363be1..2e1fef8edc4 100644
--- a/apps/docs/features/docs/MdxBase.shared.tsx
+++ b/apps/docs/features/docs/MdxBase.shared.tsx
@@ -1,11 +1,11 @@
import { ArrowDown, Check, X } from 'lucide-react'
import Link from 'next/link'
import { Button, Image } from 'ui'
+import { Heading } from 'ui/src/components/CustomHTMLElements'
import { Admonition } from 'ui-patterns/admonition'
import { GlassPanel } from 'ui-patterns/GlassPanel'
import { IconPanel } from 'ui-patterns/IconPanel'
import SqlToRest from 'ui-patterns/SqlToRest'
-import { Heading } from 'ui/src/components/CustomHTMLElements'
import { AiPromptsIndex } from '~/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex'
import { AppleSecretGenerator } from '~/components/AppleSecretGenerator'
import AuthProviders from '~/components/AuthProviders'
@@ -29,6 +29,7 @@ import InfoTooltip from '~/features/ui/InfoTooltip'
import { ShowUntil } from '~/features/ui/ShowUntil'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import { ErrorCodes } from '../ui/ErrorCodes'
+import { McpConfigPanel } from '../ui/McpConfigPanel'
const components = {
Accordion,
@@ -53,6 +54,7 @@ const components = {
Image: (props: any) => ,
JwtGenerator,
Link,
+ McpConfigPanel,
NamedCodeBlock,
NavData,
ProjectConfigVariables,
diff --git a/apps/docs/features/ui/McpConfigPanel.tsx b/apps/docs/features/ui/McpConfigPanel.tsx
new file mode 100644
index 00000000000..96411cd9299
--- /dev/null
+++ b/apps/docs/features/ui/McpConfigPanel.tsx
@@ -0,0 +1,220 @@
+'use client'
+
+import { useIsLoggedIn, useIsUserLoading } from 'common'
+import { Check, ChevronDown } from 'lucide-react'
+import { useTheme } from 'next-themes'
+import Link from 'next/link'
+import { useState } from 'react'
+import {
+ Button,
+ cn,
+ Command_Shadcn_,
+ CommandEmpty_Shadcn_,
+ CommandGroup_Shadcn_,
+ CommandInput_Shadcn_,
+ CommandItem_Shadcn_,
+ CommandList_Shadcn_,
+ Popover_Shadcn_,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+} from 'ui'
+import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder'
+import { useProjectsQuery } from '~/lib/fetch/projects'
+
+type PlatformType = (typeof PLATFORMS)[number]['value']
+
+const PLATFORMS = [
+ { value: 'hosted', label: 'Hosted' },
+ { value: 'local', label: 'CLI' },
+] as const satisfies Array<{ value: string; label: string }>
+
+function ProjectSelector({
+ className,
+ selectedProject,
+ onProjectSelect,
+}: {
+ className?: string
+ selectedProject?: { ref: string; name: string } | null
+ onProjectSelect?: (project: { ref: string; name: string } | null) => void
+}) {
+ const isUserLoading = useIsUserLoading()
+ const isLoggedIn = useIsLoggedIn()
+ const { data: projects, isLoading, isError } = useProjectsQuery({ enabled: isLoggedIn })
+
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
+
+ Project
+
+
+ {!isUserLoading && !isLoggedIn ? (
+
+
+ Log in to choose a project
+
+
+ ) : (
+
+
+ }
+ >
+
+ {isUserLoading || isLoading
+ ? 'Loading projects...'
+ : isError
+ ? 'Error fetching projects'
+ : selectedProject?.name ?? 'Select a project'}
+
+
+
+ )}
+
+
+
+
+
+ No results found.
+
+ {projects?.map((project) => (
+ {
+ onProjectSelect?.(project.ref === selectedProject?.ref ? null : project)
+ setOpen(false)
+ }}
+ className="flex gap-2 items-center"
+ >
+ {project.name}
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+function PlatformSelector({
+ className,
+ selectedPlatform,
+ onPlatformSelect,
+}: {
+ className?: string
+ selectedPlatform: PlatformType
+ onPlatformSelect?: (platform: PlatformType) => void
+}) {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
+
+ Platform
+
+
+
+
+ }
+ >
+
+ {PLATFORMS.find((p) => p.value === selectedPlatform)?.label}
+
+
+
+
+
+
+
+
+ {PLATFORMS.map((platform) => (
+ {
+ onPlatformSelect?.(platform.value)
+ setOpen(false)
+ }}
+ className="flex gap-2 items-center"
+ >
+ {platform.label}
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export function McpConfigPanel() {
+ const [selectedProject, setSelectedProject] = useState<{ ref: string; name: string } | null>(null)
+ const [selectedPlatform, setSelectedPlatform] = useState<'hosted' | 'local'>('hosted')
+ const { theme } = useTheme()
+
+ const isPlatform = selectedPlatform === 'hosted'
+ const project = isPlatform ? selectedProject : null
+
+ return (
+
+
+
+ {isPlatform && (
+
+ )}
+
+
+ {isPlatform
+ ? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.'
+ : 'Project selection is only available for the hosted platform.'}
+
+
+
+ )
+}
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index 7aed925d2db..dc97d192858 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -14,6 +14,7 @@ Ant Wilson
Ariuna K
Artur Zakirov
Artyom Borissov
+Barco Fourie
Beng Eu
Bo Lu
Bobbie Soedirgo
diff --git a/apps/docs/public/img/mcp-clients/claude-dark-icon.svg b/apps/docs/public/img/mcp-clients/claude-dark-icon.svg
new file mode 100644
index 00000000000..b68b27324e2
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/claude-dark-icon.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/public/img/mcp-clients/claude-icon.svg b/apps/docs/public/img/mcp-clients/claude-icon.svg
new file mode 100644
index 00000000000..135a8b9f33f
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/claude-icon.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg b/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg
new file mode 100644
index 00000000000..48f85eb4d5a
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/docs/public/img/mcp-clients/cursor-icon.svg b/apps/docs/public/img/mcp-clients/cursor-icon.svg
new file mode 100644
index 00000000000..48f85eb4d5a
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/cursor-icon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg b/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg
new file mode 100644
index 00000000000..c453e633f34
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/public/img/mcp-clients/vscode-icon.svg b/apps/docs/public/img/mcp-clients/vscode-icon.svg
new file mode 100644
index 00000000000..c453e633f34
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/vscode-icon.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg b/apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg
new file mode 100644
index 00000000000..2e4e4e4921d
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/docs/public/img/mcp-clients/windsurf-icon.svg b/apps/docs/public/img/mcp-clients/windsurf-icon.svg
new file mode 100644
index 00000000000..386f8c035dc
--- /dev/null
+++ b/apps/docs/public/img/mcp-clients/windsurf-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json
index d980120ebdc..5e22a06e3d6 100644
--- a/apps/docs/turbo.json
+++ b/apps/docs/turbo.json
@@ -36,6 +36,7 @@
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOTRUE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
+ "NEXT_PUBLIC_MCP_URL",
// These envs are technically passthrough env vars because they're only used on the server side of Nextjs
"ASSET_CDN_S3_ENDPOINT",
"AWS_ACCESS_KEY_ID",
diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts
index cea90123024..ec803d13c29 100644
--- a/apps/studio/components/grid/SupabaseGrid.utils.ts
+++ b/apps/studio/components/grid/SupabaseGrid.utils.ts
@@ -131,7 +131,8 @@ export function loadTableEditorStateFromLocalStorage(
schema?: string | null
): SavedState | undefined {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
- const jsonStr = localStorage.getItem(storageKey)
+ // Prefer sessionStorage (scoped to current tab) over localStorage
+ const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
if (!jsonStr) return
const json = JSON.parse(jsonStr)
const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}`
@@ -154,7 +155,7 @@ export function saveTableEditorStateToLocalStorage({
filters?: string[]
}) {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
- const savedStr = localStorage.getItem(storageKey)
+ const savedStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}`
const config = {
@@ -171,7 +172,9 @@ export function saveTableEditorStateToLocalStorage({
} else {
savedJson = { [tableKey]: config }
}
+ // Save to both localStorage and sessionStorage so it's consistent to current tab
localStorage.setItem(storageKey, JSON.stringify(savedJson))
+ sessionStorage.setItem(storageKey, JSON.stringify(savedJson))
}
export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise(
diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx
index 56b8644e1cb..a9bb25d96aa 100644
--- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx
+++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx
@@ -124,7 +124,7 @@ export const RedirectUrlList = ({
{
>
- {promptProPlanUpgrade && (
-
-
-
- )}
-
{
)}
/>
+
+ {promptProPlanUpgrade && (
+
+ )}
+
{userSessionsForm.formState.isDirty && (
userSessionsForm.reset()}>
diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
index 92c05be3e02..ca935717c1d 100644
--- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
+++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
@@ -15,7 +15,7 @@ import {
copyToClipboard,
} from 'ui'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
-import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './Users.constants'
+import { ColumnConfiguration, UsersTableColumn } from './Users.constants'
import { HeaderCell } from './UsersGridComponents'
const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com'
@@ -250,21 +250,23 @@ export function getAvatarUrl(user: User): string | undefined {
}
export const formatUserColumns = ({
+ columns,
config,
users,
visibleColumns = [],
setSortByValue,
onSelectDeleteUser,
}: {
+ columns: UsersTableColumn[]
config: ColumnConfiguration[]
users: User[]
visibleColumns?: string[]
setSortByValue: (val: string) => void
onSelectDeleteUser: (user: User) => void
}) => {
- const columnOrder = config.map((c) => c.id) ?? USERS_TABLE_COLUMNS.map((c) => c.id)
+ const columnOrder = config.map((c) => c.id) ?? columns.map((c) => c.id)
- let gridColumns = USERS_TABLE_COLUMNS.map((col) => {
+ let gridColumns = columns.map((col) => {
const savedConfig = config.find((c) => c.id === col.id)
const res: Column = {
key: col.id,
diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
index d1564757727..6570110caf0 100644
--- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
+++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
@@ -86,13 +86,25 @@ export const UsersV2 = () => {
authenticationShowSortByEmail: showSortByEmail,
authenticationShowSortByPhone: showSortByPhone,
authenticationShowUserTypeFilter: showUserTypeFilter,
+ authenticationShowEmailPhoneColumns: showEmailPhoneColumns,
} = useIsFeatureEnabled([
'authentication:show_provider_filter',
'authentication:show_sort_by_email',
'authentication:show_sort_by_phone',
'authentication:show_user_type_filter',
+ 'authentication:show_email_phone_columns',
])
+ const userTableColumns = useMemo(() => {
+ if (showEmailPhoneColumns) return USERS_TABLE_COLUMNS
+ else {
+ return USERS_TABLE_COLUMNS.filter((col) => {
+ if (col.id === 'email' || col.id === 'phone') return false
+ return true
+ })
+ }
+ }, [showEmailPhoneColumns])
+
const [columns, setColumns] = useState[]>([])
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
@@ -265,6 +277,7 @@ export const UsersV2 = () => {
(isErrorStorage && (errorStorage as Error).message.includes('data is undefined')))
) {
const columns = formatUserColumns({
+ columns: userTableColumns,
config: columnConfiguration ?? [],
users: users ?? [],
visibleColumns: selectedColumns,
@@ -272,7 +285,7 @@ export const UsersV2 = () => {
onSelectDeleteUser: setSelectedUserToDelete,
})
setColumns(columns)
- if (columns.length < USERS_TABLE_COLUMNS.length) {
+ if (columns.length < userTableColumns.length) {
setSelectedColumns(columns.filter((col) => col.key !== 'img').map((col) => col.key))
}
}
@@ -386,7 +399,7 @@ export const UsersV2 = () => {
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
title="Select columns to show"
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
- options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
+ options={userTableColumns.slice(1)} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
@@ -401,19 +414,20 @@ export const UsersV2 = () => {
let updatedConfig = (columnConfiguration ?? []).slice()
if (value.length === 0) {
- updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
+ updatedConfig = userTableColumns.map((c) => ({ id: c.id, width: c.width }))
} else {
value.forEach((col) => {
const hasExisting = updatedConfig.find((c) => c.id === col)
if (!hasExisting)
updatedConfig.push({
id: col,
- width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
+ width: userTableColumns.find((c) => c.id === col)?.width,
})
})
}
const updatedColumns = formatUserColumns({
+ columns: userTableColumns,
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx
index ca6c338b015..1dd9201fcf3 100644
--- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx
+++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx
@@ -67,7 +67,7 @@ const CurrentPaymentMethod = () => {
{subscription?.payment_method_type === 'invoice' ? (
Contact support
diff --git a/apps/studio/components/interfaces/Connect/Connect.constants.ts b/apps/studio/components/interfaces/Connect/Connect.constants.ts
index 33ff40fa88b..6636fdc76ca 100644
--- a/apps/studio/components/interfaces/Connect/Connect.constants.ts
+++ b/apps/studio/components/interfaces/Connect/Connect.constants.ts
@@ -350,6 +350,7 @@ export const CONNECTION_TYPES = [
{ key: 'frameworks', label: 'App Frameworks', obj: FRAMEWORKS },
{ key: 'mobiles', label: 'Mobile Frameworks', obj: MOBILES },
{ key: 'orms', label: 'ORMs', obj: ORMS },
+ { key: 'mcp', label: 'MCP', obj: [] },
]
export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT =
diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx
index 7a6f1e21333..5f207ea614c 100644
--- a/apps/studio/components/interfaces/Connect/Connect.tsx
+++ b/apps/studio/components/interfaces/Connect/Connect.tsx
@@ -5,6 +5,7 @@ import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString'
+import { McpTabContent } from 'components/interfaces/Connect/McpTabContent'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import Panel from 'components/ui/Panel'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -357,6 +358,18 @@ export const Connect = () => {
)
}
+ if (type.key === 'mcp') {
+ return (
+
+
+
+ )
+ }
+
return (
void
label: string
items: ConnectionType[]
+ iconFolder?: string
+ supportsDarkMode?: boolean
}
export const ConnectDropdown = ({
@@ -30,6 +32,8 @@ export const ConnectDropdown = ({
label,
items,
+ iconFolder,
+ supportsDarkMode,
}: ConnectDropdownProps) => {
const [open, setOpen] = useState(false)
@@ -54,7 +58,15 @@ export const ConnectDropdown = ({
iconRight={ }
>
- {selectedItem?.icon ? : }
+ {selectedItem?.icon ? (
+
+ ) : (
+
+ )}
{selectedItem?.label}
@@ -76,7 +88,15 @@ export const ConnectDropdown = ({
}}
className="flex gap-2 items-center"
>
- {item.icon ? : }
+ {item.icon ? (
+
+ ) : (
+
+ )}
{item.label}
{
+export const ConnectionIcon = ({ icon, iconFolder, supportsDarkMode }: ConnectionIconProps) => {
const { resolvedTheme } = useTheme()
- const imageFolder = ['ionic-angular'].includes(icon) ? 'icons/frameworks' : 'libraries'
+ const imageFolder =
+ iconFolder || (['ionic-angular'].includes(icon) ? 'icons/frameworks' : 'libraries')
+
const imageExtension = imageFolder === 'icons/frameworks' ? '' : '-icon'
+
+ const shouldUseDarkMode =
+ supportsDarkMode ||
+ ['expo', 'nextjs', 'prisma', 'drizzle', 'astro', 'remix'].includes(icon.toLowerCase())
+
const iconImgSrc = icon.startsWith('http')
? icon
: `${BASE_PATH}/img/${imageFolder}/${icon.toLowerCase()}${
- ['expo', 'nextjs', 'prisma', 'drizzle', 'astro', 'remix'].includes(icon.toLowerCase())
- ? resolvedTheme?.includes('dark')
- ? '-dark'
- : ''
- : ''
+ shouldUseDarkMode && resolvedTheme?.includes('dark') ? '-dark' : ''
}${imageExtension}.svg`
return (
diff --git a/apps/studio/components/interfaces/Connect/McpTabContent.tsx b/apps/studio/components/interfaces/Connect/McpTabContent.tsx
new file mode 100644
index 00000000000..bbb9beeb640
--- /dev/null
+++ b/apps/studio/components/interfaces/Connect/McpTabContent.tsx
@@ -0,0 +1,50 @@
+import { IS_PLATFORM, useParams } from 'common'
+import Panel from 'components/ui/Panel'
+import ShimmeringLoader from 'components/ui/ShimmeringLoader'
+import { BASE_PATH } from 'lib/constants'
+import { useTheme } from 'next-themes'
+import { McpConfigPanel } from 'ui-patterns/McpUrlBuilder'
+import type { projectKeys } from './Connect.types'
+
+export const McpTabContent = ({ projectKeys }: { projectKeys: projectKeys }) => {
+ const { ref: projectRef } = useParams()
+
+ return (
+
+ {projectRef ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+const McpTabContentInnerLoading = () => {
+ return (
+
+
+
+
+ )
+}
+
+const McpTabContentInnerLoaded = ({
+ projectRef,
+ projectKeys,
+}: {
+ projectRef: string
+ projectKeys: projectKeys
+}) => {
+ const { resolvedTheme } = useTheme()
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx
index 6c4561ad1eb..1567e57dfd4 100644
--- a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx
+++ b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx
@@ -1,5 +1,6 @@
import Link from 'next/link'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, NavMenu, NavMenuItem } from 'ui'
@@ -9,6 +10,7 @@ type Props = {
function DatabaseBackupsNav({ active }: Props) {
const { ref, cloud_provider } = useSelectedProjectQuery()?.data || {}
+ const { databaseRestoreToNewProject } = useIsFeatureEnabled(['database:restore_to_new_project'])
const navMenuItems = [
{
@@ -24,7 +26,7 @@ function DatabaseBackupsNav({ active }: Props) {
href: `/project/${ref}/database/backups/pitr`,
},
{
- enabled: cloud_provider !== 'FLY',
+ enabled: databaseRestoreToNewProject && cloud_provider !== 'FLY',
id: 'rtnp',
label: (
diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx
new file mode 100644
index 00000000000..5a2dfd17456
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/PreviousRestoreItem.tsx
@@ -0,0 +1,50 @@
+import { ChevronRightIcon } from 'lucide-react'
+import Link from 'next/link'
+
+import { CloneStatus } from 'data/projects/clone-status-query'
+import { TimestampInfo } from 'ui-patterns'
+import { StatusBadge } from './StatusBadge'
+
+export const PreviousRestoreItem = ({
+ clone,
+}: {
+ clone: NonNullable
[number]
+}) => {
+ if (clone.status === 'REMOVED') {
+ return (
+
+
{(clone.target_project as any).name}
+
+
+
+
+
+
+
+ )
+ } else {
+ return (
+
+ {(clone.target_project as any).name}
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx
new file mode 100644
index 00000000000..336f87a6a74
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/RestoreToNewProject.tsx
@@ -0,0 +1,328 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { Loader2 } from 'lucide-react'
+import Link from 'next/link'
+import { useState } from 'react'
+
+import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
+import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
+import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
+import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
+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 AlertError from 'components/ui/AlertError'
+import NoPermission from 'components/ui/NoPermission'
+import Panel from 'components/ui/Panel'
+import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import UpgradeToPro from 'components/ui/UpgradeToPro'
+import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
+import { useCloneBackupsQuery } from 'data/projects/clone-query'
+import { useCloneStatusQuery } from 'data/projects/clone-status-query'
+import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import {
+ useIsAwsK8sCloudProvider,
+ useIsOrioleDb,
+ useSelectedProjectQuery,
+} from 'hooks/misc/useSelectedProject'
+import { DOCS_URL, PROJECT_STATUS } from 'lib/constants'
+import { getDatabaseMajorVersion } from 'lib/helpers'
+import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
+import { Admonition } from 'ui-patterns'
+import { PreviousRestoreItem } from './PreviousRestoreItem'
+
+export const RestoreToNewProject = () => {
+ const { data: project } = useSelectedProjectQuery()
+ const { data: organization } = useSelectedOrganizationQuery()
+ const isFreePlan = organization?.plan?.id === 'free'
+ const isOrioleDb = useIsOrioleDb()
+ const isAwsK8s = useIsAwsK8sCloudProvider()
+
+ const [refetchInterval, setRefetchInterval] = useState(false)
+ const [selectedBackupId, setSelectedBackupId] = useState(null)
+ const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
+ const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
+ const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(null)
+
+ const {
+ data: cloneBackups,
+ error,
+ isLoading: cloneBackupsLoading,
+ isError,
+ } = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
+
+ const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
+
+ const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
+ PermissionAction.READ,
+ 'physical_backups'
+ )
+ const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions(
+ PermissionAction.INFRA_EXECUTE,
+ 'queue_job.restore.prepare'
+ )
+ const PITR_ENABLED = cloneBackups?.pitr_enabled
+ const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
+ const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
+ const IS_PG15_OR_ABOVE = dbVersion >= 15
+ const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb
+ const targetComputeSize = cloneBackups?.target_compute_size
+ const planId = organization?.plan?.id ?? 'free'
+ const { data } = useDiskAttributesQuery({ projectRef: project?.ref })
+ const storageType = data?.attributes?.type ?? 'gp3'
+
+ const {
+ data: cloneStatus,
+ refetch: refetchCloneStatus,
+ isLoading: cloneStatusLoading,
+ } = useCloneStatusQuery(
+ {
+ projectRef: project?.ref,
+ },
+ {
+ refetchInterval,
+ refetchOnWindowFocus: false,
+ onSuccess: (data) => {
+ const hasTransientState = data?.clones.some((c) => c.status === 'IN_PROGRESS')
+ if (!hasTransientState) setRefetchInterval(false)
+ },
+ enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED,
+ }
+ )
+ const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
+ const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
+
+ const previousClones = cloneStatus?.clones
+ const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS')
+ const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS')
+
+ if (organization?.managed_by === 'vercel-marketplace') {
+ return (
+
+ )
+ }
+
+ if (isFreePlan) {
+ return (
+
+ )
+ }
+
+ if (isOrioleDb) {
+ return (
+
+ )
+ }
+
+ if (isAwsK8s) {
+ return (
+
+ )
+ }
+
+ if (!canReadPhysicalBackups) {
+ return
+ }
+
+ if (!canTriggerPhysicalBackups) {
+ return
+ }
+
+ if (!IS_PG15_OR_ABOVE) {
+ return (
+
+
+
+ )
+ }
+
+ if (!PHYSICAL_BACKUPS_ENABLED) {
+ return (
+
+ Physical backups must be enabled to restore your database to a new project.
+ Find out more about how backups work at supabase{' '}
+
+ in our docs
+
+ .
+ >
+ }
+ />
+ )
+ }
+
+ if (isLoading) {
+ return
+ }
+
+ if (IS_CLONED_PROJECT) {
+ return (
+
+
+
+
+ Go to original project
+
+
+
+ )
+ }
+
+ if (isError) {
+ return
+ }
+
+ if (!isActiveHealthy) {
+ return (
+
+ )
+ }
+
+ if (
+ !isLoading &&
+ PITR_ENABLED &&
+ !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
+ ) {
+ return (
+
+ )
+ }
+
+ if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) {
+ return (
+ <>
+
+ >
+ )
+ }
+
+ const additionalMonthlySpend = projectSpecToMonthlyPrice({
+ targetVolumeSizeGb: targetVolumeSizeGb ?? 0,
+ targetComputeSize: targetComputeSize ?? 'nano',
+ planId: planId ?? 'free',
+ storageType: storageType as DiskType,
+ })
+
+ return (
+
+
{
+ setShowConfirmationDialog(false)
+ setShowNewProjectDialog(true)
+ }}
+ additionalMonthlySpend={additionalMonthlySpend}
+ />
+ {
+ refetchCloneStatus()
+ setRefetchInterval(5000)
+ setShowNewProjectDialog(false)
+ }}
+ />
+ {isRestoring ? (
+
+
+ Restoration in progress
+
+
+ The new project {(restoringClone?.target_project as any)?.name || ''} is currently
+ being created. You'll be able to restore again once the project is ready.
+
+
+ Go to new project
+
+
+
+ ) : null}
+ {previousClones?.length ? (
+
+
Previous restorations
+
+ {previousClones?.map((c) => )}
+
+
+ ) : null}
+ {PITR_ENABLED ? (
+ <>
+ {
+ setShowConfirmationDialog(true)
+ setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
+ }}
+ earliestAvailableBackupUnix={
+ cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
+ }
+ latestAvailableBackupUnix={
+ cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
+ }
+ />
+ >
+ ) : (
+ {
+ setSelectedBackupId(id)
+ setShowConfirmationDialog(true)
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx b/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx
new file mode 100644
index 00000000000..8b6fc9d6f66
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/RestoreToNewProject/StatusBadge.tsx
@@ -0,0 +1,25 @@
+import { CloneStatus } from 'data/projects/clone-status-query'
+import { Badge } from 'ui'
+
+export const StatusBadge = ({
+ status,
+}: {
+ status: NonNullable[number]['status']
+}) => {
+ const statusTextMap = {
+ IN_PROGRESS: 'RESTORING',
+ COMPLETED: 'COMPLETED',
+ REMOVED: 'REMOVED',
+ FAILED: 'FAILED',
+ }
+
+ if (status === 'IN_PROGRESS') {
+ return {statusTextMap[status]}
+ }
+
+ if (status === 'FAILED') {
+ return {statusTextMap[status]}
+ }
+
+ return {statusTextMap[status]}
+}
diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts
index 7df4a3e4f98..789a42f1dbc 100644
--- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts
+++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.schema.ts
@@ -1,3 +1,4 @@
+import { CloudProvider } from 'shared-data'
import { z } from 'zod'
import { ComputeInstanceAddonVariantId } from './DiskManagement.types'
import {
@@ -9,7 +10,6 @@ import {
formatNumber,
} from './DiskManagement.utils'
import { DISK_LIMITS, DiskType } from './ui/DiskManagement.constants'
-import { CloudProvider } from 'shared-data'
const baseSchema = z.object({
storageType: z.enum(['io2', 'gp3']).describe('Type of storage: io2 or gp3'),
@@ -41,13 +41,22 @@ const baseSchema = z.object({
.nullable(),
})
-export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: CloudProvider) => {
+export const CreateDiskStorageSchema = ({
+ defaultTotalSize,
+ cloudProvider,
+}: {
+ defaultTotalSize: number
+ cloudProvider: CloudProvider
+}) => {
const isFlyProject = cloudProvider === 'FLY'
+ const isAwsNimbusProject = cloudProvider === 'AWS_NIMBUS'
+
+ const validateDiskConfiguration = !isFlyProject && !isAwsNimbusProject
const schema = baseSchema.superRefine((data, ctx) => {
const { storageType, totalSize, provisionedIOPS, throughput, maxSizeGb } = data
- if (!isFlyProject && totalSize < 8) {
+ if (validateDiskConfiguration && totalSize < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Allocated disk size must be at least 8 GB.',
@@ -55,7 +64,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
})
}
- if (!isFlyProject && totalSize < defaultTotalSize) {
+ if (validateDiskConfiguration && totalSize < defaultTotalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Disk size cannot be reduced in size. Reduce your database size and then head to the Infrastructure settings and go through a Postgres version upgrade to right-size your disk.`,
@@ -64,7 +73,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
}
// Validate maxSizeGb cannot be lower than totalSize
- if (!isFlyProject && !!maxSizeGb && maxSizeGb < totalSize) {
+ if (validateDiskConfiguration && !!maxSizeGb && maxSizeGb < totalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Max disk size cannot be lower than the current disk size. Must be at least ${formatNumber(totalSize)} GB.`,
@@ -72,7 +81,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
})
}
- if (!isFlyProject && storageType === 'io2') {
+ if (validateDiskConfiguration && storageType === 'io2') {
// Validation rules for io2
if (provisionedIOPS > DISK_LIMITS[DiskType.IO2].maxIops) {
@@ -129,7 +138,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
}
}
- if (!isFlyProject && storageType === 'gp3') {
+ if (validateDiskConfiguration && storageType === 'gp3') {
const maxIopsAllowedForDiskSizeWithGp3 = calculateMaxIopsAllowedForDiskSizeWithGp3(totalSize)
if (provisionedIOPS > DISK_LIMITS[DiskType.GP3].maxIops) {
diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx
index a79cfa8db1e..cee0f6f73c7 100644
--- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx
+++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx
@@ -3,7 +3,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
-import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -11,6 +10,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { MAX_WIDTH_CLASSES, PADDING_CLASSES, ScaffoldContainer } from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import {
useDiskAttributesQuery,
useRemainingDurationForDiskAttributeUpdate,
@@ -30,6 +30,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import {
useIsAwsCloudProvider,
useIsAwsK8sCloudProvider,
+ useIsAwsNimbusCloudProvider,
useSelectedProjectQuery,
} from 'hooks/misc/useSelectedProject'
import { DOCS_URL, GB, PROJECT_STATUS } from 'lib/constants'
@@ -78,6 +79,7 @@ export function DiskManagementForm() {
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
const isAws = useIsAwsCloudProvider()
const isAwsK8s = useIsAwsK8sCloudProvider()
+ const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
@@ -156,7 +158,10 @@ export function DiskManagementForm() {
const form = useForm({
resolver: zodResolver(
- CreateDiskStorageSchema(defaultValues.totalSize, project?.cloud_provider as CloudProvider)
+ CreateDiskStorageSchema({
+ defaultTotalSize: defaultValues.totalSize,
+ cloudProvider: project?.cloud_provider as CloudProvider,
+ })
),
defaultValues,
mode: 'onBlur',
@@ -241,12 +246,14 @@ export function DiskManagementForm() {
let willUpdateDiskConfiguration = false
setMessageState(null)
+ // [Joshen] Skip disk configuration related stuff for AWS Nimbus
try {
if (
- payload.storageType !== form.formState.defaultValues?.storageType ||
- payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
- payload.throughput !== form.formState.defaultValues?.throughput ||
- payload.totalSize !== form.formState.defaultValues?.totalSize
+ !isAwsNimbus &&
+ (payload.storageType !== form.formState.defaultValues?.storageType ||
+ payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
+ payload.throughput !== form.formState.defaultValues?.throughput ||
+ payload.totalSize !== form.formState.defaultValues?.totalSize)
) {
willUpdateDiskConfiguration = true
@@ -260,9 +267,10 @@ export function DiskManagementForm() {
}
if (
- payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
- payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
- payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb
+ !isAwsNimbus &&
+ (payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
+ payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
+ payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb)
) {
await updateDiskAutoscaleConfig({
projectRef,
@@ -310,15 +318,7 @@ export function DiskManagementForm() {
type="default"
visible={isPlanUpgradeRequired}
title="Compute and Disk configuration is not available on the Free Plan"
- actions={
-
-
- Upgrade plan
-
-
- }
+ actions={ }
description="You will need to upgrade to at least the Pro Plan to configure compute and disk"
/>
@@ -344,12 +344,16 @@ export function DiskManagementForm() {
) : null}
+
-
+
+ {!(isAws || isAwsNimbus) && }
+
+
{
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
+ const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { formState, getValues } = form
@@ -213,19 +214,22 @@ export const DiskManagementReviewAndSubmitDialog = ({
const hasComputeChanges =
form.formState.defaultValues?.computeSize !== form.getValues('computeSize')
const hasTotalSizeChanges =
- form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
+ !isAwsNimbus && form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
const hasStorageTypeChanges =
- form.formState.defaultValues?.storageType !== form.getValues('storageType')
+ !isAwsNimbus && form.formState.defaultValues?.storageType !== form.getValues('storageType')
const hasThroughputChanges =
- form.formState.defaultValues?.throughput !== form.getValues('throughput')
+ !isAwsNimbus && form.formState.defaultValues?.throughput !== form.getValues('throughput')
const hasIOPSChanges =
+ !isAwsNimbus &&
form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS')
const hasGrowthPercentChanges =
- form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
+ !isAwsNimbus && form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
const hasMinIncrementChanges =
+ !isAwsNimbus &&
form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb')
- const hasMaxSizeChanges = form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
+ const hasMaxSizeChanges =
+ !isAwsNimbus && form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
const hasDiskConfigChanges =
hasIOPSChanges ||
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx
index 9c1826ef9b6..880542bdf43 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx
@@ -191,6 +191,10 @@ const AddNewSecretForm = () => {
{
- const { integrationsShowStripeWrapper } = useIsFeatureEnabled([
- 'integrations:show_stripe_wrapper',
- ])
+ const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const [selectedCategory, setSelectedCategory] = useQueryState(
'category',
@@ -43,9 +41,9 @@ export const AvailableIntegrations = () => {
const installedIds = installedIntegrations.map((i) => i.id)
// available integrations for install
- const availableIntegrations = integrationsShowStripeWrapper
+ const availableIntegrations = integrationsWrappers
? allIntegrations
- : allIntegrations.filter((x) => x.id !== 'stripe_wrapper')
+ : allIntegrations.filter((x) => !x.id.endsWith('_wrapper'))
const integrationsByCategory =
selectedCategory === 'all'
? availableIntegrations
diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
index 9df420f1b13..0834121c2ea 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWsQuery } from 'data/fdw/fdws-query'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { EMPTY_ARR } from 'lib/void'
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
@@ -10,6 +11,15 @@ import { INTEGRATIONS } from './Integrations.constants'
export const useInstalledIntegrations = () => {
const { data: project } = useSelectedProjectQuery()
+ const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
+
+ const allIntegrations = useMemo(() => {
+ if (integrationsWrappers) {
+ return INTEGRATIONS
+ } else {
+ return INTEGRATIONS.filter((integration) => !integration.id.endsWith('_wrapper'))
+ }
+ }, [integrationsWrappers])
const {
data,
@@ -47,28 +57,30 @@ export const useInstalledIntegrations = () => {
const wrappers = useMemo(() => data ?? EMPTY_ARR, [data])
const installedIntegrations = useMemo(() => {
- return INTEGRATIONS.filter((i) => {
- // special handling for supabase webhooks
- if (i.id === 'webhooks') {
- return isHooksEnabled
- }
- if (i.type === 'wrapper') {
- return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
- }
- if (i.type === 'postgres_extension') {
- return i.requiredExtensions.every((extName) => {
- const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
- return !!foundExtension?.installed_version
- })
- }
- return false
- }).sort((a, b) => a.name.localeCompare(b.name))
+ return allIntegrations
+ .filter((i) => {
+ // special handling for supabase webhooks
+ if (i.id === 'webhooks') {
+ return isHooksEnabled
+ }
+ if (i.type === 'wrapper') {
+ return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
+ }
+ if (i.type === 'postgres_extension') {
+ return i.requiredExtensions.every((extName) => {
+ const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
+ return !!foundExtension?.installed_version
+ })
+ }
+ return false
+ })
+ .sort((a, b) => a.name.localeCompare(b.name))
}, [wrappers, extensions, isHooksEnabled])
// available integrations are all integrations that can be installed. If an integration can't be installed (needed
// extensions are not available on this DB image), the UI will provide a tooltip explaining why.
const availableIntegrations = useMemo(
- () => INTEGRATIONS.sort((a, b) => a.name.localeCompare(b.name)),
+ () => allIntegrations.sort((a, b) => a.name.localeCompare(b.name)),
[]
)
diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx
index e4b49eecc6e..92b454ebc25 100644
--- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx
+++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx
@@ -3,7 +3,6 @@ import { useParams } from 'common'
import dayjs from 'dayjs'
import { ArrowDown, ArrowUp, RefreshCw, User } from 'lucide-react'
import Image from 'next/legacy/image'
-import Link from 'next/link'
import { useEffect, useState } from 'react'
import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
@@ -15,6 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import {
AuditLog,
@@ -231,11 +231,9 @@ export const AuditLogs = () => {
-
-
- Upgrade subscription
-
-
+
+ Upgrade subscription
+
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx
index ed79e6d5687..17a8c017113 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx
@@ -1,12 +1,13 @@
import Link from 'next/link'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { PricingMetric } from 'data/analytics/org-daily-stats-query'
import type { OrgSubscription } from 'data/subscriptions/types'
import type { OrgUsageResponse } from 'data/usage/org-usage-query'
import { formatCurrency } from 'lib/helpers'
import { ChevronRight } from 'lucide-react'
import { useMemo } from 'react'
-import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
+import { cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
import { billingMetricUnit, formatUsage } from '../helpers'
import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constants'
@@ -156,13 +157,9 @@ export const BillingMetric = ({
) : (
-
-
- Upgrade
-
-
+
+ Upgrade
+
)}
diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
index 3d8359288c8..7bd3b6df393 100644
--- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
+++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'
-import Link from 'next/link'
import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import z from 'zod'
@@ -8,6 +7,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useSSOConfigCreateMutation } from 'data/sso/sso-config-create-mutation'
import { useOrgSSOConfigQuery } from 'data/sso/sso-config-query'
import { useSSOConfigUpdateMutation } from 'data/sso/sso-config-update-mutation'
@@ -67,7 +67,7 @@ export type SSOConfigFormSchema = z.infer
export const SSOConfig = () => {
const FORM_ID = 'sso-config-form'
- const { data: organization, isLoading: isLoadingOrganization } = useSelectedOrganizationQuery()
+ const { data: organization } = useSelectedOrganizationQuery()
const plan = organization?.plan.id
const canSetupSSOConfig = ['team', 'enterprise'].includes(plan ?? '')
@@ -179,13 +179,7 @@ export const SSOConfig = () => {
-
-
- Upgrade to Team
-
-
+
diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx
index 4254c054a91..22c0513dfc9 100644
--- a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx
+++ b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import Link from 'next/link'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -12,6 +11,7 @@ import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation'
import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query'
@@ -128,11 +128,9 @@ export const SecuritySettings = () => {
-
-
- Upgrade subscription
-
-
+
+ Upgrade subscription
+
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
index 8c420f75008..6872cf036db 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
@@ -10,6 +10,7 @@ import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import InformationBox from 'components/ui/InformationBox'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
@@ -309,13 +310,7 @@ export const InviteMemberButton = () => {
{(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && (
-
-
- Upgrade to Team
-
-
+
)}
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
index b7b4b05a9e5..606d9df0910 100644
--- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
+++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
@@ -9,7 +9,8 @@ import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
-import { InlineLink } from 'components/ui/InlineLink'
+import { ToggleSpendCapButton } from 'components/ui/ToggleSpendCapButton'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation'
@@ -61,6 +62,7 @@ export const RealtimeSettings = () => {
schema: 'realtime',
})
+ const isFreePlan = organization?.plan.id === 'free'
const isUsageBillingEnabled = organization?.usage_billing_enabled
// Check if RLS policies exist for realtime.messages table
@@ -255,21 +257,27 @@ export const RealtimeSettings = () => {
{isSuccessOrganization && !isUsageBillingEnabled && (
-
- You may adjust this setting in the{' '}
-
- organization billing settings
-
- >
- }
- />
+
+
+
+
+ Spend cap needs to be disabled to configure this value
+
+
+ {isFreePlan
+ ? 'Upgrade to the Pro plan first to disable spend cap'
+ : 'You may adjust this setting in the organization billing settings'}
+
+
+
+ {false ? (
+
+ ) : (
+
+ )}
+
+
+
)}
diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/DeprecatedChartBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/DeprecatedChartBlock.tsx
index 7f8dd241d0b..4bd09d91a53 100644
--- a/apps/studio/components/interfaces/Reports/ReportBlock/DeprecatedChartBlock.tsx
+++ b/apps/studio/components/interfaces/Reports/ReportBlock/DeprecatedChartBlock.tsx
@@ -18,6 +18,10 @@ export const DeprecatedChartBlock = ({ label, attribute, actions }: DeprecatedCh
const getLogsUrl = (logsName?: string) => {
switch (logsName) {
+ case 'Database API':
+ return '/logs/postgrest-logs'
+ case 'All API usage':
+ return '/logs/explorer'
case 'Realtime':
return '/logs/realtime-logs'
case 'Storage':
@@ -38,14 +42,16 @@ export const DeprecatedChartBlock = ({ label, attribute, actions }: DeprecatedCh
label={label}
actions={actions}
>
-
- This chart is not longer available, and can be removed from your report
-
-
- You may view the equivalent of this data from the{' '}
- {logsName} Logs {' '}
- instead
-
+
+
+ This chart is not longer available, and can be removed from your report
+
+
+ You may view the equivalent of this data from the{' '}
+ {logsName} Logs {' '}
+ instead
+
+
)
}
diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts
index b80f349bb6b..1f60e7ddd3f 100644
--- a/apps/studio/components/interfaces/Reports/Reports.constants.ts
+++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts
@@ -638,6 +638,20 @@ export const DEPRECATED_REPORTS = [
'total_storage_options_requests',
'total_storage_patch_requests',
'total_options_requests',
+ 'total_rest_ingress',
+ 'total_rest_get_requests',
+ 'total_rest_post_requests',
+ 'total_rest_patch_requests',
+ 'total_rest_delete_requests',
+ 'total_storage_get_requests',
+ 'total_storage_post_requests',
+ 'total_storage_delete_requests',
+ 'total_auth_delete_requests',
+ 'total_get_requests',
+ 'total_patch_requests',
+ 'total_post_requests',
+ 'total_ingress',
+ 'total_delete_requests',
]
export const EDGE_FUNCTION_REGIONS = [
diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx
index c172b266acf..172f0c9966c 100644
--- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx
+++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
+import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
@@ -271,15 +272,7 @@ const PITRSidePanel = () => {
variant="info"
className="mb-4"
title="Changing your Point-In-Time-Recovery is only available on the Pro Plan"
- actions={
-
-
- View available plans
-
-
- }
+ actions={ }
>
Upgrade your plan to change PITR for your project
diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx
index 70c99668544..65f5cb57fcc 100644
--- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx
@@ -13,8 +13,9 @@ import Panel from 'components/ui/Panel'
import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { DOCS_URL } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
@@ -29,6 +30,9 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
+ const isAwsNimbus = useIsAwsNimbusCloudProvider()
+ const { reportsAll } = useIsFeatureEnabled(['reports:all'])
+
const [{ show_increase_disk_size_modal }, setUrlParams] = useUrlState()
const showIncreaseDiskSizeModal = show_increase_disk_size_modal === 'true'
const setShowIncreaseDiskSizeModal = (value: SetStateAction) => {
@@ -80,9 +84,10 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
Supabase employs auto-scaling storage and allows for manual disk size
adjustments when necessary
-
+ {!isAwsNimbus && (
setShowIncreaseDiskSizeModal(true)}
tooltip={{
@@ -96,7 +101,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
>
Increase disk size
-
+ )}
@@ -112,15 +117,17 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
{currentDiskSize} GB
-
- }>
-
- View detailed summary
-
-
-
+ {reportsAll && (
+
+ }>
+
+ View detailed summary
+
+
+
+ )}
diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx
index 070aa1788e4..f138b40cc57 100644
--- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx
@@ -67,7 +67,7 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => (
)
-const NetworkRestrictions = () => {
+export const NetworkRestrictions = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isAddingAddress, setIsAddingAddress] = useState
()
@@ -300,5 +300,3 @@ const NetworkRestrictions = () => {
>
)
}
-
-export default NetworkRestrictions
diff --git a/apps/studio/components/interfaces/Settings/Database/index.ts b/apps/studio/components/interfaces/Settings/Database/index.ts
index 0b38459d614..94970d9e985 100644
--- a/apps/studio/components/interfaces/Settings/Database/index.ts
+++ b/apps/studio/components/interfaces/Settings/Database/index.ts
@@ -1,2 +1 @@
export { ConnectionPooling } from './ConnectionPooling/ConnectionPooling'
-export { default as NetworkRestrictions } from './NetworkRestrictions/NetworkRestrictions'
diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx
index b58a5ff7162..46d19f4d588 100644
--- a/apps/studio/components/interfaces/Settings/General/General.tsx
+++ b/apps/studio/components/interfaces/Settings/General/General.tsx
@@ -10,6 +10,7 @@ import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useProjectUpdateMutation } from 'data/projects/project-update-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useProjectByRefQuery, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
@@ -31,6 +32,10 @@ const General = () => {
const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref)
const isBranch = parentProject !== undefined
+ const { projectSettingsRestartProject } = useIsFeatureEnabled([
+ 'project_settings:restart_project',
+ ])
+
const formId = 'project-general-settings'
const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' }
const { can: canUpdateProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
@@ -122,7 +127,9 @@ const General = () => {
-
Restart project
+
+ {projectSettingsRestartProject ? 'Restart project' : 'Restart database'}
+
Your project will not be available for a few minutes.
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
index 55c0f44e7f3..ac120c3008c 100644
--- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
+++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
@@ -12,6 +12,7 @@ import { useProjectRestartMutation } from 'data/projects/project-restart-mutatio
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
Button,
@@ -31,6 +32,10 @@ const RestartServerButton = () => {
const isAwsK8s = useIsAwsK8sCloudProvider()
const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>()
+ const { projectSettingsRestartProject } = useIsFeatureEnabled([
+ 'project_settings:restart_project',
+ ])
+
const projectRef = project?.ref ?? ''
const projectRegion = project?.region ?? ''
@@ -88,68 +93,80 @@ const RestartServerButton = () => {
return (
<>
-
-
+ setServiceToRestart('project')}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: projectRestartDisabled
+ ? 'Project restart is currently disabled'
+ : !canRestartProject
+ ? 'You need additional permissions to restart this project'
+ : !isProjectActive
+ ? 'Unable to restart project as project is not active'
+ : isAwsK8s
+ ? 'Project restart is not supported for AWS (Revamped) projects'
+ : undefined,
+ },
+ }}
+ >
+ Restart project
+
+ {canRestartProject && isProjectActive && !projectRestartDisabled && (
+
+
+ }
+ disabled={!canRestartProject}
+ />
+
+
+ {
+ setServiceToRestart('database')
+ }}
+ >
+
+
Fast database reboot
+
+ Restarts only the database - faster but may not be able to recover from all
+ failure modes
+
+
+
+
+
)}
- disabled={
- project === undefined ||
- !canRestartProject ||
- !isProjectActive ||
- projectRestartDisabled ||
- isAwsK8s
- }
- onClick={() => setServiceToRestart('project')}
- tooltip={{
- content: {
- side: 'bottom',
- text: projectRestartDisabled
- ? 'Project restart is currently disabled'
- : !canRestartProject
- ? 'You need additional permissions to restart this project'
- : !isProjectActive
- ? 'Unable to restart project as project is not active'
- : isAwsK8s
- ? 'Project restart is not supported for AWS (Revamped) projects'
- : undefined,
- },
+
+ ) : (
+
{
+ setServiceToRestart('database')
}}
>
- Restart project
-
- {canRestartProject && isProjectActive && !projectRestartDisabled && (
-
-
- }
- disabled={!canRestartProject}
- />
-
-
- {
- setServiceToRestart('database')
- }}
- >
-
-
Fast database reboot
-
- Restarts only the database - faster but may not be able to recover from all
- failure modes
-
-
-
-
-
- )}
-
+ Restart database
+
+ )}
()
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
+ const isAws = useIsAwsCloudProvider()
+ const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
+
const [view, setView] = useState<'flow' | 'map'>('flow')
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false)
const { showNewReplicaPanel, setShowNewReplicaPanel } = useShowNewReplicaPanel()
@@ -225,7 +233,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU
{isError && }
{isSuccessReplicas && !isLoadingProject && (
<>
- {!diagramOnly && (
+ {!diagramOnly && infrastructureReadReplicas && (
)}
- {project?.cloud_provider === 'AWS' && (
+ {isAws && (
{
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
- const authEnabled = useIsFeatureEnabled('project_auth:all')
+ const { projectAuthAll: authEnabled, projectSettingsDatabaseUpgrades: showDatabaseUpgrades } =
+ useIsFeatureEnabled(['project_auth:all', 'project_settings:database_upgrades'])
const {
data,
@@ -109,6 +110,7 @@ const InfrastructureInfo = () => {
) : (
<>
+ {/* [Joshen] Double check why we need this waterfall loading behaviour here */}
{isLoadingUpgradeEligibility && }
{isErrorUpgradeEligibility && (
@@ -187,7 +189,7 @@ const InfrastructureInfo = () => {
>
)}
- {data.eligible ? (
+ {showDatabaseUpgrades && data.eligible ? (
hasReadReplicas ? (
) : (
@@ -195,7 +197,7 @@ const InfrastructureInfo = () => {
)
) : null}
- {!data.eligible ? (
+ {showDatabaseUpgrades && !data.eligible ? (
hasObjectsToBeDropped ? (
Raw
+
+
{selectionOpen && (
diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
index 1eddfd8128d..f1f8bdf3053 100644
--- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
@@ -267,7 +267,15 @@ export const CreateBucketModal = () => {
labelOptional="Buckets cannot be renamed once created."
>
-
+
)}
diff --git a/apps/studio/components/interfaces/Support/Support.constants.ts b/apps/studio/components/interfaces/Support/Support.constants.ts
index 963a39e1be0..9b209304c48 100644
--- a/apps/studio/components/interfaces/Support/Support.constants.ts
+++ b/apps/studio/components/interfaces/Support/Support.constants.ts
@@ -1,6 +1,11 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
-export const CATEGORY_OPTIONS = [
+export const CATEGORY_OPTIONS: {
+ value: SupportCategories | 'Plan_upgrade'
+ label: string
+ description: string
+ query?: string
+}[] = [
{
value: SupportCategories.PROBLEM,
label: 'APIs and client libraries',
@@ -55,6 +60,13 @@ export const CATEGORY_OPTIONS = [
description: 'Issues with logging in and MFA',
query: undefined,
},
+ // [Joshen] Ideally shift this to shared-types, although not critical as API isn't validating the category
+ {
+ value: 'Plan_upgrade',
+ label: 'Plan upgrade',
+ description: 'Enquire a plan upgrade for your organization',
+ query: undefined,
+ },
]
export const SEVERITY_OPTIONS = [
diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx
index 7de9cf432ef..883f88881db 100644
--- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx
+++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx
@@ -26,6 +26,7 @@ import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common'
import { CLIENT_LIBRARIES } from 'common/constants'
import CopyButton from 'components/ui/CopyButton'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
+import { PLAN_REQUEST_EMPTY_PLACEHOLDER } from 'components/ui/UpgradePlanButton'
import { getProjectAuthConfig } from 'data/auth/auth-config-query'
import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
@@ -92,21 +93,41 @@ const createFormSchema = (showClientLibraries: boolean) => {
})
if (showClientLibraries) {
- return baseSchema.refine(
- (data) => {
- return !(data.category === 'Problem' && data.library === '')
- },
- {
- message: "Please select the library that you're facing issues with",
- path: ['library'],
- }
- )
+ return baseSchema
+ .refine(
+ (data) => {
+ return !(data.category === 'Problem' && data.library === '')
+ },
+ {
+ message: "Please select the library that you're facing issues with",
+ path: ['library'],
+ }
+ )
+ .refine(
+ (data) => {
+ return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
+ },
+ {
+ message: `Please let us know which plan you'd like to upgrade to for your organization`,
+ path: ['message'],
+ }
+ )
}
// When showClientLibraries is false, make library optional and remove the refine validation
- return baseSchema.extend({
- library: z.string().optional(),
- })
+ return baseSchema
+ .extend({
+ library: z.string().optional(),
+ })
+ .refine(
+ (data) => {
+ return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
+ },
+ {
+ message: `Please let us know which plan you'd like to upgrade to for your organization`,
+ path: ['message'],
+ }
+ )
}
const defaultValues = {
@@ -128,8 +149,6 @@ interface SupportFormV2Props {
setSentCategory: (value: string) => void
}
-// [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view
-// This is a rewrite of the old SupportForm to use the new form components
export const SupportFormV2 = ({
onProjectSelected: setSelectedProject,
onOrganizationSelected: setSelectedOrganization,
@@ -161,7 +180,10 @@ export const SupportFormV2 = ({
option.value === SupportCategories.SALES_ENQUIRY
) {
return isBillingEnabled
+ } else if (option.value === 'Plan_upgrade') {
+ return !isBillingEnabled
}
+
return true
})
}, [isBillingEnabled])
@@ -323,7 +345,7 @@ export const SupportFormV2 = ({
if (isSuccessOrganizations) {
if (organizations.length === 0) {
form.setValue('organizationSlug', 'no-org')
- } else if (urlRef) {
+ } else if (urlRef && urlRef !== 'no-project') {
// Check validity of project via project details
const selectedProject = await getProjectDetail({ ref: urlRef })
if (!!selectedProject) {
@@ -750,7 +772,7 @@ export const SupportFormV2 = ({
{library && library.length > 0 && }
- {category !== 'Login_issues' && (
+ {category !== 'Login_issues' && category !== 'Plan_upgrade' && (
(false)
const [isClosingPanel, setIsClosingPanel] = useState(false)
@@ -454,6 +456,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
isRLSEnabled,
importContent,
+ organizationSlug: org?.slug,
})
if (isRealtimeEnabled) await updateTableRealtime(table, true)
@@ -477,6 +480,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
+ organizationSlug: org?.slug,
})
if (table === undefined) {
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
index 8fcc9fa2a77..2a65b4d517e 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
@@ -32,6 +32,7 @@ import {
updateTable as updateTableMutation,
} from 'data/tables/table-update-mutation'
import { getTables } from 'data/tables/tables-query'
+import { sendEvent } from 'data/telemetry/send-event-mutation'
import { timeout, tryParseJson } from 'lib/helpers'
import {
generateCreateColumnPayload,
@@ -461,6 +462,7 @@ export const createTable = async ({
foreignKeyRelations,
isRLSEnabled,
importContent,
+ organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -474,6 +476,7 @@ export const createTable = async ({
foreignKeyRelations: ForeignKey[]
isRLSEnabled: boolean
importContent?: ImportContent
+ organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -484,6 +487,26 @@ export const createTable = async ({
payload: payload,
})
+ // Track table creation event
+ try {
+ await sendEvent({
+ event: {
+ action: 'table_created',
+ properties: {
+ method: 'table_editor',
+ schema_name: payload.schema,
+ table_name: payload.name,
+ },
+ groups: {
+ project: projectRef,
+ ...(organizationSlug && { organization: organizationSlug }),
+ },
+ },
+ })
+ } catch (error) {
+ console.error('Failed to track table creation event:', error)
+ }
+
const table = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(projectRef, payload.name, payload.schema),
queryFn: ({ signal }) =>
@@ -508,6 +531,26 @@ export const createTable = async ({
schema: table.schema,
payload: { rls_enabled: isRLSEnabled },
})
+
+ // Track RLS enablement event
+ try {
+ await sendEvent({
+ event: {
+ action: 'table_rls_enabled',
+ properties: {
+ method: 'table_editor',
+ schema_name: table.schema,
+ table_name: table.name,
+ },
+ groups: {
+ project: projectRef,
+ ...(organizationSlug && { organization: organizationSlug }),
+ },
+ },
+ })
+ } catch (error) {
+ console.error('Failed to track RLS enablement event:', error)
+ }
}
// Then insert the columns - we don't do Promise.all as we want to keep the integrity
@@ -662,6 +705,7 @@ export const updateTable = async ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
+ organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -672,6 +716,7 @@ export const updateTable = async ({
foreignKeyRelations: ForeignKey[]
existingForeignKeyRelations: ForeignKeyConstraint[]
primaryKey?: Constraint
+ organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -703,6 +748,28 @@ export const updateTable = async ({
payload,
})
+ // Track RLS enablement if it's being turned on
+ if (payload.rls_enabled === true) {
+ try {
+ await sendEvent({
+ event: {
+ action: 'table_rls_enabled',
+ properties: {
+ method: 'table_editor',
+ schema_name: table.schema,
+ table_name: payload.name ?? table.name,
+ },
+ groups: {
+ project: projectRef,
+ ...(organizationSlug && { organization: organizationSlug }),
+ },
+ },
+ })
+ } catch (error) {
+ console.error('Failed to track RLS enablement event:', error)
+ }
+ }
+
const updatedTable = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(
projectRef,
diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
index 98ab6f2b58a..17bc50b429f 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { useParams } from 'common'
import { useDataTable } from 'components/ui/DataTable/providers/DataTableProvider'
import { ServiceFlowType, useUnifiedLogInspectionQuery } from 'data/logs'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import {
CodeBlock,
ResizableHandle,
@@ -43,6 +44,8 @@ export function ServiceFlowPanel({
const { ref: projectRef } = useParams()
const [activeTab, setActiveTab] = useState('service-flow')
+ const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
+
const logType = selectedRow?.log_type
const serviceFlowType: ServiceFlowType | undefined =
logType === 'edge function' ? 'edge-function' : (logType as ServiceFlowType)
@@ -69,6 +72,14 @@ export function ServiceFlowPanel({
const jsonData =
shouldShowServiceFlow && serviceFlowData?.result?.[0] ? serviceFlowData.result[0] : selectedRow
+ const formattedJsonData =
+ !logsMetadata && 'raw_log_data' in jsonData && 'metadata' in jsonData.raw_log_data
+ ? {
+ ...jsonData,
+ raw_log_data: { ...jsonData.raw_log_data, metadata: undefined },
+ }
+ : jsonData
+
if (selectedRowKey) {
return (
<>
@@ -218,7 +229,7 @@ export function ServiceFlowPanel({
language="json"
className="max-h-[800px] overflow-auto border-none rounded-none [&_pre]:!leading-tight [&_code]:!leading-tight"
>
- {JSON.stringify(jsonData, null, 2)}
+ {JSON.stringify(formattedJsonData, null, 2)}
diff --git a/apps/studio/components/layouts/APIAuthorizationLayout.tsx b/apps/studio/components/layouts/APIAuthorizationLayout.tsx
index bebbb882323..4dada71df95 100644
--- a/apps/studio/components/layouts/APIAuthorizationLayout.tsx
+++ b/apps/studio/components/layouts/APIAuthorizationLayout.tsx
@@ -1,3 +1,4 @@
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
@@ -9,10 +10,12 @@ export interface APIAuthorizationLayoutProps {}
const APIAuthorizationLayout = ({ children }: PropsWithChildren) => {
const { resolvedTheme } = useTheme()
+ const { appTitle } = useCustomContent(['app:title'])
+
return (
<>
- Authorize API access | Supabase
+ Authorize API access | {appTitle || 'Supabase'}
diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx
index cf709526141..679f916b09f 100644
--- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx
+++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx
@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { LOCAL_STORAGE_KEYS } from 'common'
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { withAuth } from 'hooks/misc/withAuth'
import { IS_PLATFORM } from 'lib/constants'
@@ -18,6 +19,9 @@ const AccountLayout = ({ children, title }: PropsWithChildren
- {title ? `${title} | Supabase` : 'Supabase'}
+ {title ? `${title} | ${titleSuffix}` : titleSuffix}
diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
index 3abd34af48c..ddfb6dd7f72 100644
--- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
+++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx
@@ -12,9 +12,10 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
- const { databaseReplication, databaseRoles } = useIsFeatureEnabled([
+ const { databaseReplication, databaseRoles, integrationsWrappers } = useIsFeatureEnabled([
'database:replication',
'database:roles',
+ 'integrations:wrappers',
])
useRegisterCommands(
@@ -102,13 +103,17 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
route: `/project/${ref}/database/backups/scheduled`,
defaultHidden: true,
},
- {
- id: 'nav-database-wrappers',
- name: 'Wrappers',
- value: 'Database: Wrappers',
- route: `/project/${ref}/integrations/wrappers`,
- defaultHidden: true,
- },
+ ...(integrationsWrappers
+ ? [
+ {
+ id: 'nav-database-wrappers',
+ name: 'Wrappers',
+ value: 'Database: Wrappers',
+ route: `/project/${ref}/integrations?category=wrappers`,
+ defaultHidden: true,
+ } as IRouteCommand,
+ ]
+ : []),
{
id: 'nav-database-migrations',
name: 'Migrations',
diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
index 4919aac04a7..59f74122a4f 100644
--- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
+++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx
@@ -33,10 +33,11 @@ const DatabaseProductMenu = () => {
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
const enablePgReplicate = useFlag('enablePgReplicate')
- const { databaseReplication: showPgReplicate, databaseRoles: showRoles } = useIsFeatureEnabled([
- 'database:replication',
- 'database:roles',
- ])
+ const {
+ databaseReplication: showPgReplicate,
+ databaseRoles: showRoles,
+ integrationsWrappers: showWrappers,
+ } = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers'])
return (
<>
@@ -49,6 +50,7 @@ const DatabaseProductMenu = () => {
enablePgReplicate,
showPgReplicate,
showRoles,
+ showWrappers,
})}
/>
>
diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
index 8226871f54a..d551edcc0bd 100644
--- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
+++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx
@@ -13,6 +13,7 @@ export const generateDatabaseMenu = (
enablePgReplicate: boolean
showPgReplicate: boolean
showRoles: boolean
+ showWrappers: boolean
}
): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
@@ -23,6 +24,7 @@ export const generateDatabaseMenu = (
enablePgReplicate,
showPgReplicate,
showRoles,
+ showWrappers,
} = flags || {}
return [
@@ -134,13 +136,17 @@ export const generateDatabaseMenu = (
url: `/project/${ref}/database/migrations`,
items: [],
},
- {
- name: 'Wrappers',
- key: 'wrappers',
- url: `/project/${ref}/integrations?category=wrapper`,
- rightIcon:
,
- items: [],
- },
+ ...(showWrappers
+ ? [
+ {
+ name: 'Wrappers',
+ key: 'wrappers',
+ url: `/project/${ref}/integrations?category=wrapper`,
+ rightIcon:
,
+ items: [],
+ },
+ ]
+ : []),
...(!!pgNetExtensionExists
? [
{
diff --git a/apps/studio/components/layouts/Integrations/layout.tsx b/apps/studio/components/layouts/Integrations/layout.tsx
index bef2d1ce074..87732d1a80d 100644
--- a/apps/studio/components/layouts/Integrations/layout.tsx
+++ b/apps/studio/components/layouts/Integrations/layout.tsx
@@ -1,13 +1,13 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
-import { useFlag } from 'common'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
import AlertError from 'components/ui/AlertError'
import { ProductMenu } from 'components/ui/ProductMenu'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { Menu, Separator } from 'ui'
@@ -20,6 +20,7 @@ import { GenericSkeletonLoader } from 'ui-patterns'
const IntegrationsLayout = ({ children }: PropsWithChildren) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
+ const { integrationsWrappers: showWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const segments = router.asPath.split('/')
// construct the page url to be used to determine the active state for the sidebar
@@ -65,7 +66,7 @@ const IntegrationsLayout = ({ children }: PropsWithChildren) => {
: 'integrations'
: page
}
- menu={generateIntegrationsMenu({ projectRef: project?.ref })}
+ menu={generateIntegrationsMenu({ projectRef: project?.ref, flags: { showWrappers } })}
/>
@@ -110,7 +111,15 @@ const IntegrationsLayout = ({ children }: PropsWithChildren) => {
// Wrap component with authentication HOC before exporting
export default withAuth(IntegrationsLayout)
-const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => {
+const generateIntegrationsMenu = ({
+ projectRef,
+ flags,
+}: {
+ projectRef?: string
+ flags?: { showWrappers: boolean }
+}): ProductMenuGroup[] => {
+ const { showWrappers } = flags ?? {}
+
return [
{
title: 'Explore',
@@ -122,13 +131,17 @@ const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): Prod
pages: ['integrations'],
items: [],
},
- {
- name: 'Wrappers',
- key: 'integrations-wrapper',
- url: `/project/${projectRef}/integrations?category=wrapper`,
- pages: ['integrations?category=wrapper'],
- items: [],
- },
+ ...(showWrappers
+ ? [
+ {
+ name: 'Wrappers',
+ key: 'integrations-wrapper',
+ url: `/project/${projectRef}/integrations?category=wrapper`,
+ pages: ['integrations?category=wrapper'],
+ items: [],
+ },
+ ]
+ : []),
{
name: 'Postgres Modules',
key: 'integrations-postgres_extension',
diff --git a/apps/studio/components/layouts/IntegrationsLayout/Integrations.Commands.tsx b/apps/studio/components/layouts/IntegrationsLayout/Integrations.Commands.tsx
index 510407e9c72..6a775092ea8 100644
--- a/apps/studio/components/layouts/IntegrationsLayout/Integrations.Commands.tsx
+++ b/apps/studio/components/layouts/IntegrationsLayout/Integrations.Commands.tsx
@@ -13,13 +13,11 @@ export function useIntegrationsGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
- const { integrationsShowStripeWrapper } = useIsFeatureEnabled([
- 'integrations:show_stripe_wrapper',
- ])
+ const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
- const allIntegrations = integrationsShowStripeWrapper
+ const allIntegrations = integrationsWrappers
? INTEGRATIONS
- : INTEGRATIONS.filter((x) => x.id !== 'stripe_wrapper')
+ : INTEGRATIONS.filter((x) => !x.id.endsWith('_wrapper'))
const getName = (integration: IntegrationDefinition) => {
switch (integration.id) {
diff --git a/apps/studio/components/layouts/LinkAwsMarketplaceLayout.tsx b/apps/studio/components/layouts/LinkAwsMarketplaceLayout.tsx
index 8baff253240..1f14f57b65d 100644
--- a/apps/studio/components/layouts/LinkAwsMarketplaceLayout.tsx
+++ b/apps/studio/components/layouts/LinkAwsMarketplaceLayout.tsx
@@ -1,3 +1,4 @@
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
@@ -12,10 +13,12 @@ const LinkAwsMarketplaceLayout = ({
children,
}: PropsWithChildren
) => {
const { resolvedTheme } = useTheme()
+ const { appTitle } = useCustomContent(['app:title'])
+
return (
<>
- AWS Marketplace Setup | Supabase
+ AWS Marketplace Setup | {appTitle || 'Supabase'}
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
index a9f7e2403cc..02654ef391b 100644
--- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
@@ -9,6 +9,7 @@ import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant'
import { Loading } from 'components/ui/Loading'
import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
@@ -94,6 +95,9 @@ const ProjectLayout = forwardRef
aiSnap.toggleAssistant(), 'i', [aiSnap])
const editor = useEditorType()
@@ -124,14 +128,14 @@ const ProjectLayout = forwardRef
{title
- ? `${title} | Supabase`
+ ? `${title} | ${titleSuffix}`
: selectedTable
- ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase`
+ ? `${selectedTable} | ${projectName} | ${organizationName} | ${titleSuffix}`
: projectName
- ? `${projectName} | ${organizationName} | Supabase`
+ ? `${projectName} | ${organizationName} | ${titleSuffix}`
: organizationName
- ? `${organizationName} | Supabase`
- : 'Supabase'}
+ ? `${organizationName} | ${titleSuffix}`
+ : titleSuffix}
diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx
index f3f5a968cfe..ab61c00f676 100644
--- a/apps/studio/components/ui/DatabaseSelector.tsx
+++ b/apps/studio/components/ui/DatabaseSelector.tsx
@@ -10,6 +10,7 @@ import { Markdown } from 'components/interfaces/Markdown'
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'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
@@ -53,6 +54,8 @@ const DatabaseSelector = ({
const [open, setOpen] = useState(false)
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
+ const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
+
const state = useDatabaseSelectorStateSnapshot()
const selectedDatabaseId = _selectedDatabaseId ?? state.selectedDatabaseId
@@ -203,7 +206,7 @@ const DatabaseSelector = ({
})}
- {IS_PLATFORM && (
+ {IS_PLATFORM && infrastructureReadReplicas && (
{
const { status, initiated_at, latest_status_at, error } = data?.databaseUpgradeStatus ?? {}
const key = `supabase-upgrade-${ref}-${initiated_at}`
- const isAcknowledged =
- typeof window !== 'undefined' ? localStorage?.getItem(key) === 'true' : false
- const [showMessage, setShowMessage] = useState(!isAcknowledged)
+
+ const [hasDismissed, setHasDismissed] = useState(false)
+ useEffect(() => {
+ setHasDismissed(localStorage?.getItem(key) === 'true')
+ }, [key])
const isFailed = status === DatabaseUpgradeStatus.Failed
const initiatedAt = dayjs
@@ -43,11 +45,11 @@ export const ProjectUpgradeFailedBanner = () => {
const timestampFilter = `its=${initiatedAtEncoded}&ite=${latestStatusAtEncoded}`
const acknowledgeMessage = () => {
- setShowMessage(false)
+ setHasDismissed(true)
localStorage.setItem(key, 'true')
}
- if (!isFailed || !showMessage) return null
+ if (!isFailed || hasDismissed) return null
return (
diff --git a/apps/studio/components/ui/ToggleSpendCapButton.tsx b/apps/studio/components/ui/ToggleSpendCapButton.tsx
new file mode 100644
index 00000000000..30fe58b0ab2
--- /dev/null
+++ b/apps/studio/components/ui/ToggleSpendCapButton.tsx
@@ -0,0 +1,37 @@
+import Link from 'next/link'
+import { PropsWithChildren } from 'react'
+
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { Button } from 'ui'
+
+interface ToggleSpendCapButtonProps {
+ action?: 'disable' | 'enable'
+ type?: 'default' | 'primary'
+}
+
+export const ToggleSpendCapButton = ({
+ action = 'disable',
+ type = 'default',
+ children,
+}: PropsWithChildren) => {
+ const { data: organization } = useSelectedOrganizationQuery()
+ const slug = organization?.slug ?? '_'
+
+ const { billingAll } = useIsFeatureEnabled(['billing:all'])
+
+ const subject = `Enquiry to ${action} spend cap for organization`
+ const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}`
+
+ const href = billingAll
+ ? `/org/${slug}/billing?panel=costControl`
+ : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
+
+ return (
+
+
+ {children || `${action} spend cap`}
+
+
+ )
+}
diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx
new file mode 100644
index 00000000000..8c9db51c95c
--- /dev/null
+++ b/apps/studio/components/ui/UpgradePlanButton.tsx
@@ -0,0 +1,45 @@
+import Link from 'next/link'
+import { PropsWithChildren } from 'react'
+
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { Button } from 'ui'
+
+export const PLAN_REQUEST_EMPTY_PLACEHOLDER =
+ ''
+
+interface UpgradePlanButtonProps {
+ source?: string
+ type?: 'default' | 'primary'
+ plan?: 'Pro' | 'Team' | 'Enterprise'
+ href?: string // [Joshen] As an override if needed (Used in UpgradeToPro)
+ disabled?: boolean
+}
+
+export const UpgradePlanButton = ({
+ source,
+ type = 'default',
+ plan,
+ href: propsHref,
+ disabled,
+ children,
+}: PropsWithChildren) => {
+ const { data: organization } = useSelectedOrganizationQuery()
+ const slug = organization?.slug ?? '_'
+
+ const { billingAll } = useIsFeatureEnabled(['billing:all'])
+
+ const subject = `Enquiry to upgrade ${!!plan ? `to ${plan} ` : ''}plan for organization`
+ const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}\nRequested plan: ${plan ?? PLAN_REQUEST_EMPTY_PLACEHOLDER}`
+
+ const href = billingAll
+ ? propsHref ??
+ `/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}`
+ : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
+
+ return (
+
+ {children || `Upgrade to ${plan}`}
+
+ )
+}
diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx
index 7cf9aa39523..dd4e1f07dbc 100644
--- a/apps/studio/components/ui/UpgradeToPro.tsx
+++ b/apps/studio/components/ui/UpgradeToPro.tsx
@@ -1,13 +1,13 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import Link from 'next/link'
import { ReactNode } from 'react'
import { useFlag } from 'common'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { Button, cn } from 'ui'
+import { cn } from 'ui'
import { ButtonTooltip } from './ButtonTooltip'
+import { UpgradePlanButton } from './UpgradePlanButton'
interface UpgradeToProProps {
icon?: ReactNode
@@ -74,23 +74,21 @@ const UpgradeToPro = ({
{buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
) : (
-
-
- {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
-
-
+ {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
+
)}
diff --git a/apps/studio/data/projects/clone-status-query.ts b/apps/studio/data/projects/clone-status-query.ts
index 9f9b6a5253b..6a85892d138 100644
--- a/apps/studio/data/projects/clone-status-query.ts
+++ b/apps/studio/data/projects/clone-status-query.ts
@@ -1,7 +1,10 @@
-import { get, handleError } from 'data/fetchers'
-import { projectKeys } from './keys'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+import { components } from 'api-types'
+import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
+import { projectKeys } from './keys'
+
+export type CloneStatus = components['schemas']['ProjectClonedStatusResponse']
export async function getCloneStatus(projectRef?: string) {
if (!projectRef) throw new Error('Project ref is required')
diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts
index b5ddceaf427..91af532e4a4 100644
--- a/apps/studio/data/sql/execute-sql-mutation.ts
+++ b/apps/studio/data/sql/execute-sql-mutation.ts
@@ -1,6 +1,9 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
+import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { sqlEventParser } from 'lib/sql-event-parser'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
@@ -31,12 +34,38 @@ export const useExecuteSqlMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
+ const { mutate: sendEvent } = useSendEventMutation()
+ const { data: org } = useSelectedOrganizationQuery()
+
return useMutation(
(args) => executeSql(args),
{
async onSuccess(data, variables, context) {
const { contextualInvalidation, sql, projectRef } = variables
+ // Track all table-related events from SQL execution
+ try {
+ const tableEvents = sqlEventParser.getTableEvents(sql)
+ tableEvents.forEach((event) => {
+ if (projectRef) {
+ sendEvent({
+ action: event.type,
+ properties: {
+ method: 'sql_editor',
+ schema_name: event.schema,
+ table_name: event.tableName,
+ },
+ groups: {
+ project: projectRef,
+ ...(org?.slug && { organization: org.slug }),
+ },
+ })
+ }
+ })
+ } catch (error) {
+ console.error('Failed to parse SQL for telemetry:', error)
+ }
+
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
const sqlLower = sql.toLowerCase()
const isMutationSQL =
diff --git a/apps/studio/data/table-rows/table-row-create-mutation.ts b/apps/studio/data/table-rows/table-row-create-mutation.ts
index e31cc4609dc..f901c10ce84 100644
--- a/apps/studio/data/table-rows/table-row-create-mutation.ts
+++ b/apps/studio/data/table-rows/table-row-create-mutation.ts
@@ -3,6 +3,8 @@ import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import { executeSql } from 'data/sql/execute-sql-query'
+import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError } from 'types'
@@ -65,12 +67,33 @@ export const useTableRowCreateMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
+ const { mutate: sendEvent } = useSendEventMutation()
+ const { data: org } = useSelectedOrganizationQuery()
return useMutation(
(vars) => createTableRow(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, table } = variables
+
+ // Track data insertion event
+ try {
+ sendEvent({
+ action: 'table_data_added',
+ properties: {
+ method: 'table_editor',
+ schema_name: table.schema,
+ table_name: table.name,
+ },
+ groups: {
+ project: projectRef,
+ ...(org?.slug && { organization: org.slug }),
+ },
+ })
+ } catch (error) {
+ console.error('Failed to track table data insertion event:', error)
+ }
+
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
await onSuccess?.(data, variables, context)
},
diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx
index 33d9404c64e..0adedd21292 100644
--- a/apps/studio/hooks/analytics/useLogsQuery.tsx
+++ b/apps/studio/hooks/analytics/useLogsQuery.tsx
@@ -16,6 +16,7 @@ import {
checkForWithClause,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import { get } from 'data/fetchers'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { DOCS_URL } from 'lib/constants'
export interface LogsQueryHook {
@@ -46,6 +47,8 @@ const useLogsQuery = (
: defaultHelper.calcTo(),
})
+ const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
+
useEffect(() => {
setParams((prev) => ({
...prev,
@@ -113,10 +116,19 @@ const useLogsQuery = (
setParams((prev) => ({ ...prev, sql: newQuery }))
}
+ const logData = (data?.result ?? []).map((x) => {
+ if (logsMetadata) {
+ return x
+ } else {
+ const { metadata, ...log } = x
+ return log
+ }
+ })
+
return {
params,
isLoading: (_enabled && isLoading) || isRefetching,
- logData: data?.result ?? [],
+ logData: logData,
error,
changeQuery,
runQuery: () => refetch(),
diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx
index b773b45e673..6e0dbe9cc50 100644
--- a/apps/studio/hooks/analytics/useSingleLog.tsx
+++ b/apps/studio/hooks/analytics/useSingleLog.tsx
@@ -8,6 +8,7 @@ import type {
} from 'components/interfaces/Settings/Logs/Logs.types'
import { genSingleLogQuery } from 'components/interfaces/Settings/Logs/Logs.utils'
import { get } from 'data/fetchers'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
interface SingleLogHook {
data: LogData | undefined
@@ -35,6 +36,8 @@ function useSingleLog({
const enabled = Boolean(id && table)
+ const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
+
const {
data,
error: rcError,
@@ -67,8 +70,11 @@ function useSingleLog({
let error: null | string | object = rcError ? (rcError as any).message : null
const result = data?.result ? data.result[0] : undefined
+
return {
- data: result,
+ data: !!result
+ ? { ...result, metadata: logsMetadata ? result?.metadata : undefined }
+ : undefined,
isLoading: (enabled && isLoading) || isRefetching,
error,
refresh: () => refetch(),
diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts
index a84484def9c..4aa8b0216c8 100644
--- a/apps/studio/hooks/custom-content/CustomContent.types.ts
+++ b/apps/studio/hooks/custom-content/CustomContent.types.ts
@@ -1,6 +1,8 @@
import type { CloudProvider } from 'shared-data'
export type CustomContentTypes = {
+ appTitle: string
+
dashboardAuthCustomProvider: string
organizationLegalDocuments: {
diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json
index 2702c8c2728..10dfa88265f 100644
--- a/apps/studio/hooks/custom-content/custom-content.json
+++ b/apps/studio/hooks/custom-content/custom-content.json
@@ -1,6 +1,8 @@
{
"$schema": "./custom-content.schema.json",
+ "app:title": null,
+
"dashboard_auth:custom_provider": null,
"organization:legal_documents": null,
diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json
index b7adc83af68..943d5451273 100644
--- a/apps/studio/hooks/custom-content/custom-content.sample.json
+++ b/apps/studio/hooks/custom-content/custom-content.sample.json
@@ -1,6 +1,8 @@
{
"$schema": "./custom-content.schema.json",
+ "app:title": "Test Title",
+
"dashboard_auth:custom_provider": "Nimbus",
"organization:legal_documents": [
diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json
index af9bd62d272..208c77c01fd 100644
--- a/apps/studio/hooks/custom-content/custom-content.schema.json
+++ b/apps/studio/hooks/custom-content/custom-content.schema.json
@@ -6,6 +6,11 @@
"type": "string"
},
+ "app:title": {
+ "type": ["string", "null"],
+ "description": "Render a custom HTML title for the site"
+ },
+
"dashboard_auth:custom_provider": {
"type": ["string", "null"],
"description": "Show a custom provider on the sign in page (Continue with X)"
@@ -85,6 +90,7 @@
}
},
"required": [
+ "app:title",
"organization:legal_documents",
"project_homepage:client_libraries",
"project_homepage:example_projects",
diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts
index 88a4d3c2eb3..6edab42d9bd 100644
--- a/apps/studio/hooks/misc/useSelectedProject.ts
+++ b/apps/studio/hooks/misc/useSelectedProject.ts
@@ -53,6 +53,13 @@ export const useIsAwsK8sCloudProvider = () => {
return isAwsK8s
}
+export const useIsAwsNimbusCloudProvider = () => {
+ const { data: project } = useSelectedProjectQuery()
+ const isAwsNimbus = project?.cloud_provider === PROVIDERS.AWS_NIMBUS.id
+
+ return isAwsNimbus
+}
+
export const useIsOrioleDb = () => {
const { data: project } = useSelectedProjectQuery()
const isOrioleDb = project?.dbVersion?.endsWith('orioledb')
diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts
index 19c52759561..83b00581dc0 100644
--- a/apps/studio/lib/ai/prompts.ts
+++ b/apps/studio/lib/ai/prompts.ts
@@ -327,6 +327,7 @@ export const CHAT_PROMPT = `
## SQL Execution and Display
- To execute SQL, call the \`execute_sql\` tool with the relevant \`sql\` string; the client manages confirmation and display of results.
- Do not show the SQL query before execution; the client will display it to the user.
+- Set chartConfig \`view\` to \`chart\` and xAxis/yAxis if the results would be best displayed as a chart e.g. count of items by date
- On execution error, explain succinctly and attempt to correct if possible, validating each outcome briefly (1–2 lines) after execution.
- If a user skips execution, acknowledge and suggest alternatives.
- Use markdown code blocks (\`\`\`sql\`\`\`) for illustrative SQL only if requested by the user or when providing non-executable examples.
diff --git a/apps/studio/lib/ai/tools/rendering-tools.ts b/apps/studio/lib/ai/tools/rendering-tools.ts
index 96d06f631ef..4f5e9704ff4 100644
--- a/apps/studio/lib/ai/tools/rendering-tools.ts
+++ b/apps/studio/lib/ai/tools/rendering-tools.ts
@@ -7,6 +7,13 @@ export const getRenderingTools = () => ({
inputSchema: z.object({
sql: z.string().describe('The SQL statement to execute.'),
label: z.string().describe('A short 2-4 word label for the SQL statement.'),
+ chartConfig: z
+ .object({
+ view: z.enum(['table', 'chart']).describe('How to render the results after execution'),
+ xAxis: z.string().optional().describe('The column to use for the x-axis of the chart.'),
+ yAxis: z.string().optional().describe('The column to use for the y-axis of the chart.'),
+ })
+ .describe('Chart configuration for rendering the results'),
isWriteQuery: z
.boolean()
.describe(
diff --git a/apps/studio/lib/sql-event-parser.test.ts b/apps/studio/lib/sql-event-parser.test.ts
new file mode 100644
index 00000000000..dafaa37712a
--- /dev/null
+++ b/apps/studio/lib/sql-event-parser.test.ts
@@ -0,0 +1,462 @@
+import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
+import { describe, expect, it } from 'vitest'
+import { sqlEventParser } from './sql-event-parser'
+
+describe('SQL Event Parser', () => {
+ describe('CREATE TABLE detection', () => {
+ it('detects basic CREATE TABLE', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('detects CREATE TABLE with schema', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: 'public',
+ tableName: 'users',
+ })
+ })
+
+ it('detects CREATE TABLE IF NOT EXISTS', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('handles quoted identifiers', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: 'public',
+ tableName: 'user_table',
+ })
+ })
+
+ it('returns empty array for non-matching SQL', () => {
+ const results = sqlEventParser.getTableEvents('SELECT * FROM users')
+ expect(results).toHaveLength(0)
+ })
+
+ it('detects CREATE TEMPORARY TABLE', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'temp_users',
+ })
+ })
+
+ it('detects CREATE TEMP TABLE', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'temp_users',
+ })
+ })
+
+ it('detects CREATE UNLOGGED TABLE', () => {
+ const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'fast_table',
+ })
+ })
+
+ it('detects CREATE TEMP TABLE IF NOT EXISTS', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'temp_users',
+ })
+ })
+ })
+
+ describe('INSERT detection', () => {
+ it('detects basic INSERT INTO', () => {
+ const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')")
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('detects INSERT with schema', () => {
+ const results = sqlEventParser.getTableEvents(
+ "INSERT INTO public.users (name) VALUES ('John')"
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: 'public',
+ tableName: 'users',
+ })
+ })
+
+ it('handles quoted identifiers', () => {
+ const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: 'auth',
+ tableName: 'users',
+ })
+ })
+
+ it('returns empty array for non-matching SQL', () => {
+ const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"')
+ expect(results).toHaveLength(0)
+ })
+ })
+
+ describe('COPY detection', () => {
+ it('detects basic COPY FROM', () => {
+ const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'")
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('detects COPY with schema', () => {
+ const results = sqlEventParser.getTableEvents(
+ "COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER"
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: 'public',
+ tableName: 'users',
+ })
+ })
+
+ it('handles quoted identifiers', () => {
+ const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ schema: 'auth',
+ tableName: 'users',
+ })
+ })
+
+ it('returns empty array for COPY TO', () => {
+ const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'")
+ expect(results).toHaveLength(0)
+ })
+
+ it('returns empty array for non-matching SQL', () => {
+ const results = sqlEventParser.getTableEvents('SELECT * FROM users')
+ expect(results).toHaveLength(0)
+ })
+ })
+
+ describe('SELECT INTO detection', () => {
+ it('detects SELECT INTO', () => {
+ const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'new_users',
+ })
+ })
+
+ it('detects SELECT INTO with schema', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'SELECT id, name INTO public.new_users FROM users'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: 'public',
+ tableName: 'new_users',
+ })
+ })
+
+ it('detects CREATE TABLE AS SELECT', () => {
+ const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'new_users',
+ })
+ })
+
+ it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'new_users',
+ })
+ })
+
+ it('handles quoted identifiers', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'SELECT * INTO "backup"."users_2024" FROM users'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: 'backup',
+ tableName: 'users_2024',
+ })
+ })
+
+ it('returns empty array for regular SELECT', () => {
+ const results = sqlEventParser.getTableEvents('SELECT * FROM users')
+ expect(results).toHaveLength(0)
+ })
+ })
+
+ describe('RLS detection', () => {
+ it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
+ const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('detects short form ENABLE RLS', () => {
+ const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS')
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('detects with schema', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ schema: 'public',
+ tableName: 'users',
+ })
+ })
+
+ it('handles other ALTER TABLE statements in between', () => {
+ const results = sqlEventParser.getTableEvents(
+ 'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY'
+ )
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('returns empty array for disabling RLS', () => {
+ const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY')
+ expect(results).toHaveLength(0)
+ })
+ })
+
+ describe('ReDoS protection', () => {
+ it('handles extremely long identifier names efficiently', () => {
+ const longIdentifier = 'a'.repeat(10000)
+ const sql = `CREATE TABLE ${longIdentifier} (id INT)`
+
+ const startTime = Date.now()
+ const results = sqlEventParser.getTableEvents(sql)
+ const duration = Date.now() - startTime
+
+ expect(duration).toBeLessThan(100)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: longIdentifier,
+ })
+ })
+
+ it('handles nested dots in schema names without catastrophic backtracking', () => {
+ const maliciousInput = 'a.'.repeat(1000) + 'table'
+ const sql = `CREATE TABLE ${maliciousInput} (id INT)`
+
+ const startTime = Date.now()
+ const results = sqlEventParser.getTableEvents(sql)
+ const duration = Date.now() - startTime
+
+ expect(duration).toBeLessThan(100)
+ expect(results.length).toBeGreaterThan(0)
+ })
+
+ it('handles pathological SELECT INTO patterns', () => {
+ const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users'
+
+ const startTime = Date.now()
+ const results = sqlEventParser.getTableEvents(maliciousSQL)
+ const duration = Date.now() - startTime
+
+ expect(duration).toBeLessThan(100)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'table',
+ })
+ })
+
+ it('handles ALTER TABLE with many operations between', () => {
+ const manyOperations = 'ADD COLUMN test INT, '.repeat(100)
+ const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY`
+
+ const startTime = Date.now()
+ const results = sqlEventParser.getTableEvents(sql)
+ const duration = Date.now() - startTime
+
+ expect(duration).toBeLessThan(100)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+
+ it('handles mixed quotes and backticks efficiently', () => {
+ const mixedQuotes = '`"`.'.repeat(100) + 'tablename'
+ const sql = `CREATE TABLE ${mixedQuotes} (id INT)`
+
+ const startTime = Date.now()
+ sqlEventParser.getTableEvents(sql)
+ const duration = Date.now() - startTime
+
+ expect(duration).toBeLessThan(100)
+ })
+ })
+
+ describe('Edge cases and special characters', () => {
+ it('handles Unicode identifiers', () => {
+ const sql = 'CREATE TABLE 用户表 (id INT)'
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(0)
+ })
+
+ it('handles identifiers with numbers', () => {
+ const sql = 'CREATE TABLE table123 (id INT)'
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'table123',
+ })
+ })
+
+ it('handles identifiers with underscores', () => {
+ const sql = 'CREATE TABLE user_accounts (id INT)'
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'user_accounts',
+ })
+ })
+
+ it('handles escaped quotes in identifiers', () => {
+ const sql = 'CREATE TABLE "user""table" (id INT)'
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'usertable',
+ })
+ })
+
+ it('handles dollar-quoted strings in SQL', () => {
+ const sql = `
+ CREATE TABLE users (id INT);
+ INSERT INTO logs VALUES ($$CREATE TABLE fake$$);
+ INSERT INTO users VALUES (1);
+ `
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(3)
+ expect(results[0].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
+ expect(results[0]).toMatchObject({ tableName: 'users' })
+ expect(results[1].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
+ expect(results[1]).toMatchObject({ tableName: 'fake' })
+ expect(results[2].type).toBe(TABLE_EVENT_ACTIONS.TableDataAdded)
+ })
+
+ it('handles SQL injection attempts safely', () => {
+ const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)"
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(1)
+ expect(results[0]).toEqual({
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ schema: undefined,
+ tableName: 'users',
+ })
+ })
+ })
+
+ describe('getTableEvents', () => {
+ it('filters only table-related events', () => {
+ const sql = `
+ CREATE TABLE users (id INT);
+ CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
+ INSERT INTO users (id) VALUES (1);
+ ALTER TABLE users ENABLE RLS;
+ CREATE VIEW user_view AS SELECT * FROM users;
+ `
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(3)
+ expect(results.map((r) => r.type)).toEqual([
+ TABLE_EVENT_ACTIONS.TableCreated,
+ TABLE_EVENT_ACTIONS.TableDataAdded,
+ TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ ])
+ })
+
+ it('returns empty array for non-table SQL', () => {
+ const sql = `
+ CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
+ CREATE VIEW user_view AS SELECT * FROM users;
+ SELECT * FROM users;
+ `
+ const results = sqlEventParser.getTableEvents(sql)
+ expect(results).toHaveLength(0)
+ })
+ })
+})
diff --git a/apps/studio/lib/sql-event-parser.ts b/apps/studio/lib/sql-event-parser.ts
new file mode 100644
index 00000000000..dece7fd5c85
--- /dev/null
+++ b/apps/studio/lib/sql-event-parser.ts
@@ -0,0 +1,128 @@
+/**
+ * Lightweight SQL parser for telemetry event detection.
+ *
+ * [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
+ * browser-compatible version is available.
+ */
+import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
+
+export interface TableEventDetails {
+ type: TableEventAction
+ schema?: string
+ tableName?: string
+}
+
+type Detector = {
+ type: TableEventAction
+ patterns: RegExp[]
+}
+
+export class SQLEventParser {
+ private static DETECTORS: Detector[] = [
+ {
+ type: TABLE_EVENT_ACTIONS.TableCreated,
+ patterns: [
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i,
+ /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i,
+ /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i,
+ /SELECT\s+.*?\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/is,
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)\s+AS\s+SELECT/i,
+ ],
+ },
+ {
+ type: TABLE_EVENT_ACTIONS.TableDataAdded,
+ patterns: [
+ /INSERT\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i,
+ /COPY\s+(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)\s+FROM/i,
+ ],
+ },
+ {
+ type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
+ patterns: [
+ /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
+ /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+).*?ENABLE\s+RLS/i,
+ ],
+ },
+ ]
+
+ private cleanIdentifier(identifier?: string) {
+ return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
+ }
+
+ private match(sql: string): TableEventDetails | null {
+ for (const { type, patterns } of SQLEventParser.DETECTORS) {
+ for (const pattern of patterns) {
+ const match = sql.match(pattern)
+ if (match?.groups) {
+ return {
+ type,
+ schema: this.cleanIdentifier(match.groups.schema),
+ tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
+ }
+ }
+ }
+ }
+ return null
+ }
+
+ private splitStatements(sql: string): string[] {
+ // Regex matches:
+ // - single quotes ('...') with escapes
+ // - double quotes ("...")
+ // - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
+ // - semicolons
+ // - everything else
+ const tokens =
+ sql.match(
+ /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
+ ) || []
+
+ const statements: string[] = []
+ let current = ''
+
+ for (const token of tokens) {
+ if (token === ';') {
+ if (current.trim()) statements.push(current.trim())
+ current = ''
+ } else {
+ current += token
+ }
+ }
+
+ if (current.trim()) {
+ statements.push(current.trim())
+ }
+
+ return statements
+ }
+
+ private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
+ const seen = new Set()
+ return events.filter((e) => {
+ const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+ }
+
+ private removeComments(sql: string): string {
+ return sql
+ .replace(/--.*?$/gm, '') // line comments
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
+ }
+
+ getTableEvents(sql: string): TableEventDetails[] {
+ const statements = this.splitStatements(this.removeComments(sql))
+ const results: TableEventDetails[] = []
+
+ for (const stmt of statements) {
+ const event = this.match(stmt)
+ if (event) results.push(event)
+ }
+
+ return this.deduplicate(results)
+ }
+}
+
+export const sqlEventParser = new SQLEventParser()
diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx
index a4034e0b3ef..7eea0ec1d04 100644
--- a/apps/studio/pages/_app.tsx
+++ b/apps/studio/pages/_app.tsx
@@ -50,6 +50,7 @@ import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvid
import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState'
import { useRootQueryClient } from 'data/query-client'
import { customFont, sourceCodePro } from 'fonts'
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { AuthProvider } from 'lib/auth'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants'
import { ProfileProvider } from 'lib/profile'
@@ -84,6 +85,7 @@ loader.config({
function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
const queryClient = useRootQueryClient()
+ const { appTitle } = useCustomContent(['app:title'])
const getLayout = Component.getLayout ?? ((page) => page)
@@ -127,7 +129,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
>
- Supabase
+ {appTitle ?? 'Supabase'}
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
diff --git a/apps/studio/pages/claim-project.tsx b/apps/studio/pages/claim-project.tsx
index f534495011e..a109a561195 100644
--- a/apps/studio/pages/claim-project.tsx
+++ b/apps/studio/pages/claim-project.tsx
@@ -1,5 +1,5 @@
import Head from 'next/head'
-import { useMemo, useState } from 'react'
+import { PropsWithChildren, useMemo, useState } from 'react'
import { useParams } from 'common'
import { ProjectClaimBenefits } from 'components/interfaces/Organization/ProjectClaim/benefits'
@@ -10,10 +10,24 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query'
import { useOrganizationProjectClaimQuery } from 'data/organizations/organization-project-claim-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { withAuth } from 'hooks/misc/withAuth'
import type { NextPageWithLayout } from 'types'
import { Admonition } from 'ui-patterns'
+const ClaimProjectPageLayout = ({ children }: PropsWithChildren) => {
+ const { appTitle } = useCustomContent(['app:title'])
+
+ return (
+ <>
+
+ Claim project | {appTitle || 'Supabase'}
+
+ {children}
+ >
+ )
+}
+
const ClaimProjectPage: NextPageWithLayout = () => {
const { auth_id, token: claimToken } = useParams()
const [selectedOrgSlug, setSelectedOrgSlug] = useState()
@@ -111,12 +125,9 @@ const ClaimProjectPage: NextPageWithLayout = () => {
}
ClaimProjectPage.getLayout = (page) => (
- <>
-
- Claim project | Supabase
-
+
{page}
- >
+
)
export default withAuth(ClaimProjectPage)
diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx
index a95b71e6339..7f18c92f157 100644
--- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx
+++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx
@@ -1,42 +1,22 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { ChevronRightIcon, Loader2 } from 'lucide-react'
-import Link from 'next/link'
-import { useState } from 'react'
-
+import { useParams } from 'common'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
-import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
-import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
-import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
-import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
-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 { RestoreToNewProject } from 'components/interfaces/Database/RestoreToNewProject/RestoreToNewProject'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
-import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
-import NoPermission from 'components/ui/NoPermission'
-import Panel from 'components/ui/Panel'
-import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
-import UpgradeToPro from 'components/ui/UpgradeToPro'
-import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
-import { useCloneBackupsQuery } from 'data/projects/clone-query'
-import { useCloneStatusQuery } from 'data/projects/clone-status-query'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
-import {
- useIsAwsK8sCloudProvider,
- useIsOrioleDb,
- useSelectedProjectQuery,
-} from 'hooks/misc/useSelectedProject'
-import { DOCS_URL, PROJECT_STATUS } from 'lib/constants'
-import { getDatabaseMajorVersion } from 'lib/helpers'
+import { UnknownInterface } from 'components/ui/UnknownInterface'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from 'types'
-import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Button } from 'ui'
-import { Admonition, TimestampInfo } from 'ui-patterns'
const RestoreToNewProjectPage: NextPageWithLayout = () => {
+ const { ref } = useParams()
+ const { databaseRestoreToNewProject } = useIsFeatureEnabled(['database:restore_to_new_project'])
+
+ if (!databaseRestoreToNewProject) {
+ return
+ }
+
return (
@@ -60,367 +40,4 @@ RestoreToNewProjectPage.getLayout = (page) => (
)
-const RestoreToNewProject = () => {
- const { data: project } = useSelectedProjectQuery()
- const { data: organization } = useSelectedOrganizationQuery()
- const isFreePlan = organization?.plan?.id === 'free'
- const isOrioleDb = useIsOrioleDb()
- const isAwsK8s = useIsAwsK8sCloudProvider()
-
- const [refetchInterval, setRefetchInterval] = useState(false)
- const [selectedBackupId, setSelectedBackupId] = useState(null)
- const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
- const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
- const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(null)
-
- const {
- data: cloneBackups,
- error,
- isLoading: cloneBackupsLoading,
- isError,
- } = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
-
- const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
-
- const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
- PermissionAction.READ,
- 'physical_backups'
- )
- const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions(
- PermissionAction.INFRA_EXECUTE,
- 'queue_job.restore.prepare'
- )
- const PITR_ENABLED = cloneBackups?.pitr_enabled
- const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
- const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
- const IS_PG15_OR_ABOVE = dbVersion >= 15
- const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb
- const targetComputeSize = cloneBackups?.target_compute_size
- const planId = organization?.plan?.id ?? 'free'
- const { data } = useDiskAttributesQuery({ projectRef: project?.ref })
- const storageType = data?.attributes?.type ?? 'gp3'
-
- const {
- data: cloneStatus,
- refetch: refetchCloneStatus,
- isLoading: cloneStatusLoading,
- } = useCloneStatusQuery(
- {
- projectRef: project?.ref,
- },
- {
- refetchInterval,
- refetchOnWindowFocus: false,
- onSuccess: (data) => {
- const hasTransientState = data?.clones.some((c) => c.status === 'IN_PROGRESS')
- if (!hasTransientState) setRefetchInterval(false)
- },
- enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED,
- }
- )
- const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
- const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
-
- const previousClones = cloneStatus?.clones
- const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS')
- const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS')
-
- const StatusBadge = ({
- status,
- }: {
- status: NonNullable[number]['status']
- }) => {
- const statusTextMap = {
- IN_PROGRESS: 'RESTORING',
- COMPLETED: 'COMPLETED',
- REMOVED: 'REMOVED',
- FAILED: 'FAILED',
- }
-
- if (status === 'IN_PROGRESS') {
- return {statusTextMap[status]}
- }
-
- if (status === 'FAILED') {
- return {statusTextMap[status]}
- }
-
- return {statusTextMap[status]}
- }
-
- const PreviousRestoreItem = ({
- clone,
- }: {
- clone: NonNullable[number]
- }) => {
- if (clone.status === 'REMOVED') {
- return (
-
-
{(clone.target_project as any).name}
-
-
-
-
-
-
-
- )
- } else {
- return (
-
- {(clone.target_project as any).name}
-
-
-
-
-
-
-
-
-
-
- )
- }
- }
-
- if (organization?.managed_by === 'vercel-marketplace') {
- return (
-
- )
- }
-
- if (isFreePlan) {
- return (
-
- )
- }
-
- if (isOrioleDb) {
- return (
-
- )
- }
-
- if (isAwsK8s) {
- return (
-
- )
- }
-
- if (!canReadPhysicalBackups) {
- return
- }
-
- if (!canTriggerPhysicalBackups) {
- return
- }
-
- if (!IS_PG15_OR_ABOVE) {
- return (
-
-
-
- )
- }
-
- if (!PHYSICAL_BACKUPS_ENABLED) {
- return (
-
- Physical backups must be enabled to restore your database to a new project.
- Find out more about how backups work at supabase{' '}
-
- in our docs
-
- .
- >
- }
- />
- )
- }
-
- if (isLoading) {
- return
- }
-
- if (IS_CLONED_PROJECT) {
- return (
-
-
-
-
- Go to original project
-
-
-
- )
- }
-
- if (isError) {
- return
- }
-
- if (!isActiveHealthy) {
- return (
-
- )
- }
-
- if (
- !isLoading &&
- PITR_ENABLED &&
- !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
- ) {
- return (
-
- )
- }
-
- if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) {
- return (
- <>
-
- >
- )
- }
-
- const additionalMonthlySpend = projectSpecToMonthlyPrice({
- targetVolumeSizeGb: targetVolumeSizeGb ?? 0,
- targetComputeSize: targetComputeSize ?? 'nano',
- planId: planId ?? 'free',
- storageType: storageType as DiskType,
- })
-
- return (
-
-
{
- setShowConfirmationDialog(false)
- setShowNewProjectDialog(true)
- }}
- additionalMonthlySpend={additionalMonthlySpend}
- />
- {
- refetchCloneStatus()
- setRefetchInterval(5000)
- setShowNewProjectDialog(false)
- }}
- />
- {isRestoring ? (
-
-
- Restoration in progress
-
-
- The new project {(restoringClone?.target_project as any)?.name || ''} is currently
- being created. You'll be able to restore again once the project is ready.
-
-
- Go to new project
-
-
-
- ) : null}
- {previousClones?.length ? (
-
-
Previous restorations
-
- {previousClones?.map((c) => )}
-
-
- ) : null}
- {PITR_ENABLED ? (
- <>
- {
- setShowConfirmationDialog(true)
- setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
- }}
- earliestAvailableBackupUnix={
- cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
- }
- latestAvailableBackupUnix={
- cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
- }
- />
- >
- ) : (
- {
- setSelectedBackupId(id)
- setShowConfirmationDialog(true)
- }}
- />
- )}
-
- )
-}
-
export default RestoreToNewProjectPage
diff --git a/apps/studio/pages/project/[ref]/database/settings.tsx b/apps/studio/pages/project/[ref]/database/settings.tsx
index 920d10470bd..aff7f37700b 100644
--- a/apps/studio/pages/project/[ref]/database/settings.tsx
+++ b/apps/studio/pages/project/[ref]/database/settings.tsx
@@ -1,14 +1,16 @@
import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm'
-import { ConnectionPooling, NetworkRestrictions } from 'components/interfaces/Settings/Database'
+import { ConnectionPooling } from 'components/interfaces/Settings/Database'
import BannedIPs from 'components/interfaces/Settings/Database/BannedIPs'
import { DatabaseReadOnlyAlert } from 'components/interfaces/Settings/Database/DatabaseReadOnlyAlert'
import ResetDbPassword from 'components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword'
import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration'
+import { NetworkRestrictions } from 'components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions'
import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal'
import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject'
import type { NextPageWithLayout } from 'types'
@@ -18,6 +20,8 @@ const ProjectSettings: NextPageWithLayout = () => {
const showNewDiskManagementUI = isAws || isAwsK8s
+ const { databaseNetworkRestrictions } = useIsFeatureEnabled(['database:network_restrictions'])
+
return (
<>
@@ -40,7 +44,7 @@ const ProjectSettings: NextPageWithLayout = () => {
) : (
)}
-
+ {databaseNetworkRestrictions && }
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
index b0eb70e5efe..1b7ced2f8ee 100644
--- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
@@ -3,9 +3,11 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
-import { PageLayout, NavigationItem } from 'components/layouts/PageLayout/PageLayout'
+import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import { UnknownInterface } from 'components/ui/UnknownInterface'
+import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } from 'react'
import { NextPageWithLayout } from 'types'
@@ -14,6 +16,7 @@ import { Admonition } from 'ui-patterns'
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref, id, pageId, childId } = useParams()
+ const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
useInstalledIntegrations()
@@ -117,6 +120,10 @@ const IntegrationPage: NextPageWithLayout = () => {
return null
}
+ if (!integrationsWrappers && id?.endsWith('_wrapper')) {
+ return
+ }
+
return (
{
const { ref, q, queryId } = useParams()
const projectRef = ref as string
const { data: organization } = useSelectedOrganizationQuery()
+ const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
+
+ const allTemplates = useMemo(() => {
+ if (logsShowMetadataIpTemplate) return TEMPLATES
+ else return TEMPLATES.filter((x) => x.label !== 'Metadata IP')
+ }, [logsShowMetadataIpTemplate])
const editorRef = useRef()
const [editorId] = useState(uuidv4())
@@ -301,7 +308,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
defaultTo={timestampEnd || ''}
onDateChange={handleDateChange}
onSelectSource={handleInsertSource}
- templates={TEMPLATES.filter((template) => template.mode === 'custom')}
+ templates={allTemplates.filter((template) => template.mode === 'custom')}
onSelectTemplate={onSelectTemplate}
warnings={warnings}
/>
diff --git a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx
index 9c93527c570..15a52d0cbc4 100644
--- a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx
+++ b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx
@@ -15,17 +15,23 @@ import { Button, Popover, cn } from 'ui'
export const LogsTemplatesPage: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
- const isTemplatesEnabled = useIsFeatureEnabled('logs:templates')
+ const { logsTemplates: isTemplatesEnabled, logsShowMetadataIpTemplate: showMetadataIpTemplate } =
+ useIsFeatureEnabled(['logs:templates', 'logs:show_metadata_ip_template'])
if (!isTemplatesEnabled) {
return
}
+ const allTemplates = showMetadataIpTemplate
+ ? TEMPLATES
+ : TEMPLATES.filter((template) => template.label !== 'Metadata IP')
+
return (
- {TEMPLATES.sort((a, b) => a.label!.localeCompare(b.label!))
+ {allTemplates
+ .sort((a, b) => a.label!.localeCompare(b.label!))
.filter((template) => template.mode === 'custom')
.map((template, i) => {
return
diff --git a/apps/studio/public/img/mcp-clients/claude-dark-icon.svg b/apps/studio/public/img/mcp-clients/claude-dark-icon.svg
new file mode 100644
index 00000000000..b68b27324e2
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/claude-dark-icon.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/studio/public/img/mcp-clients/claude-icon.svg b/apps/studio/public/img/mcp-clients/claude-icon.svg
new file mode 100644
index 00000000000..135a8b9f33f
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/claude-icon.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/studio/public/img/mcp-clients/cursor-dark-icon.svg b/apps/studio/public/img/mcp-clients/cursor-dark-icon.svg
new file mode 100644
index 00000000000..48f85eb4d5a
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/cursor-dark-icon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/studio/public/img/mcp-clients/cursor-icon.svg b/apps/studio/public/img/mcp-clients/cursor-icon.svg
new file mode 100644
index 00000000000..48f85eb4d5a
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/cursor-icon.svg
@@ -0,0 +1 @@
+
diff --git a/apps/studio/public/img/mcp-clients/vscode-dark-icon.svg b/apps/studio/public/img/mcp-clients/vscode-dark-icon.svg
new file mode 100644
index 00000000000..c453e633f34
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/vscode-dark-icon.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/studio/public/img/mcp-clients/vscode-icon.svg b/apps/studio/public/img/mcp-clients/vscode-icon.svg
new file mode 100644
index 00000000000..c453e633f34
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/vscode-icon.svg
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/studio/public/img/mcp-clients/windsurf-dark-icon.svg b/apps/studio/public/img/mcp-clients/windsurf-dark-icon.svg
new file mode 100644
index 00000000000..2e4e4e4921d
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/windsurf-dark-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/studio/public/img/mcp-clients/windsurf-icon.svg b/apps/studio/public/img/mcp-clients/windsurf-icon.svg
new file mode 100644
index 00000000000..386f8c035dc
--- /dev/null
+++ b/apps/studio/public/img/mcp-clients/windsurf-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json
index 3ecd75981cf..e6a8f46bbc0 100644
--- a/packages/common/enabled-features/enabled-features.json
+++ b/packages/common/enabled-features/enabled-features.json
@@ -14,14 +14,14 @@
"authentication:attack_protection": true,
"authentication:advanced": true,
- "authentication:show_providers": true,
+ "authentication:show_email_phone_columns": true,
"authentication:show_manual_linking": true,
- "authentication:show_send_invitation": true,
"authentication:show_provider_filter": true,
+ "authentication:show_providers": true,
+ "authentication:show_send_invitation": true,
"authentication:show_sort_by_email": true,
"authentication:show_sort_by_phone": true,
"authentication:show_user_type_filter": true,
-
"billing:all": true,
"dashboard_auth:sign_up": true,
@@ -32,6 +32,8 @@
"database:replication": true,
"database:roles": true,
+ "database:restore_to_new_project": true,
+ "database:network_restrictions": true,
"docs:auth_architecture": true,
"docs:auth_configuration": true,
@@ -59,11 +61,15 @@
"feedback:docs": true,
"integrations:partners": true,
- "integrations:show_stripe_wrapper": true,
+ "integrations:wrappers": true,
"integrations:vercel": true,
+ "infrastructure:read_replicas": true,
+
"logs:templates": true,
"logs:collections": true,
+ "logs:metadata": true,
+ "logs:show_metadata_ip_template": true,
"organization:show_sso_settings": true,
"organization:show_security_settings": true,
@@ -91,6 +97,8 @@
"project_settings:show_disable_legacy_api_keys": true,
"project_settings:legacy_jwt_keys": true,
"project_settings:log_drains": true,
+ "project_settings:database_upgrades": true,
+ "project_settings:restart_project": true,
"quickstarts:hide_nimbus": true,
diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json
index e678d317abc..54ba0767619 100644
--- a/packages/common/enabled-features/enabled-features.schema.json
+++ b/packages/common/enabled-features/enabled-features.schema.json
@@ -80,6 +80,10 @@
"type": "boolean",
"description": "Show the user type filter in the authentication users page"
},
+ "authentication:show_email_phone_columns": {
+ "type": "boolean",
+ "description": "Show the email and phone columns in the authentication users page"
+ },
"billing:all": {
"type": "boolean",
@@ -115,6 +119,14 @@
"type": "boolean",
"description": "Enable the database roles page"
},
+ "database:restore_to_new_project": {
+ "type": "boolean",
+ "description": "Enable the restore to new project page under backups"
+ },
+ "database:network_restrictions": {
+ "type": "boolean",
+ "description": "Show the network restrictions setting under database settings"
+ },
"docs:auth_architecture": {
"type": "boolean",
@@ -211,15 +223,20 @@
"type": "boolean",
"description": "Show all third-party-managed integrations"
},
- "integrations:show_stripe_wrapper": {
+ "integrations:wrappers": {
"type": "boolean",
- "description": "Show the Stripe wrapper under project integrations"
+ "description": "Show all wrappers (FDW) under project integrations"
},
"integrations:vercel": {
"type": "boolean",
"description": "Enable the vercel integration section in the organization and project settings pages"
},
+ "infrastructure:read_replicas": {
+ "type": "boolean",
+ "description": "Enable read replicas management"
+ },
+
"logs:templates": {
"type": "boolean",
"description": "Enable the logs templates page"
@@ -228,6 +245,14 @@
"type": "boolean",
"description": "Enable the logs collections page"
},
+ "logs:metadata": {
+ "type": "boolean",
+ "description": "Show the metadata field for logs in logs explorer"
+ },
+ "logs:show_metadata_ip_template": {
+ "type": "boolean",
+ "description": "Show the Metadata IP template in the logs explorer"
+ },
"organization:show_sso_settings": {
"type": "boolean",
@@ -315,6 +340,14 @@
"type": "boolean",
"description": "Enable the log drains page in project settings"
},
+ "project_settings:restart_project": {
+ "type": "boolean",
+ "description": "Show the option to restart project in project settings (If off, only fast database reboot will be shown)"
+ },
+ "project_settings:database_upgrades": {
+ "type": "boolean",
+ "description": "Show database upgrade callouts in the project settings infrastructure page"
+ },
"quickstarts:hide_nimbus": {
"type": "boolean",
@@ -366,20 +399,21 @@
"ai:opt_in_level_schema",
"ai:opt_in_level_schema_and_log",
"ai:opt_in_level_schema_and_log_and_data",
- "authentication:sign_in_providers",
- "authentication:third_party_auth",
- "authentication:rate_limits",
+ "authentication:advanced",
+ "authentication:attack_protection",
"authentication:emails",
"authentication:multi_factor",
- "authentication:attack_protection",
- "authentication:advanced",
- "authentication:show_providers",
+ "authentication:rate_limits",
+ "authentication:show_email_phone_columns",
"authentication:show_manual_linking",
"authentication:show_provider_filter",
+ "authentication:show_providers",
"authentication:show_send_invitation",
"authentication:show_sort_by_email",
"authentication:show_sort_by_phone",
"authentication:show_user_type_filter",
+ "authentication:sign_in_providers",
+ "authentication:third_party_auth",
"billing:all",
"dashboard_auth:sign_up",
"dashboard_auth:sign_in_with_github",
@@ -387,6 +421,8 @@
"dashboard_auth:sign_in_with_email",
"database:replication",
"database:roles",
+ "database:restore_to_new_project",
+ "database:network_restrictions",
"docs:auth_architecture",
"docs:auth_configuration",
"docs:auth_flows",
@@ -408,14 +444,17 @@
"docs:web_apps",
"feedback:docs",
"integrations:partners",
- "integrations:show_stripe_wrapper",
+ "integrations:wrappers",
"integrations:vercel",
+ "infrastructure:read_replicas",
"profile:show_email",
"profile:show_information",
"profile:show_analytics_and_marketing",
"profile:show_account_deletion",
"logs:templates",
"logs:collections",
+ "logs:metadata",
+ "logs:show_metadata_ip_template",
"organization:show_sso_settings",
"project_creation:show_advanced_config",
"project_homepage:show_instance_size",
@@ -426,6 +465,8 @@
"project_settings:show_disable_legacy_api_keys",
"project_settings:legacy_jwt_keys",
"project_settings:log_drains",
+ "project_settings:restart_project",
+ "project_settings:database_upgrades",
"project_connection:javascript_example",
"project_connection:dart_example",
"project_connection:show_app_frameworks",
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 4b1f69133b7..f12911ecde7 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -14,6 +14,16 @@ type TelemetryGroups = {
organization: string
}
+export const TABLE_EVENT_ACTIONS = {
+ TableCreated: 'table_created',
+ TableDataAdded: 'table_data_added',
+ TableRLSEnabled: 'table_rls_enabled',
+} as const
+
+export type TableEventAction = (typeof TABLE_EVENT_ACTIONS)[keyof typeof TABLE_EVENT_ACTIONS]
+
+export const TABLE_EVENT_VALUES: TableEventAction[] = Object.values(TABLE_EVENT_ACTIONS)
+
/**
* Triggered when a user signs up. When signing up with Email and Password, this is only triggered once user confirms their email.
*
@@ -1744,6 +1754,84 @@ export interface HipaaRequestButtonClickedEvent {
groups: Omit
}
+/**
+ * User successfully created a table in the project.
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
+ */
+export interface TableCreatedEvent {
+ action: 'table_created'
+ properties: {
+ /**
+ * Method used to create the table
+ */
+ method: 'sql_editor' | 'table_editor'
+ /**
+ * Schema where table was created
+ */
+ schema_name?: string
+ /**
+ * Name of the table created
+ */
+ table_name?: string
+ }
+ groups: Partial
+}
+
+/**
+ * User successfully added data to a table.
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
+ */
+export interface TableDataAddedEvent {
+ action: 'table_data_added'
+ properties: {
+ /**
+ * Method used to insert data
+ */
+ method: 'sql_editor' | 'table_editor' | 'spreadsheet_import'
+ /**
+ * Schema of the table
+ */
+ schema_name?: string
+ /**
+ * Name of the table
+ */
+ table_name?: string
+ }
+ groups: Partial
+}
+
+/**
+ * User successfully enabled RLS on a table.
+ *
+ * @group Events
+ * @source studio
+ * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
+ */
+export interface TableRLSEnabledEvent {
+ action: 'table_rls_enabled'
+ properties: {
+ /**
+ * Method used to enable RLS
+ */
+ method: 'sql_editor' | 'table_editor'
+ /**
+ * Schema of the table
+ */
+ schema_name?: string
+ /**
+ * Name of the table
+ */
+ table_name?: string
+ }
+ groups: Partial
+}
+
/**
* @hidden
*/
@@ -1851,3 +1939,6 @@ export type TelemetryEvent =
| DpaRequestButtonClickedEvent
| DocumentViewButtonClickedEvent
| HipaaRequestButtonClickedEvent
+ | TableCreatedEvent
+ | TableDataAddedEvent
+ | TableRLSEnabledEvent
diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx
index 9685d8fff4f..f33a01ebe8d 100644
--- a/packages/ui-patterns/index.tsx
+++ b/packages/ui-patterns/index.tsx
@@ -11,6 +11,7 @@ export * from './src/ComputeBadge'
export * from './src/FilterBar'
export * from './src/GlassPanel'
export * from './src/InnerSideMenu'
+export * from './src/McpUrlBuilder'
export * from './src/PopupFrame'
export * from './src/ShimmeringLoader'
export * from './src/TimestampInfo'
diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json
index fd886e8c602..5df8c48b9e3 100644
--- a/packages/ui-patterns/package.json
+++ b/packages/ui-patterns/package.json
@@ -314,6 +314,10 @@
"import": "./src/LogsBarChart/index.tsx",
"types": "./src/LogsBarChart/index.tsx"
},
+ "./McpUrlBuilder": {
+ "import": "./src/McpUrlBuilder/index.ts",
+ "types": "./src/McpUrlBuilder/index.ts"
+ },
"./MobileSheetNav/MobileSheetNav": {
"import": "./src/MobileSheetNav/MobileSheetNav.tsx",
"types": "./src/MobileSheetNav/MobileSheetNav.tsx"
diff --git a/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx
new file mode 100644
index 00000000000..709130225f3
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/McpConfigPanel.tsx
@@ -0,0 +1,110 @@
+'use client'
+
+import React, { useMemo, useState } from 'react'
+import { cn, Separator } from 'ui'
+
+import { ClientSelectDropdown } from './components/ClientSelectDropdown'
+import { McpConfigurationDisplay } from './components/McpConfigurationDisplay'
+import { McpConfigurationOptions } from './components/McpConfigurationOptions'
+import { FEATURE_GROUPS_PLATFORM, FEATURE_GROUPS_NON_PLATFORM, MCP_CLIENTS } from './constants'
+import type { McpClient } from './types'
+import { getMcpUrl } from './utils/getMcpUrl'
+
+export interface McpConfigPanelProps {
+ basePath: string
+ baseUrl?: string
+ projectRef?: string
+ initialSelectedClient?: McpClient
+ onClientSelect?: (client: McpClient) => void
+ theme?: 'light' | 'dark'
+ className?: string
+ isPlatform: boolean // For docs this is controlled by state, for studio by environment variable
+ apiUrl?: string
+}
+
+export function McpConfigPanel({
+ basePath,
+ projectRef,
+ initialSelectedClient,
+ onClientSelect,
+ className,
+ theme = 'dark',
+ isPlatform,
+ apiUrl,
+}: McpConfigPanelProps) {
+ const [readonly, setReadonly] = useState(false)
+ const [selectedFeatures, setSelectedFeatures] = useState([])
+ const [selectedClient, setSelectedClient] = useState(initialSelectedClient ?? MCP_CLIENTS[0])
+
+ const supportedFeatures = isPlatform ? FEATURE_GROUPS_PLATFORM : FEATURE_GROUPS_NON_PLATFORM
+ const selectedFeaturesSupported = useMemo(() => {
+ return selectedFeatures.filter((feature) =>
+ supportedFeatures.some((group) => group.id === feature)
+ )
+ }, [selectedFeatures, supportedFeatures])
+
+ const { clientConfig } = getMcpUrl({
+ projectRef,
+ isPlatform,
+ apiUrl,
+ readonly,
+ features: selectedFeaturesSupported,
+ selectedClient,
+ })
+
+ const handleClientChange = (clientKey: string) => {
+ const client = MCP_CLIENTS.find((c) => c.key === clientKey)
+ if (client) {
+ setSelectedClient(client)
+ }
+ }
+ React.useEffect(() => {
+ onClientSelect?.(selectedClient)
+ }, [selectedClient, onClientSelect])
+
+ const innerPanelSpacing = 'px-4 py-3'
+
+ return (
+
+
+
Options
+
+
+
+
+
+
+ Configure your MCP client to connect with your Supabase project
+
+
+
+
+ )
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/ClientSelectDropdown.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/ClientSelectDropdown.tsx
new file mode 100644
index 00000000000..7c1111c2b78
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/components/ClientSelectDropdown.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { Bot, Check, ChevronDown } from 'lucide-react'
+import { useState } from 'react'
+import {
+ Button,
+ CommandEmpty_Shadcn_,
+ CommandGroup_Shadcn_,
+ CommandInput_Shadcn_,
+ CommandItem_Shadcn_,
+ CommandList_Shadcn_,
+ Command_Shadcn_,
+ PopoverContent_Shadcn_,
+ PopoverTrigger_Shadcn_,
+ Popover_Shadcn_,
+ cn,
+} from 'ui'
+import type { McpClient } from '../types'
+import { ConnectionIcon } from './ConnectionIcon'
+
+interface ClientSelectDropdownProps {
+ basePath: string
+ theme?: 'light' | 'dark'
+ label?: string
+ clients: McpClient[]
+ selectedClient: McpClient
+ onClientChange: (clientKey: string) => void
+}
+
+export const ClientSelectDropdown = ({
+ basePath,
+ theme = 'light',
+ label = 'Client',
+ clients,
+ selectedClient,
+ onClientChange,
+}: ClientSelectDropdownProps) => {
+ const [open, setOpen] = useState(false)
+
+ function onSelectClient(key: string) {
+ onClientChange(key)
+ setOpen(false)
+ }
+
+ return (
+
+
+
+ {label}
+
+
+
+ }
+ >
+
+ {selectedClient?.icon ? (
+
+ ) : (
+
+ )}
+ {selectedClient?.label}
+
+
+
+
+
+
+
+
+ No results found.
+
+ {clients.map((client) => (
+ {
+ onSelectClient(client.key)
+ setOpen(false)
+ }}
+ className="flex gap-2 items-center"
+ >
+ {client.icon ? (
+
+ ) : (
+
+ )}
+ {client.label}
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/ConnectionIcon.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/ConnectionIcon.tsx
new file mode 100644
index 00000000000..ec4d367e42c
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/components/ConnectionIcon.tsx
@@ -0,0 +1,26 @@
+interface ConnectionIconProps {
+ basePath: string
+ theme?: 'light' | 'dark'
+ supportsDarkMode?: boolean
+ iconFolder: string
+ connection: string
+}
+
+export const ConnectionIcon = ({
+ basePath,
+ theme = 'dark',
+ supportsDarkMode,
+ iconFolder,
+ connection,
+}: ConnectionIconProps) => {
+ return (
+
+ )
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx
new file mode 100644
index 00000000000..5ea38d01fe6
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationDisplay.tsx
@@ -0,0 +1,101 @@
+'use client'
+
+import { ExternalLink } from 'lucide-react'
+import Image from 'next/image'
+import { Button, cn } from 'ui'
+import { CodeBlock } from 'ui/src/components/CodeBlock'
+import type { McpClient, McpClientConfig } from '../types'
+import { getMcpButtonData } from '../utils/getMcpButtonData'
+
+interface McpConfigurationDisplayProps {
+ selectedClient: McpClient
+ clientConfig: McpClientConfig
+ className?: string
+ theme?: 'light' | 'dark'
+ basePath: string
+}
+
+export function McpConfigurationDisplay({
+ selectedClient,
+ clientConfig,
+ className,
+ theme = 'dark',
+ basePath,
+}: McpConfigurationDisplayProps) {
+ const mcpButtonData = getMcpButtonData({
+ basePath,
+ theme,
+ client: selectedClient,
+ clientConfig,
+ })
+
+ return (
+
+ {mcpButtonData && (
+ <>
+
Install in one click:
+
+
+
+ Add to {selectedClient.label}
+
+
+ >
+ )}
+
+ {selectedClient.configFile && (
+
+ {mcpButtonData ? 'Or add' : 'Add'} this configuration to{' '}
+ {selectedClient.configFile}:
+
+ )}
+
+
+
+ {selectedClient.alternateInstructions && selectedClient.alternateInstructions(clientConfig)}
+
+ {(selectedClient.docsUrl || selectedClient.externalDocsUrl) && (
+
+ )}
+
+ )
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx
new file mode 100644
index 00000000000..7d71e8a8f91
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { cn, Switch } from 'ui'
+import { Label } from 'ui/src/components/shadcn/ui/label'
+import { InfoTooltip } from '../../info-tooltip'
+import {
+ MultiSelector,
+ MultiSelectorContent,
+ MultiSelectorItem,
+ MultiSelectorList,
+ MultiSelectorTrigger,
+} from '../../multi-select'
+import type { McpFeatureGroup } from '../types'
+
+interface McpConfigurationOptionsProps {
+ isPlatform: boolean
+ readonly: boolean
+ onReadonlyChange: (readonly: boolean) => void
+ selectedFeatures: string[]
+ onFeaturesChange: (features: string[]) => void
+ featureGroups: McpFeatureGroup[]
+ className?: string
+}
+
+export function McpConfigurationOptions({
+ isPlatform,
+ readonly,
+ onReadonlyChange,
+ selectedFeatures,
+ onFeaturesChange,
+ featureGroups,
+ className,
+}: McpConfigurationOptionsProps) {
+ return (
+
+ {/* Readonly Mode */}
+ {isPlatform && (
+
+
+
+ Read-only
+
+ Only allow read operations on your database
+
+
+
+
+
+ )}
+
+ {/* Feature Groups */}
+
+
+ Feature Groups
+
+ Only enable a subset of features. Helps keep the number of tools within MCP client
+ limits
+
+
+
+
+
+
+
+ {featureGroups.map((feature) => (
+
+
+ {feature.name}
+ {feature.description}
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/constants.tsx b/packages/ui-patterns/src/McpUrlBuilder/constants.tsx
new file mode 100644
index 00000000000..67f6e7c8875
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/constants.tsx
@@ -0,0 +1,180 @@
+import { CodeBlock } from 'ui/src/components/CodeBlock'
+import type {
+ ClaudeCodeMcpConfig,
+ McpClient,
+ McpFeatureGroup,
+ OtherMcpConfig,
+ VSCodeMcpConfig,
+ WindsurfMcpConfig,
+} from './types'
+
+export const FEATURE_GROUPS_PLATFORM: McpFeatureGroup[] = [
+ {
+ id: 'docs',
+ name: 'Documentation',
+ description: 'Access project documentation and guides',
+ },
+ {
+ id: 'account',
+ name: 'Account',
+ description: 'Manage account settings and preferences',
+ },
+ {
+ id: 'database',
+ name: 'Database',
+ description: 'Query and manage database schema and data',
+ },
+ {
+ id: 'debugging',
+ name: 'Debugging',
+ description: 'Debug and troubleshoot issues',
+ },
+ {
+ id: 'development',
+ name: 'Development',
+ description: 'Development tools and utilities',
+ },
+ {
+ id: 'functions',
+ name: 'Functions',
+ description: 'Manage and deploy Edge Functions',
+ },
+ {
+ id: 'branching',
+ name: 'Branching',
+ description: 'Manage database branches',
+ },
+ {
+ id: 'storage',
+ name: 'Storage',
+ description: 'Manage files and storage buckets',
+ },
+]
+
+export const FEATURE_GROUPS_NON_PLATFORM = FEATURE_GROUPS_PLATFORM.filter((group) =>
+ ['docs', 'database', 'development'].includes(group.id)
+)
+
+export const MCP_CLIENTS: McpClient[] = [
+ {
+ key: 'cursor',
+ label: 'Cursor',
+ icon: 'cursor',
+ configFile: '.cursor/mcp.json',
+ externalDocsUrl: 'https://docs.cursor.com/context/mcp',
+ generateDeepLink: (config) => {
+ const name = 'supabase'
+ const base64Config = Buffer.from(JSON.stringify(config.mcpServers.supabase)).toString(
+ 'base64'
+ )
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${name}&config=${encodeURIComponent(base64Config)}`
+ },
+ },
+ {
+ key: 'vscode',
+ label: 'VS Code',
+ icon: 'vscode',
+ configFile: '.vscode/mcp.json',
+ externalDocsUrl: 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers',
+ transformConfig: (config): VSCodeMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ type: 'http',
+ url: config.mcpServers.supabase.url,
+ },
+ },
+ }
+ },
+ generateDeepLink: (_config) => {
+ const config = _config as VSCodeMcpConfig
+ const mcpConfig = { name: 'supabase', ...config.mcpServers.supabase }
+
+ return `vscode:mcp/install?${encodeURIComponent(JSON.stringify(mcpConfig))}`
+ },
+ },
+ {
+ key: 'windsurf',
+ label: 'Windsurf',
+ icon: 'windsurf',
+ configFile: '~/.codeium/windsurf/mcp_config.json',
+ externalDocsUrl: '',
+ transformConfig: (config): WindsurfMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ command: 'npx',
+ args: ['-y', 'mcp-remote', config.mcpServers.supabase.url],
+ },
+ },
+ }
+ },
+ alternateInstructions: () => (
+
+ Windsurf does not currently support remote MCP servers over HTTP transport. You need to use
+ the mcp-remote package as a proxy.
+
+ ),
+ },
+ {
+ key: 'claude-code',
+ label: 'Claude Code',
+ icon: 'claude',
+ configFile: '~/.claude.json',
+ externalDocsUrl: 'https://docs.anthropic.com/en/docs/claude-code/mcp',
+ transformConfig: (config): ClaudeCodeMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ type: 'http',
+ url: config.mcpServers.supabase.url,
+ },
+ },
+ }
+ },
+ alternateInstructions: (_config) => {
+ const config = _config as ClaudeCodeMcpConfig
+ const command = `claude mcp add --transport http supabase "${config.mcpServers.supabase.url}"`
+ return (
+
+
+ Alternatively, add the MCP server using the command line:
+
+
+
+ )
+ },
+ },
+ {
+ key: 'other',
+ label: 'Other',
+ transformConfig: (config): OtherMcpConfig => {
+ return {
+ mcpServers: {
+ supabase: {
+ type: 'http',
+ url: config.mcpServers.supabase.url,
+ },
+ },
+ }
+ },
+ alternateInstructions: (_config) => {
+ return (
+
+ These generic MCP settings may work with other MCP clients, but there are no guarantees,
+ due to differences between clients. Refer to your specific client docs for where to input
+ the configuration.
+
+ )
+ },
+ },
+]
+
+export const DEFAULT_MCP_URL_PLATFORM = 'http://localhost:8080/mcp'
+export const DEFAULT_MCP_URL_NON_PLATFORM = 'http://localhost:54321/mcp'
diff --git a/packages/ui-patterns/src/McpUrlBuilder/index.ts b/packages/ui-patterns/src/McpUrlBuilder/index.ts
new file mode 100644
index 00000000000..a15b032d884
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/index.ts
@@ -0,0 +1,19 @@
+export { ClientSelectDropdown } from './components/ClientSelectDropdown'
+export { ConnectionIcon } from './components/ConnectionIcon'
+export { McpConfigurationDisplay } from './components/McpConfigurationDisplay'
+export { McpConfigurationOptions } from './components/McpConfigurationOptions'
+export {
+ DEFAULT_MCP_URL_PLATFORM,
+ DEFAULT_MCP_URL_NON_PLATFORM,
+ FEATURE_GROUPS_PLATFORM,
+ FEATURE_GROUPS_NON_PLATFORM,
+ MCP_CLIENTS,
+} from './constants'
+export { getMcpUrl } from './utils/getMcpUrl'
+export { McpConfigPanel, type McpConfigPanelProps } from './McpConfigPanel'
+export type {
+ McpClient,
+ McpClientBaseConfig as McpClientConfig,
+ McpFeatureGroup,
+ McpUrlBuilderConfig,
+} from './types'
diff --git a/packages/ui-patterns/src/McpUrlBuilder/types.ts b/packages/ui-patterns/src/McpUrlBuilder/types.ts
new file mode 100644
index 00000000000..7b6ae930012
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/types.ts
@@ -0,0 +1,88 @@
+export interface McpFeatureGroup {
+ id: string
+ name: string
+ description: string
+}
+
+export interface McpClient {
+ key: string
+ label: string
+ icon?: string
+ docsUrl?: string
+ externalDocsUrl?: string
+ configFile?: string
+ generateDeepLink?: (config: McpClientConfig) => string | null
+ transformConfig?: (config: McpClientBaseConfig) => McpClientConfig
+ alternateInstructions?: (config: McpClientConfig) => React.ReactNode
+}
+
+export interface McpUrlBuilderConfig {
+ projectRef: string
+ readonly?: boolean
+ features?: string[]
+}
+
+export interface McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ url: string
+ }
+ }
+}
+
+export interface CursorMcpConfig extends McpClientBaseConfig {}
+
+export interface VSCodeMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ type: 'http'
+ url: string
+ }
+ }
+}
+
+export interface WindsurfMcpConfig {
+ mcpServers: {
+ supabase: {
+ command: 'npx'
+ args: ['-y', 'mcp-remote', string]
+ }
+ }
+}
+
+export interface ClaudeCodeMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ type: 'http'
+ url: string
+ }
+ }
+}
+
+export interface ClaudeDesktopMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ type: 'http'
+ url: string
+ }
+ }
+}
+
+export interface OtherMcpConfig extends McpClientBaseConfig {
+ mcpServers: {
+ supabase: {
+ type: 'http'
+ url: string
+ }
+ }
+}
+
+// Union of all possible config types
+export type McpClientConfig =
+ | ClaudeCodeMcpConfig
+ | ClaudeDesktopMcpConfig
+ | CursorMcpConfig
+ | McpClientBaseConfig
+ | OtherMcpConfig
+ | VSCodeMcpConfig
+ | WindsurfMcpConfig
diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpButtonData.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpButtonData.ts
new file mode 100644
index 00000000000..b53d07c5828
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpButtonData.ts
@@ -0,0 +1,31 @@
+import type { McpClient, McpClientConfig } from '../types'
+
+interface GetMcpButtonDataOptions {
+ basePath: string
+ theme?: 'light' | 'dark'
+ client: McpClient
+ clientConfig: McpClientConfig
+}
+
+export function getMcpButtonData({
+ basePath,
+ theme,
+ client,
+ clientConfig,
+}: GetMcpButtonDataOptions) {
+ if (client.generateDeepLink) {
+ const deepLink = client.generateDeepLink(clientConfig)
+ if (!deepLink) return null
+
+ const imageSrc = `${basePath}/img/mcp-clients/${client.icon}${
+ theme === 'dark' ? '-dark' : ''
+ }-icon.svg`
+
+ return {
+ deepLink,
+ imageSrc,
+ label: client.label,
+ }
+ }
+ return null
+}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts
new file mode 100644
index 00000000000..94dc41f8cb7
--- /dev/null
+++ b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts
@@ -0,0 +1,68 @@
+import { DEFAULT_MCP_URL_NON_PLATFORM, DEFAULT_MCP_URL_PLATFORM } from '../constants'
+import type { McpClient, McpClientConfig } from '../types'
+
+interface GetMcpUrlOptions {
+ projectRef?: string
+ readonly?: boolean
+ features?: string[]
+ selectedClient?: McpClient
+ isPlatform: boolean
+ apiUrl?: string
+}
+
+interface GetMcpUrlReturn {
+ mcpUrl: string
+ clientConfig: McpClientConfig
+}
+
+export function getMcpUrl({
+ projectRef,
+ isPlatform,
+ apiUrl,
+ readonly = false,
+ features = [],
+ selectedClient,
+}: GetMcpUrlOptions): GetMcpUrlReturn {
+ // Generate the MCP URL based on current configuration
+ const url = new URL(getMcpUrlBase({ isPlatform, apiUrl }))
+ if (projectRef && isPlatform) {
+ url.searchParams.set('project_ref', projectRef)
+ }
+ if (readonly && isPlatform) {
+ url.searchParams.set('read_only', 'true')
+ }
+ if (features.length > 0) {
+ url.searchParams.set('features', features.join(','))
+ }
+ const mcpUrl = url.toString()
+
+ let clientConfig: McpClientConfig = {
+ mcpServers: {
+ supabase: {
+ url: mcpUrl,
+ },
+ },
+ }
+ // Apply client-specific transformation if available
+ if (selectedClient?.transformConfig) {
+ clientConfig = selectedClient.transformConfig(clientConfig)
+ }
+
+ return {
+ mcpUrl,
+ clientConfig,
+ }
+}
+
+/**
+ * Assembles base `/mcp` endpoint URL for the given environment
+ */
+function getMcpUrlBase({ isPlatform, apiUrl }: { isPlatform: boolean; apiUrl?: string }) {
+ // Hosted platform uses environment variable with fallback
+ if (isPlatform) {
+ return process.env.NEXT_PUBLIC_MCP_URL ?? DEFAULT_MCP_URL_PLATFORM
+ }
+
+ // Self-hosted uses API URL with fallback
+ return apiUrl ? `${apiUrl}/mcp` : DEFAULT_MCP_URL_NON_PLATFORM
+}
diff --git a/turbo.json b/turbo.json
index 028863f609c..a7c33bf5533 100644
--- a/turbo.json
+++ b/turbo.json
@@ -78,6 +78,7 @@
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_VERCEL_ENV",
"NEXT_PUBLIC_USERCENTRICS_RULESET_ID",
+ "NEXT_PUBLIC_MCP_URL",
// These envs are technically passthrough env vars because they're only used on the server side of Nextjs
"PLATFORM_PG_META_URL",
"STUDIO_PG_META_URL",