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) - -
- [![Install with NPX in VS - Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) - [![Install with NPX in VS Code - Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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 ? ( + + ) : ( + + + + )} +
+ + + + + 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.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 && ( @@ -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 ( + + + + + ) + } + + 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. +

+ +
+
+ ) : 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={ - - } + 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 = () => {
- + + 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 +
)} 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 = () => {
- +
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 +
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') && ( - + )} 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={ - - } + 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
-
- -
+ {reportsAll && ( +
+ +
+ )}
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 && ( + + +
+ ) : ( +
+ 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 && (
+ ) +} 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 ( + + ) +} 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')} + )}
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 ( - - - - - ) - } - - 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. -

- -
-
- ) : 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