Merge branch 'master' into feat/report-v2

This commit is contained in:
Saxon Fletcher committed 2024-11-26 21:14:07 +10:00
commit 26e4ac8d9b
319 files changed
+13611 -6888

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
name: '[Docs] Lint v2'
on:
pull_request:
paths:
- '.github/workflows/docs-lint-v2.yml'
- 'supa-mdx-lint.config.toml'
- 'apps/docs/content/**'
env:
CARGO_NET_GIT_FETCH_WITH_CLI: true
jobs:
supa-mdx-lint:
name: supa-mdx-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
sparse-checkout: |
supa-mdx-lint.config.toml
apps/docs/content
- name: cache cargo
id: cache-cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: 9fd8c8fa7487a3d454a676e6e3d7409af34fc715
- name: install linter
if: steps.cache-cargo.outputs.cache-hit != 'true'
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 9fd8c8fa7487a3d454a676e6e3d7409af34fc715
- name: install reviewdog
uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0
with:
reviewdog_version: v0.20.2
- name: run linter
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
supa-mdx-lint apps/docs/content/guides/getting-started --format rdf | reviewdog -f=rdjsonl -reporter=github-pr-review
+11 -5
View File
@@ -11,15 +11,21 @@ jobs:
name: runner / vale
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
sparse-checkout: |
.vale.ini
vale
apps/docs/content
- name: Get changed files
id: changed-files
env:
BASE_REF: ${{ github.base_ref }}
SHA: ${{ github.sha }}
run: |
git fetch origin $BASE_REF
echo "CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/$BASE_REF $SHA | grep '^apps/docs/content/' | tr '\n' ',' | sed 's/,$//')" >> $GITHUB_OUTPUT
- uses: errata-ai/vale-action@reviewdog
with:
files: apps/docs/content
# [Charis] Temporary because we still have to go through giant backlog of existing errors
# Don't want people to worry about this (just want them looking at the specific PR comments)
# fail_on_error: true
files: '${{ steps.changed-files.outputs.CHANGED_FILES }}'
separator: ','
+1
View File
@@ -7,6 +7,7 @@ apps/**/out
# prettier-plugin-sql-cst only supports sqlite syntax
**/supabase/migrations/*.sql
apps/www/schema.sql
apps/www/public/images/*
apps/docs/**/generated/*
examples/slack-clone/nextjs-slack-clone/full-schema.sql
# ignore files with custom js formatting
+4 -2
View File
@@ -167,7 +167,7 @@ const PI = 3.14
```
````
Of, you can use the `<$CodeSample />` component to include code samples from a source code file.
Or, you can use the `<$CodeSample />` component to include code samples from a source code file.
If the file is within the `supabase/supabase` repo's `examples` directory:
@@ -183,7 +183,7 @@ meta="display/path.js"
/>
```
If the file is within some other GitHub repo (note that the repo must be public):
If the file is within some other GitHub repo:
```mdx
<$CodeSample
@@ -197,6 +197,8 @@ meta="display/path.js"
/>
```
The repo must be public, the org must be on the allow list, and the commit must be an immutable SHA (not a mutable tag or branch name).
### Icons
The following icons are available. They can be styled with [Tailwind](https://tailwindcss.com/) classes:
@@ -5,6 +5,9 @@ import {
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
// Serve 404 when accessing pages that aren't prebuilt
export const dynamicParams = false
type Params = { slug: string[] }
const GuidePage = async ({ params }: { params: Params }) => {
@@ -1,10 +1,14 @@
import { type PropsWithChildren } from 'react'
import { cache, type PropsWithChildren } from 'react'
import { IS_PLATFORM } from 'common'
import { supabaseMisc } from '~/lib/supabaseMisc'
import Layout from '~/layouts/guides'
// Revalidate occasionally to pick up changes to partners
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
export const revalidate = 86_400
const GuidesLayout = async ({ children }: PropsWithChildren) => {
const partners = IS_PLATFORM ? await getPartners() : []
const partnerNavItems = partners.map((partner) => ({
@@ -15,10 +19,12 @@ const GuidesLayout = async ({ children }: PropsWithChildren) => {
return <Layout additionalNavItems={partnerNavItems}>{children}</Layout>
}
async function getPartners() {
const getPartners = cache(getPartnersImpl)
async function getPartnersImpl() {
const { data, error } = await supabaseMisc()
.from('partners')
.select('slug, title')
.eq('approved', true)
.eq('type', 'technology')
.order('title')
if (error) {
@@ -701,6 +701,50 @@ export const auth = {
],
}
const ormQuickstarts: NavMenuSection = {
name: 'ORM Quickstarts',
url: undefined,
items: [
{
name: 'Prisma',
url: '/guides/database/prisma',
items: [
{
name: 'Prisma troubleshooting',
url: '/guides/database/prisma/prisma-troubleshooting',
},
],
},
{
name: 'Drizzle',
url: '/guides/database/drizzle',
},
{
name: 'Postgres.js',
url: '/guides/database/postgres-js',
},
],
}
const guiQuickstarts: NavMenuSection = {
name: 'GUI quickstarts',
url: undefined,
items: [
{
name: 'pgAdmin',
url: '/guides/database/pgadmin',
},
{
name: 'PSQL',
url: '/guides/database/psql',
},
{
name: 'DBeaver',
url: '/guides/database/dbeaver',
},
],
}
export const database: NavMenuConstant = {
icon: 'database',
title: 'Database',
@@ -714,12 +758,6 @@ export const database: NavMenuConstant = {
{
name: 'Connecting to your database',
url: '/guides/database/connecting-to-postgres',
items: [
{
name: 'Serverless Drivers',
url: '/guides/database/connecting-to-postgres/serverless-drivers',
},
],
},
{ name: 'Importing data', url: '/guides/database/import-data' },
{ name: 'Securing your data', url: '/guides/database/secure-data' },
@@ -864,48 +902,8 @@ export const database: NavMenuConstant = {
},
],
},
{
name: 'ORM Quickstarts',
url: undefined,
items: [
{
name: 'Prisma',
url: '/guides/database/prisma',
items: [
{
name: 'Prisma troubleshooting',
url: '/guides/database/prisma/prisma-troubleshooting',
},
],
},
{
name: 'Drizzle',
url: '/guides/database/drizzle',
},
{
name: 'Postgres.js',
url: '/guides/database/postgres-js',
},
],
},
{
name: 'GUI quickstarts',
url: undefined,
items: [
{
name: 'pgAdmin',
url: '/guides/database/pgadmin',
},
{
name: 'PSQL',
url: '/guides/database/psql',
},
{
name: 'DBeaver',
url: '/guides/database/dbeaver',
},
],
},
ormQuickstarts,
guiQuickstarts,
{
name: 'Extensions',
url: undefined,
@@ -2540,4 +2538,6 @@ export const navDataForMdx = {
nativeMobileLoginItems: NativeMobileLoginItems,
phoneLoginsItems: PhoneLoginsItems,
socialLoginItems: SocialLoginItems,
ormQuickstarts,
guiQuickstarts,
}
@@ -37,13 +37,13 @@ npx supabase init
Generate types for your project to produce the `database.types.ts` file:
```bash
npx supabase gen types --lang=typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
```
or in case of local development:
```bash
npx supabase gen types --lang=typescript --local > database.types.ts
npx supabase gen types typescript --local > database.types.ts
```
These types are generated from your database schema. Given a table `public.movies`, the generated types will look like:
@@ -1,89 +1,138 @@
---
id: 'connecting-to-postgres'
title: 'Connecting to your database'
description: 'Explore the options for connecting to your Postgres database.'
subtitle: 'Explore the options for connecting to your Postgres database.'
title: 'Connect to your database'
description: 'Connect to Postgres from your frontend, backend, or serverless environment'
subtitle: 'Connect to Postgres from your frontend, backend, or serverless environment'
---
Supabase provides several options for programmatically connecting to your Postgres database:
## Quick summary
1. The Data APIs
1. Using one of the many [Client Libraries](/docs/reference)
1. Direct connections using the built-in Postgres connection system
1. Connection pooling for scalable connections
How you connect to your database depends on where you're connecting from:
## Data APIs
- For frontend applications, use the [Data API](#data-apis-and-client-libraries)
- For Postgres clients, use a connection string
- For persistent clients (for example, long-running servers or database GUIs) use the [direct connection string](#direct-connection) if your environment supports IPv6, and [Supavisor session mode](#supavisor-session-mode) if not
- For temporary clients (for example, serverless or edge functions) use [Supavisor transaction mode](#supavisor-transaction-mode)
Supabase's Data APIs are the easiest way to get started if you are managing data (fetching, inserting, updating). We provide several types of API to suit your preferences:
## Quickstarts
- [REST](/docs/guides/api): interact with your database through a REST interface.
- [GraphQL](/docs/guides/graphql/api): interact with your database through a GraphQL interface.
- [Realtime](/docs/guides/realtime#realtime-api): listen to database changes over websockets.
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-6 not-prose">
<NavData data="ormQuickstarts">
{(data) =>
data.items?.map((quickstart) => (
<Link key={quickstart.url} href={quickstart.url} passHref>
<GlassPanel
key={quickstart.name}
title={quickstart.name}
className="[&>div]:p-2 flex justify-center [&_p]:text-foreground-light"
/>
</Link>
))
}
</NavData>
<NavData data="guiQuickstarts">
{(data) =>
data.items?.map((quickstart) => (
<Link key={quickstart.url} href={quickstart.url} passHref>
<GlassPanel
key={quickstart.name}
title={quickstart.name}
className="[&>div]:p-2 flex justify-center [&_p]:text-foreground-light"
/>
</Link>
))
}
</NavData>
</div>
## Client libraries
## Data APIs and client libraries
Supabase offers [client libraries](/docs/reference) for popular programming languages. These libraries provide a convenient wrapper around the Data APIs, making it easier to interact with your database using your preferred language.
The Data APIs allow you to interact with your database using REST or GraphQL requests. You can use these APIs to fetch and insert data from the frontend, as long as you have [RLS](/docs/guides/database/postgres/row-level-security) enabled.
## Connecting to external libraries and tools
- [REST](/docs/guides/api)
- [GraphQL](/docs/guides/graphql/api)
Beyond the [Supabase client-libraries](/docs/guides/api/rest/client-libs), Supabase provides three connection strings for direct database access, compatible with all postgres libraries, such as [Prisma](/docs/guides/database/prisma) and [Drizzle](/docs/guides/database/drizzle), as well as tools like [PSQL](/docs/guides/database/psql) and [pgAdmin](/docs/guides/database/pgadmin).
For convenience, you can also use the Supabase client libraries, which wrap the Data APIs with a developer-friendly interface and automatically handle authentication:
You can find these connection strings in your [Database Settings](https://supabase.com/dashboard/project/_/settings/database).
- [JavaScript](/docs/reference/javascript)
- [Flutter](/docs/reference/dart)
- [Swift](/docs/reference/swift)
- [Python](/docs/reference/python)
- [C#](/docs/reference/csharp)
- [Kotlin](/docs/reference/kotlin)
### Direct connections:
## Direct connection
It connects directly to your Postgres instance. It is ideal for persistent servers, such as virtual machines (VMs) and long-lasting containers. Examples include AWS EC2 machines, Fly.io VMs, and DigitalOcean Droplets.
The direct connection string connects directly to your Postgres instance. It is ideal for persistent servers, such as virtual machines (VMs) and long-lasting containers. Examples include AWS EC2 machines, Fly.io VMs, and DigitalOcean Droplets.
<Admonition type="caution">
Direct connections use IPv6 by default. If your environment doesn't support IPv6 or you're unsure but still want to use a direct connection, check out the [networking guide](/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility) for more info.
Direct connections use IPv6 by default. If your environment doesn't support IPv6, use [Supavisor session mode](#supavisor-session-mode) or get the [IPv4 addon](/docs/guides/platform/ipv4-address).
</Admonition>
```md
// Example String
The connection string looks like this:
```
postgresql://postgres:[YOUR-PASSWORD]@db.apbkobhfnmcqqzqeeqss.supabase.co:5432/postgres
```
You can find the direct connection string in the [Database settings](https://supabase.com/dashboard/project/_/settings/database) inside the dashboard:
Get your project's direct string from the [Database Settings](/dashboard/project/_/settings/database) page:
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Use connection pooling` is unchecked. Copy the URI.
3. Under `Connection string`, make sure `Display connection pooler` is unchecked. Copy the URI.
### Supavisor session mode (port 5432):
## Supavisor session mode
In Session mode, Supavisor acts as an IPv4 proxy. It allows developers in IPv4-only environments to access a direct-connection experience without needing the IPv4 add-on.
The session mode connection string connects to your Postgres instance via a proxy. This is ideal for persistent servers when IPv4 is not supported.
```md
// Example String
The connection string looks like this:
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:5432/postgres
```
### Transaction mode (port 6543):
Get your project's session mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Session mode` is selected. Copy the URI.
## Supavisor transaction mode
The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is deal for serverless or edge functions, which require many transient connections.
<Admonition type="caution">
Transaction mode does not support [prepared statements](https://postgresql.org/docs/current/sql-prepare.html). To avoid errors, [turn off prepared statements](https://github.com/orgs/supabase/discussions/28239) for your specific connection library.
Transaction mode does not support [prepared statements](https://postgresql.org/docs/current/sql-prepare.html). To avoid errors, [turn off prepared statements](https://github.com/orgs/supabase/discussions/28239) for your connection library.
</Admonition>
Supavisor in transaction mode forms hot connections with the database and only allows clients to access them when a query is pending. It has amazing scaling capacity, and is best used with temporary servers, such as serverless/Edge Functions or auto-scaling servers.
The connection string looks like this:
```md
// Example String
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:6543/postgres
```
## Connection pooling in-depth
Get your project's transaction mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
### Application side poolers
1. Go to the `Settings` section.
2. Click `Database`.
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Transaction mode` is selected. Copy the URI.
## More about connection pooling
Connection pooling improves database performance by reusing existing connections between queries. This reduces the overhead of establishing connections and improves scalability.
You can use an application-side pooler or a server-side pooler (Supabase automatically provides one called Supavisor), depending on whether your backend is persistent or serverless.
### Application-side poolers
Application-side poolers are built into connection libraries and API servers, such as Prisma, SQLAlchemy, and PostgREST. They maintain several active connections with Postgres or a server-side pooler, reducing the overhead of establishing connections between queries. When deploying to static architecture, such as long-standing containers or VMs, application-side poolers are satisfactory on their own.
### Serverside poolers
Postgres connections are like WebSocket, once established, they are preserved until the client (application server) disconnects. A server might only make a single 10 ms query, but needlessly reserve its database connection for seconds or longer.
Postgres connections are like a WebSocket. Once established, they are preserved until the client (application server) disconnects. A server might only make a single 10 ms query, but needlessly reserve its database connection for seconds or longer.
Serverside-poolers, such as Supabase's [Supavisor](https://github.com/supabase/supavisor) in transaction mode, sit between clients and the database and can be thought of as load balancers for Postgres connections.
@@ -108,34 +157,6 @@ You can obtain your connection info and Server root certificate from your applic
![Connection Info and Certificate.](/docs/img/database/database-settings-ssl.png)
## Choosing a connection method
- **Data APIs:** Best for operations supported by the Data APIs.
- **Supavisor Pooler:**
- **Transaction mode (port 6543):** Best for serverless/edge functions and rapidly auto-scaling servers
- **Session mode (port 5432):** For long-lived servers, such as virtual machines (VM) and enduring containers (if IPv6 is unsupported)
- **Direct Connections:** For long-lived servers, such as VMs and enduring containers (if IPv6 is supported or the IPv4 Add-On is enabled).
## Quickstart connection guides
<Admonition type="tip">
If you'd like us to add a new quickstart guide, share your [feedback](https://supabase.com/dashboard/support/new), and we'll try to add it going forwards.
</Admonition>
### External libraries
- [Prisma](/docs/guides/database/prisma)
- [Drizzle](/docs/guides/database/drizzle)
- [Postgres.js](/docs/guides/database/postgres-js)
### External tools
- [pgAdmin](/docs/guides/database/pgadmin)
- [PSQL](/docs/guides/database/psql)
- [DBeaver](/docs/guides/database/dbeaver)
## Resources
- [Connection management](/docs/guides/database/connection-management)
+3 -3
View File
@@ -184,7 +184,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Create your migrations">
If you have already modified your Supabase database, synchronize it with your migration file. Otherwise create new tables for your database
@@ -373,7 +373,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Install the prisma client">
Install the prisma client and generate its model
</StepHikeCompact.Details>
@@ -414,7 +414,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={7}>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Test your API">
Create a index.ts file and run it to test your connection
</StepHikeCompact.Details>
@@ -16,7 +16,7 @@ Every project is a full Postgres database. [Docs](/docs/guides/database).
Store vector embeddings right next to the rest of your data. [Docs](/docs/guides/ai).
### Auto-generated Rest API via PostgREST
### Auto-generated REST API via PostgREST
RESTful APIs are auto-generated from your database, without a single line of code. [Docs](/docs/guides/api#rest-api-overview).
@@ -66,13 +66,13 @@ Deploy read-only databases across multiple regions, for lower latency and better
Launch the Supabase stack on Fly.io edge network. [Docs](/docs/guides/platform/fly-postgres).
### Log Drains
### Log drains
Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains).
## Studio
### Studio Single Sign On
### Studio Single Sign-On
Login to the Supabase dashboard via SSO. [Docs](/docs/guides/platform/sso).
@@ -118,7 +118,7 @@ Control the data each user can access with Postgres Policies. [Docs](/docs/guide
Add Captcha to your sign-in, sign-up, and password reset forms. [Docs](/docs/guides/auth/auth-captcha).
### Server-side Auth
### Server-Side Auth
Helpers for implementing user authentication in popular server-side languages and frameworks like Next.js, SvelteKit and Remix. [Docs](/docs/guides/auth/server-side).
@@ -183,11 +183,11 @@ Unofficial libraries are supported by the community.
Supabase Features are in 4 different states - Private Alpha, Public Alpha, Beta and Generally Available.
### Private Alpha
### Private alpha
Features are initially launched as a private alpha to gather feedback from the community. To join our early access program, send an email to product-ops@supabase.io.
### Public Alpha
### Public alpha
The alpha stage indicates that the API might change in the future, not that the service isn’t stable. Even though the [uptime Service Level Agreement](https://supabase.com/sla) does not cover products in Alpha, we do our best to have the service as stable as possible.
@@ -195,7 +195,7 @@ The alpha stage indicates that the API might change in the future, not that the
Features in Beta are tested by an external penetration tester for security issues. The API is guaranteed to be stable and there is a strict communication process for breaking changes.
### Generally Available
### Generally available
In addition to the Beta requirements, features in GA are covered by the [uptime SLA](https://supabase.com/sla).
@@ -116,7 +116,7 @@ hideToc: true
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```prisma api/prisma/schema.prisma
```prisma api/db/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -96,6 +96,8 @@ plugins {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Set up Hilt for dependency injection
In the `build.gradle` (app) file, add the following:
@@ -134,6 +136,8 @@ class MainActivity : ComponentActivity() {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Provide Supabase instances with Hilt
To make the app easier to test, create a `SupabaseModule.kt` file as follows:
@@ -301,7 +301,7 @@ meta="utils/supabase/middleware.ts"
## Set up a login page
### Login and Signup form
### Login and signup form
Create a login/signup page for your application:
@@ -97,7 +97,7 @@ You can find the full contents of this file [here](https://raw.githubusercontent
In order for us to add login and user profile pages in this App, we have to tweak the `<Refine />` component inside `App.tsx`.
### The `<Refine />` Component
### The `<Refine />` component
The `App.tsx` file initially looks like this:
@@ -293,6 +293,8 @@ Update the entry point to the newly created `AppView`. Run in Xcode to launch yo
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
photos and videos.
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Add PhotosPicker
Let's add support for the user to pick an image from the library and upload it.
@@ -332,6 +334,8 @@ enum TransferError: Error {
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
#### Add PhotosPicker to profile page
```swift ProfileView.swift
@@ -10,11 +10,9 @@ Every project on the Supabase Platform comes with its own dedicated Postgres ins
The following table describes the base instances, Nano (free plan) and Micro (paid plans), with additional compute instance sizes available if you need extra performance when scaling up.
<Admonition type="note" label="Upgrade to Micro Instances at No Additional Charge for Paid Projects">
<Admonition type="note" label="Nano instances in Pro organizations">
If you upgraded your Supabase organization from the Free plan to a paid plan, any projects created on the Free plan will remain on Nano instances due to the upgrade process [incurring downtime](/docs/guides/platform/compute-and-disk#upgrade-downtime).
Since every project on a paid plan is already paying for a Micro instance you can upgrade to Micro at no additional charge. See [Supabase Pricing](https://supabase.com/pricing) for more information.
In Pro organizations, Nano Compute are billed at the same price as Micro Compute. It is recommended to upgrade your Project from Nano Compute to Micro Compute when it's convenient for you. Compute sizes are not auto-upgraded because of the downtime incurred. See [Supabase Pricing](https://supabase.com/pricing) for more information.
</Admonition>
@@ -5,3 +5,5 @@ hideTitle: true
---
This reference documents every object and method available in Supabase's isomorphic JavaScript library, `supabase-js`. You can use `supabase-js` to interact with your Postgres database, listen to database changes, invoke Deno Edge Functions, build login and user management functionality, and manage large files.
To convert SQL queries to `supabase-js` calls, use the [SQL to REST API translator](/docs/guides/api/sql-to-rest).
+2 -1
View File
@@ -11,6 +11,7 @@ import { QueryClientProvider } from './data/queryClient.client'
import { PageTelemetry } from './telemetry/telemetry.client'
import { ScrollRestoration } from './ui/helpers.scroll.client'
import { ThemeSandbox } from './ui/theme.client'
import { PromoToast } from 'ui-patterns'
/**
* Global providers that wrap the entire app
@@ -25,7 +26,7 @@ function GlobalProviders({ children }: PropsWithChildren) {
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
{/* <PromoToast /> */}
<PromoToast />
{children}
<DocsCommandMenu />
</SiteLayout>
+5 -1
View File
@@ -48,6 +48,8 @@ import { z, type SafeParseError } from 'zod'
import { fetchWithNextOptions } from '~/features/helpers.fetch'
import { EXAMPLES_DIRECTORY } from '~/lib/docs'
const ALLOW_LISTED_GITHUB_ORGS = ['supabase'] as [string, ...string[]]
const linesSchema = z.array(z.tuple([z.coerce.number(), z.coerce.number()]))
const linesValidator = z
.string()
@@ -73,7 +75,9 @@ type AdditionalMeta = {
const codeSampleExternalSchema = z.object({
external: z.coerce.boolean().refine((v) => v === true),
org: z.string(),
org: z.enum(ALLOW_LISTED_GITHUB_ORGS, {
errorMap: () => ({ message: 'Org must be one of: ' + ALLOW_LISTED_GITHUB_ORGS.join(', ') }),
}),
repo: z.string(),
commit: z.string(),
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
+4 -1
View File
@@ -9,6 +9,7 @@ import { mdxjs } from 'micromark-extension-mdxjs'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { getGitHubFileContents } from '~/lib/octokit'
import { getGitHubFileContentsImmutableOnly } from '~/lib/octokit'
import { codeSampleRemark } from './CodeSample'
type Transformer = (ast: Root) => Root | Promise<Root>
@@ -31,6 +32,8 @@ export function preprocessMdxWithDefaults(mdx: string) {
return preprocessMdx(mdx, [
remarkMkDocsAdmonition(),
remarkPyMdownTabs(),
codeSampleRemark({ fetchFromGitHub: getGitHubFileContents }),
codeSampleRemark({
fetchFromGitHub: getGitHubFileContentsImmutableOnly,
}),
])
}
+79 -2
View File
@@ -35,7 +35,7 @@ export async function getGitHubFileContents({
repo,
path,
branch,
options: { onError },
options: { onError, fetch },
}: {
org: string
repo: string
@@ -63,7 +63,7 @@ export async function getGitHubFileContents({
path: path,
ref: branch,
options: {
fetch: fetchRevalidatePerDay,
fetch: fetch ?? fetchRevalidatePerDay,
},
})
if (response.status !== 200 || !response.data) {
@@ -79,3 +79,80 @@ export async function getGitHubFileContents({
onError?.(err)
}
}
export async function getGitHubFileContentsImmutableOnly({
org,
repo,
branch,
path,
options: { onError, fetch },
}: {
org: string
repo: string
branch: string
path: string
options: {
onError: (err?: unknown) => void
fetch?: (info: RequestInfo, init?: RequestInit) => Promise<Response>
}
}) {
const isImmutableCommit = await checkForImmutableCommit({
org,
repo,
branch,
})
if (!isImmutableCommit) {
throw Error('The commit is not an immutable commit SHA. Tags and branch names are not allowed.')
}
return getGitHubFileContents({
org,
repo,
branch,
path,
options: { onError, fetch },
})
}
async function checkForImmutableCommit({
org,
repo,
branch,
options: { fetch: _fetch = fetch } = {},
}: {
org: string
repo: string
branch: string
options?: {
/**
*
* A custom fetch implementation to control Next.js caching.
*/
fetch?: (info: RequestInfo, init?: RequestInit) => Promise<Response>
}
}) {
try {
const response = await octokit().request('GET /repos/{owner}/{repo}/git/commits/{commit_sha}', {
owner: org,
repo: repo,
commit_sha: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
options: {
fetch: _fetch,
},
})
if (response.status === 200) {
return true
} else {
throw Error(
"Checking for an immutable commit didn't throw an error, but it also didn't return a 200. Erring on the side of safety, assuming this is not an immutable commit.",
{ cause: response }
)
}
} catch (err) {
console.error('Not an immutable commit: %o', err)
return false
}
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+1
View File
@@ -58,6 +58,7 @@ const nextConfig = {
experimental: {
outputFileTracingIncludes: {
'/api/crawlers': ['./features/docs/generated/**/*', './docs/ref/**/*'],
'/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*'],
'/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'],
},
},
+168 -53
View File
@@ -130,7 +130,7 @@
"application/json": {
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/V1ProjectResponse" }
"items": { "$ref": "#/components/schemas/V1ProjectWithDatabaseResponse" }
}
}
}
@@ -146,7 +146,9 @@
"requestBody": {
"required": true,
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/V1CreateProjectBody" } }
"application/json": {
"schema": { "$ref": "#/components/schemas/V1CreateProjectBodyDto" }
}
}
},
"responses": {
@@ -192,7 +194,7 @@
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateOrganizationBodyV1" }
"schema": { "$ref": "#/components/schemas/CreateOrganizationV1Dto" }
}
}
},
@@ -312,7 +314,7 @@
"operationId": "v1-get-a-snippet",
"summary": "Gets a specific SQL snippet",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "uuid" } }
],
"responses": {
"200": {
@@ -338,7 +340,8 @@
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
}
},
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
@@ -366,7 +369,8 @@
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
}
},
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"requestBody": {
"required": true,
@@ -398,7 +402,8 @@
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"requestBody": {
"required": true,
@@ -417,6 +422,31 @@
"tags": ["Secrets"],
"security": [{ "bearer": [] }]
},
"get": {
"operationId": "getApiKey",
"summary": "[Alpha] Get API key",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/ApiKeyResponse" } }
}
}
},
"tags": ["Secrets"],
"security": [{ "bearer": [] }]
},
"delete": {
"operationId": "deleteApiKey",
"summary": "[Alpha] Deletes an API key for the project",
@@ -428,7 +458,8 @@
"description": "Project ref",
"schema": { "minLength": 20, "maxLength": 20, "type": "string" }
},
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "reveal", "required": true, "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
@@ -940,7 +971,9 @@
"200": {
"description": "",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/V1ProjectResponse" } }
"application/json": {
"schema": { "$ref": "#/components/schemas/V1ProjectWithDatabaseResponse" }
}
}
},
"500": { "description": "Failed to retrieve project" }
@@ -2035,6 +2068,12 @@
"required": false,
"in": "query",
"schema": { "type": "string" }
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": { "type": "string" }
}
],
"requestBody": {
@@ -2178,6 +2217,12 @@
"required": false,
"in": "query",
"schema": { "type": "string" }
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": { "type": "string" }
}
],
"requestBody": {
@@ -2697,7 +2742,7 @@
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"V1ProjectResponse": {
"V1ProjectWithDatabaseResponse": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Id of your project" },
@@ -2735,33 +2780,9 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": ["id", "organization_id", "name", "region", "created_at", "database", "status"]
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"V1CreateProjectBody": {
"V1CreateProjectBodyDto": {
"type": "object",
"properties": {
"db_pass": { "type": "string", "description": "Database password" },
@@ -2773,12 +2794,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -2798,35 +2819,97 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
"deprecated": true,
"description": "This field is deprecated and is ignored in this request"
},
"desired_instance_size": { "$ref": "#/components/schemas/DesiredInstanceSize" },
"desired_instance_size": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": { "$ref": "#/components/schemas/ReleaseChannel" },
"postgres_engine": { "$ref": "#/components/schemas/PostgresEngine" }
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
},
"V1ProjectResponse": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Id of your project" },
"organization_id": { "type": "string", "description": "Slug of your organization" },
"name": { "type": "string", "description": "Name of your project" },
"region": {
"type": "string",
"description": "Region of your project",
"example": "us-east-1"
},
"created_at": {
"type": "string",
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
"ACTIVE_UNHEALTHY",
"COMING_UP",
"GOING_DOWN",
"INACTIVE",
"INIT_FAILED",
"REMOVED",
"RESTARTING",
"UNKNOWN",
"UPGRADING",
"PAUSING",
"RESTORING",
"RESTORE_FAILED",
"PAUSE_FAILED",
"RESIZING"
],
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
},
"OrganizationResponseV1": {
"type": "object",
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
"required": ["id", "name"]
},
"CreateOrganizationBodyV1": {
"CreateOrganizationV1Dto": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
"required": ["name"],
"additionalProperties": false
},
"OAuthTokenBody": {
"type": "object",
@@ -2945,10 +3028,14 @@
"ApiKeyResponse": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": { "type": "string" },
"api_key": { "type": "string" },
"id": { "type": "string", "nullable": true },
"type": { "nullable": true, "type": "object" },
"prefix": { "type": "string", "nullable": true },
"description": { "type": "string", "nullable": true },
"hash": { "type": "string", "nullable": true },
@@ -2983,6 +3070,30 @@
}
}
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"CreateBranchBody": {
"type": "object",
"properties": {
@@ -4134,7 +4245,8 @@
"slug": { "type": "string", "pattern": "/^[A-Za-z0-9_-]+$/" },
"name": { "type": "string" },
"body": { "type": "string" },
"verify_jwt": { "type": "boolean" }
"verify_jwt": { "type": "boolean" },
"resource_multiplier": { "type": "string" }
},
"required": ["slug", "name", "body"]
},
@@ -4151,7 +4263,8 @@
"verify_jwt": { "type": "boolean" },
"import_map": { "type": "boolean" },
"entrypoint_path": { "type": "string" },
"import_map_path": { "type": "string" }
"import_map_path": { "type": "string" },
"resource_multiplier": { "type": "string" }
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
},
@@ -4168,7 +4281,8 @@
"verify_jwt": { "type": "boolean" },
"import_map": { "type": "boolean" },
"entrypoint_path": { "type": "string" },
"import_map_path": { "type": "string" }
"import_map_path": { "type": "string" },
"resource_multiplier": { "type": "string" }
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
},
@@ -4177,7 +4291,8 @@
"properties": {
"name": { "type": "string" },
"body": { "type": "string" },
"verify_jwt": { "type": "boolean" }
"verify_jwt": { "type": "boolean" },
"resource_multiplier": { "type": "string" }
}
},
"V1StorageBucketResponse": {
+384 -1
View File
@@ -44,6 +44,9 @@ info:
- id: analytics
title: Analytics
description: Analytics settings.
- id: experimental
title: Experimental
description: Settings in alpha testing, subject to changes or removal at any time.
- id: local
title: Local Development
description: Local Development settings.
@@ -190,6 +193,237 @@ parameters:
- name: 'PgBouncer Configuration'
link: https://www.pgbouncer.org/config.html#default_pool_size
- id: 'db.settings.effective_cache_size'
title: 'db.settings.effective_cache_size'
tags: ['database']
required: false
description: |
Sets the planner's assumption about the effective size of the disk cache.
This is a query planner parameter that doesn't affect actual memory allocation.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE'
- id: 'db.settings.logical_decoding_work_mem'
title: 'db.settings.logical_decoding_work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by logical decoding, before writing data to local disk.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM'
- id: 'db.settings.maintenance_work_mem'
title: 'db.settings.maintenance_work_mem'
tags: ['database']
required: false
description: |
Specifies the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, and ALTER TABLE ADD FOREIGN KEY.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM'
- id: 'db.settings.max_connections'
title: 'db.settings.max_connections'
tags: ['database']
required: false
description: |
Determines the maximum number of concurrent connections to the database server.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS'
- id: 'db.settings.max_locks_per_transaction'
title: 'db.settings.max_locks_per_transaction'
tags: ['database']
required: false
description: |
Controls the average number of object locks allocated for each transaction.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION'
- id: 'db.settings.max_parallel_maintenance_workers'
title: 'db.settings.max_parallel_maintenance_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single utility command.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS'
- id: 'db.settings.max_parallel_workers'
title: 'db.settings.max_parallel_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS'
- id: 'db.settings.max_parallel_workers_per_gather'
title: 'db.settings.max_parallel_workers_per_gather'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single Gather or Gather Merge node.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER'
- id: 'db.settings.max_replication_slots'
title: 'db.settings.max_replication_slots'
tags: ['database']
required: false
description: |
Specifies the maximum number of replication slots that the server can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS'
- id: 'db.settings.max_slot_wal_keep_size'
title: 'db.settings.max_slot_wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the maximum size of WAL files that replication slots are allowed to retain in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE'
- id: 'db.settings.max_standby_archive_delay'
title: 'db.settings.max_standby_archive_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing archived WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY'
- id: 'db.settings.max_standby_streaming_delay'
title: 'db.settings.max_standby_streaming_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing streamed WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY'
- id: 'db.settings.max_wal_size'
title: 'db.settings.max_wal_size'
tags: ['database']
required: false
description: |
Sets the maximum size of WAL files that the system will keep in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE'
- id: 'db.settings.max_wal_senders'
title: 'db.settings.max_wal_senders'
tags: ['database']
required: false
description: |
Specifies the maximum number of concurrent connections from standby servers or streaming base backup clients.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS'
- id: 'db.settings.max_worker_processes'
title: 'db.settings.max_worker_processes'
tags: ['database']
required: false
type: 'uint'
description: |
Sets the maximum number of background processes that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES'
- id: 'db.settings.session_replication_role'
title: 'db.settings.session_replication_role'
tags: ['database']
required: false
description: |
Controls whether triggers and rewrite rules are enabled. Valid values are: "origin", "replica", or "local".
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE'
- id: 'db.settings.shared_buffers'
title: 'db.settings.shared_buffers'
tags: ['database']
required: false
description: |
Sets the amount of memory the database server uses for shared memory buffers.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS'
- id: 'db.settings.statement_timeout'
title: 'db.settings.statement_timeout'
tags: ['database']
required: false
description: |
Abort any statement that takes more than the specified amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT'
- id: 'db.settings.track_commit_timestamp'
title: 'db.settings.track_commit_timestamp'
tags: ['database']
required: false
type: 'bool'
description: |
Record commit time of transactions.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP'
- id: 'db.settings.wal_keep_size'
title: 'db.settings.wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the minimum size of past log file segments kept in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE'
- id: 'db.settings.wal_sender_timeout'
title: 'db.settings.wal_sender_timeout'
tags: ['database']
required: false
description: |
Terminate replication connections that are inactive for longer than this amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT'
- id: 'db.settings.work_mem'
title: 'db.settings.work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM'
- id: 'db.pooler.max_client_conn'
title: 'db.pooler.max_client_conn'
tags: ['database']
@@ -463,6 +697,56 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.secure_password_change'
title: 'auth.email.secure_password_change'
tags: ['auth']
required: false
type: 'bool'
default: false
description: |
If enabled, requires the user's current password to be provided when changing to a new password.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.max_frequency'
title: 'auth.email.max_frequency'
tags: ['auth']
required: false
type: 'duration'
default: '1m'
description: |
The minimum amount of time that must pass between email requests.
Helps prevent email spam by limiting how frequently emails can be sent.
Example values: "1m", "1h", "24h"
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_length'
title: 'auth.email.otp_length'
tags: ['auth']
required: false
default: '6'
description: |
The length of the OTP code to be sent in emails.
Must be between 6 and 10 digits.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_exp'
title: 'auth.email.otp_exp'
tags: ['auth']
required: false
default: '300'
description: |
The expiry time for an OTP code in seconds.
Default is 300 seconds (5 minutes).
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.smtp.host'
title: 'auth.email.smtp.host'
tags: ['auth']
@@ -779,7 +1063,7 @@ parameters:
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.external.provider.skip_nonce_check'
title: 'auth.external.<provider>.skip_nonce_check'
tags: ['auth']
@@ -790,6 +1074,28 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.enroll_enabled'
title: 'auth.mfa.web_authn.enroll_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn enrollment for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.verify_enabled'
title: 'auth.mfa.web_authn.verify_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn verification for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'functions.function_name.enabled'
title: 'functions.<function_name>.enabled'
tags: ['edge-functions']
@@ -831,6 +1137,20 @@ parameters:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
- id: 'functions.function_name.entrypoint'
title: 'functions.<function_name>.entrypoint'
tags: ['edge-functions']
required: false
description: |
Specify a custom entrypoint path for the function relative to the project root.
When not specified, defaults to `supabase/functions/<function_name>/index.ts`.
links:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
usage: |
[functions.my_function]
entrypoint = "path/to/custom/function.ts"
- id: 'analytics.enabled'
title: 'analytics.enabled'
tags: ['analytics']
@@ -873,3 +1193,66 @@ parameters:
links:
- name: Self-hosted Logflare Configuration
link: https://supabase.com/docs/reference/self-hosting-analytics/list-endpoints#getting-started
- id: 'experimental.webhooks.enabled'
title: 'experimental.webhooks.enabled'
tags: ['experimental']
required: false
default: 'false'
description: |
Automatically enable webhook features on each new created branch
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.orioledb_version'
title: 'experimental.orioledb_version'
tags: ['experimental']
required: false
description: |
Configures Postgres storage engine to use OrioleDB with S3 support.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_host'
title: 'experimental.s3_host'
tags: ['experimental']
required: false
default: 'env(S3_HOST)'
description: |
Configures S3 bucket URL for OrioleDB storage.
Format example: <bucket_name>.s3-<region>.amazonaws.com
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_region'
title: 'experimental.s3_region'
tags: ['experimental']
required: false
default: 'env(S3_REGION)'
description: |
Configures S3 bucket region for OrioleDB storage.
Example: us-east-1
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_access_key'
title: 'experimental.s3_access_key'
tags: ['experimental']
required: false
default: 'env(S3_ACCESS_KEY)'
description: |
Configures AWS_ACCESS_KEY_ID for S3 bucket access.
DO NOT commit your AWS access key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_secret_key'
title: 'experimental.s3_secret_key'
tags: ['experimental']
required: false
default: 'env(S3_SECRET_KEY)'
description: |
Configures AWS_SECRET_ACCESS_KEY for S3 bucket access.
DO NOT commit your AWS secret key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
@@ -458,7 +458,15 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": [
"id",
"organization_id",
"name",
"region",
"created_at",
"database",
"status"
]
}
}
}
@@ -498,12 +506,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -523,9 +531,7 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
@@ -549,20 +555,23 @@
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
}
}
}
@@ -597,28 +606,6 @@
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"database": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Database host"
},
"version": {
"type": "string",
"description": "Database version"
},
"postgres_engine": {
"type": "string",
"description": "Database engine"
},
"release_channel": {
"type": "string",
"description": "Release channel"
}
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
@@ -709,7 +696,8 @@
"type": "string"
}
},
"required": ["name"]
"required": ["name"],
"additionalProperties": false
}
}
}
@@ -1039,7 +1027,7 @@
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "uuid"
}
}
],
@@ -1172,6 +1160,14 @@
"maxLength": 20,
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1184,6 +1180,11 @@
"items": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1194,10 +1195,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -1261,6 +1258,14 @@
"maxLength": 20,
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"requestBody": {
@@ -1306,6 +1311,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1316,10 +1326,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -1392,6 +1398,14 @@
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"requestBody": {
@@ -1432,6 +1446,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1442,9 +1461,108 @@
"type": "string",
"nullable": true
},
"prefix": {
"type": "string",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"hash": {
"type": "string",
"nullable": true
},
"secret_jwt_template": {
"nullable": true,
"allOf": [
{
"type": "object",
"properties": {
"role": {
"type": "string"
}
},
"required": ["role"]
}
]
},
"inserted_at": {
"type": "string",
"nullable": true
},
"updated_at": {
"type": "string",
"nullable": true
}
},
"required": ["name", "api_key"]
}
}
}
}
},
"tags": ["Secrets"],
"security": [
{
"bearer": []
}
]
},
"get": {
"operationId": "getApiKey",
"summary": "[Alpha] Get API key",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"description": "Project ref",
"schema": {
"minLength": 20,
"maxLength": 20,
"type": "string"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "object"
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
"api_key": {
"type": "string"
},
"id": {
"type": "string",
"nullable": true
},
"prefix": {
"type": "string",
@@ -1516,6 +1634,14 @@
"schema": {
"type": "string"
}
},
{
"name": "reveal",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1526,6 +1652,11 @@
"schema": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -1536,10 +1667,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -3238,7 +3365,15 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": [
"id",
"organization_id",
"name",
"region",
"created_at",
"database",
"status"
]
}
}
}
@@ -8045,6 +8180,14 @@
"schema": {
"type": "string"
}
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -8066,6 +8209,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -8087,6 +8233,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -8137,6 +8286,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8228,6 +8380,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8330,6 +8485,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -8433,6 +8591,14 @@
"schema": {
"type": "string"
}
},
{
"name": "resource_multiplier",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -8450,6 +8616,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
}
@@ -8466,6 +8635,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
}
@@ -8515,6 +8687,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": [
@@ -10018,7 +10193,7 @@
},
"required": ["host", "version", "postgres_engine", "release_channel"]
},
"V1ProjectResponse": {
"V1ProjectWithDatabaseResponse": {
"type": "object",
"properties": {
"id": {
@@ -10086,33 +10261,9 @@
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
"required": ["id", "organization_id", "name", "region", "created_at", "database", "status"]
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"V1CreateProjectBody": {
"V1CreateProjectBodyDto": {
"type": "object",
"properties": {
"db_pass": {
@@ -10130,12 +10281,12 @@
"plan": {
"type": "string",
"enum": ["free", "pro"],
"description": "Subscription Plan is now set on organization level and is ignored in this request",
"example": "free",
"deprecated": true
"deprecated": true,
"description": "Subscription Plan is now set on organization level and is ignored in this request"
},
"region": {
"type": "string",
"description": "Region you want your server to reside in",
"enum": [
"us-east-1",
"us-east-2",
@@ -10155,9 +10306,7 @@
"ca-central-1",
"ap-south-1",
"sa-east-1"
],
"description": "Region you want your server to reside in",
"example": "us-east-1"
]
},
"kps_enabled": {
"type": "boolean",
@@ -10181,20 +10330,71 @@
},
"template_url": {
"type": "string",
"format": "uri",
"description": "Template URL used to create the project from the CLI.",
"example": "https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone"
},
"release_channel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"],
"description": "Release channel. If not provided, GA will be used."
},
"postgres_engine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
"enum": ["15"],
"description": "Postgres engine version. If not provided, the latest version will be used."
}
},
"required": ["db_pass", "name", "organization_id", "region"]
"required": ["db_pass", "name", "organization_id", "region"],
"additionalProperties": false
},
"V1ProjectResponse": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Id of your project"
},
"organization_id": {
"type": "string",
"description": "Slug of your organization"
},
"name": {
"type": "string",
"description": "Name of your project"
},
"region": {
"type": "string",
"description": "Region of your project",
"example": "us-east-1"
},
"created_at": {
"type": "string",
"description": "Creation timestamp",
"example": "2023-03-29T16:32:59Z"
},
"status": {
"enum": [
"ACTIVE_HEALTHY",
"ACTIVE_UNHEALTHY",
"COMING_UP",
"GOING_DOWN",
"INACTIVE",
"INIT_FAILED",
"REMOVED",
"RESTARTING",
"UNKNOWN",
"UPGRADING",
"PAUSING",
"RESTORING",
"RESTORE_FAILED",
"PAUSE_FAILED",
"RESIZING"
],
"type": "string"
}
},
"required": ["id", "organization_id", "name", "region", "created_at", "status"]
},
"OrganizationResponseV1": {
"type": "object",
@@ -10208,14 +10408,15 @@
},
"required": ["id", "name"]
},
"CreateOrganizationBodyV1": {
"CreateOrganizationV1Dto": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
"required": ["name"],
"additionalProperties": false
},
"OAuthTokenBody": {
"type": "object",
@@ -10577,6 +10778,11 @@
"ApiKeyResponse": {
"type": "object",
"properties": {
"type": {
"nullable": true,
"type": "string",
"enum": ["publishable", "secret", "legacy"]
},
"name": {
"type": "string"
},
@@ -10587,10 +10793,6 @@
"type": "string",
"nullable": true
},
"type": {
"nullable": true,
"type": "object"
},
"prefix": {
"type": "string",
"nullable": true
@@ -10679,6 +10881,30 @@
}
}
},
"DesiredInstanceSize": {
"type": "string",
"enum": [
"micro",
"small",
"medium",
"large",
"xlarge",
"2xlarge",
"4xlarge",
"8xlarge",
"12xlarge",
"16xlarge"
]
},
"ReleaseChannel": {
"type": "string",
"enum": ["internal", "alpha", "beta", "ga", "withdrawn"]
},
"PostgresEngine": {
"type": "string",
"description": "Postgres engine version. If not provided, the latest version will be used.",
"enum": ["15"]
},
"CreateBranchBody": {
"type": "object",
"properties": {
@@ -13596,6 +13822,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["slug", "name", "body"]
@@ -13638,6 +13867,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
@@ -13680,6 +13912,9 @@
},
"import_map_path": {
"type": "string"
},
"resource_multiplier": {
"type": "string"
}
},
"required": ["version", "created_at", "updated_at", "id", "slug", "name", "status"]
@@ -13695,6 +13930,9 @@
},
"verify_jwt": {
"type": "boolean"
},
"resource_multiplier": {
"type": "string"
}
}
},
@@ -0,0 +1,55 @@
import Image from 'next/image'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { BASE_PATH } from 'lib/constants'
import { detectOS } from 'lib/helpers'
export const AssistantV2Preview = () => {
const os = detectOS()
const { ref } = useParams()
return (
<div className="flex flex-col gap-y-2">
<p className="text-foreground-light text-sm mb-4">
We're changing the way our AI Assistant integrates with the dashboard by making it shared
and accessible universally across the whole dashboard. This hopes to make using the
Assistant as a supporting tool more seamless while you build your project.
</p>
<Image
src={`${BASE_PATH}/img/previews/assistant-v2.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<p className="text-foreground-light text-sm mb-4">
The Assistant will also be automatically provided with contexts depending on where you are
in the dashboard to generate more relevant and higher quality outputs. You may also ask for
insights on your own data apart from help with SQL and Postgres!
</p>
<p className="text-foreground-light text-sm mb-4">
We believe that this should further lower the barrier of working with databases especially
if you're not well acquainted with Postgres (yet!), so please do feel free to let us know
what you think in the attached GitHub discussion above!
</p>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Add a button in the top navigation bar where you can access the AI Assistant from anywhere in the dashboard`}
/>
</li>
<li>
<Markdown
className="text-foreground-light"
content={`Add a keyboard shortcut (${os === 'macos' ? 'Cmd' : 'Ctrl'} + I) that can also open the Assistant from anywhere in the dashboard`}
/>
</li>
</ul>
</div>
</div>
)
}
@@ -5,13 +5,13 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { EMPTY_OBJ } from 'lib/void'
import { APISidePanelPreview } from './APISidePanelPreview'
import { CLSPreview } from './CLSPreview'
import { FunctionsAssistantPreview } from './FunctionsAssistantPreview'
import { AssistantV2Preview } from './AssistantV2Preview'
export const FEATURE_PREVIEWS = [
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT,
name: 'Database Functions Assistant',
content: <FunctionsAssistantPreview />,
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2,
name: 'Supabase AI Assistant V2',
content: <AssistantV2Preview />,
discussionsUrl: undefined,
isNew: true,
},
@@ -86,7 +86,7 @@ export const useIsColumnLevelPrivilegesEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]
}
export const useIsDatabaseFunctionsAssistantEnabled = () => {
export const useIsAssistantV2Enabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT]
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2]
}
@@ -3,10 +3,12 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useFlag } from 'hooks/ui/useFlag'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { TELEMETRY_EVENTS } from 'lib/constants/telemetry'
import { useAppStateSnapshot } from 'state/app-state'
import { Badge, Button, Modal, ScrollArea, cn } from 'ui'
import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext'
import { useFlag } from 'hooks/ui/useFlag'
const FeaturePreviewModal = () => {
const snap = useAppStateSnapshot()
@@ -36,10 +38,17 @@ const FeaturePreviewModal = () => {
const toggleFeature = () => {
onUpdateFlag(selectedFeatureKey, !isSelectedFeatureEnabled)
sendEvent({
category: 'ui_feature_previews',
action: isSelectedFeatureEnabled ? 'disabled' : 'enabled',
action: TELEMETRY_EVENTS.FEATURE_PREVIEWS,
label: selectedFeatureKey,
value: isSelectedFeatureEnabled ? 'disabled' : 'enabled',
})
if (
selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_PREVIEW_ASSISTANT_V2 &&
isSelectedFeatureEnabled
) {
snap.setAiAssistantPanel({ open: false })
}
}
function handleCloseFeaturePreviewModal() {
@@ -1,55 +0,0 @@
import Image from 'next/image'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { BASE_PATH } from 'lib/constants'
import { detectOS } from 'lib/helpers'
export const FunctionsAssistantPreview = () => {
const os = detectOS()
const { ref } = useParams()
return (
<div className="flex flex-col gap-y-2">
<p className="text-foreground-light text-sm mb-4">
We're providing an additional alternative UX to creating database functions through the
dashboard with the integration of our AI Assistant that you might have seen in the Auth
Policies section.
</p>
<Image
src={`${BASE_PATH}/img/previews/functions-assistant.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<p className="text-foreground-light text-sm mb-4">
This preview also shares an improved Assistant interface where you may provide the Assistant
with contexts in hopes to generate more relevant and higher quality outputs. Contexts that
you may provide include specific schemas, and / or specific tables from your database.
</p>
<p className="text-foreground-light text-sm mb-4">
We'd hope to use this as a consistent pattern throughout the dashboard eventually if this
feature preview proves itself to benefit most of our users, so as usual please do feel free
to let us know what you think if the attached GitHub discussion above!
</p>
<div className="flex flex-col gap-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Add a button beside the "Create new function" button on the [Database Functions page](/project/${ref}/database/functions) that will open up a code editor paired with a contextualized AI assistant in a side panel.`}
/>
</li>
<li>
<Markdown
className="text-foreground-light"
content={`Add a keyboard shortcut (${os === 'macos' ? 'Cmd' : 'Ctrl'} + I) that will open the Assistant in an uncontextualized mode, along with a quick SQL Editor that you can run queries with from anywhere in the dashboard`}
/>
</li>
</ul>
</div>
</div>
)
}
@@ -21,8 +21,9 @@ import {
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
import { generatePolicyCreateSQL } from './PolicyTableRow.utils'
interface PolicyRowProps {
policy: PostgresPolicy
@@ -38,7 +39,7 @@ const PolicyRow = ({
onSelectDeletePolicy = noop,
}: PolicyRowProps) => {
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
const { project } = useProjectContext()
@@ -102,21 +103,21 @@ const PolicyRow = ({
<DropdownMenuContent
side="bottom"
align="end"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{enableAssistantV2 && (
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
const sql = generatePolicyCreateSQL(policy)
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: policy,
tables: [{ schema: policy.schema, name: policy.table }],
sqlSnippets: [sql],
initialInput: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
})
}}
>
@@ -0,0 +1,22 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
export const generatePolicyCreateSQL = (policy: PostgresPolicy) => {
let expression = ''
if (policy.definition !== null && policy.definition !== undefined) {
expression += `USING (${policy.definition})${
policy.check === null || policy.check === undefined ? ';' : ''
}\n`
}
if (policy.check !== null && policy.check !== undefined) {
expression += `WITH CHECK (${policy.check});\n`
}
return `
CREATE POLICY "${policy.name}"
ON "${policy.schema}"."${policy.table}"
AS ${policy.action}
FOR ${policy.command}
TO ${policy.roles.join(', ')}
${expression}
`.trim()
}
@@ -4,7 +4,7 @@ import { Lock, Unlock } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -37,7 +37,8 @@ const PolicyTableRowHeader = ({
const { ref } = useParams()
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const enableAssistantV2 = useIsAssistantV2Enabled()
const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isRealtimeSchema = table.schema === 'realtime'
@@ -96,13 +97,13 @@ const PolicyTableRowHeader = ({
)}
<ButtonTooltip
type="default"
disabled={!canToggleRLS}
disabled={!canToggleRLS || !canCreatePolicies}
onClick={() => onSelectCreatePolicy()}
tooltip={{
content: {
side: 'bottom',
text: !canToggleRLS
? !canToggleRLS
? !canToggleRLS || !canCreatePolicies
? 'You need additional permissions to create RLS policies'
: undefined
: undefined,
@@ -119,9 +120,7 @@ const PolicyTableRowHeader = ({
if (enableAssistantV2) {
setAiAssistantPanel({
open: true,
editor: 'rls-policies',
entity: undefined,
tables: [{ schema: table.schema, name: table.name }],
initialInput: `Create a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
})
} else {
onSelectCreatePolicy()
@@ -131,9 +130,10 @@ const PolicyTableRowHeader = ({
tooltip={{
content: {
side: 'bottom',
text: !canToggleRLS
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant',
text:
!canToggleRLS || !canCreatePolicies
? 'You need additional permissions to create RLS policies'
: 'Create with Supabase Assistant',
},
}}
>
@@ -29,6 +29,7 @@ import { urlRegex } from './../Auth.constants'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import { AlertTriangle, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
const SmtpForm = () => {
const { ref: projectRef } = useParams()
@@ -218,10 +219,17 @@ const SmtpForm = () => {
// @ts-ignore
onChange={(value: boolean) => setEnableSmtp(value)}
descriptionText={
<Markdown
className="max-w-full [&>p]:text-foreground-lighter"
content={`Emails will be sent using your custom SMTP provider. Email rate limits can be adjusted [here](/dashboard/project/${projectRef}/auth/rate-limits).`}
/>
<p className="max-w-full prose text-sm text-foreground-lighter">
Emails will be sent using your custom SMTP provider. Email rate limits can
be adjusted{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
/>
{enableSmtp ? (
@@ -2,7 +2,7 @@ import { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import type { Organization } from 'types'
import { Admonition } from 'ui-patterns'
import { Markdown } from '../Markdown'
import Link from 'next/link'
interface NoProjectsOnPaidOrgInfoProps {
organization?: Organization
@@ -28,11 +28,11 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
type="default"
title={`Your organization is on the ${orgSubscription.plan.name} plan with no projects running`}
description={
<Markdown
className="!max-w-full"
content={`The monthly fees for the paid plan still apply. To cancel your subscription, head over to
your [organization billing settings](/dashboard/org/${organization?.slug}/billing).`}
/>
<div className="!max-w-full prose text-sm">
The monthly fees for the paid plan still apply. To cancel your subscription, head over to
your{' '}
<Link href={`/org/${organization?.slug}/billing`}>organization billing settings</Link>
</div>
}
/>
)
@@ -1,36 +1,56 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useFlag } from 'hooks/ui/useFlag'
import Link from 'next/link'
import React from 'react'
import { useParams } from 'common'
import { NavMenu, NavMenuItem } from 'ui'
import { Badge, NavMenu, NavMenuItem } from 'ui'
type Props = {
active: 'scheduled' | 'pitr'
active: 'pitr' | 'scheduled' | 'rtnp'
}
function DatabaseBackupsNav({ active }: Props) {
const { ref } = useParams()
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
const ref = useProjectContext()?.project?.ref
const navMenuItems = [
{
enabled: true,
id: 'scheduled',
label: 'Scheduled backups',
href: `/project/${ref}/database/backups/scheduled`,
},
{
enabled: true,
id: 'pitr',
label: 'Point in time',
href: `/project/${ref}/database/backups/pitr`,
},
]
{
enabled: isCloneToNewProjectEnabled,
id: 'rtnp',
label: (
<div className="flex items-center gap-1">
Restore to new project{' '}
<Badge size="small" className="!text-[10px] px-1.5 py-0">
New
</Badge>
</div>
),
href: `/project/${ref}/database/backups/restore-to-new-project`,
},
] as const
return (
<NavMenu>
{navMenuItems.map((item) => (
<NavMenuItem key={item.label} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
{navMenuItems.map(
(item) =>
item.enabled && (
<NavMenuItem key={item.id} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
)
)}
</NavMenu>
)
}
@@ -1,23 +1,14 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import DatePicker from 'react-datepicker'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import InformationBox from 'components/ui/InformationBox'
import { useBackupsQuery } from 'data/database/backups-query'
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { PROJECT_STATUS } from 'lib/constants'
import {
Alert,
@@ -31,15 +22,9 @@ import {
import BackupsEmpty from '../BackupsEmpty'
import BackupsStorageAlert from '../BackupsStorageAlert'
import type { Timezone } from './PITR.types'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { getClientTimezone } from './PITR.utils'
import PITRStatus from './PITRStatus'
import TimeInput from './TimeInput'
import { TimezoneSelection } from './TimezoneSelection'
import { PITRForm } from './pitr-form'
const PITRSelection = () => {
const router = useRouter()
@@ -51,11 +36,11 @@ const PITRSelection = () => {
const [showConfiguration, setShowConfiguration] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(false)
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const canTriggerPhysicalBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const [selectedRecoveryPoint, setSelectedRecoveryPoint] = useState<{
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}>()
const hasReadReplicas = (databases ?? []).length > 1
@@ -76,67 +61,16 @@ const PITRSelection = () => {
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
backups?.physicalBackupData ?? {}
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
const earliestAvailableBackup = dayjs
.unix(earliestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
// This will be the actual unix timestamp for the backup
const recoveryTimeTargetUnix = selectedDate.unix()
// Formatting from the unix again just to double check correctness
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isSelectedOutOfRange =
selectedDate &&
(selectedDate.isBefore(earliestAvailableBackup) || selectedDate.isAfter(latestAvailableBackup))
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const onCancel = () => {
setShowConfiguration(false)
setSelectedTimezone(getClientTimezone())
}
const onConfirmRestore = async () => {
if (!ref) return console.error('Project ref is required')
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
if (!selectedRecoveryPoint?.recoveryTimeTargetUnix)
return console.error('Recovery time target unix is required')
restoreFromPitr({
ref,
recovery_time_target_unix: selectedRecoveryPoint.recoveryTimeTargetUnix,
})
}
return (
@@ -177,173 +111,14 @@ const PITRSelection = () => {
onSetConfiguration={() => setShowConfiguration(true)}
/>
) : (
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<ButtonTooltip
type="warning"
disabled={isSelectedOutOfRange || !selectedDate || !canTriggerPhysicalBackups}
onClick={() => setShowConfirmation(true)}
tooltip={{
content: {
side: 'bottom',
text: isSelectedOutOfRange
? 'Selected date is out of range where backups are available'
: !canTriggerPhysicalBackups
? 'You need additional permissions to trigger a restore'
: undefined,
},
}}
>
Review restore details
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Restore database to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={
isSelectedOnLatestDay ? latestAvailableBackupTime : undefined
}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups
are captured every 2 minutes, allowing you to enter a time and restore
your database to the closest backup point. We'll match the time you
enter to the closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
<PITRForm
earliestAvailableBackupUnix={earliestPhysicalBackupDateUnix}
latestAvailableBackupUnix={latestPhysicalBackupDateUnix}
onSubmit={(recoveryPoint) => {
setSelectedRecoveryPoint(recoveryPoint)
setShowConfirmation(true)
}}
/>
)}
</>
)}
@@ -379,12 +154,12 @@ const PITRSelection = () => {
</div>
<div className="py-2 flex flex-col gap-3">
<div>
<p className="text-sm font-mono text-foreground-lighter">{selectedTimezone?.text}</p>
<p className="text-2xl">{recoveryTimeString}</p>
<p className="text-sm font-mono text-foreground-lighter">Local Time</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeString}</p>
</div>
<div>
<p className="text-sm font-mono text-foreground-lighter">(UTC+00:00)</p>
<p className="text-2xl">{recoveryTimeStringUtc}</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeStringUtc}</p>
</div>
</div>
</Modal.Content>
@@ -61,7 +61,7 @@ export const TimezoneSelection = ({
value={option}
onSelect={(text) => {
const selectedTimezone = ALL_TIMEZONES.find(
(option) => option.text.toLocaleLowerCase() === text
(option) => option.text === text
)
if (selectedTimezone) {
onSelectTimezone(selectedTimezone)
@@ -0,0 +1,264 @@
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import DatePicker from 'react-datepicker'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { format } from 'date-fns'
import InformationBox from 'components/ui/InformationBox'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { TimezoneSelection } from './TimezoneSelection'
import TimeInput from './TimeInput'
import { Timezone } from './PITR.types'
import { useMemo, useState } from 'react'
type Props = {
onSubmit: (data: {
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}) => void
earliestAvailableBackupUnix: number
latestAvailableBackupUnix: number
}
export function PITRForm({
onSubmit,
earliestAvailableBackupUnix,
latestAvailableBackupUnix,
}: Props) {
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const earliestAvailableBackup = dayjs
.unix(earliestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true)
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
const recoveryTimeTargetUnix = selectedDate.unix()
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isWithinRange = useMemo(
() =>
(!!selectedDate && selectedDate.isSame(latestAvailableBackup)) ||
selectedDate.isSame(earliestAvailableBackup) ||
(selectedDate.isBefore(latestAvailableBackup) &&
selectedDate.isAfter(earliestAvailableBackup)),
[selectedDate, latestAvailableBackup, earliestAvailableBackup]
)
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const handleSubmit = () => {
onSubmit({
recoveryTimeTargetUnix,
recoveryTimeString,
recoveryTimeStringUtc,
})
}
return (
<div>
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<ButtonTooltip
type="default"
disabled={!selectedDate || !isWithinRange}
onClick={handleSubmit}
tooltip={{
content: {
hidden: !isWithinRange,
side: 'bottom',
text: !isWithinRange
? 'Selected date is out of range where backups are available'
: undefined,
},
}}
>
Continue
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
{availableDates.length > 1 && (
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
)}
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Date to restore to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups are
captured every 2 minutes, allowing you to enter a time and restore your
database to the closest backup point. We'll match the time you enter to the
closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
</div>
)
}
@@ -0,0 +1,45 @@
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
export const AdditionalMonthlySpend = () => {
const { project } = useProjectContext()
/**
* New project will have the same compute size and disk size as the original project
*/
function getAdditionalMonthlySpend() {
const currentProjectComputeSize = project?.infra_compute_size
if (!currentProjectComputeSize) {
return null
}
if (currentProjectComputeSize === 'nano') {
return null
}
const additionalMonthlySpend = instanceSizeSpecs[currentProjectComputeSize]
return additionalMonthlySpend
}
const additionalMonthlySpend = getAdditionalMonthlySpend()
if (!additionalMonthlySpend) {
return null
}
return (
<div className="text-sm text-foreground-lighter border-t p-5">
<p>
The new project will have the same compute size and disk size as this project. You will be
able to update the compute size and disk size after the new project is created in{' '}
<span className="font-mono text-xs tracking-tighter text-foreground-light">
Project Settings &gt; Compute and Disk
</span>
</p>
<div className="flex justify-between text-foreground mt-2">
<p>Additional Monthly Compute + Disk Cost</p>
<p className="font-mono text-right text-brand">${additionalMonthlySpend.priceMonthly}</p>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import Panel from 'components/ui/Panel'
import BackupsEmpty from '../BackupsEmpty'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Badge, Button } from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface BackupsListProps {
onSelectRestore: (id: number) => void
}
export const BackupsList = ({ onSelectRestore }: BackupsListProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
return (
<Panel>
{cloneBackups?.backups.length === 0 ? (
<>
<BackupsEmpty />
</>
) : (
<div className="divide-y">
{/* <pre>{JSON.stringify({ cloneStatus }, null, 2)}</pre> */}
{cloneBackups?.backups.map((backup) => {
if (!backup.isPhysicalBackup) return null
return (
<div className="flex p-4 gap-4" key={backup.id}>
<div>
<TimestampInfo value={backup.inserted_at} />
</div>
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
{(backup.status as any) === 'COMPLETED' && (
<Button
className="ml-auto"
type="outline"
onClick={() => onSelectRestore(backup.id)}
>
Restore
</Button>
)}
</div>
)
})}
</div>
)}
</Panel>
)
}
@@ -0,0 +1,84 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
} from 'ui'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface ConfirmRestoreDialogProps {
open: boolean
onOpenChange: (value: boolean) => void
onSelectContinue: () => void
}
export const ConfirmRestoreDialog = ({
open,
onOpenChange,
onSelectContinue,
}: ConfirmRestoreDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Confirm restore to a new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<DialogSection className="prose pb-6 space-y-4 text-sm">
<ul className="space-y-2">
<li>
Project organization will stay the same: <code>{organization?.name}</code>
</li>
<li>
Project region will stay the same: <code>{project?.region || ''}</code>
</li>
<li>
A project can only be restored to another project once. <br />
<span className="text-foreground-lighter text-xs">
This is a temporary limitation. Please contact us if you need to restore a project
to multiple other projects.
</span>
</li>
</ul>
<ul>
<li>What will be transferred?</li>
<ul className="ml-4">
<li>Database schema (tables, views, procedures)</li>
<li>All data and indexes</li>
<li>Database roles, permissions and users</li>
</ul>
</ul>
<ul>
<li>What needs manual reconfiguration?</li>
<ul className="ml-4">
<li>Storage objects & settings</li>
<li>Edge Functions</li>
<li>Auth settings & API keys</li>
<li>Realtime settings</li>
<li>Database extensions and settings</li>
<li>Read replicas</li>
</ul>
</ul>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSelectContinue()}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,205 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { debounce } from 'lodash'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { passwordStrength } from 'lib/helpers'
import { generateStrongPassword } from 'lib/project'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface CreateNewProjectDialogProps {
open: boolean
selectedBackupId: number | null
recoveryTimeTarget: number | null
onOpenChange: (value: boolean) => void
onCloneSuccess: () => void
}
export const CreateNewProjectDialog = ({
open,
selectedBackupId,
recoveryTimeTarget,
onOpenChange,
onCloneSuccess,
}: CreateNewProjectDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
const FormSchema = z.object({
name: z.string().min(1),
password: z.string().min(1),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
password: '',
},
})
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
const hasPITREnabled = cloneBackups?.pitr_enabled
const { mutate: triggerClone, isLoading: cloneMutationLoading } = useProjectCloneMutation({
onError: (error) => {
console.error('error', error)
toast.error('Failed to restore to new project')
},
onSuccess: () => {
toast.success('Restoration process started')
onCloneSuccess()
},
})
const delayedCheckPasswordStrength = useRef(
debounce((value: string) => checkPasswordStrength(value), 300)
).current
async function checkPasswordStrength(value: string) {
const { message, strength } = await passwordStrength(value)
setPasswordStrengthScore(strength)
setPasswordStrengthMessage(message)
}
const generatePassword = () => {
const password = generateStrongPassword()
form.setValue('password', password)
delayedCheckPasswordStrength(password)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Create new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<Form_Shadcn_ {...form}>
<form
id={'create-new-project-form'}
onSubmit={form.handleSubmit((data) => {
if (!project?.ref) {
toast.error('Project ref is required')
return
}
if (hasPITREnabled && recoveryTimeTarget) {
triggerClone({
projectRef: project?.ref,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: recoveryTimeTarget,
cloneBackupId: undefined,
})
} else if (selectedBackupId) {
triggerClone({
projectRef: project?.ref,
cloneBackupId: selectedBackupId,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: undefined,
})
} else {
toast.error('No backup or point in time selected')
return
}
})}
>
<DialogSection className="pb-6 space-y-4 text-sm">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="New Project Name">
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="Enter a name" type="text" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout>
<FormControl_Shadcn_>
<Input
id="db-password"
label="Database Password"
type="password"
placeholder="Type in a strong password"
value={field.value}
copy={field.value?.length > 0}
onChange={(e) => {
const value = e.target.value
field.onChange(value)
if (value == '') {
setPasswordStrengthScore(-1)
setPasswordStrengthMessage('')
} else delayedCheckPasswordStrength(value)
}}
descriptionText={
<PasswordStrengthBar
passwordStrengthScore={passwordStrengthScore}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generatePassword}
/>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button htmlType="reset" type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={cloneMutationLoading}>
Restore to new project
</Button>
</DialogFooter>
</form>
</Form_Shadcn_>
</DialogContent>
</Dialog>
)
}
@@ -63,16 +63,16 @@ const EnumeratedTypes = () => {
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Input
size="small"
size="tiny"
value={search}
className="w-64"
className="w-52"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for a type"
icon={<Search size={14} />}
@@ -57,11 +57,11 @@ const Extensions = () => {
<div className="mb-4">
<div className="flex items-center justify-between">
<Input
size="small"
size="tiny"
placeholder="Search for an extension"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
className="w-64"
className="w-52"
icon={<Search size={14} />}
/>
<DocsButton href="https://supabase.com/docs/guides/database/extensions" />
@@ -18,7 +18,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface FunctionListProps {
schema: string
@@ -37,8 +37,8 @@ const FunctionList = ({
}: FunctionListProps) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
const { setAiAssistantPanel } = useAppStateSnapshot()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const { data: functions } = useDatabaseFunctionsQuery({
projectRef: selectedProject?.ref,
@@ -113,7 +113,7 @@ const FunctionList = ({
</DropdownMenuTrigger>
<DropdownMenuContent
side="left"
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
{isApiDocumentAvailable && (
<DropdownMenuItem
@@ -128,11 +128,24 @@ const FunctionList = ({
<Edit2 size={14} />
<p>Edit function</p>
</DropdownMenuItem>
{enableAssistantV2 && (
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
setAiAssistantPanel({
open: true,
initialInput: 'Update this function to do...',
suggestions: {
title:
'I can help you make a change to this function, here are a few example prompts to get you started:',
prompts: [
'Rename this function to ...',
'Modify this function so that it ...',
'Add a trigger for this function that calls it when ...',
],
},
sqlSnippets: [x.complete_statement],
})
}}
>
<Edit size={14} />
@@ -5,7 +5,7 @@ import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import Table from 'components/to-be-cleaned/Table'
@@ -38,7 +38,7 @@ const FunctionsList = ({
const { search } = useParams()
const { project } = useProjectContext()
const { setAiAssistantPanel } = useAppStateSnapshot()
const enableFunctionsAssistant = useIsDatabaseFunctionsAssistantEnabled()
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const filterString = search ?? ''
@@ -104,10 +104,10 @@ const FunctionsList = ({
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center space-x-4">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={(schema) => {
@@ -119,10 +119,10 @@ const FunctionsList = ({
/>
<Input
placeholder="Search for a function"
size="small"
size="tiny"
icon={<Search size={14} />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
@@ -144,7 +144,7 @@ const FunctionsList = ({
>
Create a new function
</ButtonTooltip>
{enableFunctionsAssistant && (
{isAssistantV2Enabled && (
<ButtonTooltip
type="default"
disabled={!canCreateFunctions}
@@ -155,8 +155,7 @@ const FunctionsList = ({
onClick={() =>
setAiAssistantPanel({
open: true,
editor: 'functions',
entity: undefined,
initialInput: `Create a new function for the schema ${selectedSchema} that does ...`,
})
}
tooltip={{
@@ -49,10 +49,10 @@ const HooksList = ({ createHook = noop, editHook = noop, deleteHook = noop }: Ho
<div className="flex items-center justify-between">
<Input
placeholder="Search for a webhook"
size="small"
size="tiny"
icon={<Search size="14" />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
<div className="flex items-center gap-x-2">
@@ -1,7 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -347,8 +347,8 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{INDEX_TYPES.map((index, i) => (
<>
<SelectItem_Shadcn_ key={index.name} value={index.value}>
<Fragment key={index.name}>
<SelectItem_Shadcn_ value={index.value}>
<div className="flex flex-col gap-0.5">
<span>{index.name}</span>
{index.description.split('\n').map((x, idx) => (
@@ -362,7 +362,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
</div>
</SelectItem_Shadcn_>
{i < INDEX_TYPES.length - 1 && <SelectSeparator_Shadcn_ />}
</>
</Fragment>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
@@ -109,17 +109,17 @@ const Indexes = () => {
)}
{isSuccessSchemas && (
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
)}
<Input
size="small"
size="tiny"
value={search}
className="w-64"
className="w-52"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for an index"
icon={<Search size={14} />}
@@ -43,23 +43,15 @@ const PrivilegesHead = ({
}: PrivilegesHeadProps) => {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-x-2">
<SchemaSelector
className="bg-control rounded-md w-[200px] [&>button]:py-[5px]"
className="bg-control rounded-md w-[180px] [&>button]:py-[5px]"
selectedSchemaName={selectedSchema}
onSelectSchema={onChangeSchema}
/>
<div className="w-[200px]">
<TablesSelect
selectedTable={selectedTable}
tables={tables}
onChangeTable={onChangeTable}
/>
</div>
<TablesSelect selectedTable={selectedTable} tables={tables} onChangeTable={onChangeTable} />
<div className="h-[20px] w-px border-r border-scale-600"></div>
<div className="w-[200px]">
<RolesSelect selectedRole={selectedRole} roles={roles} onChangeRole={onChangeRole} />
</div>
<RolesSelect selectedRole={selectedRole} roles={roles} onChangeRole={onChangeRole} />
</div>
<div className="flex items-center gap-2">
@@ -96,14 +88,14 @@ const RolesSelect = ({
}) => {
return (
<Select_Shadcn_ value={selectedRole} onValueChange={onChangeRole}>
<SelectTrigger_Shadcn_>
<SelectTrigger_Shadcn_ size="tiny" className="w-40">
<SelectValue_Shadcn_ placeholder="Select a role" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{roles.map((role) => (
<SelectItem_Shadcn_ key={role} value={role}>
<span className="text-foreground-light">role</span> {role}
<SelectItem_Shadcn_ key={role} value={role} className="text-xs">
<span className="text-foreground-light mr-1">role</span> {role}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
@@ -125,7 +117,7 @@ const TablesSelect = ({
}) => {
return (
<Select_Shadcn_ value={selectedTable?.name} onValueChange={onChangeTable}>
<SelectTrigger_Shadcn_>
<SelectTrigger_Shadcn_ size="tiny" className="w-44">
<SelectValue_Shadcn_ placeholder="Select a table" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
@@ -136,8 +128,8 @@ const TablesSelect = ({
</div>
) : null}
{tables.map((table) => (
<SelectItem_Shadcn_ key={table} value={table}>
<span className="text-foreground-light">table</span> {table}
<SelectItem_Shadcn_ key={table} value={table} className="text-xs">
<span className="text-foreground-light mr-1">table</span> {table}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
@@ -81,7 +81,7 @@ const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps)
<div className="flex items-center justify-between">
<div className="flex items-center">
<Input
size="small"
size="tiny"
icon={<Search size="14" />}
placeholder={'Filter'}
value={filterString}
@@ -63,7 +63,8 @@ const RolesList = () => {
<div className="mb-4 flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center space-x-4">
<Input
size="small"
size="tiny"
className="w-52"
placeholder="Search for a role"
icon={<Search size={12} />}
value={filterString}
@@ -81,10 +82,10 @@ const RolesList = () => {
)
}
/>
<div className="flex items-center border border-strong rounded-full w-min h-[34px]">
<div className="flex items-center border border-strong rounded-full w-min h-[26px]">
<button
className={[
'text-xs w-[90px] h-full text-center rounded-l-full flex items-center justify-center transition',
'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
filterType === 'all'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
@@ -96,7 +97,7 @@ const RolesList = () => {
<div className="h-full w-[1px] border-r border-strong"></div>
<button
className={[
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
'text-xs w-[80px] h-full text-center rounded-r-full flex items-center justify-center transition',
filterType === 'active'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
@@ -131,7 +132,9 @@ const RolesList = () => {
? `${totalActiveConnections}/${maxConnectionLimit}`
: `${totalActiveConnections}`
}
labelTopClass="text-xs"
labelBottom="Active connections"
labelBottomClass="text-xs"
/>
</div>
</Tooltip.Trigger>
@@ -8,12 +8,12 @@ import 'reactflow/dist/style.css'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
@@ -117,22 +117,21 @@ export const SchemaGraph = () => {
{isSuccessSchemas && (
<>
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button type="default" onClick={resetLayout}>
Auto layout
</Button>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom">
Automatically arrange the layout of all nodes
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
<ButtonTooltip
type="default"
onClick={resetLayout}
tooltip={{
content: { side: 'bottom', text: 'Automatically arrange the layout of all nodes' },
}}
>
Auto layout
</ButtonTooltip>
</>
)}
</div>
@@ -203,11 +203,11 @@ const TableList = ({
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex flex-col gap-y-4">
<div className="flex items-center gap-x-2 flex-wrap">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
@@ -215,8 +215,9 @@ const TableList = ({
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="tiny"
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
className="py-4 px-2"
className="px-1"
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
@@ -259,8 +260,8 @@ const TableList = ({
</Popover_Shadcn_>
<Input
size="small"
className="w-64"
size="tiny"
className="w-52"
placeholder="Search for a table"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
@@ -294,7 +295,7 @@ const TableList = ({
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<div className="my-4 w-full">
<div className="w-full">
<Table
head={[
<Table.th key="icon" className="!px-0" />,
@@ -8,6 +8,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -78,9 +81,14 @@ const TriggerList = ({
{_triggers.map((x: any) => (
<Table.tr key={x.id}>
<Table.td className="space-x-2">
<p title={x.name} className="truncate">
{x.name}
</p>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ className="cursor-default truncate max-w-48 inline-block">
{x.name}
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
{x.name}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</Table.td>
<Table.td className="break-all">
@@ -89,18 +89,18 @@ const TriggersList = ({
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
<SchemaSelector
className="w-[260px]"
size="small"
className="w-[180px]"
size="tiny"
showError={false}
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
<Input
placeholder="Search for a trigger"
size="small"
size="tiny"
icon={<Search size="14" />}
value={filterString}
className="w-64"
className="w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
{!isLocked && (
@@ -1,339 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { ArrowLeft, Edit, Trash } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { FormsContainer } from 'components/ui/Forms/FormsContainer'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
WarningIcon,
cn,
} from 'ui'
import InputField from './InputField'
import WrapperTableEditor from './WrapperTableEditor'
import { WRAPPERS } from './Wrappers.constants'
import { makeValidateRequired } from './Wrappers.utils'
const CreateWrapper = () => {
const formId = 'create-wrapper-form'
const router = useRouter()
const queryClient = useQueryClient()
const { ref, type } = useParams()
const { project } = useProjectContext()
const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const [newTables, setNewTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
setNewTables([])
const hasNewSchema = newTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, ref)
router.push(`/project/${ref}/integrations/wrappers`)
},
})
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === type)
const initialValues =
wrapperMeta !== undefined
? {
wrapper_name: '',
server_name: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
: {}
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappersExtension = data?.find((ext) => ext.name === 'wrappers')
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
if (wrapperMeta === undefined) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unsupported wrapper type</p>
<p className="text-sm text-center text-foreground-light">
The wrapper type {type} not supported by the dashboard. Head back to create a different
wrapper.
</p>
</div>
<Button asChild type="default">
<Link href={`/project/${ref}/integrations/wrappers`}>Head back</Link>
</Button>
</div>
)
}
const databaseNeedsUpgrading =
wrappersExtension?.installed_version !== wrappersExtension?.default_version
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: newTables,
})
}
return (
<FormsContainer className="[&>div]:space-y-0">
<div className="relative flex items-center justify-between mb-6">
<div
className={cn(
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100'
)}
>
<Link href={`/project/${ref}/integrations/wrappers`}>
<div className="flex items-center space-x-2">
<ArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</Link>
</div>
<h3 className="mb-2 text-xl text-foreground">Create a {wrapperMeta.label} Wrapper</h3>
<DocsButton href={wrapperMeta.docsUrl} />
</div>
{hasRequiredVersion ? (
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
return (
<FormPanel
disabled={!canCreateWrapper}
footer={
<div className="flex px-8 py-4">
<FormActions
form={formId}
isSubmitting={isCreating}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canCreateWrapper}
helper={
!canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
(values?.wrapper_name ?? '').length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is
created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{newTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{newTables.map((table, i) => (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
</div>
)}
{newTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
) : (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Your extension version is outdated for this wrapper.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
{wrappersExtension?.installed_version} installed. Please{' '}
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
disabling and enabling the <code className="text-xs">wrappers</code> extension to
create this wrapper.
</p>
<p className="text-warning">
Warning: Before reinstalling the wrapper extension, you must first remove all existing
wrappers. Afterward, you can recreate the wrappers.
</p>
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button asChild type="default">
<Link
href={
databaseNeedsUpgrading
? `/project/${ref}/settings/infrastructure`
: `/project/${ref}/database/extensions?filter=wrappers`
}
>
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
export default CreateWrapper
@@ -1,395 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { ArrowLeft, Edit, Loader, Trash } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { FormsContainer } from 'components/ui/Forms/FormsContainer'
import { Loading } from 'components/ui/Loading'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useImmutableValue } from 'hooks/misc/useImmutableValue'
import { Button, cn, Form, Input } from 'ui'
import InputField from './InputField'
import WrapperTableEditor from './WrapperTableEditor'
import {
convertKVStringArrayToJson,
formatWrapperTables,
getWrapperMetaForWrapper,
makeValidateRequired,
} from './Wrappers.utils'
const EditWrapper = () => {
const formId = 'edit-wrapper-form'
const router = useRouter()
const queryClient = useQueryClient()
const { ref, id } = useParams()
const { project } = useProjectContext()
const { data, isLoading } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: secrets, isLoading: isSecretsLoading } = useVaultSecretsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappers = data ?? []
const foundWrapper = wrappers.find((w) => Number(w.id) === Number(id))
// this call to useImmutableValue should be removed if the redirect after update is also removed
const wrapper = useImmutableValue(foundWrapper)
const wrapperMeta = getWrapperMetaForWrapper(wrapper)
const { mutate: updateFDW, isLoading: isSaving } = useFDWUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
setWrapperTables([])
const hasNewSchema = wrapperTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, ref)
router.push(`/project/${ref}/integrations/wrappers`)
},
})
const [wrapperTables, setWrapperTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const canUpdateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const initialValues =
wrapperMeta !== undefined
? {
wrapper_name: wrapper?.name,
server_name: wrapper?.server_name,
...convertKVStringArrayToJson(wrapper?.server_options ?? []),
}
: {}
useEffect(() => {
if (wrapper?.id) {
setWrapperTables(formatWrapperTables(wrapper, wrapperMeta))
}
}, [wrapper?.id])
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loading />
</div>
)
}
if (wrapper === undefined || wrapperMeta === undefined) {
if (isSaving) {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="flex items-center space-x-4">
<Loader className="animate-spin" size={16} />
<p className="text-sm">Updating wrapper</p>
</div>
</div>
)
} else {
return (
<div className="flex flex-col items-center justify-center w-full h-full space-y-4">
<div className="space-y-2 flex flex-col items-center w-[400px]">
<p>Unknown wrapper</p>
<p className="text-sm text-center text-foreground-light">
The wrapper ID {id} cannot be found in your project. Head back to select another
wrapper.
</p>
</div>
<Button asChild type="default">
<Link href={`/project/${ref}/integrations/wrappers`}>Head back</Link>
</Button>
</div>
)
}
}
const onUpdateTable = (values: any) => {
setWrapperTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const wrapperTables = [...prev]
delete values.tableIndex
wrapperTables[tableIndex] = values
return wrapperTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (wrapperTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
updateFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: wrapperTables,
})
}
return (
<FormsContainer className="[&>div]:space-y-0">
<div className="relative flex items-center justify-between mb-6">
<div
className={cn(
'transition cursor-pointer',
'absolute -left-20 top-1 opacity-75 hover:opacity-100'
)}
>
<Link href={`/project/${ref}/integrations/wrappers`}>
<div className="flex items-center space-x-2">
<ArrowLeft strokeWidth={1.5} size={14} />
<p className="text-sm">Back</p>
</div>
</Link>
</div>
<h3 className="mb-2 text-xl text-foreground">
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</h3>
<DocsButton href="https://supabase.github.io/wrappers/stripe/" />
</div>
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit}>
{({ handleReset, values, initialValues, resetForm }: any) => {
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loadingSecrets, setLoadingSecrets] = useState(false)
const initialTables = formatWrapperTables(wrapper?.tables ?? [])
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const fetchEncryptedValues = async () => {
setLoadingSecrets(true)
// If the secrets haven't loaded, escape and run the effect again when they're loaded
if (isSecretsLoading) {
return
}
const res = await Promise.all(
encryptedOptions.map(async (option) => {
const secret = secrets?.find(
(secret) => secret.name === `${wrapper.name}_${option.name}`
)
if (secret !== undefined) {
const value = await getDecryptedValue({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: secret.id,
})
return { [option.name]: value[0]?.decrypted_secret ?? '' }
} else {
return { [option.name]: '' }
}
})
)
const secretValues = res.reduce((a: any, b: any) => {
const [key] = Object.keys(b)
return { ...a, [key]: b[key] }
}, {})
resetForm({
values: { ...values, ...secretValues },
initialValues: { ...initialValues, ...secretValues },
})
setLoadingSecrets(false)
}
if (encryptedOptions.length > 0) fetchEncryptedValues()
}, [isSecretsLoading])
return (
<FormPanel
disabled={!canUpdateWrapper}
footer={
<div className="flex px-8 py-4">
<FormActions
form={formId}
isSubmitting={isSaving}
hasChanges={hasChanges}
handleReset={() => {
handleReset()
setWrapperTables(initialTables)
}}
disabled={!canUpdateWrapper}
helper={
!canUpdateWrapper
? 'You need additional permissions to edit a foreign data wrapper'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
values.wrapper_name !== initialValues.wrapper_name ? (
<>
Your wrapper's server name will be updated to{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={option.encrypted ? loadingSecrets : false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{wrapperTables.map((table, i) => {
const target = table?.table ?? table.object
return (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}{' '}
</p>
<p className="text-sm text-foreground-light mt-1">Target: {target}</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setWrapperTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
)
})}
</div>
)}
{wrapperTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{wrapperTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
)
}}
</Form>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</FormsContainer>
)
}
export default EditWrapper
@@ -1,185 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition } from 'lodash'
import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react'
import Image from 'next/legacy/image'
import Link from 'next/link'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { FDW } from 'data/fdw/fdws-query'
import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Collapsible } from 'ui'
import type { WrapperMeta } from './Wrappers.types'
interface WrapperRowProps {
wrappers: FDW[]
wrapperMeta: WrapperMeta
isOpen: boolean
onOpen: (wrapper: string) => void
onSelectDelete: (wrapper: FDW) => void
}
const WrapperRow = ({
wrappers = [],
wrapperMeta,
isOpen,
onOpen,
onSelectDelete,
}: WrapperRowProps) => {
const { ref } = useParams()
const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
return (
<>
<Collapsible
open={isOpen}
onOpenChange={() => onOpen(wrapperMeta.name)}
className={[
'bg-surface-100',
'hover:bg-overlay-hover',
'data-open:bg-selection',
'border-default',
'hover:border-strong',
'data-open:border-strong',
'col-span-12 mx-auto',
'-space-y-px overflow-hidden',
'transition border shadow hover:z-50',
'first:rounded-tl first:rounded-tr first:!border-t',
'last:rounded-bl last:rounded-br last:border-t-0',
].join(' ')}
>
<Collapsible.Trigger asChild>
<button
type="button"
className="flex items-center justify-between w-full px-6 py-3 rounded group text-foreground"
>
<div className="flex items-center gap-3">
<ChevronUp
className="transition text-border-stronger data-open-parent:rotate-0 data-closed-parent:rotate-180"
strokeWidth={2}
width={14}
/>
<Image
src={wrapperMeta.icon}
width={20}
height={20}
alt={`${wrapperMeta.name} wrapper icon`}
/>
<span className="text-sm capitalize">{wrapperMeta.label}</span>
</div>
<div className="flex items-center gap-3">
<div className="px-3 py-1 text-xs border rounded-md border-strong bg-surface-100 text-foreground">
{wrappers.length} wrapper{wrappers.length > 1 ? 's' : ''}
</div>
</div>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="border-t group border-strong bg-surface-100 text-foreground divide-y">
{wrappers.map((wrapper) => {
const serverOptions = Object.fromEntries(
wrapper.server_options.map((option: any) => option.split('='))
)
const [encryptedMetadata, visibleMetadata] = partition(
wrapperMeta.server.options.filter((option) => !option.hidden),
'secureEntry'
)
return (
<div key={wrapper.id} className="px-6 py-4 flex items-center justify-between">
<div className="space-y-1 w-3/4">
<div className="flex items-center space-x-2">
<p className="text-base">{wrapper.name}</p>
</div>
{visibleMetadata.map((metadata) => (
<div
key={metadata.name}
className="flex items-center space-x-2 text-sm text-foreground-light"
>
<p>{metadata.label}:</p>
<p>{serverOptions[metadata.name]}</p>
</div>
))}
{encryptedMetadata.map((metadata) => (
<div key={metadata.name} className="flex items-center space-x-2 text-sm">
<p className="text-foreground-light">{metadata.label}:</p>
<Link
href={`/project/${ref}/settings/vault/secrets?search=${wrapper.name}_${metadata.name}`}
className="transition text-foreground-light hover:text-foreground flex items-center space-x-2"
>
<span>Encrypted in Vault</span>
<ExternalLink size={14} strokeWidth={1.5} />
</Link>
</div>
))}
<div className="!mt-3 space-y-1">
<p className="text-sm text-foreground-light">
Foreign tables{wrapper.tables && `: (${wrapper.tables.length})`}
</p>
<div className="flex flex-wrap gap-2">
{wrapper.tables ? (
wrapper.tables.map((table) => (
<EditorTablePageLink
key={table.id}
projectRef={ref}
id={String(table.id)}
>
<div className="text-sm border rounded px-2 py-1 transition bg-surface-200 hover:bg-overlay-hover">
{table.name}
</div>
</EditorTablePageLink>
))
) : (
<p className="text-sm text-foreground-light">No tables available</p>
)}
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<ButtonTooltip
asChild
disabled={!canManageWrappers}
type="default"
icon={<Edit strokeWidth={1.5} />}
className="px-1.5 space-x-0"
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to edit wrappers'
: 'Edit wrapper',
},
}}
>
<Link href={`/project/${ref}/integrations/wrappers/${wrapper.id}`}>
<span className="sr-only">Edit</span>
</Link>
</ButtonTooltip>
<ButtonTooltip
type="default"
disabled={!canManageWrappers}
icon={<Trash strokeWidth={1.5} />}
className="px-1.5"
onClick={() => onSelectDelete(wrapper)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to delete wrappers'
: 'Delete wrapper',
},
}}
/>
</div>
</div>
)
})}
</div>
</Collapsible.Content>
</Collapsible>
</>
)
}
export default WrapperRow
@@ -1,68 +0,0 @@
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FDW, useFDWsQuery } from 'data/fdw/fdws-query'
import DeleteWrapperModal from './DeleteWrapperModal'
import WrapperRow from './WrapperRow'
import { WRAPPERS } from './Wrappers.constants'
import { wrapperMetaComparator } from './Wrappers.utils'
import WrappersDisabledState from './WrappersDisabledState'
import WrappersEmptyState from './WrappersEmptyState'
const Wrappers = ({ isEnabled }: { isEnabled: boolean }) => {
const { project } = useProjectContext()
const { data } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [open, setOpen] = useState<string>('')
const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState<FDW>()
const wrappers = data ?? []
return (
<>
{isEnabled ? (
<div>
{wrappers.length === 0 ? (
<WrappersEmptyState />
) : (
<>
{WRAPPERS.map((wrapper, i) => {
const createdWrappers = wrappers.filter((w) => wrapperMetaComparator(wrapper, w))
if (createdWrappers.length > 0) {
return (
<WrapperRow
key={i}
wrapperMeta={wrapper}
wrappers={createdWrappers}
isOpen={open === wrapper.name}
onOpen={(wrapperName) => {
if (open !== wrapperName) setOpen(wrapperName)
else setOpen('')
}}
onSelectDelete={setSelectedWrapperToDelete}
/>
)
} else {
return null
}
})}
</>
)}
</div>
) : (
<WrappersDisabledState />
)}
<DeleteWrapperModal
selectedWrapper={selectedWrapperToDelete}
onClose={() => setSelectedWrapperToDelete(undefined)}
/>
</>
)
}
export default Wrappers
@@ -1,179 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { Button } from 'ui'
import { ExternalLink } from 'lucide-react'
const WrappersDisabledState = () => {
const { ref } = useParams()
const { resolvedTheme } = useTheme()
const { project } = useProjectContext()
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappersExtension = (data ?? []).find((ext) => ext.name === 'wrappers')
const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault')
const isNotAvailable = wrappersExtension === undefined || vaultExtension === undefined
const [isEnabling, setIsEnabling] = useState<boolean>(false)
const canToggleWrappers = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { mutateAsync: enableExtension } = useDatabaseExtensionEnableMutation({ onError: () => {} })
const onEnableWrappers = async () => {
if (wrappersExtension === undefined || vaultExtension === undefined) return
if (project === undefined) return console.error('Project is required')
let hasError = false
try {
setIsEnabling(true)
await Promise.all([
enableExtension({
projectRef: project.ref,
connectionString: project.connectionString,
schema: wrappersExtension.schema ?? 'extensions',
name: wrappersExtension.name,
version: wrappersExtension.default_version,
cascade: true,
}),
enableExtension({
projectRef: project.ref,
connectionString: project.connectionString,
schema: vaultExtension.schema ?? 'vault',
name: vaultExtension.name,
version: vaultExtension.default_version,
cascade: true,
}),
])
} catch (error: any) {
if (!error.message.includes('already exists')) {
hasError = true
toast.error(`Failed to enable Wrappers: ${error.message}`)
}
} finally {
setIsEnabling(false)
if (!hasError) toast.success('Wrappers are now enabled!')
}
}
return (
<div>
<div
className="w-full px-12 py-12 bg-no-repeat border rounded-md bg-alternative-200 border-default"
style={{
backgroundSize: '45%',
backgroundPosition: '105% 40%',
backgroundImage: resolvedTheme?.includes('dark')
? `url("${BASE_PATH}/img/wrappers-dark.png")`
: `url("${BASE_PATH}/img/wrappers-light.png")`,
}}
>
<div className="w-3/5 space-y-8">
<div className="space-y-2">
<h4 className="text-lg">Supabase Wrappers</h4>
<p className="text-sm text-foreground-light">
Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW)
which connect Postgres to external systems. Query your data warehouse or third-party
APIs directly from your database.
</p>
</div>
{isNotAvailable ? (
<div className="space-y-4">
<div className="rounded border border-default px-4 py-2 flex items-center justify-between">
<div>
<p className="text-foreground-light text-sm">
Wrappers is not available for this project yet.
</p>
<p className="text-foreground-light text-sm">
Do reach out to us if you're interested!
</p>
</div>
<div>
<Button asChild type="primary">
<Link
href={`/support/new?ref=${ref}&category=sales&subject=Request%20for%20access%20to%20wrappers`}
target="_blank"
rel="noreferrer"
>
Contact us
</Link>
</Button>
</div>
</div>
<div className="flex items-center space-x-2 my-1 ml-[1px]">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/docs/guides/database/extensions/wrappers/overview"
target="_blank"
rel="noreferrer"
>
About Wrappers
</Link>
</Button>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/docs/guides/database/extensions/wrappers/overview"
target="_blank"
rel="noreferrer"
>
About Wrappers
</Link>
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
loading={isEnabling}
disabled={isNotAvailable || isEnabling || !canToggleWrappers}
onClick={() => onEnableWrappers()}
>
Enable Wrappers
</Button>
</Tooltip.Trigger>
{!canToggleWrappers && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to enable Wrappers for this project
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</div>
)}
</div>
</div>
</div>
)
}
export default WrappersDisabledState
@@ -1,112 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Plus } from 'lucide-react'
import Image from 'next/legacy/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Command_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
ScrollArea,
} from 'ui'
import { WRAPPERS } from './Wrappers.constants'
interface WrapperDropdownProps {
buttonText?: string
align?: 'center' | 'end'
}
const WrapperDropdown = ({ buttonText = 'Add wrapper', align = 'end' }: WrapperDropdownProps) => {
const router = useRouter()
const { ref } = useParams()
const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const [open, setOpen] = useState(false)
if (!canManageWrappers) {
return (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button disabled type="primary" icon={<Plus strokeWidth={1.5} />}>
{buttonText}
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to add wrappers
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="primary"
role="combobox"
aria-expanded={open}
icon={<Plus className="h-4 w-4 shrink-0" />}
>
{buttonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[200px] p-0" align={align}>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search wrappers..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_ className="py-3">No wrappers found...</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className="max-h-[270px] overflow-scroll">
{WRAPPERS.map((wrapper) => (
<CommandItem_Shadcn_
key={wrapper.name}
value={wrapper.name}
onSelect={() => {
setOpen(false)
router.push(
`/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}`
)
}}
className="gap-2 cursor-pointer"
>
<Image
width={20}
height={20}
src={wrapper.icon}
alt={`${wrapper.name} wrapper icon`}
/>
{wrapper.label}
</CommandItem_Shadcn_>
))}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default WrapperDropdown
@@ -1,41 +0,0 @@
import { useParams } from 'common'
import Image from 'next/image'
import Link from 'next/link'
import { Card } from 'ui'
import { WRAPPERS } from './Wrappers.constants'
import CardButton from 'components/ui/CardButton'
const WrappersEmptyState = () => {
const { ref } = useParams()
return (
<div className="flex flex-col gap-4">
<hr />
<div className="flex flex-col gap-1">
<h2 className="text-foreground text-xl">No wrappers created yet</h2>
<p className="prose text-sm">Create a new wrapper by choosing one below</p>
</div>
<div className="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{WRAPPERS.map((wrapper) => (
<CardButton
linkHref={`/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}`}
title={wrapper.label}
fixedHeight={false}
>
<div className="flex items-center justify-center mt-3 mb-1">
<Image
width={40}
height={40}
src={wrapper.icon}
alt={`${wrapper.name} wrapper icon`}
/>
</div>
</CardButton>
))}
</div>
</div>
)
}
export default WrappersEmptyState
@@ -5,10 +5,6 @@ export { default as RolesList } from './Roles/RolesList'
export { default as Extensions } from './Extensions/Extensions'
export { default as CreateWrapper } from './Wrappers/CreateWrapper'
export { default as EditWrapper } from './Wrappers/EditWrapper'
export { default as Wrappers } from './Wrappers/Wrappers'
export { default as PublicationsList } from './Publications/PublicationsList'
export { default as PublicationsTables } from './Publications/PublicationsTables'
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
import MotionNumber from 'motion-number'
import MotionNumber from '@number-flow/react'
import { useTheme } from 'next-themes'
import { UseFormReturn } from 'react-hook-form'
@@ -64,14 +64,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
{usedSize.toFixed(2)}
<span>GB used of </span>
<span className="text-foreground font-semibold -mt-[2px]">
<MotionNumber
value={newTotalSize}
style={{ lineHeight: 0.8 }}
transition={{
y: { type: 'spring', duration: 0.35, bounce: 0 },
}}
className="font-mono"
/>
<MotionNumber value={newTotalSize} style={{ lineHeight: 0.8 }} className="font-mono" />
</span>{' '}
GB
</span>
@@ -1,69 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
export const CronJobsDisabledState = () => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { project } = useProjectContext()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgCronExtension = (data ?? []).find((ext) => ext.name === 'pg_cron')
if (!pgCronExtension) return null
return (
<>
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState title="Cron Jobs">
<div className="text-sm text-foreground-light mb-4 grid gap-2">
<p>
Cron jobs in PostgreSQL allow you to schedule and automate tasks such as running SQL
queries or maintenance routines at specified intervals.
</p>
<p>Enable cron jobs in your project.</p>
</div>
<div className="flex items-center gap-3 mt-4">
<ButtonTooltip
type="primary"
disabled={!canToggleExtensions}
onClick={() => setShowEnableExtensionModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canToggleExtensions
? 'You need additional permissions to enable pg_cron for this project'
: undefined,
},
}}
>
Enable Cron Jobs
</ButtonTooltip>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pg_cron" />
</div>
</ProductEmptyState>
</div>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgCronExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -1,76 +0,0 @@
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { CronJobCard } from './CronJobCard'
import DeleteCronJob from './DeleteCronJob'
import { Sheet, SheetContent } from 'ui'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
export const CronJobsListing = () => {
const { project } = useProjectContext()
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (isLoading) return <GenericSkeletonLoader />
return (
<>
<div className="w-full space-y-4">
{(cronJobs ?? []).length == 0 ? (
<div className="grid text-center h-full w-full items-center justify-center border border-border rounded-md p-4 border-dashed">
<p className="text-sm text-foreground">No cron jobs created yet</p>
<p className="text-sm text-foreground-lighter">
Create one by clicking "Create a new cron job"
</p>
</div>
) : (
<div className="w-full space-y-4">
{(cronJobs ?? []).map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
/>
))}
</div>
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { Search } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CronJobCard } from '../CronJobs/CronJobCard'
import DeleteCronJob from '../CronJobs/DeleteCronJob'
export const CronjobsTab = () => {
const { project } = useProjectContext()
const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (isLoading)
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
const filteredCronJobs = (cronJobs ?? []).filter((cj) => cj.jobname.includes(searchQuery))
return (
<>
<div className="w-full space-y-4 p-10">
{(cronJobs ?? []).length == 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No cron jobs created yet</p>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Add a new cron job
</Button>
</div>
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between flex-wrap">
<Input
placeholder="Search for a job"
size="small"
icon={<Search size={14} />}
value={searchQuery || ''}
className="w-64"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Create a queue
</Button>
</div>
{filteredCronJobs.length === 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchQuery}" did not return any results
</p>
</div>
) : (
filteredCronJobs.map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
/>
))
)}
</div>
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -16,10 +16,8 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex
import { API_URL, IS_PLATFORM } from 'lib/constants'
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
import { useGetImpersonatedRole } from 'state/role-impersonation-state'
import type { NextPageWithLayout } from 'types'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
const GraphiQLPage: NextPageWithLayout = () => {
export const GraphiQLTab = () => {
const { resolvedTheme } = useTheme()
const { ref: projectRef } = useParams()
const { project } = useProjectContext()
@@ -107,9 +105,3 @@ const GraphiQLPage: NextPageWithLayout = () => {
return <GraphiQL fetcher={fetcher} theme={currentTheme} />
}
GraphiQLPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="GraphiQL">{page}</ProjectIntegrationsLayout>
)
export default GraphiQLPage
@@ -0,0 +1,63 @@
import { Book } from 'lucide-react'
import Link from 'next/link'
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'
import { cn } from 'ui'
import { IntegrationDefinition } from '../Landing/Integrations.constants'
interface BuiltBySectionProps extends ComponentPropsWithoutRef<'div'> {
integration: IntegrationDefinition
}
export const BuiltBySection = forwardRef<ElementRef<'div'>, BuiltBySectionProps>(
({ integration, className, ...props }, ref) => {
const { docsUrl } = integration
const { name, websiteUrl } = integration?.author ?? {}
if (!name && !docsUrl && !websiteUrl) return null
return (
<div ref={ref} className={cn('flex items-center gap-10 px-10', className)} {...props}>
{name && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">BUILT BY</div>
<div className="text-foreground-light text-sm">{name}</div>
</div>
)}
{docsUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">DOCS</div>
<Link
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm flex items-center gap-2"
>
<Book size={16} />
{docsUrl.includes('supabase.com/docs')
? 'Supabase Docs'
: docsUrl.includes('github.com')
? 'GitHub Docs'
: 'Documentation'}
</Link>
</div>
)}
{websiteUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">WEBSITE</div>
<Link
href={websiteUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm"
>
{websiteUrl.replace('https://', '')}
</Link>
</div>
)}
</div>
)
}
)
BuiltBySection.displayName = 'BuiltBySection'
@@ -0,0 +1,86 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, ReactNode } from 'react'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { Badge, Separator } from 'ui'
import { Admonition } from 'ui-patterns'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { BuiltBySection } from './BuildBySection'
import { MarkdownContent } from './MarkdownContent'
import { MissingExtensionAlert } from './MissingExtensionAlert'
interface IntegrationOverviewTabProps {
actions?: ReactNode
}
export const IntegrationOverviewTab = ({
actions,
children,
}: PropsWithChildren<IntegrationOverviewTabProps>) => {
const { id } = useParams()
const router = useRouter()
const project = useSelectedProject()
const integration = INTEGRATIONS.find((i) => i.id === id)
const dependsOnExtension = (integration?.requiredExtensions ?? []).length > 0
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const neededExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const hasMissingExtensions = neededExtensions.some((x) => !x.installed_version)
if (!integration) {
return <div>Unsupported integration type</div>
}
return (
<div className="flex flex-col gap-8 py-10">
<BuiltBySection integration={integration} />
{dependsOnExtension && (
<div className="px-10 max-w-4xl">
<Admonition
showIcon={false}
type="default"
className="[&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
>
<Badge className="bg-surface-300 bg-opacity-100 flex items-center gap-x-2 w-max">
<img
alt="Supabase"
src={`${router.basePath}/img/supabase-logo.svg`}
className=" h-2.5 cursor-pointer rounded"
/>
<span>Database Extension</span>
</Badge>
<Markdown
className="max-w-full"
content={`This integration uses the ${integration.requiredExtensions.map((x) => `\`${x}\``).join(', ')}
extension${integration.requiredExtensions.length > 1 ? 's' : ''} directly in your Postgres database.
${hasMissingExtensions ? `Install ${integration.requiredExtensions.length > 1 ? 'these' : 'this'} database extension${integration.requiredExtensions.length > 1 ? 's' : ''} to use ${integration.name} in your project.` : ''}
`}
/>
<div className="flex flex-row gap-x-2">
{neededExtensions.map((extension) => (
<MissingExtensionAlert key={extension.name} extension={extension} />
))}
</div>
</Admonition>
</div>
)}
{!!actions && !hasMissingExtensions && <div className="px-10 max-w-4xl">{actions}</div>}
<MarkdownContent key={integration.id} integrationId={integration.id} />
<Separator />
{children}
</div>
)
}
@@ -0,0 +1,57 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Markdown } from 'components/interfaces/Markdown'
import { cn } from 'ui'
const CHAR_LIMIT = 500 // Adjust this number as needed
export const MarkdownContent = ({ integrationId }: { integrationId: string }) => {
const [content, setContent] = useState<string>('')
const [isExpanded, setIsExpanded] = useState(false)
useEffect(() => {
import(`static-data/integrations/${integrationId}/overview.md`)
.then((module) => setContent(String(module.default)))
.catch((error) => console.error('Error loading markdown:', error))
}, [integrationId])
const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT)
const supportExpanding = content.length > CHAR_LIMIT || (content.match(/\n/g) || []).length > 1
if (displayContent.length === 0) return null
return (
<div className="px-10">
<div className="relative">
<motion.div
initial={false}
animate={{ height: isExpanded ? 'auto' : 80 }}
className="overflow-hidden"
transition={{ duration: 0.4 }}
>
<Markdown content={displayContent} className="!max-w-3xl" />
</motion.div>
{!isExpanded && (
<div
className={cn(
'bottom-0 left-0 right-0 h-24',
supportExpanding && 'bg-gradient-to-t from-background-200 to-transparent',
!isExpanded ? 'absolute' : 'relative'
)}
/>
)}
{supportExpanding && (
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
<button
className="text-foreground-light hover:text-foreground underline text-sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Show less' : 'Read more'}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { DatabaseExtension } from 'data/database-extensions/database-extensions-query'
import { Button } from 'ui'
export const MissingExtensionAlert = ({ extension }: { extension: DatabaseExtension }) => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const extensionInstalled = !!extension?.installed_version
if (!extensionInstalled) {
return (
<>
<Button type="primary" className="w-min" onClick={() => setShowEnableExtensionModal(true)}>
Enable {extension.name}
</Button>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={extension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
return null
}
@@ -0,0 +1,107 @@
import { Search } from 'lucide-react'
import { useState } from 'react'
import AlertError from 'components/ui/AlertError'
import NoSearchResults from 'components/ui/NoSearchResults'
import { Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard'
import { useInstalledIntegrations } from './useInstalledIntegrations'
import { Admonition } from 'ui-patterns'
type IntegrationCategory = 'all' | 'wrapper' | 'postgres_extensions' | 'custom'
export const AvailableIntegrations = () => {
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState<IntegrationCategory>('all')
const { availableIntegrations, installedIntegrations, error, isError, isLoading, isSuccess } =
useInstalledIntegrations()
const installedIds = installedIntegrations.map((i) => i.id)
// available integrations for install
const integrationsByCategory =
selectedCategory === 'all'
? availableIntegrations.filter((i) => !installedIds.includes(i.id))
: availableIntegrations.filter(
(i) => !installedIds.includes(i.id) && i.type === selectedCategory
)
const filteredIntegrations = (
search.length > 0
? integrationsByCategory.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
: integrationsByCategory
).sort((a, b) => a.name.localeCompare(b.name))
return (
<>
<Tabs_Shadcn_
className="mt-4"
value={selectedCategory}
onValueChange={(value) => setSelectedCategory(value as IntegrationCategory)}
>
<TabsList_Shadcn_ className="px-10">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
containerClassName="group w-40"
icon={
<Search
size={14}
className="transition text-foreground-lighter group-hover:text-foreground"
/>
}
iconContainerClassName="p-0"
className="pl-7 rounded-none !border-0 border-transparent bg-transparent !shadow-none !ring-0 !ring-offset-0"
placeholder="Search..."
/>
<TabsTrigger_Shadcn_ value="all" className="px-4">
All Integrations
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="wrapper" className="px-4">
Foreign Data Wrappers
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="postgres_extension" className="px-4">
Postgres Extensions
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="p-10 py-8 flex flex-col gap-y-5">
<h2>Available integrations</h2>
<div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3">
{isLoading &&
new Array(3)
.fill(0)
.map((_, idx) => <IntegrationLoadingCard key={`integration-loading-${idx}`} />)}
{isError && (
<AlertError
className="xl:col-span-3 2xl:col-span-4"
subject="Failed to retrieve available integrations"
error={error}
/>
)}
{isSuccess && filteredIntegrations.map((i) => <IntegrationCard key={i.id} {...i} />)}
{isSuccess && search.length > 0 && filteredIntegrations.length === 0 && (
<NoSearchResults
className="xl:col-span-3 2xl:col-span-4"
searchString={search}
onResetFilter={() => setSearch('')}
/>
)}
{isSuccess &&
selectedCategory !== 'all' &&
search.length === 0 &&
filteredIntegrations.length === 0 && (
<Admonition
showIcon={false}
className="xl:col-span-3 2xl:col-span-4"
type="default"
title="All integrations in this category are currently in use"
description="Manage your installed integrations in the section above"
/>
)}
</div>
</div>
</>
)
}
@@ -0,0 +1,39 @@
import AlertError from 'components/ui/AlertError'
import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard'
import { useInstalledIntegrations } from './useInstalledIntegrations'
export const InstalledIntegrations = () => {
const { installedIntegrations, error, isLoading, isSuccess, isError } = useInstalledIntegrations()
return (
<div className="px-10 py-6 flex flex-col gap-y-5">
<h2>Installed integrations</h2>
<div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3">
{isLoading &&
new Array(3)
.fill(0)
.map((_, idx) => <IntegrationLoadingCard key={`integration-loading-${idx}`} />)}
{isError && (
<AlertError
className="xl:col-span-3 2xl:col-span-4"
subject="Failed to retrieve installed integrations"
error={error}
/>
)}
{isSuccess && (
<>
{installedIntegrations.length === 0 ? (
<div className="xl:col-span-3 2xl:col-span-4 w-full h-[110px] border rounded flex items-center justify-center">
{/* [Joshen] Not high priority imo - very low chance this state will be seen cause Vault is always installed */}
{/* Some placeholder image when no integrations are installed */}
<p className="text-sm text-foreground-light">No integrations installed yet</p>
</div>
) : (
installedIntegrations.map((i) => <IntegrationCard key={i.id} {...i} />)
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import Link from 'next/link'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { Badge, cn } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { IntegrationDefinition } from './Integrations.constants'
type IntegrationCardProps = IntegrationDefinition & {}
const INTEGRATION_CARD_STYLE = cn(
'w-full h-full pl-5 pr-6 py-3 bg-surface-100 hover:bg-surface-200 hover:border-strong',
'border border-border gap-3 rounded-md inline-flex ease-out duration-200 transition-all'
)
export const IntegrationLoadingCard = () => {
return (
<div className={cn(INTEGRATION_CARD_STYLE, 'h-[110px]')}>
<div className="w-10 h-10 relative">
<ShimmeringLoader className="w-full h-full bg-white border rounded-md" />
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="flex-col justify-start items-start gap-y-1 flex">
<ShimmeringLoader className="w-20 py-2.5" />
<ShimmeringLoader className="w-56 py-2.5" />
</div>
</div>
</div>
)
}
export const IntegrationCard = ({ id, beta, name, icon, description }: IntegrationCardProps) => {
const { project } = useProjectContext()
return (
<Link href={`/project/${project?.ref}/integrations/${id}/overview`}>
<div className={INTEGRATION_CARD_STYLE}>
<div className="w-10 h-10 relative bg-white border rounded-md flex items-center justify-center">
{icon()}
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="flex-col justify-start items-start gap-y-0.5 flex">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-sm">{name}</p>
{beta && (
<Badge variant="warning" className="py-0 px-1.5">
Beta
</Badge>
)}
</div>
<p className="text-foreground-light text-xs">{description}</p>
</div>
<Badge className="bg-opacity-100 bg-surface-300 flex items-center gap-x-1.5">
<span>Official</span>
</Badge>
</div>
</div>
</Link>
)
}
@@ -0,0 +1,365 @@
import { Clock5, Layers, Vault, Webhook } from 'lucide-react'
import Image from 'next/image'
import { ComponentType, ReactNode } from 'react'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { BASE_PATH } from 'lib/constants'
import dynamic from 'next/dynamic'
import { cn } from 'ui'
import { WRAPPERS } from '../Wrappers/Wrappers.constants'
import { WrapperMeta } from '../Wrappers/Wrappers.types'
export type Navigation = {
route: string
label: string
hasChild?: boolean
childIcon?: React.ReactNode
children?: Navigation[]
}
const Loading = () => (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
export type IntegrationDefinition = {
id: string
name: string
beta?: boolean
icon: (props?: { className?: string; style?: Record<string, any> }) => ReactNode
description: string
docsUrl: string
author: {
name: string
websiteUrl: string
}
requiredExtensions: string[]
navigation?: Navigation[]
navigate: (
id: string,
pageId: string | undefined,
childId: string | undefined
) => ComponentType<{}> | null
} & ({ type: 'wrapper'; meta: WrapperMeta } | { type: 'postgres_extension' } | { type: 'custom' })
const authorSupabase = {
name: 'Supabase',
websiteUrl: 'https://supabase.com',
}
const supabaseIntegrations: IntegrationDefinition[] = [
{
id: 'queues',
type: 'postgres_extension' as const,
requiredExtensions: ['pgmq'],
name: `Queues`,
icon: ({ className, ...props } = {}) => (
<Layers className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Lightweight message queue in Postgres',
docsUrl: 'https://github.com/tembo-io/pgmq',
author: {
name: 'pgmq',
websiteUrl: 'https://github.com/tembo-io/pgmq',
},
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'queues',
label: 'Queues',
hasChild: true,
childIcon: (
<Layers size={12} strokeWidth={1.5} className={cn('text-foreground w-full h-full')} />
),
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
if (childId) {
return dynamic(() => import('../Queues/QueueTab').then((mod) => mod.QueueTab), {
loading: Loading,
})
}
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'queues':
return dynamic(() => import('../Queues/QueuesTab').then((mod) => mod.QueuesTab), {
loading: Loading,
})
}
return null
},
},
{
id: 'cron-jobs',
type: 'postgres_extension' as const,
requiredExtensions: ['pg_cron'],
name: `Cron Jobs`,
icon: ({ className, ...props } = {}) => (
<Clock5 className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Schedule and automate tasks to run maintenance routines at specified intervals.',
docsUrl: 'https://github.com/citusdata/pg_cron',
author: {
name: 'pg_cron',
websiteUrl: 'https://github.com/citusdata/pg_cron',
},
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'cron-jobs',
label: 'Cron Jobs',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'cron-jobs':
return dynamic(() => import('../CronJobs/CronJobsTab').then((mod) => mod.CronjobsTab), {
loading: Loading,
})
}
return null
},
},
{
id: 'vault',
type: 'postgres_extension' as const,
requiredExtensions: ['supabase_vault'],
name: `Vault`,
beta: true,
icon: ({ className, ...props } = {}) => (
<Vault className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Application level encryption for your project',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'keys',
label: 'Keys',
},
{
route: 'secrets',
label: 'Secrets',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'keys':
return dynamic(
() =>
import('../Vault/Keys/EncryptionKeysManagement').then(
(mod) => mod.EncryptionKeysManagement
),
{
loading: Loading,
}
)
case 'secrets':
return dynamic(
() => import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'webhooks',
type: 'custom' as const,
name: `Webhooks`,
icon: ({ className, ...props } = {}) => (
<Webhook className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description:
'Send real-time data from your database to another system when a table event occurs',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
requiredExtensions: [],
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'webhooks',
label: 'Webhooks',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Webhooks/OverviewTab').then(
(mod) => mod.WebhooksOverviewTab
),
{
loading: Loading,
}
)
case 'webhooks':
return dynamic(
() =>
import('components/interfaces/Integrations/Webhooks/ListTab').then(
(mod) => mod.WebhooksListTab
),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'graphiql',
type: 'postgres_extension' as const,
requiredExtensions: ['pg_graphql'],
name: `GraphiQL`,
icon: ({ className, ...props } = {}) => (
<Image
fill
src={`${BASE_PATH}/img/graphql.svg`}
alt="GraphiQL"
className={cn('p-2', className)}
{...props}
/>
),
description: 'Run GraphQL queries through our interactive in-browser IDE',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'graphiql',
label: 'GraphiQL',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'graphiql':
return dynamic(
() =>
import('components/interfaces/Integrations/GraphQL/GraphiQLTab').then(
(mod) => mod.GraphiQLTab
),
{
loading: Loading,
}
)
}
return null
},
},
] as const
const wrapperIntegrations: IntegrationDefinition[] = WRAPPERS.map((w) => {
return {
id: w.name,
type: 'wrapper' as const,
name: `${w.label} Wrapper`,
icon: ({ className, ...props } = {}) => (
<Image fill src={w.icon} alt={w.name} className={cn('p-2', className)} {...props} />
),
requiredExtensions: ['wrappers', 'supabase_vault'],
description: w.description,
docsUrl: w.docsUrl,
meta: w,
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'wrappers',
label: 'Wrappers',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Wrappers/OverviewTab').then(
(mod) => mod.WrapperOverviewTab
),
{
loading: Loading,
}
)
case 'wrappers':
return dynamic(
() =>
import('components/interfaces/Integrations/Wrappers/WrappersTab').then(
(mod) => mod.WrappersTab
),
{
loading: Loading,
}
)
}
return null
},
}
})
export const INTEGRATIONS: IntegrationDefinition[] = [
...wrapperIntegrations,
...supabaseIntegrations,
]
@@ -0,0 +1,103 @@
import { useMemo } from 'react'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { EMPTY_ARR } from 'lib/void'
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
import { INTEGRATIONS } from './Integrations.constants'
export const useInstalledIntegrations = () => {
const project = useSelectedProject()
const {
data,
error: fdwError,
isError: isErrorFDWs,
isLoading: isFDWLoading,
isSuccess: isSuccessFDWs,
} = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: extensions,
error: extensionsError,
isError: isErrorExtensions,
isLoading: isExtensionsLoading,
isSuccess: isSuccessExtensions,
} = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: schemas,
error: schemasError,
isError: isErrorSchemas,
isLoading: isSchemasLoading,
isSuccess: isSuccessSchemas,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions')
const wrappers = useMemo(() => data ?? EMPTY_ARR, [data])
const installedIntegrations = useMemo(() => {
return INTEGRATIONS.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return isHooksEnabled
}
if (i.type === 'wrapper') {
return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension?.installed_version
})
}
return false
}).sort((a, b) => a.name.localeCompare(b.name))
}, [wrappers, extensions, isHooksEnabled])
// available integrations are all integrations that can be installed. If an extension is on available to be installed
// on the project, that integration is hidden.
const availableIntegrations = useMemo(() => {
return INTEGRATIONS.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return true
}
if (i.type === 'wrapper') {
return true
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension
})
}
return false
}).sort((a, b) => a.name.localeCompare(b.name))
}, [extensions])
const error = fdwError || extensionsError || schemasError
const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading
const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas
const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas
return {
// show all integrations at once instead of showing partial results
installedIntegrations: isLoading ? EMPTY_ARR : installedIntegrations,
availableIntegrations: isLoading ? EMPTY_ARR : availableIntegrations,
error,
isError,
isLoading,
isSuccess,
}
}
@@ -1,15 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Badge,
Button,
@@ -37,7 +33,7 @@ export interface CreateQueueSheetProps {
}
const normalQueueSchema = z.object({
type: z.literal('normal'),
type: z.literal('basic'),
})
const partitionedQueueSchema = z.object({
@@ -69,20 +65,21 @@ export type QueueType = CreateQueueForm['values']
const FORM_ID = 'create-queue-sidepanel'
export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
// This is for enabling pg_partman extension which will be used for partitioned queues (3rd kind of queue)
// const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
// const canToggleExtensions = useCheckPermissions(
// PermissionAction.TENANT_SQL_ADMIN_WRITE,
// 'extensions'
// )
const form = useForm<CreateQueueForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
values: {
type: 'normal',
type: 'basic',
},
},
})
@@ -329,13 +326,13 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue
lost.
</p>
</ConfirmationModal>
{pgPartmanExtension && (
{/* {pgPartmanExtension && (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgPartmanExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
)}
)} */}
</>
)
}
@@ -1,5 +1,4 @@
import { Paintbrush, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
@@ -9,32 +8,12 @@ import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/Si
import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid'
import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters'
import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query'
import type { NextPageWithLayout } from 'types'
import {
Breadcrumb_Shadcn_,
BreadcrumbItem_Shadcn_,
BreadcrumbLink_Shadcn_,
BreadcrumbList_Shadcn_,
BreadcrumbPage_Shadcn_,
BreadcrumbSeparator_Shadcn_,
Button,
LoadingLine,
Separator,
} from 'ui'
import { Button, LoadingLine, Separator } from 'ui'
const QueueMessagesPage: NextPageWithLayout = () => {
// TODO: Change this to the correct permissions
// const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions')
// const isPermissionsLoaded = usePermissionsLoaded()
// if (isPermissionsLoaded && !canReadFunctions) {
// return <NoPermission isFullPage resourceText="manage database queues" />
// }
const { name: queueName } = useParams()
export const QueueTab = () => {
const { childId: queueName } = useParams()
const { project } = useProjectContext()
const [sendMessageModalShown, setSendMessageModalShown] = useState(false)
const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false)
@@ -59,22 +38,7 @@ const QueueMessagesPage: NextPageWithLayout = () => {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between gap-x-4 py-4 px-6 mb-0">
<div className="space-y-1 flex-shrink">
<Breadcrumb_Shadcn_>
<BreadcrumbList_Shadcn_>
<BreadcrumbItem_Shadcn_>
<BreadcrumbLink_Shadcn_ asChild className="text-xl">
<Link href={`/project/${project?.ref}/integrations/queues`}>Queues</Link>
</BreadcrumbLink_Shadcn_>
</BreadcrumbItem_Shadcn_>
<BreadcrumbSeparator_Shadcn_ />
<BreadcrumbItem_Shadcn_>
<BreadcrumbPage_Shadcn_ className="text-xl">{queueName}</BreadcrumbPage_Shadcn_>
</BreadcrumbItem_Shadcn_>
</BreadcrumbList_Shadcn_>
</Breadcrumb_Shadcn_>
</div>
<div className="flex items-center justify-end gap-x-4 py-4 px-6 mb-0">
<div className="flex gap-x-2">
<Button
type="text"
@@ -122,9 +86,3 @@ const QueueMessagesPage: NextPageWithLayout = () => {
</div>
)
}
QueueMessagesPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="Queues">{page}</ProjectIntegrationsLayout>
)
export default QueueMessagesPage
@@ -1,79 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Button } from 'ui'
export const QueuesDisabledState = () => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { project } = useProjectContext()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgmqExtension = (data ?? []).find((ext) => ext.name === 'pgmq')
if (!pgmqExtension) return null
return (
<>
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState title="Queues">
<div className="text-sm text-foreground-light mb-4 grid gap-2">
<p>
Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads,
and enable reliable communication between different parts of your application.
</p>
<p>Enable queues in your project.</p>
</div>
<div className="flex items-center gap-3 mt-4">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button type="primary" onClick={() => setShowEnableExtensionModal(true)}>
Enable Queues
</Button>
</Tooltip.Trigger>
{!canToggleExtensions && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to enable pgmq for this project
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pgmq" />
</div>
</ProductEmptyState>
</div>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgmqExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -25,7 +25,7 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
connectionString: selectedProject?.connectionString,
},
{
staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute
staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute
}
)
@@ -35,9 +35,10 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
<Table.tr
key={queue.queue_name}
onClick={() => {
router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`)
router.push(
`/project/${selectedProject?.ref}/integrations/queues/queues/${queue.queue_name}`
)
}}
className="hover:"
>
<Table.td className="truncate">
<p title={queue.queue_name}>{queue.queue_name}</p>
@@ -1,24 +1,19 @@
import { useEffect, useState } from 'react'
import { Search } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useQueuesQuery } from 'data/database-queues/database-queues-query'
import { Search } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CreateQueueSheet } from './CreateQueueSheet'
import { QueuesDisabledState } from './QueuesDisabledState'
import { QueuesRows } from './QueuesRows'
export const QueuesListing = () => {
export const QueuesTab = () => {
const { project } = useProjectContext()
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
// used for confirmation prompt in the Create Queue Sheet
const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false)
const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false)
@@ -28,44 +23,29 @@ export const QueuesListing = () => {
error,
isLoading,
isError,
refetch,
} = useQueuesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgmqExtension = (extensions ?? []).find((ext) => ext.name === 'pgmq')
// TODO: Change this to true for local development to work
const pgmqExtensionInstalled = pgmqExtension?.installed_version
useEffect(() => {
// refetch the queues after the pgmq extension has been installed
if (pgmqExtensionInstalled && isError) {
refetch()
}
}, [isError, pgmqExtensionInstalled, refetch])
const [searchQuery, setSearchQuery] = useQueryState('search')
// this avoid showing loading screen when the extension is not installed. Otherwise, we'll have to wait for three
// retries (which are sure to fail because the extension is not installed)
if (isLoadingExtensions) return <GenericSkeletonLoader />
if (!pgmqExtensionInstalled) return <QueuesDisabledState />
if (isLoading) return <GenericSkeletonLoader />
if (isError) return <AlertError error={error} subject="Failed to retrieve database queues" />
if (isLoading)
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
if (isError)
return (
<div className="p-10">
<AlertError error={error} subject="Failed to retrieve database queues" />
</div>
)
return (
<>
<div className="w-full space-y-4">
<FormHeader
title="Queues"
description="Use message queues to handle asynchronous tasks, manage workloads, and enable reliable communication between different parts of your application."
/>
<div className="w-full space-y-4 p-10">
{queues.length === 0 ? (
<div
className={
@@ -125,13 +105,6 @@ export const QueuesListing = () => {
/>
</SheetContent>
</Sheet>
{pgmqExtension ? (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgmqExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
) : null}
</>
)
}
@@ -47,8 +47,7 @@ export const MessageDetailsPanel = ({
selectedMessage,
setSelectedMessage,
}: MessageDetailsPanelProps) => {
const { id: _id } = useParams()
const { name: queueName } = useParams()
const { id: _id, childId: queueName } = useParams()
const project = useSelectedProject()
useEscapeKeydown(() => setSelectedMessage(null))
@@ -130,7 +130,7 @@ export const QueueMessagesDataGrid = ({
const gridRef = useRef<DataGridHandle>(null)
const router = useRouter()
const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger)
const [selectedMessageId, setSelectedMessageId] = useQueryState('messageId', parseAsInteger)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
@@ -171,8 +171,8 @@ export const QueueMessagesDataGrid = ({
if (typeof idx === 'number' && idx >= 0) {
setSelectedMessageId(props.row.msg_id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
const { id, ...rest } = router.query
router.push({ ...router, query: { ...rest, id: props.row.msg_id } })
const { messageId, ...rest } = router.query
router.push({ ...router, query: { ...rest, messageId: props.row.msg_id } })
}
}}
/>
@@ -37,7 +37,7 @@ export type SendMessageForm = z.infer<typeof FormSchema>
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
const { name: queueName } = useParams()
const { childId: queueName } = useParams()
const { project } = useProjectContext()
const form = useForm<SendMessageForm>({
resolver: zodResolver(FormSchema),
@@ -18,8 +18,8 @@ import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui'
const DEFAULT_KEY_NAME = 'No description provided'
const EncryptionKeysManagement = () => {
const { id } = useParams()
export const EncryptionKeysManagement = () => {
const { search } = useParams()
const { project } = useProjectContext()
const [searchValue, setSearchValue] = useState<string>('')
@@ -29,8 +29,8 @@ const EncryptionKeysManagement = () => {
const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
useEffect(() => {
if (id !== undefined) setSearchValue(id)
}, [id])
if (search !== undefined) setSearchValue(search)
}, [search])
const { data, isLoading } = usePgSodiumKeysQuery({
projectRef: project?.ref,
@@ -90,12 +90,12 @@ const EncryptionKeysManagement = () => {
return (
<>
<div className="space-y-4">
<div className="space-y-4 p-10">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
className="w-64 input-clear"
size="small"
className="w-52 input-clear"
size="tiny"
placeholder="Search by name or ID"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
@@ -116,7 +116,7 @@ const EncryptionKeysManagement = () => {
}
/>
<div className="w-44">
<Listbox size="small" value={selectedSort} onChange={setSelectedSort}>
<Listbox size="tiny" value={selectedSort} onChange={setSelectedSort}>
<Listbox.Option
id="created"
className="max-w-[180px]"
@@ -324,5 +324,3 @@ const EncryptionKeysManagement = () => {
</>
)
}
export default EncryptionKeysManagement
@@ -109,26 +109,34 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
onSubmit={onUpdateSecret}
>
{({ isSubmitting, resetForm }: any) => {
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
const { isLoading: isLoadingSecretValue } = useVaultSecretDecryptedValueQuery(
const {
data,
isLoading: isLoadingSecretValue,
isSuccess: isSuccessSecretValue,
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useVaultSecretDecryptedValueQuery(
{
projectRef: project?.ref!,
id: selectedSecret?.id!,
connectionString: project?.connectionString,
},
{
enabled: !!(project?.ref && selectedSecret?.id),
onSuccess: (res) => {
resetForm({
values: { ...INITIAL_VALUES, secret: res },
initialValues: { ...INITIAL_VALUES, secret: res },
})
},
}
{ enabled: !!(project?.ref && selectedSecret?.id) }
)
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (selectedSecret !== undefined && isSuccessSecretValue) {
resetForm({
values: { ...INITIAL_VALUES, secret: data },
initialValues: { ...INITIAL_VALUES, secret: data },
})
}
}, [selectedSecret, isSuccessSecretValue])
return isLoadingSecretValue ? (
<Modal.Content>
<GenericSkeletonLoader />
@@ -16,8 +16,8 @@ import {
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react'
import type { VaultSecret } from 'types'
import { Key, Loader, Eye, EyeOff, MoreVertical, Edit3, Trash } from 'lucide-react'
interface SecretRowProps {
secret: VaultSecret
@@ -65,7 +65,7 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) =>
className="text-foreground-light transition group-hover:text-brand"
/>
<Link
href={`/project/${ref}/settings/vault/keys?id=${secret.key_id}`}
href={`/project/${ref}/integrations/vault/keys?search=${secret.key_id}`}
className="text-foreground-light font-mono text-xs cursor-pointer transition group-hover:text-brand"
title={secret.key_id}
>
@@ -16,7 +16,7 @@ import DeleteSecretModal from './DeleteSecretModal'
import EditSecretModal from './EditSecretModal'
import SecretRow from './SecretRow'
const SecretsManagement = () => {
export const SecretsManagement = () => {
const { search } = useParams()
const { project } = useProjectContext()
@@ -56,12 +56,12 @@ const SecretsManagement = () => {
return (
<>
<div className="space-y-4">
<div className="space-y-4 p-10">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
className="w-64"
size="small"
className="w-52"
size="tiny"
placeholder="Search by name or key ID"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
@@ -82,7 +82,7 @@ const SecretsManagement = () => {
}
/>
<div className="w-44">
<Listbox size="small" value={selectedSort} onChange={setSelectedSort}>
<Listbox size="tiny" value={selectedSort} onChange={setSelectedSort}>
<Listbox.Option
id="updated_at"
className="max-w-[180px]"
@@ -190,5 +190,3 @@ const SecretsManagement = () => {
</>
)
}
export default SecretsManagement
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
import {
IntegrationConnection,
IntegrationConnectionProps,
} from 'components/interfaces/Integrations/IntegrationPanels'
} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more