mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 15:19:08 +03:00
Merge branch 'master' into feat/products-submenu
This commit is contained in:
417 files changed
+14934
-7865
No files matched your search
@@ -1,63 +0,0 @@
|
||||
name: Config (Preview)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "supabase/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
wait:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.check.outputs.conclusion }}
|
||||
steps:
|
||||
- uses: fountainhead/action-wait-for-check@v1.2.0
|
||||
id: check
|
||||
with:
|
||||
checkName: Supabase
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
apply:
|
||||
needs:
|
||||
- wait
|
||||
if: ${{ needs.wait.outputs.status == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||
TF_VAR_linked_project: xguihxuzqibwxjnimxev
|
||||
TF_VAR_git_branch: ${{ github.head_ref }}
|
||||
TF_CLI_ARGS_apply: -target=supabase_settings.preview
|
||||
defaults:
|
||||
run:
|
||||
working-directory: supabase/remotes
|
||||
outputs:
|
||||
db_user: ${{ steps.branch.outputs.user }}
|
||||
db_pass: ${{ steps.branch.outputs.password }}
|
||||
db_host: ${{ steps.branch.outputs.host }}
|
||||
db_port: ${{ steps.branch.outputs.port }}
|
||||
jwt_secret: ${{ steps.branch.outputs.jwt_secret }}
|
||||
ref: ${{ steps.branch.outputs.id }}
|
||||
status: ${{ steps.branch.outputs.status }}
|
||||
version: ${{ steps.branch.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
- run: terraform init
|
||||
- run: terraform apply -auto-approve -no-color
|
||||
- id: branch
|
||||
run: |
|
||||
terraform output -json branch_database \
|
||||
| jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
@@ -1,28 +0,0 @@
|
||||
name: Config (Production)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "supabase/remotes/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
||||
TF_VAR_linked_project: xguihxuzqibwxjnimxev
|
||||
defaults:
|
||||
run:
|
||||
working-directory: supabase/remotes
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
- run: terraform init
|
||||
- run: terraform apply -auto-approve -no-color
|
||||
@@ -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,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: ','
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -142,7 +142,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
|
||||
|
||||
##### Step 1: Update signup confirmation email
|
||||
|
||||
Update your signup email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
|
||||
Update your signup email template to send the token hash. For detailed instructions on how to configure your email templates, including the use of variables like `{{ .SiteURL }}`, `{{ .TokenHash }}`, and `{{ .RedirectTo }}`, please refer to our [Email Templates](/docs/guides/auth/auth-email-templates) guide.
|
||||
|
||||
Your signup email template should contain the following HTML:
|
||||
|
||||
@@ -151,7 +151,8 @@ Your signup email template should contain the following HTML:
|
||||
|
||||
<p>Follow this link to confirm your user:</p>
|
||||
<p>
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
|
||||
<a
|
||||
href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}"
|
||||
>Confirm your email</a
|
||||
>
|
||||
</p>
|
||||
@@ -170,18 +171,16 @@ Create a new file at `app/auth/confirm/route.ts` and populate with the following
|
||||
|
||||
```ts app/auth/confirm/route.ts
|
||||
import { type EmailOtpType } from '@supabase/supabase-js'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
// The client you created from the Server-Side Auth instructions
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const token_hash = searchParams.get('token_hash')
|
||||
const type = searchParams.get('type') as EmailOtpType | null
|
||||
const next = searchParams.get('next') ?? '/'
|
||||
const redirectTo = request.nextUrl.clone()
|
||||
redirectTo.pathname = next
|
||||
|
||||
if (token_hash && type) {
|
||||
const supabase = await createClient()
|
||||
@@ -191,13 +190,13 @@ export async function GET(request: NextRequest) {
|
||||
token_hash,
|
||||
})
|
||||
if (!error) {
|
||||
return NextResponse.redirect(redirectTo)
|
||||
// redirect user to specified redirect URL or root of app
|
||||
redirect(next)
|
||||
}
|
||||
}
|
||||
|
||||
// return the user to an error page with some instructions
|
||||
redirectTo.pathname = '/auth/auth-code-error'
|
||||
return NextResponse.redirect(redirectTo)
|
||||
// redirect the user to an error page with some instructions
|
||||
redirect('/auth/auth-code-error')
|
||||
}
|
||||
```
|
||||
|
||||
@@ -381,11 +380,18 @@ app.get("/auth/confirm", async function (req, res) {
|
||||
|
||||
To sign up the user, call [signUp()](/docs/reference/javascript/auth-signup) with their email address and password:
|
||||
|
||||
You can optionally specify a URL to redirect to after the user clicks the confirmation link. This URL must be configured as a [Redirect URL](/docs/guides/auth/redirect-urls), which you can do in the [dashboard](/dashboard/project/_/auth/url-configuration) for hosted projects, or in the [configuration file](/docs/guides/cli/config#auth.additional_redirect_urls) for self-hosted projects.
|
||||
|
||||
If you don't specify a redirect URL, the user is automatically redirected to your site URL. This defaults to `localhost:3000`, but you can also configure this.
|
||||
|
||||
```js
|
||||
async function signUpNewUser() {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: 'example@email.com',
|
||||
password: 'example-password',
|
||||
options: {
|
||||
emailRedirectTo: 'https://example.com/welcome',
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -219,7 +219,6 @@ hideToc: true
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import Auth from './components/Auth'
|
||||
import Account from './components/Account'
|
||||
import { View, Text } from 'react-native'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
|
||||
@@ -439,6 +439,8 @@ Unlike the OAuth flow which requires the use of a web browser, the native Sign i
|
||||
|
||||
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that is then sent to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
|
||||
|
||||
By default, Supabase Auth implements nonce validation during the authentication flow. This can be disabled in production under `Authentication > Providers > Google > Skip Nonce Check` in the Dashboard, or when developing locally by setting `auth.external.<provider>.skip_nonce_check`. Only disable this if your client libraries cannot properly handle nonce verification.
|
||||
|
||||
When working with Expo, you can use the [`react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) library to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
|
||||
|
||||
Follow the [Expo installation docs](https://react-native-google-signin.github.io/docs/setting-up/expo) for installation and configuration instructions. See the [supabase-js reference](/docs/reference/javascript/initializing?example=react-native-options-async-storage) for instructions on initializing the supabase-js client in React Native.
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
@@ -53,6 +53,8 @@ Some settings can only be modified by a superuser. Supabase pre-enables the [`su
|
||||
| `auto_explain.log_min_duration` | Logs query plans taking longer than this duration. |
|
||||
| `auto_explain.log_nested_statements` | Log nested statements' plans. |
|
||||
| `log_min_messages` | Minimum severity level of messages to log. |
|
||||
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
|
||||
| `pg_net.batch_size` | Sets how many requests the [pg_net extension](/docs/guides/database/extensions/pg_net) can make per second |
|
||||
| `pgaudit.*` | Configures the [PGAudit extension](https://supabase.com/docs/guides/database/extensions/pgaudit). The `log_parameter` is still restricted to protect secrets |
|
||||
| `pgrst.*` | [`PostgREST` settings](https://docs.postgrest.org/en/stable/references/configuration.html#db-aggregates-enabled) |
|
||||
| `plan_filter.*` | Configures the [pg_plan_filter extension](https://supabase.com/docs/guides/database/extensions/pg_plan_filter) |
|
||||
|
||||
@@ -8,6 +8,12 @@ Supabase DOES NOT RECOMMEND any new usage of [`pgsodium`](https://github.com/mic
|
||||
|
||||
The [`pgsodium`](https://github.com/michelp/pgsodium) extension is expected to go through a deprecation cycle in the near future. We will reach out to owners of impacted projects to assist with migrations away from [`pgsodium`](https://github.com/michelp/pgsodium) once the deprecation process begins.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
The [Vault extension](/docs/guides/database/vault) won’t be impacted. Its internal implementation will shift away from pgsodium, but the interface and API will remain unchanged.
|
||||
|
||||
</Admonition>
|
||||
|
||||
[`pgsodium`](https://github.com/michelp/pgsodium) is a Postgres extension which provides SQL access to [`libsodium`'s](https://doc.libsodium.org/) high-level cryptographic algorithms.
|
||||
|
||||
Supabase previously documented two features derived from pgsodium. Namely [Server Key Management](https://github.com/michelp/pgsodium#server-key-management) and [Transparent Column Encryption](https://github.com/michelp/pgsodium#transparent-column-encryption). At this time, we do not recommend using either on the Supabase platform due to their high level of operational complexity and misconfiguration risk.
|
||||
|
||||
@@ -58,7 +58,7 @@ Let’s create our table. Each row represents a restaurant with its location sto
|
||||
create table if not exists public.restaurants (
|
||||
id int generated by default as identity primary key,
|
||||
name text not null,
|
||||
location geography(POINT) not null
|
||||
location gis.geography(POINT) not null
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,11 @@ tocVideo: 'rSKBTJxG9VA'
|
||||
|
||||
Edge Function instances can process background tasks outside of the request handler. Background tasks are useful for asynchronous operations like uploading a file to Storage, updating a database, or sending events to a logging service. You can respond to the request immediately and leave the task running in the background.
|
||||
|
||||
A background task can run until the Edge Function instance hits its [wall-clock limit](/docs/guides/functions/limits).
|
||||
### How long a background task can run
|
||||
|
||||
A background task can run until the Edge Function instance hits its wall-clock limit or reaches CPU/Memory limit. Check [limits](/docs/guides/functions/limits) for current caps.
|
||||
|
||||
### Example
|
||||
|
||||
Here's an example of defining a background task using a custom event.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
PROJECT_ID: YOUR_SUPABASE_PROJECT_ID
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ This SQL query will show the size of all databases in your Postgres cluster:
|
||||
|
||||
```sql
|
||||
select
|
||||
sum(pg_database_size(pg_database.datname)) / (1024 * 1024) as db_size_mb
|
||||
pg_size_pretty(sum(pg_database_size(pg_database.datname)))
|
||||
from pg_database;
|
||||
```
|
||||
|
||||
@@ -30,6 +30,16 @@ Depending on your billing plan, your database can go into read-only mode which c
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Disk space usage
|
||||
|
||||
Your database size is part of the disk usage for your Supabase project, there are many components to Postgres that consume additional disk space. One of the primary components, is the [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html). Postgres will store database changes in log files that are cleared away after they are applied to the database. These same files are also used by [Read Replicas](/docs/guides/platform/read-replicas) or other replication methods.
|
||||
|
||||
If you would like to determine the size of the WAL files stored on disk, Postgres provides `pg_ls_waldir` as a helper function; the following query can be run:
|
||||
|
||||
```sql
|
||||
select pg_size_pretty(sum(size)) as wal_size from pg_ls_waldir();
|
||||
```
|
||||
|
||||
### Vacuum operations
|
||||
|
||||
Postgres does not immediately reclaim the physical space used by dead tuples (i.e., deleted rows) in the DB. They are marked as "removed" until a [vacuum operation](https://www.postgresql.org/docs/current/routine-vacuuming.html) is executed. As a result, deleting data from your database may not immediately reduce the reported disk usage. You can use the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) `inspect db bloat` command to view all dead tuples in your database. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](https://supabase.com/dashboard/project/_/sql/)
|
||||
|
||||
@@ -22,6 +22,11 @@ This upgrade method is currently in Beta.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
For security purposes, passwords for custom roles are not backed up and, following a restore, they
|
||||
would need to be reset. See [here](/docs/guides/platform/backups#daily-backups) for more details
|
||||
</Admonition>
|
||||
|
||||
pg_upgrade performs an in-place upgrade on your database. For projects larger than 1GB, pg_upgrade is generally faster than a pause+restore cycle, and the speed advantage grows with the size of the database.
|
||||
|
||||
1. Plan for an appropriate downtime window, and ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade.
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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.
|
||||
@@ -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/**/*'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 1.219.2
|
||||
version: 1.223.10
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -3354,6 +3354,27 @@ commands:
|
||||
name: --use-pgadmin
|
||||
description: Use pgAdmin to generate schema diff.
|
||||
default_value: 'false'
|
||||
- id: supabase-config
|
||||
title: supabase config
|
||||
summary: Manage Supabase project configurations
|
||||
tags:
|
||||
- management-api
|
||||
links: []
|
||||
subcommands:
|
||||
- supabase-config-push
|
||||
flags: []
|
||||
- id: supabase-config-push
|
||||
title: supabase config push
|
||||
summary: Pushes local config.toml to the linked project
|
||||
tags: []
|
||||
links: []
|
||||
usage: supabase config push
|
||||
subcommands: []
|
||||
flags:
|
||||
- id: project-ref
|
||||
name: --project-ref <string>
|
||||
description: Project ref of the Supabase project.
|
||||
default_value: ''
|
||||
- id: supabase-completion
|
||||
title: supabase completion
|
||||
summary: Generate the autocompletion script for the specified shell
|
||||
|
||||
@@ -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']
|
||||
@@ -780,6 +1064,38 @@ parameters:
|
||||
- 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']
|
||||
required: false
|
||||
description: |
|
||||
Disables nonce validation during OIDC authentication flow for the specified provider. Enable only when client libraries cannot properly handle nonce verification. Be aware that this reduces security by allowing potential replay attacks with stolen ID tokens.
|
||||
links:
|
||||
- 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']
|
||||
@@ -821,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']
|
||||
@@ -863,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: []
|
||||
@@ -357,6 +357,30 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Organizations",
|
||||
"items": [
|
||||
{
|
||||
"id": "supabase-orgs",
|
||||
"title": "Manage organizations",
|
||||
"slug": "supabase-orgs",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
"id": "supabase-orgs-create",
|
||||
"title": "Create an organization",
|
||||
"slug": "supabase-orgs-create",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
"id": "supabase-orgs-list",
|
||||
"title": "List all organizations",
|
||||
"slug": "supabase-orgs-list",
|
||||
"type": "cli-command"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Projects",
|
||||
@@ -393,6 +417,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Config",
|
||||
"items": [
|
||||
{
|
||||
"id": "supabase-config",
|
||||
"title": "Manage project configurations",
|
||||
"slug": "supabase-config",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
"id": "supabase-config-push",
|
||||
"title": "Push config from a local file",
|
||||
"slug": "supabase-config-push",
|
||||
"type": "cli-command"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Branches",
|
||||
@@ -441,30 +483,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Organizations",
|
||||
"items": [
|
||||
{
|
||||
"id": "supabase-orgs",
|
||||
"title": "Manage organizations",
|
||||
"slug": "supabase-orgs",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
"id": "supabase-orgs-create",
|
||||
"title": "Create an organization",
|
||||
"slug": "supabase-orgs-create",
|
||||
"type": "cli-command"
|
||||
},
|
||||
{
|
||||
"id": "supabase-orgs-list",
|
||||
"title": "List all organizations",
|
||||
"slug": "supabase-orgs-list",
|
||||
"type": "cli-command"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Edge Functions",
|
||||
|
||||
@@ -194,6 +194,7 @@ functions:
|
||||
- If you're using an email, you can configure whether you want the user to receive a magiclink or a OTP.
|
||||
- If you're using phone, you can configure whether you want the user to receive a OTP.
|
||||
- The magic link's destination URL is determined by the [`SITE_URL`](https://supabase.com/docs/guides/auth/redirect-urls). You can modify the `SITE_URL` or add additional redirect URLs in [your project](https://supabase.com/dashboard/project/_/auth/url-configuration).
|
||||
- If you are looking to send your own OTP emails, you can use the [Auth Admin command](https://supabase.com/docs/reference/javascript/auth-admin-generatelink) or the `Send Email` [Auth Hook](https://supabase.com/docs/guides/auth/auth-hooks)
|
||||
examples:
|
||||
- id: sign-in-with-email.
|
||||
name: Sign in with email.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import { toast } from 'sonner'
|
||||
|
||||
import { useDispatch, useTrackedState } from 'components/grid/store/Store'
|
||||
import type { Filter, Sort, SupaTable } from 'components/grid/types'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
@@ -31,12 +30,22 @@ import {
|
||||
} from 'ui'
|
||||
import FilterPopover from './filter/FilterPopover'
|
||||
import { SortPopover } from './sort'
|
||||
import Link from 'next/link'
|
||||
|
||||
// [Joshen] CSV exports require this guard as a fail-safe if the table is
|
||||
// just too large for a browser to keep all the rows in memory before
|
||||
// exporting. Either that or export as multiple CSV sheets with max n rows each
|
||||
export const MAX_EXPORT_ROW_COUNT = 500000
|
||||
export const MAX_EXPORT_ROW_COUNT_MESSAGE = `Sorry! We're unable to support exporting row counts larger than ${MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using [pg_dump](https://supabase.com/docs/reference/cli/supabase-db-dump) via our CLI instead.`
|
||||
export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
|
||||
<>
|
||||
Sorry! We're unable to support exporting row counts larger than $
|
||||
{MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using
|
||||
<Link href="https://supabase.com/docs/reference/cli/supabase-db-dump" target="_blank">
|
||||
pg_dump
|
||||
</Link>{' '}
|
||||
via our CLI instead.
|
||||
</>
|
||||
)
|
||||
|
||||
export type HeaderProps = {
|
||||
table: SupaTable
|
||||
@@ -290,7 +299,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
|
||||
toast.error(
|
||||
<div className="prose text-sm text-foreground">{MAX_EXPORT_ROW_COUNT_MESSAGE}</div>
|
||||
)
|
||||
return setIsExporting(false)
|
||||
}
|
||||
|
||||
@@ -331,7 +342,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
toast.error(<Markdown content={MAX_EXPORT_ROW_COUNT_MESSAGE} className="text-foreground" />)
|
||||
toast.error(
|
||||
<div className="prose text-sm text-foreground">{MAX_EXPORT_ROW_COUNT_MESSAGE}</div>
|
||||
)
|
||||
return setIsExporting(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,27 +21,34 @@ 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
|
||||
onSelectEditPolicy: (policy: PostgresPolicy) => void
|
||||
onSelectDeletePolicy: (policy: PostgresPolicy) => void
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
const PolicyRow = ({
|
||||
policy,
|
||||
isLocked: isLockedDefault,
|
||||
onSelectEditPolicy = noop,
|
||||
onSelectDeletePolicy = noop,
|
||||
}: PolicyRowProps) => {
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
|
||||
const isAssistantV2Enabled = useIsAssistantV2Enabled()
|
||||
const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies')
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { data: authConfig } = useAuthConfigQuery({ projectRef: project?.ref })
|
||||
|
||||
// override islocked for Realtime messages table
|
||||
const isLocked =
|
||||
policy.schema === 'realtime' && policy.table === 'messages' ? false : isLockedDefault
|
||||
|
||||
// TODO(km): Simple check for roles that allow authenticated access.
|
||||
// In the future, we'll use splinter to return proper warnings for policies that allow anonymous user access.
|
||||
const appliesToAnonymousUsers =
|
||||
@@ -88,52 +95,62 @@ const PolicyRow = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
|
||||
>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
|
||||
<Edit size={14} />
|
||||
<p>Edit policy</p>
|
||||
</DropdownMenuItem>
|
||||
{enableAssistantV2 && (
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
editor: 'rls-policies',
|
||||
entity: policy,
|
||||
tables: [{ schema: policy.schema, name: policy.table }],
|
||||
})
|
||||
{!isLocked && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
|
||||
>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
|
||||
<Edit size={14} />
|
||||
<p>Edit policy</p>
|
||||
</DropdownMenuItem>
|
||||
{isAssistantV2Enabled && (
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
const sql = generatePolicyCreateSQL(policy)
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
sqlSnippets: [sql],
|
||||
initialInput: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
|
||||
suggestions: {
|
||||
title: `I can help you make a change to the policy "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table, here are a few example prompts to get you started:`,
|
||||
prompts: [
|
||||
'Tell me how I can improve this policy...',
|
||||
'Duplicate this policy for another table...',
|
||||
'Add extra conditions to this policy...',
|
||||
],
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Edit policy with Assistant</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdatePolicies}
|
||||
onClick={() => onSelectDeletePolicy(policy)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to delete policies',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Edit policy with Assistant</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdatePolicies}
|
||||
onClick={() => onSelectDeletePolicy(policy)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to delete policies',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete policy</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Trash size={14} />
|
||||
<p>Delete policy</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Panel.Content>
|
||||
)
|
||||
|
||||
+22
@@ -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()
|
||||
}
|
||||
+10
-10
@@ -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',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -88,6 +88,7 @@ const PolicyTableRow = ({
|
||||
{policies?.map((policy) => (
|
||||
<PolicyRow
|
||||
key={policy.id}
|
||||
isLocked={isLocked}
|
||||
policy={policy}
|
||||
onSelectEditPolicy={onSelectEditPolicy}
|
||||
onSelectDeletePolicy={onSelectDeletePolicy}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -116,7 +116,6 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
autoFocus
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
|
||||
@@ -176,7 +176,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
<Separator />
|
||||
)}
|
||||
|
||||
<div className={cn('flex flex-col', PANEL_PADDING)}>
|
||||
<div className={cn('flex flex-col gap-1.5', PANEL_PADDING)}>
|
||||
<RowData property="User UID" value={user.id} />
|
||||
<RowData
|
||||
property="Created at"
|
||||
@@ -461,35 +461,38 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
|
||||
export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2 group">
|
||||
<p className="w-36 text-foreground-light text-sm">{property}</p>
|
||||
{typeof value === 'boolean' ? (
|
||||
<div className="h-[26px] flex items-center justify-center">
|
||||
{value ? (
|
||||
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
|
||||
<Check size={10} className="text-contrast" strokeWidth={4} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
|
||||
<X size={10} className="text-contrast" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2 h-[26px]">
|
||||
<p className="text-sm">{!value ? '-' : value}</p>
|
||||
{!!value && (
|
||||
<CopyButton
|
||||
iconOnly
|
||||
type="text"
|
||||
icon={<Copy />}
|
||||
className="transition opacity-0 group-hover:opacity-100 px-1"
|
||||
text={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-x-2 group justify-between">
|
||||
<p className=" text-foreground-lighter text-sm">{property}</p>
|
||||
{typeof value === 'boolean' ? (
|
||||
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
|
||||
{value ? (
|
||||
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
|
||||
<Check size={10} className="text-contrast" strokeWidth={4} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
|
||||
<X size={10} className="text-contrast" strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
|
||||
<p className="text-sm">{!value ? '-' : value}</p>
|
||||
{!!value && (
|
||||
<CopyButton
|
||||
iconOnly
|
||||
type="text"
|
||||
icon={<Copy />}
|
||||
className="transition opacity-0 group-hover:opacity-100 px-1"
|
||||
text={value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidatio
|
||||
|
||||
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
|
||||
name: provider.title,
|
||||
value: provider.title.toLowerCase(),
|
||||
value:
|
||||
provider.title === 'Slack (OIDC)'
|
||||
? 'slack_oidc'
|
||||
: provider.title === 'LinkedIn (OIDC)'
|
||||
? 'linkedin_oidc'
|
||||
: provider.title.toLowerCase(),
|
||||
icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`,
|
||||
iconClass: provider.title === 'GitHub' ? 'dark:invert' : '',
|
||||
})).concat(
|
||||
|
||||
@@ -123,9 +123,8 @@ export const UsersV2 = () => {
|
||||
providers: selectedProviders,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const totalUsers = useMemo(() => countData?.result[0].count ?? 0, [countData?.result[0].count])
|
||||
const users = useMemo(() => data?.pages.flatMap((page) => page.result), [data?.pages])
|
||||
const totalUsers = countData ?? 0
|
||||
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (isLoading || !isAtBottom(event)) return
|
||||
@@ -188,7 +187,7 @@ export const UsersV2 = () => {
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage])
|
||||
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage, users])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+45
@@ -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 > 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>
|
||||
)
|
||||
}
|
||||
+59
@@ -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>
|
||||
)
|
||||
}
|
||||
+84
@@ -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>
|
||||
)
|
||||
}
|
||||
+205
@@ -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
-5
@@ -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} />
|
||||
|
||||
+9
-10
@@ -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,12 +1,13 @@
|
||||
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'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useIndexesQuery } from 'data/database/indexes-query'
|
||||
import { databaseKeys } from 'data/database/keys'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useTableColumnsQuery } from 'data/database/table-columns-query'
|
||||
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
|
||||
@@ -43,6 +44,7 @@ interface CreateIndexSidePanelProps {
|
||||
}
|
||||
|
||||
const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { project } = useProjectContext()
|
||||
const [selectedSchema, setSelectedSchema] = useState('public')
|
||||
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
|
||||
@@ -51,11 +53,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
|
||||
const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false)
|
||||
const [tableDropdownOpen, setTableDropdownOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const { refetch: refetchIndexes } = useIndexesQuery({
|
||||
schema: selectedSchema,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
@@ -80,7 +78,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
|
||||
|
||||
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess: async () => {
|
||||
await refetchIndexes()
|
||||
await queryClient.invalidateQueries(databaseKeys.indexes(project?.ref, selectedSchema))
|
||||
onClose()
|
||||
toast.success(`Successfully created index`)
|
||||
},
|
||||
@@ -98,7 +96,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) =
|
||||
setSearchTerm(value)
|
||||
}
|
||||
|
||||
const columns = tableColumns?.result[0]?.columns ?? []
|
||||
const columns = tableColumns?.[0]?.columns ?? []
|
||||
const columnOptions: MultiSelectOption[] = columns
|
||||
.filter((column): column is NonNullable<typeof column> => column !== null)
|
||||
.map((column) => ({
|
||||
@@ -349,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) => (
|
||||
@@ -364,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_>
|
||||
|
||||
@@ -53,12 +53,12 @@ const Indexes = () => {
|
||||
})
|
||||
|
||||
const { mutate: execute, isLoading: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess() {
|
||||
refetchIndexes()
|
||||
onSuccess: async () => {
|
||||
await refetchIndexes()
|
||||
setSelectedIndexToDelete(undefined)
|
||||
toast.success('Successfully deleted index')
|
||||
},
|
||||
onError(error) {
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete index: ${error.message}`)
|
||||
},
|
||||
})
|
||||
@@ -69,7 +69,7 @@ const Indexes = () => {
|
||||
const schema = schemas?.find((schema) => schema.name === selectedSchema)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
|
||||
|
||||
const sortedIndexes = sortBy(allIndexes?.result ?? [], (index) => index.name.toLocaleLowerCase())
|
||||
const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase())
|
||||
const indexes =
|
||||
search.length > 0
|
||||
? sortedIndexes.filter((index) => index.name.includes(search) || index.table.includes(search))
|
||||
@@ -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} />}
|
||||
|
||||
@@ -22,8 +22,8 @@ const Migrations = () => {
|
||||
})
|
||||
const migrations =
|
||||
search.length === 0
|
||||
? data?.result ?? []
|
||||
: data?.result.filter(
|
||||
? data ?? []
|
||||
: data?.filter(
|
||||
(migration) => migration.version.includes(search) || migration.name?.includes(search)
|
||||
) ?? []
|
||||
|
||||
@@ -62,9 +62,9 @@ const Migrations = () => {
|
||||
)}
|
||||
{isSuccess && (
|
||||
<div>
|
||||
{data.result.length <= 0 && <MigrationsEmptyState />}
|
||||
{data.length <= 0 && <MigrationsEmptyState />}
|
||||
|
||||
{data.result.length > 0 && (
|
||||
{data.length > 0 && (
|
||||
<>
|
||||
<div className="w-80 mb-4">
|
||||
<Input
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@ import DeleteRoleModal from './DeleteRoleModal'
|
||||
import RoleRow from './RoleRow'
|
||||
import RoleRowSkeleton from './RoleRowSkeleton'
|
||||
import { SUPABASE_ROLES } from './Roles.constants'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
|
||||
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
|
||||
|
||||
@@ -62,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}
|
||||
@@ -80,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',
|
||||
@@ -95,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',
|
||||
@@ -130,7 +132,9 @@ const RolesList = () => {
|
||||
? `${totalActiveConnections}/${maxConnectionLimit}`
|
||||
: `${totalActiveConnections}`
|
||||
}
|
||||
labelTopClass="text-xs"
|
||||
labelBottom="Active connections"
|
||||
labelBottomClass="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
@@ -151,73 +155,60 @@ const RolesList = () => {
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canUpdateRoles}
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
>
|
||||
Add role
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canUpdateRoles && (
|
||||
<Tooltip.Content align="start" side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background text-xs',
|
||||
].join(' ')}
|
||||
>
|
||||
You need additional permissions to add a new role
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canUpdateRoles}
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => setIsCreatingRole(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateRoles
|
||||
? 'You need additional permissions to add a new role'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add role
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{supabaseRoles.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
|
||||
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
|
||||
<Badge variant="brand">Protected</Badge>
|
||||
</div>
|
||||
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
|
||||
: supabaseRoles.map((role) => (
|
||||
<RoleRow
|
||||
disabled
|
||||
key={role.id}
|
||||
role={role}
|
||||
onSelectDelete={setSelectedRoleToDelete}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
|
||||
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
|
||||
<Badge variant="brand">Protected</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherRoles.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
|
||||
<p className="text-sm text-foreground-light">Other database roles</p>
|
||||
</div>
|
||||
{isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
|
||||
: supabaseRoles.map((role) => (
|
||||
<RoleRow
|
||||
disabled
|
||||
key={role.id}
|
||||
role={role}
|
||||
onSelectDelete={setSelectedRoleToDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading
|
||||
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
|
||||
: otherRoles.map((role) => (
|
||||
<RoleRow
|
||||
key={role.id}
|
||||
disabled={!canUpdateRoles}
|
||||
role={role}
|
||||
onSelectDelete={setSelectedRoleToDelete}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
|
||||
<p className="text-sm text-foreground-light">Other database roles</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading
|
||||
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
|
||||
: otherRoles.map((role) => (
|
||||
<RoleRow
|
||||
key={role.id}
|
||||
disabled={!canUpdateRoles}
|
||||
role={role}
|
||||
onSelectDelete={setSelectedRoleToDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterString.length > 0 && filteredRoles.length === 0 && (
|
||||
|
||||
@@ -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,7 @@ import { Badge, Button, Checkbox, Input, Listbox, SidePanel } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import FormEmptyBox from 'components/ui/FormBoxEmpty'
|
||||
import NoTableState from 'components/ui/States/NoTableState'
|
||||
import {
|
||||
@@ -19,9 +20,9 @@ import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/databas
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
|
||||
import type { Dictionary } from 'types'
|
||||
import ChooseFunctionForm from './ChooseFunctionForm'
|
||||
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
|
||||
|
||||
class CreateTriggerFormState {
|
||||
id: number | undefined
|
||||
@@ -46,6 +47,8 @@ class CreateTriggerFormState {
|
||||
table: { value: string }
|
||||
// @ts-ignore
|
||||
tableId: { value: number; error?: string }
|
||||
// @ts-ignore
|
||||
condition: { value: string }
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
@@ -80,6 +83,7 @@ class CreateTriggerFormState {
|
||||
this.schema = { value: trigger?.schema ?? '' }
|
||||
this.table = { value: trigger?.table ?? '' }
|
||||
this.tableId = { value: trigger?.table_id ?? '' }
|
||||
this.condition = { value: trigger?.condition ?? '' }
|
||||
}
|
||||
|
||||
update(state: Dictionary<any>) {
|
||||
@@ -342,6 +346,7 @@ const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) =>
|
||||
<div className="space-y-6 px-6">
|
||||
<InputName />
|
||||
<SelectEnabledMode />
|
||||
{_localState.formState.condition.value && <DisplayCondition />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -492,6 +497,34 @@ const SelectEnabledMode = observer(({}) => {
|
||||
)
|
||||
})
|
||||
|
||||
const DisplayCondition = observer(() => {
|
||||
const _localState = useContext(CreateTriggerContext)
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-4 grid gap-2 md:grid md:grid-cols-12">
|
||||
<div className="flex flex-col space-y-2 col-span-4">
|
||||
<label className="block text-foreground-light text-sm leading-4 break-all">Condition</label>
|
||||
</div>
|
||||
<div className="col-span-8 h-[100px]">
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
autofocus={false}
|
||||
id={`trigger-condition-${_localState!.formState.id}`}
|
||||
language="pgsql"
|
||||
defaultValue={_localState!.formState.condition.value}
|
||||
/>
|
||||
<div className="mt-2 text-foreground-lighter leading-normal text-sm">
|
||||
This condition must be met for the trigger to fire.
|
||||
<br />
|
||||
<span className="text-foreground">
|
||||
To update the condition, you must drop and recreate this trigger.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SelectOrientation = observer(({}) => {
|
||||
const _localState = useContext(CreateTriggerContext)
|
||||
return (
|
||||
|
||||
@@ -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?.result ?? []
|
||||
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?.result ?? []
|
||||
|
||||
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'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
@@ -24,8 +25,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
|
||||
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
|
||||
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
|
||||
import { AddonVariantId } from 'data/subscriptions/types'
|
||||
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { GB, PROJECT_STATUS } from 'lib/constants'
|
||||
import {
|
||||
Button,
|
||||
@@ -56,7 +59,6 @@ import {
|
||||
} from './ui/DiskManagement.constants'
|
||||
import { NoticeBar } from './ui/NoticeBar'
|
||||
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
|
||||
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
|
||||
|
||||
export function DiskManagementForm() {
|
||||
const {
|
||||
@@ -67,6 +69,8 @@ export function DiskManagementForm() {
|
||||
const org = useSelectedOrganization()
|
||||
const { ref: projectRef } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const diskAndComputeForm = useFlag('diskAndComputeForm')
|
||||
|
||||
const { data: resourceWarnings } = useResourceWarningsQuery()
|
||||
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
|
||||
@@ -186,14 +190,6 @@ export function DiskManagementForm() {
|
||||
isFlyArchitecture
|
||||
|
||||
const disableComputeInputs = isPlanUpgradeRequired
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize field values properly when data has been loaded, preserving any user changes
|
||||
if (isSuccess) {
|
||||
form.reset(defaultValues, {})
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
const isDirty = !!Object.keys(form.formState.dirtyFields).length
|
||||
const isProjectResizing = project?.status === PROJECT_STATUS.RESIZING
|
||||
const isProjectRequestingDiskChanges = isRequestingChanges && !isProjectResizing
|
||||
@@ -262,6 +258,20 @@ export function DiskManagementForm() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize field values properly when data has been loaded, preserving any user changes
|
||||
if (isSuccess) {
|
||||
form.reset(defaultValues, {})
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
// Redirect logic incase disk and compute feature is not live yet
|
||||
useEffect(() => {
|
||||
if (diskAndComputeForm !== undefined && !diskAndComputeForm && projectRef) {
|
||||
router.push(`/project/${projectRef}/settings/addons?panel=computeInstance`)
|
||||
}
|
||||
}, [diskAndComputeForm, projectRef, router])
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-8">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useRemainingDurationForDiskAttributeUpdate } from 'data/config/disk-attributes-query'
|
||||
import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query'
|
||||
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
|
||||
import { GB } from 'lib/constants'
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
} from 'ui'
|
||||
import { DiskStorageSchemaType } from '../DiskManagement.schema'
|
||||
import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants'
|
||||
import { useRemainingDurationForDiskAttributeUpdate } from 'data/config/disk-attributes-query'
|
||||
|
||||
interface DiskSpaceBarProps {
|
||||
form: UseFormReturn<DiskStorageSchemaType>
|
||||
@@ -56,7 +56,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
})
|
||||
const { remainingDuration } = useRemainingDurationForDiskAttributeUpdate({ projectRef: ref })
|
||||
|
||||
const databaseSizeBytes = data?.result[0].db_size ?? 0
|
||||
const databaseSizeBytes = data ?? 0
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center h-6 gap-3">
|
||||
@@ -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>
|
||||
|
||||
@@ -50,7 +50,7 @@ const NewProjectPanel = () => {
|
||||
<EditorIndexPageLink projectRef={ref}>Table Editor</EditorIndexPageLink>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<SqlEditor strokeWidth={1.5} />}>
|
||||
<Link href={`/project/${ref}/sql/new`}>SQL editor</Link>
|
||||
<Link href={`/project/${ref}/sql/new`}>SQL Editor</Link>
|
||||
</Button>
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
@@ -35,7 +34,13 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
|
||||
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from './CronJobs.utils'
|
||||
import {
|
||||
buildCronQuery,
|
||||
buildHttpRequestCommand,
|
||||
cronPattern,
|
||||
secondsPattern,
|
||||
parseCronJobCommand,
|
||||
} from './CronJobs.utils'
|
||||
import { CronJobScheduleSection } from './CronJobScheduleSection'
|
||||
import { EdgeFunctionSection } from './EdgeFunctionSection'
|
||||
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
|
||||
@@ -44,8 +49,6 @@ import { HttpRequestSection } from './HttpRequestSection'
|
||||
import { SqlFunctionSection } from './SqlFunctionSection'
|
||||
import { SqlSnippetSection } from './SqlSnippetSection'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
|
||||
import { InfoIcon } from 'lucide-react'
|
||||
|
||||
export interface CreateCronJobSheetProps {
|
||||
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
|
||||
@@ -94,13 +97,18 @@ const FormSchema = z.object({
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
try {
|
||||
CronToString(value)
|
||||
} catch {
|
||||
return false
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
CronToString(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}, 'The schedule needs to be in a Cron format.'),
|
||||
return false
|
||||
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
@@ -135,7 +143,7 @@ export const CreateCronJobSheet = ({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: selectedCronJob?.jobname || '',
|
||||
schedule: selectedCronJob?.schedule || '* * * * * *',
|
||||
schedule: selectedCronJob?.schedule || '*/5 * * * *',
|
||||
values: cronJobValues,
|
||||
},
|
||||
})
|
||||
|
||||
+71
-37
@@ -26,17 +26,19 @@ import {
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import CronSyntaxChart from './CronSyntaxChart'
|
||||
import { secondsPattern } from './CronJobs.utils'
|
||||
|
||||
interface CronJobScheduleSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ name: 'Every minute', expression: '* * * * * *' },
|
||||
const PRESETS = [
|
||||
{ name: 'Every minute', expression: '* * * * *' },
|
||||
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
|
||||
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
|
||||
{ name: 'Every Monday at midnight', expression: '0 0 * * 1' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 0 * * *' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 * * *' },
|
||||
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
|
||||
{ name: 'Every 30 seconds', expression: '30 seconds' },
|
||||
] as const
|
||||
|
||||
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
|
||||
@@ -46,7 +48,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
|
||||
const [presetValue, setPresetValue] = useState<string>(initialValue)
|
||||
const [inputValue, setInputValue] = useState(initialValue)
|
||||
const [debouncedValue] = useDebounce(inputValue, 500)
|
||||
const [debouncedValue] = useDebounce(inputValue, 750)
|
||||
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
|
||||
const [scheduleString, setScheduleString] = useState('')
|
||||
|
||||
@@ -78,19 +80,31 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
}, [debouncedValue, useNaturalLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputValue) return
|
||||
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
|
||||
|
||||
// update the cronstrue string when the input value changes
|
||||
try {
|
||||
setScheduleString(CronToString(inputValue))
|
||||
form.setValue('schedule', inputValue)
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
}, [form, inputValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!useNaturalLanguage) {
|
||||
setPresetValue(schedule)
|
||||
if (useNaturalLanguage) return
|
||||
|
||||
setPresetValue(schedule)
|
||||
|
||||
if (!schedule) {
|
||||
setScheduleString('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setScheduleString(CronToString(schedule))
|
||||
} catch (error) {
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
}, [schedule])
|
||||
|
||||
@@ -113,6 +127,11 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
value={inputValue}
|
||||
placeholder="E.g. every 5 minutes"
|
||||
className={cn(!useNaturalLanguage && 'hidden')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -136,6 +155,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
setInputValue('')
|
||||
setPresetValue('')
|
||||
setScheduleString('')
|
||||
form.setValue('schedule', '')
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">Use natural language</p>
|
||||
@@ -143,7 +163,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
|
||||
<div className="mt-2">
|
||||
<ul className="flex gap-2 flex-wrap mt-2">
|
||||
{presets.map((preset) => (
|
||||
{PRESETS.map((preset) => (
|
||||
<li key={preset.name}>
|
||||
<Button
|
||||
type="outline"
|
||||
@@ -183,46 +203,38 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xl font-mono text-foreground-lighter">
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
)}
|
||||
{!inputValue && !isGeneratingCron && !scheduleString ? (
|
||||
<span className="text-sm text-foreground-light">
|
||||
Describe your schedule above
|
||||
</span>
|
||||
) : scheduleString ? (
|
||||
) : (
|
||||
<span className="text-sm text-foreground-light flex items-center gap-2">
|
||||
The cron will be run {/* lowercase the first letter */}
|
||||
{isGeneratingCron ? (
|
||||
<span className="inline-flex items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
<LoadingDots />
|
||||
) : scheduleString === '' ? ( // set a min length before showing invalid message
|
||||
'Enter a valid cron expression above'
|
||||
) : scheduleString.includes('Invalid cron expression') ? (
|
||||
'Invalid cron expression'
|
||||
) : (
|
||||
scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'
|
||||
<>
|
||||
The cron will be run{' '}
|
||||
{secondsPattern.test(schedule)
|
||||
? 'every ' + schedule
|
||||
: scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)
|
||||
@@ -235,7 +247,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
const CronSyntaxLoader = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{['*', '*', '*', '*', '*', '*'].map((char, i) => (
|
||||
{['*', '*', '*', '*', '*'].map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0.3 }}
|
||||
@@ -253,3 +265,25 @@ const CronSyntaxLoader = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadingDots = () => {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { it } from 'vitest'
|
||||
import { parseCronJobCommand } from './CronJobs.utils'
|
||||
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
|
||||
|
||||
describe('parseCronJobCommand', () => {
|
||||
it('should return a default object when the command is null', () => {
|
||||
@@ -34,4 +34,38 @@ describe('parseCronJobCommand', () => {
|
||||
type: 'edge_function',
|
||||
})
|
||||
})
|
||||
|
||||
// Array of test cases for secondsPattern
|
||||
const secondsPatternTests = [
|
||||
{ description: '10 seconds', command: '10 seconds' },
|
||||
{ description: '30 seconds', command: '30 seconds' },
|
||||
]
|
||||
|
||||
// Run tests for secondsPattern
|
||||
secondsPatternTests.forEach(({ description, command }) => {
|
||||
it(`should match the regex for a secondsPattern with "${description}"`, () => {
|
||||
expect(command).toMatch(secondsPattern)
|
||||
})
|
||||
})
|
||||
|
||||
// Array of test cases for cronPattern
|
||||
const cronPatternTests = [
|
||||
{ description: 'every hour', command: '0 * * * *' },
|
||||
{ description: 'every day at midnight', command: '0 0 * * *' },
|
||||
{ description: 'every Monday at 9 AM', command: '0 9 * * 1' },
|
||||
{ description: 'every 15th of the month at noon', command: '0 12 15 * *' },
|
||||
{ description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
|
||||
{ description: 'every 30 minutes', command: '30 * * * *' },
|
||||
{ description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
|
||||
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
|
||||
{ description: 'every quarter hour', command: '*/15 * * * *' },
|
||||
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
|
||||
]
|
||||
|
||||
// Replace the single cronPattern test with forEach
|
||||
cronPatternTests.forEach(({ description, command }) => {
|
||||
it(`should match the regex for a cronPattern with "${description}"`, () => {
|
||||
expect(command).toMatch(cronPattern)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -110,3 +110,8 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
|
||||
|
||||
return DEFAULT_CRONJOB_COMMAND
|
||||
}
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds$/
|
||||
export const cronPattern =
|
||||
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,13 @@ export default function CronSyntaxChart() {
|
||||
<pre className="text-xs font-mono text-foreground-light">
|
||||
{/* prettier-ignore */}
|
||||
{`
|
||||
┌───────────── second (0 - 59)
|
||||
│ ┌───────────── minute (0 - 59)
|
||||
│ │ ┌───────────── hour (0 - 23)
|
||||
│ │ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ │ ┌───────────── day of week (0 - 7)
|
||||
│ │ │ │ │ │
|
||||
* * * * * * `}
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 7)
|
||||
│ │ │ │ │
|
||||
* * * * * `}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user