mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
Merge branch 'master' into feat/move-realtime-publications
This commit is contained in:
commit
8febffd6fe
314 files changed
+7539
-2980
No files matched your search
+7
-1
@@ -10,4 +10,10 @@
|
||||
/apps/www/public/images/blog @supabase/marketing
|
||||
/apps/www/lib/redirects.js
|
||||
|
||||
/docker/ @supabase/cli
|
||||
/docker/ @supabase/dev-workflows
|
||||
|
||||
/apps/studio/next.config.js @supabase/security
|
||||
/apps/studio/components/interfaces/Billing/Payment @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
|
||||
/apps/studio/pages/new/index.tsx @supabase/security
|
||||
@@ -18,10 +18,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request' && (github.event.label.name == 'autofix') }}
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: apps
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
git config --global user.email 'github-tidy-bot@supabase.com'
|
||||
if [[ `git status --porcelain` ]]; then
|
||||
echo "[bot] Changes detected, committing."
|
||||
|
||||
|
||||
echo "[bot] Running in non-squash mode."
|
||||
git commit -am "ci: Autofix updates from GitHub workflow"
|
||||
git push
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@ import OpenAI from 'openai'
|
||||
export const runtime = 'edge'
|
||||
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
|
||||
overlap with the OpenAI API regions.
|
||||
|
||||
|
||||
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
|
||||
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type SerializeOptions } from 'next-mdx-remote/dist/types'
|
||||
import type { SerializeOptions } from 'next-mdx-remote/dist/types'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { relative } from 'path'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils'
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
|
||||
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
|
||||
@@ -84,7 +84,8 @@ const getContent = async ({ slug }: Params) => {
|
||||
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
|
||||
)
|
||||
|
||||
const content = await response.text()
|
||||
let content = await response.text()
|
||||
content = removeRedundantH1(content)
|
||||
|
||||
return {
|
||||
pathname: `/guides/ai/python/${slug}` satisfies `/${string}`,
|
||||
|
||||
@@ -86,7 +86,7 @@ const {
|
||||
|
||||
## Customization
|
||||
|
||||
Supabase Auth makes use of [Go Templates](https://pkg.go.dev/text/template). This means it is possible to conditionally render information based on template properties.You may wish to checkout this [guide by Hugo](https://gohugo.io/templates/introduction/) for a guide on the templating language.
|
||||
Supabase Auth makes use of [Go Templates](https://pkg.go.dev/text/template). This means it is possible to conditionally render information based on template properties.
|
||||
|
||||
### Send different email to early access users
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ secrets = "env(SEND_SMS_HOOK_SECRET)"
|
||||
Fill in the Hook Secret in `supabase/functions/.env`
|
||||
|
||||
```bash
|
||||
SEND_SMS_HOOK_SECRETS='v1,whsec_<base64_standard_secret>'
|
||||
SEND_SMS_HOOK_SECRET='v1,whsec_<base64_standard_secret>'
|
||||
```
|
||||
|
||||
Start the function locally:
|
||||
|
||||
@@ -421,3 +421,47 @@ If you really need access to the key, please [open a support ticket](https://sup
|
||||
Yes, Supabase supports multi-tenant Single Sign-On (SSO) using SAML 2.0. While the dashboard displays only one SAML field, you can set up multiple SAML connections using the Supabase CLI.
|
||||
Each connection is assigned a unique `sso_provider_id`, which is included in the user's JWT and can be used in Row Level Security (RLS) policies. You can configure custom attribute mappings for each connection to include tenant-specific information, such as roles.
|
||||
This setup allows you to implement multi-tenant SSO for multiple clients or organizations within a single application. For example, if you have an app with multiple clients using different Azure Active Directories, you can create separate SAML connections for each and use the `sso_provider_id` to manage access and apply appropriate security policies.
|
||||
|
||||
### Is multi-subdomain SSO with SAML supported?
|
||||
|
||||
Yes, also referred to as [cross-origin authentication within the same site](https://web.dev/articles/same-site-same-origin). To redirect to a URL other than the [Site URL](https://supabase.com/docs/guides/auth/redirect-urls), following the SAML response from the IdP, the `redirectTo` option can be added to [`signInWithSSO`](https://supabase.com/docs/reference/javascript/auth-signinwithsso).
|
||||
|
||||
```ts
|
||||
const { data, error } = await supabase.auth.signInWithSSO({
|
||||
domain: 'company.com'
|
||||
options: {
|
||||
redirectTo: `https://app.company.com/callback`,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When redirecting to a URL other than the Site URL, a `/callback` endpoint is necessary to process the auth code from the IdP and exchange it for a session. This assumes the [Supabase SSR client](https://supabase.com/docs/guides/auth/server-side/creating-a-client) has already been configured.
|
||||
|
||||
<Tabs scrollable size="small" type="underlined" defaultActiveId="sveltekit" queryGroup="framework">
|
||||
|
||||
<TabPanel id="sveltekit" label="SvelteKit">
|
||||
|
||||
```ts
|
||||
import { error, redirect } from '@sveltejs/kit'
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code')
|
||||
|
||||
if (!code) {
|
||||
error(400, 'No authorization code provided')
|
||||
}
|
||||
|
||||
const { error: tokenExchangeError } = await locals.supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (tokenExchangeError) {
|
||||
error(400, 'Failed to exchange authorization code for session')
|
||||
}
|
||||
|
||||
redirect(303, '/')
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
|
||||
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
|
||||
2. Obtain a **Services ID** attached to the App ID that uniquely identifies the website. Use this value as the client ID when initializing Sign in with Apple JS. You can create a new Services ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/serviceId) section in the Apple Developer Console (use the filter menu in the upper right side to see all Services IDs). These usually are a reverse domain name string, for example `com.example.app.website`.
|
||||
3. Configure Website URLs for the newly created **Services ID**. The web domain you should use is the domain your website is hosted on. The redirect URL must also point to a page on your website that will receive the callback from Apple.
|
||||
4. Register the Services ID you created to your project's [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
|
||||
4. Register the Services ID you created to your project's [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
|
||||
|
||||
<Admonition type="note">
|
||||
If you're using Sign in with Apple JS you do not need to configure the OAuth settings.
|
||||
@@ -184,12 +184,12 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
When testing with Expo Go, the Expo App ID `host.exp.Exponent` will be used. Make sure to add this to the "Authorized Client IDs" list in your [Supabase dashboard Apple provider configuration](https://supabase.com/dashboard/project/_/auth/providers)!
|
||||
When testing with Expo Go, the Expo App ID `host.exp.Exponent` will be used. Make sure to add this to the "Client IDs" list in your [Supabase dashboard Apple provider configuration](https://supabase.com/dashboard/project/_/auth/providers)!
|
||||
|
||||
</Admonition>
|
||||
|
||||
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
@@ -243,7 +243,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
|
||||
### Configuration [#flutter-configuration-native-app]
|
||||
|
||||
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
|
||||
|
||||
## Apple sign in on Android, Web, Windows and Linux
|
||||
|
||||
@@ -339,7 +339,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
|
||||
### Configuration [#swift-configuration-native-app]
|
||||
|
||||
1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._)
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Authorized Client IDs_.
|
||||
2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ To use Google's pre-built signin buttons:
|
||||
1. Click `Create credentials` and choose `OAuth Client ID`.
|
||||
1. For application type, choose `Web application`.
|
||||
1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. If you're testing in localhost, ensure that you have `http://localhost` set in the **Authorized JavaScript origins** section as well. This is important when integrating with Google One-Tap to ensure you can use it locally.
|
||||
1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Authorized Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
|
||||
1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -80,7 +80,7 @@ To use Google's pre-built signin buttons:
|
||||
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
|
||||
@@ -96,7 +96,7 @@ To use Google's pre-built signin buttons:
|
||||
- You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard.
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_. If you need iOS support, enable the `Skip nonce check` option.
|
||||
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_. If you need iOS support, enable the `Skip nonce check` option.
|
||||
4. For iOS, add the `CFBundleURLTypes` attributes below into the `/ios/Runner/Info.plist` file.
|
||||
```xml
|
||||
<!-- Put me in the [my_project]/ios/Runner/Info.plist file -->
|
||||
@@ -168,7 +168,7 @@ To use Google's pre-built signin buttons:
|
||||
|
||||
1. Follow the integration instructions on the [get started with Google Sign-In](https://developers.google.com/identity/sign-in/ios/start-integrating) for iOS guide.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_. Enable the `Skip nonce check` option.
|
||||
3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_. Enable the `Skip nonce check` option.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -178,7 +178,7 @@ To use Google's pre-built signin buttons:
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. You need two client IDs, a web client ID and Android client ID. When creating a new Android OAuth client ID, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
|
||||
3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
|
||||
@@ -190,7 +190,7 @@ To use Google's pre-built signin buttons:
|
||||
- For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple AppStore.
|
||||
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in.
|
||||
|
||||
@@ -202,7 +202,7 @@ To use Google's pre-built signin buttons:
|
||||
|
||||
1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Chrome extension_ for the application type. For _Item ID_ provide the unique ID of your Chrome extension. You can get this by calling `chrome.runtime.id` within the extension, or from the Web Store URL of the extension. For example, the [Google Translate extension](https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb) has the Web Store URL `https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb` and the last part `aapbdbdomjkkjkaonfhkkikfgjllcleb` is its unique ID.
|
||||
2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Authorized Client IDs_.
|
||||
3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](https://supabase.com/dashboard/project/_/auth/providers), under _Client IDs_.
|
||||
|
||||
Note that you do not have to configure the OAuth flow in the Supabase Dashboard to sign in with Google inside Chrome extensions.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -102,6 +102,20 @@ const { data, error } = await supabase.from('posts').insert({
|
||||
})
|
||||
```
|
||||
|
||||
## Specific usage cases
|
||||
|
||||
### Queries with filtering
|
||||
|
||||
If you use an IVFFlat or HNSW index and naively filter the results based on the value of another column, you may get fewer rows returned than requested.
|
||||
|
||||
For example, the following query may return fewer than 5 rows, even if 5 corresponding rows exist in the database. This is because the embedding index may not return 5 rows matching the filter.
|
||||
|
||||
```
|
||||
SELECT * FROM items WHERE category_id = 123 ORDER BY embedding <-> '[3,1,2]' LIMIT 5;
|
||||
```
|
||||
|
||||
To get the exact number of requested rows, use [iterative search](https://github.com/pgvector/pgvector/?tab=readme-ov-file#iterative-index-scans) to continue scanning the index until enough results are found.
|
||||
|
||||
## More pgvector and Supabase resources
|
||||
|
||||
- [Supabase Clippy: ChatGPT for Supabase Docs](https://supabase.com/blog/chatgpt-supabase-docs)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ This video demonstrates how you can create a new customer in Stripe each time a
|
||||
|
||||
## Creating a webhook
|
||||
|
||||
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/database/hooks) in the Dashboard.
|
||||
1. Create a new [Database Webhook](https://supabase.com/dashboard/project/_/integrations/hooks) in the Dashboard.
|
||||
1. Give your Webhook a name.
|
||||
1. Select the table you want to hook into.
|
||||
1. Select one or more events (table inserts, updates, or deletes) you want to hook into.
|
||||
|
||||
@@ -3,27 +3,32 @@ id: 'function-background-tasks'
|
||||
title: 'Background Tasks'
|
||||
description: 'How to run background tasks in an Edge Function outside of the request handler'
|
||||
subtitle: 'How to run background tasks in an Edge Function outside of the request handler'
|
||||
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.
|
||||
|
||||
```ts
|
||||
// define a custom event
|
||||
// Define a custom event type for the background task.
|
||||
class MyBackgroundTaskEvent extends Event {
|
||||
readonly taskPromise: Promise<string>
|
||||
readonly taskPromise: Promise<Response>
|
||||
|
||||
constructor(taskPromise) {
|
||||
constructor(taskPromise: Promise<Response>) {
|
||||
super('myBackgroundTask')
|
||||
this.taskPromise = taskPromise
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('myBackgroundTask', async (event: MyBackgroundTaskEvent) => {
|
||||
const res = await event.taskPromise
|
||||
globalThis.addEventListener('myBackgroundTask', async (event) => {
|
||||
const res = await (event as MyBackgroundTaskEvent).taskPromise
|
||||
console.log(await res.json())
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The `supabase-js` client is a great option for connecting to your Supabase datab
|
||||
```ts index.ts
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (_req) => {
|
||||
Deno.serve(async (req) => {
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,53 +9,7 @@ hideToc: true
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
<StepHikeCompact.Details title="Create a Supabase project">
|
||||
|
||||
Go to [database.new](https://database.new) and create a new Supabase project.
|
||||
|
||||
When your project is up and running, go to the [Table Editor](https://supabase.com/dashboard/project/_/editor), create a new table and insert some data.
|
||||
|
||||
Alternatively, you can run the following snippet in your project's [SQL Editor](https://supabase.com/dashboard/project/_/sql/new). This will create a `notes` table with some sample data.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```sql SQL_EDITOR
|
||||
-- Create the table
|
||||
create table notes (
|
||||
id bigint primary key generated always as identity,
|
||||
title text not null
|
||||
);
|
||||
|
||||
-- Insert some sample data into the table
|
||||
insert into notes (title)
|
||||
values
|
||||
('Today I created a Supabase project.'),
|
||||
('I added some data and queried it from Next.js.'),
|
||||
('It was awesome!');
|
||||
|
||||
alter table notes enable row level security;
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
<StepHikeCompact.Details>
|
||||
|
||||
Make the data in your table publicly readable by adding an RLS policy:
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```sql SQL_EDITOR
|
||||
create policy "public can read notes"
|
||||
on public.notes
|
||||
for select to anon
|
||||
using (true);
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
<QuickstartDbSetup />
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
@@ -104,9 +58,9 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Query Supabase data from Next.js">
|
||||
|
||||
Create a new file at `app/notes/page.tsx` and populate with the following.
|
||||
Create a new file at `app/countries/page.tsx` and populate with the following.
|
||||
|
||||
This will select all the rows from the `notes` table in Supabase and render them on the page.
|
||||
This will select all the rows from the `countries` table in Supabase and render them on the page.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
@@ -114,14 +68,14 @@ hideToc: true
|
||||
|
||||
<CH.Code className="min-h-[34rem]">
|
||||
|
||||
```ts app/notes/page.tsx
|
||||
```ts app/countries/page.tsx
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
export default async function Notes() {
|
||||
export default async function Countries() {
|
||||
const supabase = await createClient();
|
||||
const { data: notes } = await supabase.from("notes").select();
|
||||
const { data: countries } = await supabase.from("countries").select();
|
||||
|
||||
return <pre>{JSON.stringify(notes, null, 2)}</pre>
|
||||
return <pre>{JSON.stringify(countries, null, 2)}</pre>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -166,7 +120,7 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Start the app">
|
||||
|
||||
Run the development server, go to http://localhost:3000/notes in a browser and you should see the list of notes.
|
||||
Run the development server, go to http://localhost:3000/countries in a browser and you should see the list of countries.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ hideToc: true
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
npm create svelte@latest myapp
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -43,7 +43,7 @@ hideToc: true
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
cd myapp && npm install @supabase/supabase-js
|
||||
cd my-app && npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -53,7 +53,7 @@ hideToc: true
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Create the Supabase client">
|
||||
|
||||
Create a `/src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
|
||||
Create a `src/lib` directory in your SvelteKit app, create a file called `supabaseClient.js` and add the following code to initialize the Supabase client with your project URL and public API (anon) key:
|
||||
|
||||
<ProjectConfigVariables variable="url" />
|
||||
<ProjectConfigVariables variable="anonKey" />
|
||||
@@ -77,7 +77,7 @@ hideToc: true
|
||||
|
||||
Use `load` method to fetch the data server-side and display the query results as a simple list.
|
||||
|
||||
Create `+page.server.js` file in the `routes` directory with the following code.
|
||||
Create `+page.server.js` file in the `src/routes` directory with the following code.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
@@ -98,7 +98,7 @@ hideToc: true
|
||||
|
||||
<StepHikeCompact.Details title="">
|
||||
|
||||
Replace the existing content in your `+page.svelte` file in the `routes` directory with the following code.
|
||||
Replace the existing content in your `+page.svelte` file in the `src/routes` directory with the following code.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
@@ -40,7 +40,7 @@ npx expo install @supabase/supabase-js @react-native-async-storage/async-storage
|
||||
Now let's create a helper file to initialize the Supabase client.
|
||||
We need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
These variables are safe to expose in your Expo app since Supabase has
|
||||
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your Database.
|
||||
[Row Level Security](/docs/guides/database/postgres/row-level-security) enabled on your Database.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
|
||||
@@ -115,7 +115,7 @@ The table below shows the actions each role can take on the resources belonging
|
||||
|
||||
The table below shows the actions each role can take on the resources belonging to the project.
|
||||
|
||||
| Resource | Action | Owner | Admin | Developer | Read-Only [^3] |
|
||||
| Resource | Action | Owner | Admin | Developer | Read-Only[^3][^5] |
|
||||
| ------------------------------------------------------------------------------------------------------ | ---------------------- | :-------------------------------------: | :-------------------------------------: | :-------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <a href="#project-permissions" id="project-permissions">**Project**</a> | | | | | |
|
||||
| Project Management | Transfer | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
@@ -188,7 +188,7 @@ The table below shows the actions each role can take on the resources belonging
|
||||
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck className="inline" size={14} color="#3FCF8E" /> [^5] |
|
||||
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck className="inline" size={14} color="#3FCF8E" /> [^6] |
|
||||
| <a href="#database-permissions" id="database-permissions">**Database**</a> | | | | | |
|
||||
| Scheduled Backups | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Download | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
@@ -261,4 +261,5 @@ The table below shows the actions each role can take on the resources belonging
|
||||
[^2]: Invites sent from a SSO account can only be accepted by another SSO account coming from the same identity provider. This is a security measure that prevents accidental invites to accounts not managed by your company's enterprise systems.
|
||||
[^3]: Available on the Enterprise Plan.
|
||||
[^4]: Read-Only role is able to access secrets.
|
||||
[^5]: Limited to executing SELECT queries.
|
||||
[^5]: Listed permissions are for the API and Dashboard.
|
||||
[^6]: Limited to executing SELECT queries. SQL Query Snippets run by the Read-Only role are run against the database using the **supabase_read_only_user**. This role has the [predefined Postgres role pg_read_all_data](https://www.postgresql.org/docs/current/predefined-roles.html).
|
||||
@@ -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/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Dedicated IPv4 Address'
|
||||
title: 'Dedicated IPv4 Address for Ingress'
|
||||
description: 'Attach an IPv4 address to your database'
|
||||
subtitle: 'Attach an IPv4 address to your database'
|
||||
---
|
||||
@@ -15,6 +15,12 @@ The Internet Protocol (IP) addresses devices on the internet. There are two main
|
||||
|
||||
## When you need the IPv4 add-on:
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
IPv4 addresses are guaranteed to be static for ingress traffic. If your database is making outbound connections, the outbound IP address is not static and cannot be guaranteed.
|
||||
|
||||
</Admonition>
|
||||
|
||||
- When using the direct connection string in an IPv6-incompatible network instead of Supavisor or client libraries.
|
||||
- When you need a dedicated IP address for your direct connection string
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1540,6 +1540,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": { "description": "" },
|
||||
"500": { "description": "Failed to retrieve project's service health status" }
|
||||
},
|
||||
"tags": ["Projects"],
|
||||
@@ -3461,6 +3462,7 @@
|
||||
"max_worker_processes": { "type": "integer", "minimum": 0, "maximum": 262143 },
|
||||
"shared_buffers": { "type": "string" },
|
||||
"statement_timeout": { "type": "string" },
|
||||
"track_commit_timestamp": { "type": "boolean" },
|
||||
"wal_keep_size": { "type": "string" },
|
||||
"wal_sender_timeout": { "type": "string" },
|
||||
"work_mem": { "type": "string" },
|
||||
@@ -3487,6 +3489,7 @@
|
||||
"max_worker_processes": { "type": "integer", "minimum": 0, "maximum": 262143 },
|
||||
"shared_buffers": { "type": "string" },
|
||||
"statement_timeout": { "type": "string" },
|
||||
"track_commit_timestamp": { "type": "boolean" },
|
||||
"wal_keep_size": { "type": "string" },
|
||||
"wal_sender_timeout": { "type": "string" },
|
||||
"work_mem": { "type": "string" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 1.215.0
|
||||
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
|
||||
|
||||
@@ -779,6 +779,16 @@ parameters:
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.external.provider.skip_nonce_check'
|
||||
title: 'auth.external.<provider>.skip_nonce_check'
|
||||
tags: ['auth']
|
||||
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: 'functions.function_name.enabled'
|
||||
title: 'functions.<function_name>.enabled'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -594,7 +594,7 @@ functions:
|
||||
- [Steps to obtain Android client ID](https://developers.google.com/identity/sign-in/android/start-integrating#configure_a_project)
|
||||
- [Steps to obtain iOS client ID](https://developers.google.com/identity/sign-in/ios/start-integrating#get_an_oauth_client_id)
|
||||
|
||||
Add the web client ID to the `Authentication -> Providers -> Google -> Authorized Client IDs` section in your Supabase dashboard
|
||||
Add the web client ID to the `Authentication -> Providers -> Google -> Client IDs` section in your Supabase dashboard
|
||||
code: |
|
||||
```dart
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
|
||||
@@ -4488,6 +4488,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": ""
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to retrieve project's service health status"
|
||||
}
|
||||
@@ -4720,6 +4723,9 @@
|
||||
"statement_timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"track_commit_timestamp": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wal_keep_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4835,6 +4841,9 @@
|
||||
"statement_timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"track_commit_timestamp": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wal_keep_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4927,6 +4936,9 @@
|
||||
"statement_timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"track_commit_timestamp": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wal_keep_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11882,6 +11894,9 @@
|
||||
"statement_timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"track_commit_timestamp": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wal_keep_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11963,6 +11978,9 @@
|
||||
"statement_timeout": {
|
||||
"type": "string"
|
||||
},
|
||||
"track_commit_timestamp": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wal_keep_size": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -149,10 +149,9 @@ const SupabaseGridLayout = (props: SupabaseGridProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const { data, error, isSuccess, isError, isLoading, isRefetching } = useTableRowsQuery(
|
||||
{
|
||||
queryKey: [props.table.schema, props.table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table: props.table,
|
||||
tableId: props.table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
|
||||
@@ -23,7 +23,6 @@ export function SelectEditor<TRow, TSummaryRow = unknown>({
|
||||
const value = row[column.key as keyof TRow] as unknown as string
|
||||
|
||||
function onChange(event: any) {
|
||||
debugger
|
||||
if (!event.target.value || event.target.value == '') {
|
||||
onRowChange({ ...row, [column.key]: null }, true)
|
||||
} else {
|
||||
|
||||
@@ -44,7 +44,6 @@ const Pagination = () => {
|
||||
const [{ filter }] = useUrlState({ arrayKeys: ['filter'] })
|
||||
const filters = formatFilterURLParams(filter as string[])
|
||||
const page = snap.page
|
||||
const table = state.table ?? undefined
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
|
||||
@@ -60,10 +59,9 @@ const Pagination = () => {
|
||||
|
||||
const { data, isLoading, isSuccess, isError, isFetching } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count-estimate'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: id,
|
||||
filters,
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import clsx from 'clsx'
|
||||
import saveAs from 'file-saver'
|
||||
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
|
||||
import Papa from 'papaparse'
|
||||
@@ -196,7 +195,7 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow, onImportData }: DefaultHe
|
||||
className="-translate-x-[2px]"
|
||||
/>
|
||||
<ArrowUp
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'transition duration-200 absolute bottom-0 right-0 translate-y-1 opacity-0 bg-brand-400 rounded-full',
|
||||
'group-data-[highlighted]:translate-y-0 group-data-[highlighted]:text-brand group-data-[highlighted]:opacity-100'
|
||||
)}
|
||||
@@ -240,10 +239,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const { data } = useTableRowsQuery({
|
||||
queryKey: [table.schema, table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: table.id,
|
||||
sorts,
|
||||
filters,
|
||||
page: snap.page,
|
||||
@@ -253,10 +251,9 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
|
||||
const { data: countData } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count-estimate'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
tableId: table.id,
|
||||
filters,
|
||||
enforceExactCount: snap.enforceExactCount,
|
||||
impersonatedRole: roleImpersonationState.role,
|
||||
|
||||
@@ -2,20 +2,18 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import type { SupaTable } from 'components/grid/types'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { Entity } from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export type RefreshButtonProps = {
|
||||
table: Entity | SupaTable
|
||||
table: { id: number }
|
||||
isRefetching?: boolean
|
||||
}
|
||||
|
||||
const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = sqlKeys.query(ref, [table.schema, table.name])
|
||||
const queryKey = tableRowKeys.tableRowsAndCount(ref, table.id)
|
||||
|
||||
async function onClick() {
|
||||
await queryClient.invalidateQueries(queryKey)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, Input, InputNumber, Listbox, Toggle } from 'ui'
|
||||
@@ -109,16 +110,15 @@ const FormField = ({
|
||||
label={properties.title}
|
||||
labelOptional={
|
||||
properties.descriptionOptional ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.descriptionOptional}
|
||||
</ReactMarkdown>
|
||||
<Markdown
|
||||
content={properties.descriptionOptional}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
@@ -140,6 +140,7 @@ const FormField = ({
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'multiline-string':
|
||||
return (
|
||||
<Input.TextArea
|
||||
@@ -159,9 +160,7 @@ const FormField = ({
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
@@ -183,6 +182,7 @@ const FormField = ({
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<InputNumber
|
||||
@@ -201,9 +201,7 @@ const FormField = ({
|
||||
}
|
||||
descriptionText={
|
||||
properties.description ? (
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{properties.description}
|
||||
</ReactMarkdown>
|
||||
<Markdown content={properties.description} className="text-foreground-lighter" />
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
|
||||
import type { Provider } from './AuthProvidersForm.types'
|
||||
import FormField from './FormField'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
export interface ProviderFormProps {
|
||||
config: components['schemas']['GoTrueConfigResponse']
|
||||
@@ -281,9 +282,10 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
: `${apiUrl}/auth/v1/callback`
|
||||
}
|
||||
descriptionText={
|
||||
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
|
||||
{provider.misc.helper}
|
||||
</ReactMarkdown>
|
||||
<Markdown
|
||||
content={provider.misc.helper}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -399,23 +399,16 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
type: 'boolean',
|
||||
},
|
||||
EXTERNAL_APPLE_CLIENT_ID: {
|
||||
title: 'Service ID (for OAuth)',
|
||||
description: `Client identifier used in the OAuth flow on the web.
|
||||
[Learn more](https://developer.apple.com/documentation/sign_in_with_apple/configuring_your_environment_for_sign_in_with_apple)`,
|
||||
title: 'Client IDs',
|
||||
description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`,
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_APPLE_SECRET: {
|
||||
title: 'Secret Key (for OAuth)',
|
||||
description: `Secret key used in the OAuth flow.
|
||||
[Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#generate-a-client_secret)`,
|
||||
description: `Secret key used in the OAuth flow. [Learn more](https://supabase.com/docs/guides/auth/social-login/auth-apple#generate-a-client_secret)`,
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: {
|
||||
title: 'Authorized Client IDs (iOS, macOS, watchOS, tvOS bundle IDs or service IDs)',
|
||||
description: `Comma separated list of allowed Apple app bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_APPLE_ENABLED: boolean().required(),
|
||||
@@ -426,7 +419,6 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
},
|
||||
then: (schema) =>
|
||||
schema
|
||||
.required('Secret key is required when using the OAuth flow.')
|
||||
.matches(/^[a-z0-9_-]+([.][a-z0-9_-]+){2}$/i, 'Secret key should be a JWT.')
|
||||
.test({
|
||||
message: 'Secret key is not a correctly generated JWT.',
|
||||
@@ -473,49 +465,27 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
},
|
||||
}),
|
||||
})
|
||||
.when(
|
||||
[
|
||||
'EXTERNAL_APPLE_ENABLED',
|
||||
'EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS',
|
||||
'EXTERNAL_APPLE_CLIENT_ID',
|
||||
],
|
||||
{
|
||||
is: (
|
||||
EXTERNAL_APPLE_ENABLED: boolean,
|
||||
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string,
|
||||
EXTERNAL_APPLE_CLIENT_ID: string
|
||||
) => {
|
||||
return (
|
||||
EXTERNAL_APPLE_ENABLED &&
|
||||
!!EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS &&
|
||||
!EXTERNAL_APPLE_CLIENT_ID
|
||||
)
|
||||
},
|
||||
then: (schema) =>
|
||||
schema.matches(
|
||||
/^$/,
|
||||
'Secret Key should only be set if Service ID for OAuth is provided.'
|
||||
),
|
||||
}
|
||||
),
|
||||
EXTERNAL_APPLE_CLIENT_ID: string().matches(
|
||||
/^[a-z0-9.-]+$/i,
|
||||
'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app).'
|
||||
),
|
||||
EXTERNAL_APPLE_ADDITIONAL_CLIENT_IDS: string()
|
||||
.matches(
|
||||
/^([.a-z0-9-]+(,\s*[.a-z0-9-]+)*,*\s*)?$/i,
|
||||
'Invalid characters. Apple recommends a reverse-domain name style string (e.g. com.example.app). You must only use explicit bundle IDs, asterisks (*) are not allowed.'
|
||||
)
|
||||
.when(['EXTERNAL_APPLE_ENABLED', 'EXTERNAL_APPLE_CLIENT_ID'], {
|
||||
is: (EXTERNAL_APPLE_ENABLED: boolean, EXTERNAL_APPLE_CLIENT_ID: string) => {
|
||||
return EXTERNAL_APPLE_ENABLED && !EXTERNAL_APPLE_CLIENT_ID
|
||||
},
|
||||
then: (schema) =>
|
||||
schema.required(
|
||||
'At least one Authorized Client ID is required when not using the OAuth flow.'
|
||||
schema.matches(
|
||||
/^$/,
|
||||
'Secret Key should only be set if Service ID for OAuth is provided.'
|
||||
),
|
||||
}),
|
||||
EXTERNAL_APPLE_CLIENT_ID: string()
|
||||
.matches(/^\S+$/, 'Client IDs should not contain spaces.')
|
||||
.matches(
|
||||
/^([a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*(,[a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*)*)$/i,
|
||||
'Invalid characters. Each ID should follow a reverse-domain style string (e.g. com.example.app). Use commas to separate multiple IDs.'
|
||||
)
|
||||
.when('EXTERNAL_APPLE_ENABLED', {
|
||||
is: true,
|
||||
then: (schema) =>
|
||||
schema.required('At least one Client ID is required when Apple sign-in is enabled.'),
|
||||
}),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'apple-icon',
|
||||
@@ -833,8 +803,9 @@ const EXTERNAL_PROVIDER_GOOGLE = {
|
||||
type: 'boolean',
|
||||
},
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: {
|
||||
title: 'Client ID (for OAuth)',
|
||||
description: 'Client ID to use with the OAuth flow on the web.',
|
||||
title: 'Client IDs',
|
||||
description:
|
||||
'Comma-separated list of client IDs for Web, OAuth, Android apps, One Tap, and Chrome extensions.',
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_GOOGLE_SECRET: {
|
||||
@@ -843,78 +814,34 @@ const EXTERNAL_PROVIDER_GOOGLE = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: {
|
||||
title: 'Authorized Client IDs (for Android, One Tap, and Chrome extensions)',
|
||||
description:
|
||||
'Comma separated list of client IDs of Android apps, One Tap or Chrome extensions that are allowed to log in to your project.',
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: {
|
||||
title: 'Skip nonce checks',
|
||||
description:
|
||||
"Allows ID tokens with any nonce to be accepted, which is less secure. Useful in situations where you don't have access to the nonce used to issue the ID token, such with iOS.",
|
||||
"Allows ID tokens with any nonce to be accepted, which is less secure. Useful in situations where you don't have access to the nonce used to issue the ID token, such as with iOS.",
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_GOOGLE_ENABLED: boolean().required(),
|
||||
EXTERNAL_GOOGLE_SECRET: string()
|
||||
.when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], {
|
||||
is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => {
|
||||
return EXTERNAL_GOOGLE_ENABLED && !!EXTERNAL_GOOGLE_CLIENT_ID
|
||||
},
|
||||
then: (schema) =>
|
||||
schema
|
||||
.matches(
|
||||
/^[a-z0-9.\/_-]*$/i,
|
||||
'Invalid characters. Google OAuth Client Secrets usually contain letters, numbers, dots, dashes and underscores.'
|
||||
)
|
||||
.required('Client Secret is required when using the OAuth flow.'),
|
||||
})
|
||||
.when(
|
||||
[
|
||||
'EXTERNAL_GOOGLE_ENABLED',
|
||||
'EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS',
|
||||
'EXTERNAL_GOOGLE_CLIENT_ID',
|
||||
],
|
||||
{
|
||||
is: (
|
||||
EXTERNAL_GOOGLE_ENABLED: boolean,
|
||||
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string,
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: string
|
||||
) => {
|
||||
return (
|
||||
EXTERNAL_GOOGLE_ENABLED &&
|
||||
!!EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS &&
|
||||
!EXTERNAL_GOOGLE_CLIENT_ID
|
||||
)
|
||||
},
|
||||
then: (schema) =>
|
||||
schema.matches(
|
||||
/^$/,
|
||||
'Client Secret should only be set when Client ID for OAuth is set.'
|
||||
),
|
||||
}
|
||||
),
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: string().matches(
|
||||
/^([a-z0-9-]+([.][a-z0-9-]+)+)?$/i,
|
||||
'Invalid characters. Google OAuth Client IDs are usually a domain-name (e.g. 01234567890-abcdefghijklmnopqrstuvwxyz012345.apps.googleusercontent.com).'
|
||||
),
|
||||
EXTERNAL_GOOGLE_ADDITIONAL_CLIENT_IDS: string()
|
||||
EXTERNAL_GOOGLE_CLIENT_ID: string()
|
||||
.matches(/^\S+$/, 'Client IDs should not contain spaces.')
|
||||
.matches(
|
||||
/^([a-z0-9-]+([.][a-z0-9-]+)*(,\s*[a-z0-9-]+([.][a-z0-9-]+)*)*,*\s*)?$/i,
|
||||
'Invalid characters. Google Client IDs are usually a domain-name style string (e.g. com.example.com.app or *.apps.googleusercontent.com).'
|
||||
/^([a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*(,[a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*)*)$/i,
|
||||
'Invalid characters. Google Client IDs should be a comma-separated list of domain-like strings.'
|
||||
)
|
||||
|
||||
.when(['EXTERNAL_GOOGLE_ENABLED', 'EXTERNAL_GOOGLE_CLIENT_ID'], {
|
||||
is: (EXTERNAL_GOOGLE_ENABLED: boolean, EXTERNAL_GOOGLE_CLIENT_ID: string) => {
|
||||
return EXTERNAL_GOOGLE_ENABLED && !EXTERNAL_GOOGLE_CLIENT_ID
|
||||
},
|
||||
.when('EXTERNAL_GOOGLE_ENABLED', {
|
||||
is: true,
|
||||
then: (schema) =>
|
||||
schema.required(
|
||||
'At least one Authorized Client ID is required when not using the OAuth flow.'
|
||||
),
|
||||
schema.required('At least one Client ID is required when Google sign-in is enabled.'),
|
||||
}),
|
||||
EXTERNAL_GOOGLE_SECRET: string().when('EXTERNAL_GOOGLE_ENABLED', {
|
||||
is: true,
|
||||
then: (schema) =>
|
||||
schema.matches(
|
||||
/^[a-z0-9.\/_-]*$/i,
|
||||
'Invalid characters. Google OAuth Client Secrets usually contain letters, numbers, dots, dashes, and underscores.'
|
||||
),
|
||||
}),
|
||||
EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: boolean().required(),
|
||||
}),
|
||||
misc: {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import type { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import { isEqual } from 'lodash'
|
||||
|
||||
import type { Message } from 'ai/react'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { isEqual } from 'lodash'
|
||||
|
||||
export type MessageWithDebug = Message & { isDebug: boolean }
|
||||
|
||||
|
||||
-47
@@ -1,29 +1,17 @@
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { Button } from 'ui'
|
||||
import { generateAlterPolicyQuery, generateCreatePolicyQuery } from './AIPolicyEditorPanel.utils'
|
||||
|
||||
interface LockedCreateQuerySection {
|
||||
schema: string
|
||||
selectedPolicy?: PostgresPolicy
|
||||
formFields: { name: string; table: string; behavior: string; command: string; roles: string }
|
||||
editorOneRef: any
|
||||
editorTwoRef: any
|
||||
}
|
||||
|
||||
export const LockedCreateQuerySection = ({
|
||||
schema,
|
||||
selectedPolicy,
|
||||
formFields,
|
||||
editorOneRef,
|
||||
editorTwoRef,
|
||||
}: LockedCreateQuerySection) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const isEditing = selectedPolicy !== undefined
|
||||
const { name, table, behavior, command, roles } = formFields
|
||||
|
||||
@@ -38,41 +26,6 @@ export const LockedCreateQuerySection = ({
|
||||
Use options above to edit
|
||||
</p>
|
||||
</div>
|
||||
{/* <Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
const query = isEditing
|
||||
? generateAlterPolicyQuery({
|
||||
name: '',
|
||||
newName: name,
|
||||
schema,
|
||||
table,
|
||||
command,
|
||||
roles: roles.length === 0 ? 'public' : roles,
|
||||
using: (editorOneRef.current?.getValue() ?? undefined)?.trim(),
|
||||
check:
|
||||
command === 'insert'
|
||||
? (editorOneRef.current?.getValue() ?? undefined)?.trim()
|
||||
: (editorTwoRef.current?.getValue() ?? undefined)?.trim(),
|
||||
})
|
||||
: generateCreatePolicyQuery({
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
behavior,
|
||||
command,
|
||||
roles: roles.length === 0 ? 'public' : roles,
|
||||
using: (editorOneRef.current?.getValue() ?? undefined)?.trim(),
|
||||
check:
|
||||
command === 'insert'
|
||||
? (editorOneRef.current?.getValue() ?? undefined)?.trim()
|
||||
: (editorTwoRef.current?.getValue() ?? undefined)?.trim(),
|
||||
})
|
||||
router.push(`/project/${ref}/sql/new?content=${query}`)
|
||||
}}
|
||||
>
|
||||
Open in SQL Editor
|
||||
</Button> */}
|
||||
</div>
|
||||
<div className="flex items-start" style={{ fontSize: '14px' }}>
|
||||
<p className="px-6 font-mono text-sm text-foreground-light select-none">1</p>
|
||||
|
||||
@@ -595,8 +595,6 @@ export const AIPolicyEditorPanel = memo(function ({
|
||||
<LockedCreateQuerySection
|
||||
schema={schema}
|
||||
selectedPolicy={selectedPolicy}
|
||||
editorOneRef={editorOneRef}
|
||||
editorTwoRef={editorTwoRef}
|
||||
formFields={{ name, table, behavior, command, roles }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,23 +21,33 @@ import {
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
|
||||
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 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 =
|
||||
@@ -84,32 +94,54 @@ const PolicyRow = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
|
||||
<Edit size={14} />
|
||||
<p>Edit policy</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',
|
||||
},
|
||||
}}
|
||||
{!isLocked && (
|
||||
<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')}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete policy</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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 }],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</Panel.Content>
|
||||
)
|
||||
|
||||
+33
-28
@@ -1,20 +1,15 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { noop } from 'lodash'
|
||||
import { Lock, Unlock } from 'lucide-react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useIsDatabaseFunctionsAssistantEnabled } 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'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Badge,
|
||||
Button,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
} from 'ui'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { AiIconAnimation, Badge } from 'ui'
|
||||
|
||||
interface PolicyTableRowHeaderProps {
|
||||
table: {
|
||||
@@ -40,7 +35,9 @@ const PolicyTableRowHeader = ({
|
||||
onSelectCreatePolicy,
|
||||
}: PolicyTableRowHeaderProps) => {
|
||||
const { ref } = useParams()
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
|
||||
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
|
||||
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
const isRealtimeSchema = table.schema === 'realtime'
|
||||
@@ -115,25 +112,33 @@ const PolicyTableRowHeader = ({
|
||||
Create policy
|
||||
</ButtonTooltip>
|
||||
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="default"
|
||||
className="px-1"
|
||||
onClick={() => {
|
||||
onSelectCreatePolicy()
|
||||
setEditView('conversation')
|
||||
}}
|
||||
>
|
||||
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
|
||||
</Button>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
{!canToggleRLS
|
||||
? 'You need additional permissions to create RLS policies'
|
||||
: 'Create with Supabase Assistant'}
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
className="px-1"
|
||||
onClick={() => {
|
||||
if (enableAssistantV2) {
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
editor: 'rls-policies',
|
||||
entity: undefined,
|
||||
tables: [{ schema: table.schema, name: table.name }],
|
||||
})
|
||||
} else {
|
||||
onSelectCreatePolicy()
|
||||
setEditView('conversation')
|
||||
}
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canToggleRLS
|
||||
? 'You need additional permissions to create RLS policies'
|
||||
: 'Create with Supabase Assistant',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -88,6 +88,7 @@ const PolicyTableRow = ({
|
||||
{policies?.map((policy) => (
|
||||
<PolicyRow
|
||||
key={policy.id}
|
||||
isLocked={isLocked}
|
||||
policy={policy}
|
||||
onSelectEditPolicy={onSelectEditPolicy}
|
||||
onSelectDeletePolicy={onSelectDeletePolicy}
|
||||
|
||||
@@ -18,18 +18,8 @@ import {
|
||||
import CreateUserModal from './CreateUserModal'
|
||||
import InviteUserModal from './InviteUserModal'
|
||||
|
||||
export type AddUserDropdownProps = {
|
||||
projectKpsVersion?: string
|
||||
}
|
||||
|
||||
const AddUserDropdown = ({ projectKpsVersion }: AddUserDropdownProps) => {
|
||||
const AddUserDropdown = () => {
|
||||
const inviteEnabled = IS_PLATFORM
|
||||
? semver.gte(
|
||||
// @ts-ignore
|
||||
semver.coerce(projectKpsVersion ?? 'kps-v2.5.4'),
|
||||
semver.coerce('kps-v2.5.3')
|
||||
)
|
||||
: true
|
||||
|
||||
const canInviteUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user')
|
||||
const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user')
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as z from 'zod'
|
||||
import { useParams } from 'common'
|
||||
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -34,7 +33,6 @@ interface BanUserModalProps {
|
||||
export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
const bannedUntil = dayjs()
|
||||
@@ -62,25 +60,15 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => {
|
||||
const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)')
|
||||
|
||||
const onSubmit = (data: FormType) => {
|
||||
if (!settings) {
|
||||
return toast.error(`Failed to ban user: Error loading project config`)
|
||||
} else if (user.id === undefined) {
|
||||
if (projectRef === undefined) return console.error('Project ref is required')
|
||||
if (user.id === undefined) {
|
||||
return toast.error(`Failed to ban user: User ID not found`)
|
||||
}
|
||||
|
||||
const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key) return toast.error(`Failed to ban user: Unable to retrieve API key`)
|
||||
|
||||
updateUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
userId: user.id,
|
||||
banDuration: durationHours,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useUserCreateMutation } from 'data/auth/user-create-mutation'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Button,
|
||||
@@ -39,13 +38,6 @@ const CreateUserFormSchema = z.object({
|
||||
|
||||
const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useProjectSettingsV2Query({ projectRef }, { enabled: visible })
|
||||
|
||||
const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user')
|
||||
|
||||
const { mutate: createUser, isLoading: isCreatingUser } = useUserCreateMutation({
|
||||
@@ -57,24 +49,9 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
})
|
||||
|
||||
const onCreateUser = async (values: any) => {
|
||||
if (!isSuccess) {
|
||||
return toast.error(`Failed to create user: Error loading project config`)
|
||||
}
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key)
|
||||
return toast.error(`Failed to create user: Unable to retrieve API key`)
|
||||
|
||||
createUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
user: values,
|
||||
})
|
||||
createUser({ projectRef, user: values })
|
||||
}
|
||||
|
||||
const form = useForm<z.infer<typeof CreateUserFormSchema>>({
|
||||
@@ -115,7 +92,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="user@example.com"
|
||||
disabled={isCreatingUser || isLoading}
|
||||
disabled={isCreatingUser}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -139,15 +116,14 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
autoFocus
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
disabled={isCreatingUser || isLoading}
|
||||
disabled={isCreatingUser}
|
||||
className="pl-8"
|
||||
></Input_Shadcn_>
|
||||
/>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ />
|
||||
@@ -182,7 +158,7 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => {
|
||||
size="small"
|
||||
htmlType="submit"
|
||||
loading={isCreatingUser}
|
||||
disabled={!canCreateUsers || isCreatingUser || isLoading}
|
||||
disabled={!canCreateUsers || isCreatingUser}
|
||||
>
|
||||
Create user
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut
|
||||
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
|
||||
import { useUserUpdateMutation } from 'data/auth/user-update-mutation'
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { timeout } from 'lib/helpers'
|
||||
@@ -58,6 +57,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
}
|
||||
})
|
||||
|
||||
const canUpdateUser = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
|
||||
const canSendMagicLink = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_magic_link')
|
||||
const canSendRecovery = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_recovery')
|
||||
const canSendOtp = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp')
|
||||
@@ -76,7 +76,6 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false)
|
||||
|
||||
const { data } = useAuthConfigQuery({ projectRef })
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
|
||||
const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0
|
||||
const minutes = Math.floor(mailerOtpExpiry / 60)
|
||||
@@ -143,24 +142,13 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
}
|
||||
|
||||
const handleUnban = () => {
|
||||
if (!settings) {
|
||||
return toast.error(`Failed to ban user: Error loading project config`)
|
||||
} else if (user.id === undefined) {
|
||||
if (projectRef === undefined) return console.error('Proejct ref is required')
|
||||
if (user.id === undefined) {
|
||||
return toast.error(`Failed to ban user: User ID not found`)
|
||||
}
|
||||
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const { serviceKey } = getAPIKeys(settings)
|
||||
|
||||
if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`)
|
||||
if (!serviceKey?.api_key) return toast.error(`Failed to unban user: Unable to retrieve API key`)
|
||||
|
||||
updateUser({
|
||||
projectRef,
|
||||
protocol,
|
||||
endpoint,
|
||||
serviceApiKey: serviceKey.api_key,
|
||||
userId: user.id,
|
||||
banDuration: 'none',
|
||||
})
|
||||
@@ -188,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"
|
||||
@@ -221,13 +209,19 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
<div className={cn('flex flex-col -space-y-1 !pt-0', PANEL_PADDING)}>
|
||||
{providers.map((provider) => {
|
||||
const providerMeta = PROVIDERS_SCHEMAS.find(
|
||||
(x) => x.title.toLowerCase() === provider.name
|
||||
(x) =>
|
||||
x.title.toLowerCase() ===
|
||||
(provider.name === 'linkedin' ? 'linkedin (oidc)' : provider.name)
|
||||
)
|
||||
const enabledProperty = Object.keys(providerMeta?.properties ?? {}).find((x) =>
|
||||
x.toLowerCase().endsWith('_enabled')
|
||||
)
|
||||
const providerName =
|
||||
provider.name === 'email' ? 'email' : providerMeta?.title ?? provider.name
|
||||
provider.name === 'email'
|
||||
? 'email'
|
||||
: provider.name === 'linkedin'
|
||||
? 'LinkedIn'
|
||||
: providerMeta?.title ?? provider.name
|
||||
const isActive = data?.[enabledProperty as keyof typeof data] ?? false
|
||||
|
||||
return (
|
||||
@@ -241,7 +235,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
/>
|
||||
)}
|
||||
<div className="flex-grow mt-0.5">
|
||||
<p className="capitalize">{provider.name}</p>
|
||||
<p className="capitalize">{providerName}</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Signed in with a {providerName} account via{' '}
|
||||
{providerName === 'SAML' ? 'SSO' : 'OAuth'}
|
||||
@@ -380,7 +374,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
button={{
|
||||
icon: <Ban />,
|
||||
text: isBanned ? 'Unban user' : 'Ban user',
|
||||
disabled: !canRemoveMFAFactors,
|
||||
disabled: !canUpdateUser,
|
||||
onClick: () => {
|
||||
if (isBanned) {
|
||||
setIsUnbanModalOpen(true)
|
||||
@@ -465,41 +459,44 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
|
||||
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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const RowAction = ({
|
||||
export const RowAction = ({
|
||||
title,
|
||||
description,
|
||||
button,
|
||||
|
||||
@@ -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">
|
||||
@@ -363,7 +362,7 @@ export const UsersV2 = () => {
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<AddUserDropdown projectKpsVersion={project?.kpsVersion} />
|
||||
<AddUserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
|
||||
|
||||
@@ -31,7 +31,7 @@ export const NoProjectsOnPaidOrgInfo = ({ organization }: NoProjectsOnPaidOrgInf
|
||||
<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](/org/${organization?.slug}/billing).`}
|
||||
your [organization billing settings](/dashboard/org/${organization?.slug}/billing).`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -7,16 +7,17 @@ import { useBackupDownloadMutation } from 'data/database/backup-download-mutatio
|
||||
import type { DatabaseBackup } from 'data/database/backups-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Badge } from 'ui'
|
||||
import { useParams } from 'common'
|
||||
|
||||
interface BackupItemProps {
|
||||
index: number
|
||||
isHealthy: boolean
|
||||
projectRef: string
|
||||
backup: DatabaseBackup
|
||||
onSelectBackup: () => void
|
||||
}
|
||||
|
||||
const BackupItem = ({ index, isHealthy, backup, projectRef, onSelectBackup }: BackupItemProps) => {
|
||||
const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const canTriggerScheduledBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
@@ -62,7 +63,10 @@ const BackupItem = ({ index, isHealthy, backup, projectRef, onSelectBackup }: Ba
|
||||
icon={<Download />}
|
||||
loading={isDownloading}
|
||||
disabled={!canTriggerScheduledBackups || isDownloading}
|
||||
onClick={() => downloadBackup({ ref: projectRef, backup })}
|
||||
onClick={() => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
downloadBackup({ ref: projectRef, backup })
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Clock } from 'lucide-react'
|
||||
@@ -12,28 +11,23 @@ import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useBackupRestoreMutation } from 'data/database/backup-restore-mutation'
|
||||
import { DatabaseBackup, useBackupsQuery } from 'data/database/backups-query'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import BackupItem from './BackupItem'
|
||||
import BackupsEmpty from './BackupsEmpty'
|
||||
import BackupsStorageAlert from './BackupsStorageAlert'
|
||||
import { useParams } from 'common'
|
||||
|
||||
const BackupsList = () => {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { project: selectedProject } = useProjectContext()
|
||||
const projectRef = selectedProject?.ref || 'default'
|
||||
const isHealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
|
||||
const [selectedBackup, setSelectedBackup] = useState<DatabaseBackup>()
|
||||
|
||||
const canTriggerScheduledBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
|
||||
const { data: backups } = useBackupsQuery({ projectRef })
|
||||
const {
|
||||
mutate: restoreFromBackup,
|
||||
@@ -41,15 +35,17 @@ const BackupsList = () => {
|
||||
isSuccess: isSuccessBackup,
|
||||
} = useBackupRestoreMutation({
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
toast.success(
|
||||
`Restoring database back to ${dayjs(selectedBackup?.inserted_at).format(
|
||||
'DD MMM YYYY HH:mm:ss'
|
||||
)}`
|
||||
)
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
if (projectRef) {
|
||||
setTimeout(() => {
|
||||
setProjectStatus(queryClient, projectRef, PROJECT_STATUS.RESTORING)
|
||||
toast.success(
|
||||
`Restoring database back to ${dayjs(selectedBackup?.inserted_at).format(
|
||||
'DD MMM YYYY HH:mm:ss'
|
||||
)}`
|
||||
)
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -86,7 +82,6 @@ const BackupsList = () => {
|
||||
<BackupItem
|
||||
key={x.id}
|
||||
backup={x}
|
||||
projectRef={projectRef}
|
||||
index={i}
|
||||
isHealthy={isHealthy}
|
||||
onSelectBackup={() => setSelectedBackup(x)}
|
||||
@@ -106,6 +101,7 @@ const BackupsList = () => {
|
||||
loading={isRestoring || isSuccessBackup}
|
||||
onCancel={() => setSelectedBackup(undefined)}
|
||||
onConfirm={() => {
|
||||
if (projectRef === undefined) return console.error('Project ref required')
|
||||
if (selectedBackup === undefined) return console.error('Backup required')
|
||||
restoreFromBackup({ ref: projectRef, backup: selectedBackup })
|
||||
}}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { NavMenu, NavMenuItem } from 'ui'
|
||||
|
||||
type Props = {
|
||||
projRef: string
|
||||
active: 'scheduled' | 'pitr'
|
||||
}
|
||||
|
||||
function DatabaseBackupsNav({ projRef, active }: Props) {
|
||||
function DatabaseBackupsNav({ active }: Props) {
|
||||
const { ref } = useParams()
|
||||
|
||||
const navMenuItems = [
|
||||
{
|
||||
id: 'scheduled',
|
||||
label: 'Scheduled backups',
|
||||
href: `/project/${projRef}/database/backups/scheduled`,
|
||||
href: `/project/${ref}/database/backups/scheduled`,
|
||||
},
|
||||
{
|
||||
id: 'pitr',
|
||||
label: 'Point in time',
|
||||
href: `/project/${projRef}/database/backups/pitr`,
|
||||
href: `/project/${ref}/database/backups/pitr`,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import { DatabaseExtension } from 'data/database-extensions/database-extensions-
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { extensions } from 'shared-data'
|
||||
import { Button, cn, Switch } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import EnableExtensionModal from './EnableExtensionModal'
|
||||
import { EXTENSION_DISABLE_WARNINGS } from './Extensions.constants'
|
||||
|
||||
interface ExtensionCardProps {
|
||||
extension: DatabaseExtension
|
||||
@@ -166,9 +168,16 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
onCancel={() => setIsDisableModalOpen(false)}
|
||||
onConfirm={() => onConfirmDisable()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Are you sure you want to turn OFF the "{extension.name}" extension?
|
||||
</p>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Are you sure you want to turn OFF the "{extension.name}" extension?
|
||||
</p>
|
||||
{EXTENSION_DISABLE_WARNINGS[extension.name] && (
|
||||
<Admonition type="warning" className="m-0">
|
||||
{EXTENSION_DISABLE_WARNINGS[extension.name]}
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -19,3 +19,7 @@ export const HIDDEN_EXTENSIONS = [
|
||||
export const SEARCH_TERMS: Record<string, string[]> = {
|
||||
vector: ['pgvector', 'pg_vector'],
|
||||
}
|
||||
|
||||
export const EXTENSION_DISABLE_WARNINGS: Record<string, string> = {
|
||||
pg_cron: 'Disabling this extension will delete all scheduled jobs. This cannot be undone.',
|
||||
}
|
||||
+18
-10
@@ -11,12 +11,14 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
|
||||
interface FunctionListProps {
|
||||
schema: string
|
||||
@@ -35,6 +37,7 @@ const FunctionList = ({
|
||||
}: FunctionListProps) => {
|
||||
const router = useRouter()
|
||||
const { project: selectedProject } = useProjectContext()
|
||||
const enableAssistantV2 = useIsDatabaseFunctionsAssistantEnabled()
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
|
||||
const { data: functions } = useDatabaseFunctionsQuery({
|
||||
@@ -108,7 +111,10 @@ const FunctionList = ({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left">
|
||||
<DropdownMenuContent
|
||||
side="left"
|
||||
className={cn(enableAssistantV2 ? 'w-52' : 'w-40')}
|
||||
>
|
||||
{isApiDocumentAvailable && (
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
@@ -122,15 +128,17 @@ const FunctionList = ({
|
||||
<Edit2 size={14} />
|
||||
<p>Edit function</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Edit function with Assistant</p>
|
||||
</DropdownMenuItem>
|
||||
{enableAssistantV2 && (
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setAiAssistantPanel({ open: true, editor: 'functions', entity: x })
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Edit function with Assistant</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="space-x-2" onClick={() => deleteFunction(x)}>
|
||||
<Trash size={14} className="text-destructive" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
@@ -10,6 +9,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -157,7 +157,7 @@ const HTTPRequestFields = ({
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Plus />}
|
||||
className={clsx(type === 'supabase_function' && 'rounded-r-none px-3')}
|
||||
className={cn(type === 'supabase_function' && 'rounded-r-none px-3')}
|
||||
onClick={onAddHeader}
|
||||
>
|
||||
Add a new header
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -6,7 +7,7 @@ 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) => ({
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import clsx from 'clsx'
|
||||
import { Maximize2, Minimize2, Terminal } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Collapsible } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import CommandRender from 'components/interfaces/Functions/CommandRender'
|
||||
import { Terminal, Minimize2, Maximize2 } from 'lucide-react'
|
||||
import { cn, Collapsible } from 'ui'
|
||||
|
||||
const MigrationsEmptyState = () => {
|
||||
const { ref } = useParams()
|
||||
@@ -62,7 +61,7 @@ const MigrationsEmptyState = () => {
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded py-3 px-6 text-foreground border bg-surface-100',
|
||||
showInstructions && 'rounded-b-none'
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -151,73 +152,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,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 (
|
||||
|
||||
@@ -49,7 +49,7 @@ const EditWrapper = () => {
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const wrappers = data?.result ?? []
|
||||
const wrappers = data ?? []
|
||||
const foundWrapper = wrappers.find((w) => Number(w.id) === Number(id))
|
||||
// this call to useImmutableValue should be removed if the redirect after update is also removed
|
||||
const wrapper = useImmutableValue(foundWrapper)
|
||||
|
||||
@@ -19,7 +19,7 @@ const Wrappers = ({ isEnabled }: { isEnabled: boolean }) => {
|
||||
const [open, setOpen] = useState<string>('')
|
||||
const [selectedWrapperToDelete, setSelectedWrapperToDelete] = useState<FDW>()
|
||||
|
||||
const wrappers = data?.result ?? []
|
||||
const wrappers = data ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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'
|
||||
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { MAX_WIDTH_CLASSES, PADDING_CLASSES, ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import {
|
||||
useDiskAttributesQuery,
|
||||
useRemainingDurationForDiskAttributeUpdate,
|
||||
@@ -23,9 +25,11 @@ 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 { PROJECT_STATUS } from 'lib/constants'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { GB, PROJECT_STATUS } from 'lib/constants'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -35,9 +39,11 @@ import {
|
||||
Form_Shadcn_,
|
||||
Separator,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
|
||||
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
|
||||
import { mapComputeSizeNameToAddonVariantId, showMicroUpgrade } from './DiskManagement.utils'
|
||||
import { DiskManagementMessage } from './DiskManagement.types'
|
||||
import { mapComputeSizeNameToAddonVariantId } from './DiskManagement.utils'
|
||||
import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection'
|
||||
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
|
||||
import { ComputeSizeField } from './fields/ComputeSizeField'
|
||||
@@ -53,7 +59,6 @@ import {
|
||||
} from './ui/DiskManagement.constants'
|
||||
import { NoticeBar } from './ui/NoticeBar'
|
||||
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
|
||||
import { DiskManagementMessage } from './DiskManagement.types'
|
||||
|
||||
export function DiskManagementForm() {
|
||||
const {
|
||||
@@ -64,6 +69,14 @@ 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(
|
||||
(warning) => warning.project === project?.ref
|
||||
)
|
||||
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
|
||||
|
||||
/**
|
||||
* Permissions
|
||||
@@ -86,18 +99,8 @@ export function DiskManagementForm() {
|
||||
/**
|
||||
* Fetch form data
|
||||
*/
|
||||
const {
|
||||
data: databases,
|
||||
// isLoading: isReadReplicasLoading,
|
||||
// error: readReplicasError,
|
||||
isSuccess: isReadReplicasSuccess,
|
||||
} = useReadReplicasQuery({ projectRef })
|
||||
const {
|
||||
data,
|
||||
// isLoading: isDiskAttributesLoading,
|
||||
// error: diskAttributesError,
|
||||
isSuccess: isDiskAttributesSuccess,
|
||||
} = useDiskAttributesQuery(
|
||||
const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef })
|
||||
const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery(
|
||||
{ projectRef },
|
||||
{
|
||||
refetchInterval,
|
||||
@@ -127,7 +130,7 @@ export function DiskManagementForm() {
|
||||
useRemainingDurationForDiskAttributeUpdate({
|
||||
projectRef,
|
||||
})
|
||||
const { isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery({
|
||||
const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery({
|
||||
projectRef,
|
||||
})
|
||||
const { data: subscription, isSuccess: isSubscriptionSuccess } = useOrgSubscriptionQuery({
|
||||
@@ -173,6 +176,9 @@ export function DiskManagementForm() {
|
||||
const isPlanUpgradeRequired = subscription?.plan.id === 'free'
|
||||
|
||||
const { formState } = form
|
||||
const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
|
||||
const totalSize = formState.defaultValues?.totalSize || 0
|
||||
const usedPercentage = (usedSize / totalSize) * 100
|
||||
|
||||
const isFlyArchitecture = project?.cloud_provider === 'FLY'
|
||||
|
||||
@@ -184,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
|
||||
@@ -260,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">
|
||||
@@ -300,7 +312,6 @@ export function DiskManagementForm() {
|
||||
<Separator />
|
||||
<ComputeSizeField form={form} disabled={disableComputeInputs} />
|
||||
<Separator />
|
||||
<DiskCountdownRadial />
|
||||
<SpendCapDisabledSection />
|
||||
<NoticeBar
|
||||
type="default"
|
||||
@@ -310,6 +321,35 @@ export function DiskManagementForm() {
|
||||
/>
|
||||
{!isFlyArchitecture && (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<DiskCountdownRadial />
|
||||
{!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Database size is currently over 90% of disk size"
|
||||
description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations"
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2"
|
||||
href="https://supabase.com/docs/guides/platform/database-size#read-only-mode"
|
||||
/>
|
||||
</Admonition>
|
||||
)}
|
||||
{isReadOnlyMode && (
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Project is currently in read-only mode"
|
||||
description="You will need to manually override read-only mode and reduce the database size to below 95% of the disk size"
|
||||
>
|
||||
<DocsButton
|
||||
abbrev={false}
|
||||
className="mt-2"
|
||||
href="https://supabase.com/docs/guides/platform/database-size#disabling-read-only-mode"
|
||||
/>
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
<DiskSizeField
|
||||
form={form}
|
||||
disableInput={disableDiskInputs}
|
||||
@@ -336,7 +376,12 @@ export function DiskManagementForm() {
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="data-[state=open]:border flex flex-col gap-8 px-8 py-8 transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
||||
<CollapsibleContent_Shadcn_
|
||||
className={cn(
|
||||
'flex flex-col gap-8 px-8 py-8 transition-all',
|
||||
'data-[state=open]:border data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down'
|
||||
)}
|
||||
>
|
||||
<StorageTypeField form={form} disableInput={disableDiskInputs} />
|
||||
<NoticeBar
|
||||
type="default"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { BillingChangeBadge } from '../ui/BillingChangeBadge'
|
||||
import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants'
|
||||
import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas'
|
||||
import DiskSpaceBar from '../ui/DiskSpaceBar'
|
||||
import { DiskTypeReccomendationSection } from '../ui/DiskTypeRecommendationSection'
|
||||
import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection'
|
||||
import FormMessage from '../ui/FormMessage'
|
||||
import { InputPostTab } from '../ui/InputPostTab'
|
||||
import { InputResetButton } from '../ui/InputResetButton'
|
||||
@@ -149,7 +149,7 @@ export function DiskSizeField({
|
||||
subscription?.plan.id &&
|
||||
`Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`}
|
||||
</span>
|
||||
<DiskTypeReccomendationSection
|
||||
<DiskTypeRecommendationSection
|
||||
form={form}
|
||||
actions={
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Info } from 'lucide-react'
|
||||
import MotionNumber from 'motion-number'
|
||||
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'
|
||||
@@ -25,7 +27,9 @@ interface DiskSpaceBarProps {
|
||||
|
||||
export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
const { ref } = useParams()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { formState, watch } = form
|
||||
const isDarkMode = resolvedTheme?.includes('dark')
|
||||
|
||||
const {
|
||||
data: diskUtil,
|
||||
@@ -50,8 +54,9 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
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">
|
||||
@@ -91,16 +96,21 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
>
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-foreground relative overflow-hidden transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${usedPercentage}%` }}
|
||||
className={cn(
|
||||
usedPercentage >= 90 && remainingDuration > 0
|
||||
? 'bg-destructive'
|
||||
: 'bg-foreground',
|
||||
'relative overflow-hidden transition-all duration-500 ease-in-out'
|
||||
)}
|
||||
style={{ width: `${usedPercentage >= 100 ? 100 : usedPercentage}%` }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(255,255,255,0.1),
|
||||
rgba(255,255,255,0.1) 1px,
|
||||
${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'},
|
||||
${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'},
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
)`,
|
||||
@@ -109,7 +119,9 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
</div>
|
||||
<div
|
||||
className="bg-transparent border-r transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${resizePercentage - usedPercentage}%` }}
|
||||
style={{
|
||||
width: `${resizePercentage - usedPercentage <= 0 ? 0 : resizePercentage - usedPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -126,15 +138,15 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-foreground relative overflow-hidden transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${newUsedPercentage}%` }}
|
||||
style={{ width: `${newUsedPercentage >= 100 ? 100 : newUsedPercentage}%` }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(255,255,255,0.1),
|
||||
rgba(255,255,255,0.1) 1px,
|
||||
${isDarkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)'},
|
||||
${isDarkMode ? 'rgba(0,0,0,0.1) 1px' : 'rgba(255,255,255,0.1) 1px'},
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
)`,
|
||||
@@ -151,10 +163,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
|
||||
initial={{ opacity: 0, x: 4 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 4 }}
|
||||
transition={{
|
||||
duration: 0.12,
|
||||
delay: 0.12,
|
||||
}}
|
||||
transition={{ duration: 0.12, delay: 0.12 }}
|
||||
className={cn(badgeVariants({ variant: 'success' }), 'absolute right-1 top-[5px]')}
|
||||
>
|
||||
New disk size
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { Admonition } from 'ui-patterns/admonition'
|
||||
import { DiskStorageSchemaType } from '../DiskManagement.schema'
|
||||
import { DISK_LIMITS, DISK_TYPE_OPTIONS, DiskType } from './DiskManagement.constants'
|
||||
|
||||
export function DiskTypeReccomendationSection({
|
||||
export function DiskTypeRecommendationSection({
|
||||
actions,
|
||||
form,
|
||||
}: {
|
||||
|
||||
+2
-2
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
Toggle,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import CommandRender from '../CommandRender'
|
||||
import { generateCLICommands } from './EdgeFunctionDetails.utils'
|
||||
@@ -181,7 +181,7 @@ const EdgeFunctionDetails = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm">
|
||||
Import maps are{' '}
|
||||
<span className={clsx(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
{hasImportMap ? 'used' : 'not used'}
|
||||
</span>{' '}
|
||||
for this function
|
||||
|
||||
@@ -32,14 +32,13 @@ import {
|
||||
} from '@graphiql/react'
|
||||
import { Fetcher } from '@graphiql/toolkit'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import clsx from 'clsx'
|
||||
import { AlertTriangle, XIcon } from 'lucide-react'
|
||||
import { MouseEventHandler, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { AlertTriangle, XIcon } from 'lucide-react'
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, cn } from 'ui'
|
||||
import { RoleImpersonationSelector } from '../RoleImpersonationSelector'
|
||||
import styles from './graphiql.module.css'
|
||||
|
||||
@@ -199,15 +198,15 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className={clsx('graphiql-container', styles.graphiqlContainer)}>
|
||||
<div className={cn('graphiql-container', styles.graphiqlContainer)}>
|
||||
<div className="graphiql-main">
|
||||
<div
|
||||
ref={pluginResize.firstRef}
|
||||
style={{ minWidth: '750px' }}
|
||||
className={clsx('graphiql-sessions', styles.graphiqlSessions)}
|
||||
className={cn('graphiql-sessions', styles.graphiqlSessions)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'graphiql-session-header',
|
||||
!hasSingleTab && styles.graphiqlSessionHeader
|
||||
)}
|
||||
@@ -256,12 +255,12 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="graphiql-session"
|
||||
className={clsx('graphiql-session', styles.graphiqlSession)}
|
||||
className={cn('graphiql-session', styles.graphiqlSession)}
|
||||
aria-labelledby={`graphiql-session-tab-${editorContext.activeTabIndex}`}
|
||||
>
|
||||
<div ref={editorResize.firstRef}>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'graphiql-editors',
|
||||
styles.graphiqlEditors,
|
||||
hasSingleTab && 'full-height'
|
||||
@@ -269,7 +268,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
>
|
||||
<div ref={editorToolsResize.firstRef}>
|
||||
<section
|
||||
className={clsx('graphiql-query-editor text-sm', styles.graphiqlQueryEditor)}
|
||||
className={cn('graphiql-query-editor text-sm', styles.graphiqlQueryEditor)}
|
||||
aria-label="Query Editor"
|
||||
>
|
||||
<QueryEditor onClickReference={onClickReference} />
|
||||
@@ -367,7 +366,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
|
||||
{canReadJWTSecret && (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'graphiql-editor px-1',
|
||||
activeSecondaryEditor !== 'role-impersonation' && 'hidden'
|
||||
)}
|
||||
@@ -381,13 +380,13 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
|
||||
className={cn('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
|
||||
ref={editorResize.dragBarRef}
|
||||
/>
|
||||
|
||||
<div ref={editorResize.secondRef}>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'graphiql-response text-sm relative',
|
||||
hasSingleTab
|
||||
? styles.graphiqlResponseSingleTab
|
||||
@@ -440,7 +439,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
<div className="graphiql-plugin">{PluginContent ? <PluginContent /> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx('graphiql-sidebar', styles.graphiqlSidebar)}>
|
||||
<div className={cn('graphiql-sidebar', styles.graphiqlSidebar)}>
|
||||
<div className="graphiql-sidebar-section">
|
||||
{pluginContext?.plugins.map((plugin, index) => {
|
||||
const isVisible = plugin === pluginContext.visiblePlugin
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitBranch, Github } from 'lucide-react'
|
||||
import CardButton from 'components/ui/CardButton'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
|
||||
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
|
||||
import type { ProjectInfo } from 'data/projects/projects-query'
|
||||
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
@@ -73,6 +74,7 @@ const ProjectCard = ({
|
||||
footer={
|
||||
<ProjectCardStatus projectStatus={projectStatus} resourceWarnings={resourceWarnings} />
|
||||
}
|
||||
containerElement={<ProjectIndexPageLink projectRef={projectRef} />}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
@@ -33,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'
|
||||
@@ -90,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,
|
||||
@@ -131,7 +143,7 @@ export const CreateCronJobSheet = ({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: selectedCronJob?.jobname || '',
|
||||
schedule: selectedCronJob?.schedule || '',
|
||||
schedule: selectedCronJob?.schedule || '*/5 * * * *',
|
||||
values: cronJobValues,
|
||||
},
|
||||
})
|
||||
@@ -228,10 +240,14 @@ export const CreateCronJobSheet = ({
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="vertical" className="gap-1">
|
||||
<FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} />
|
||||
<Input_Shadcn_ {...field} disabled={isEditing} />
|
||||
</FormControl_Shadcn_>
|
||||
|
||||
<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
|
||||
Cron jobs cannot be renamed once created
|
||||
</FormLabel_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
+236
-59
@@ -1,108 +1,241 @@
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
import { useCompletion } from 'ai/react'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import {
|
||||
Accordion_Shadcn_,
|
||||
AccordionContent_Shadcn_,
|
||||
AccordionItem_Shadcn_,
|
||||
AccordionTrigger_Shadcn_,
|
||||
Button,
|
||||
cn,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
SheetSection,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
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 schedules = [
|
||||
const PRESETS = [
|
||||
{ name: 'Every minute', expression: '* * * * *' },
|
||||
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
|
||||
{ name: 'Every hour, at 30 minutes', expression: '30 * * * *' },
|
||||
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
|
||||
{ name: 'Custom', expression: 'custom' },
|
||||
{ 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) => {
|
||||
let initialValue = schedules[0].expression as string
|
||||
const scheduleValue = form.getValues('schedule')
|
||||
if (scheduleValue && scheduleValue.length > 0) {
|
||||
initialValue = 'custom'
|
||||
}
|
||||
const { project } = useProjectContext()
|
||||
const initialValue = form.getValues('schedule')
|
||||
const { schedule } = form.watch()
|
||||
|
||||
const [presetValue, setPresetValue] = useState<string>(initialValue)
|
||||
const [inputValue, setInputValue] = useState(initialValue)
|
||||
const [debouncedValue] = useDebounce(inputValue, 750)
|
||||
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
|
||||
const [scheduleString, setScheduleString] = useState('')
|
||||
|
||||
const onChangeSelectValue = (v: string) => {
|
||||
setPresetValue(v)
|
||||
}
|
||||
const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
|
||||
api: `${BASE_PATH}/api/ai/sql/cron`,
|
||||
onResponse: async (response) => {
|
||||
if (response.ok) {
|
||||
// remove quotes from the cron expression
|
||||
const expression = (await response.text()).trim().replace(/^"|"$/g, '')
|
||||
form.setValue('schedule', expression)
|
||||
setPresetValue(expression)
|
||||
setScheduleString(CronToString(expression))
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error generating cron:', error)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: timezone } = useCronTimezoneQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (presetValue !== 'custom') {
|
||||
form.setValue('schedule', presetValue)
|
||||
if (!useNaturalLanguage || !debouncedValue) return
|
||||
generateCronSyntax(debouncedValue)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedValue, useNaturalLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
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 (error) {
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
}, [presetValue, form])
|
||||
}, [form, inputValue])
|
||||
|
||||
useEffect(() => {
|
||||
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])
|
||||
|
||||
return (
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
render={({ field, fieldState }) => {
|
||||
let scheduleString = ''
|
||||
try {
|
||||
scheduleString = CronToString(field.value)
|
||||
} catch {}
|
||||
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem_Shadcn_ className="flex flex-col gap-1">
|
||||
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
|
||||
<FormLabel_Shadcn_ className="text-foreground-lighter">
|
||||
{useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'}
|
||||
</FormLabel_Shadcn_>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select_Shadcn_ onValueChange={onChangeSelectValue} value={presetValue}>
|
||||
<FormControl_Shadcn_>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select a method for the HTTP request" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{schedules.map((schedule) => {
|
||||
return (
|
||||
<SelectItem_Shadcn_ key={schedule.name} value={schedule.expression}>
|
||||
{schedule.name}
|
||||
</SelectItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
disabled={field.disabled || presetValue !== 'custom'}
|
||||
size="large"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{useNaturalLanguage ? (
|
||||
<Input
|
||||
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)}
|
||||
/>
|
||||
) : (
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="* * * * *"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={useNaturalLanguage}
|
||||
onCheckedChange={() => {
|
||||
setUseNaturalLanguage(!useNaturalLanguage)
|
||||
setInputValue('')
|
||||
setPresetValue('')
|
||||
setScheduleString('')
|
||||
form.setValue('schedule', '')
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">Use natural language</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<ul className="flex gap-2 flex-wrap mt-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<li key={preset.name}>
|
||||
<Button
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setUseNaturalLanguage(false)
|
||||
form.setValue('schedule', preset.expression)
|
||||
setPresetValue(preset.expression)
|
||||
}}
|
||||
>
|
||||
{preset.name}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Accordion_Shadcn_ type="single" collapsible className="mt-2 pb-2">
|
||||
<AccordionItem_Shadcn_ value="item-1" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="text-xs text-foreground-light font-normal gap-2 justify-start py-1 ">
|
||||
View syntax chart
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ asChild className="!pb-0">
|
||||
<CronSyntaxChart />
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
<FormMessage_Shadcn_ className="mt-1">
|
||||
|
||||
<div className="bg-surface-100 p-4 rounded grid gap-y-4 border">
|
||||
<h4 className="text-sm text-foreground">
|
||||
Schedule {timezone ? `(${timezone})` : ''}
|
||||
</h4>
|
||||
{scheduleString ? (
|
||||
<span className="text-foreground-light">
|
||||
The cron will be run {/* lowercase the first letter */}
|
||||
{scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ')}
|
||||
.
|
||||
<span
|
||||
className={cn(
|
||||
'text-xl font-mono',
|
||||
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
) : (
|
||||
!fieldState.error && <span className="h-5" />
|
||||
<span className="text-xl font-mono text-foreground-lighter">
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
)}
|
||||
</FormMessage_Shadcn_>
|
||||
{!inputValue && !isGeneratingCron && !scheduleString ? (
|
||||
<span className="text-sm text-foreground-light">
|
||||
Describe your schedule above
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-foreground-light flex items-center gap-2">
|
||||
{isGeneratingCron ? (
|
||||
<LoadingDots />
|
||||
) : scheduleString === '' ? ( // set a min length before showing invalid message
|
||||
'Enter a valid cron expression above'
|
||||
) : scheduleString.includes('Invalid cron expression') ? (
|
||||
'Invalid cron expression'
|
||||
) : (
|
||||
<>
|
||||
The cron will be run{' '}
|
||||
{secondsPattern.test(schedule)
|
||||
? 'every ' + schedule
|
||||
: scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)
|
||||
}}
|
||||
@@ -110,3 +243,47 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
</SheetSection>
|
||||
)
|
||||
}
|
||||
|
||||
const CronSyntaxLoader = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{['*', '*', '*', '*', '*'].map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0.3 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
delay: i * 0.15,
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</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,24 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { Button, Sheet, SheetContent } from 'ui'
|
||||
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { CreateCronJobSheet } from './CreateCronJobSheet'
|
||||
import { CronJobCard } from './CronJobCard'
|
||||
import { CronJobsDisabledState } from './CronJobsDisabledState'
|
||||
import DeleteCronJob from './DeleteCronJob'
|
||||
import { Sheet, SheetContent } from 'ui'
|
||||
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
|
||||
|
||||
export const CronJobsListing = () => {
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
|
||||
// used for confirmation prompt in the Create Cron Job Sheet
|
||||
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
|
||||
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
|
||||
@@ -26,64 +19,16 @@ export const CronJobsListing = () => {
|
||||
>()
|
||||
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
|
||||
|
||||
const {
|
||||
data: cronJobs,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useCronJobsQuery({
|
||||
const { data: cronJobs, isLoading } = useCronJobsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron')
|
||||
const pgCronExtensionInstalled = pgCronExtension?.installed_version
|
||||
|
||||
useEffect(() => {
|
||||
// refetch the cron jobs afte the pgcron extension has been installed
|
||||
if (pgCronExtensionInstalled && isError) {
|
||||
refetch()
|
||||
}
|
||||
}, [isError, pgCronExtensionInstalled, refetch])
|
||||
|
||||
// this avoid showing loading screen when the extension is not installed. Otherwise, we'll have to wait for three
|
||||
// retries (which are sure to fail because the extension is not installed)
|
||||
if (isLoadingExtensions) return <GenericSkeletonLoader />
|
||||
if (!pgCronExtensionInstalled) return <CronJobsDisabledState />
|
||||
if (isLoading) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to retrieve database cron jobs" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full space-y-4">
|
||||
<FormHeader
|
||||
title="Cron jobs"
|
||||
description="Schedule and automate tasks like running queries or maintenance routines at specified intervals"
|
||||
actions={
|
||||
<>
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pg_cron" />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
setCreateCronJobSheetShown({
|
||||
jobname: '',
|
||||
schedule: '',
|
||||
command: '',
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create a new cron job
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{(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>
|
||||
@@ -93,7 +38,7 @@ export const CronJobsListing = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{cronJobs.map((job) => (
|
||||
{(cronJobs ?? []).map((job) => (
|
||||
<CronJobCard
|
||||
job={job}
|
||||
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
|
||||
@@ -104,6 +49,12 @@ export const CronJobsListing = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteCronJob
|
||||
visible={!!cronJobForDeletion}
|
||||
onClose={() => setCronJobForDeletion(undefined)}
|
||||
cronJob={cronJobForDeletion!}
|
||||
/>
|
||||
|
||||
<Sheet
|
||||
open={!!createCronJobSheetShown}
|
||||
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
|
||||
@@ -120,16 +71,6 @@ export const CronJobsListing = () => {
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DeleteCronJob
|
||||
visible={!!cronJobForDeletion}
|
||||
onClose={() => setCronJobForDeletion(undefined)}
|
||||
cronJob={cronJobForDeletion!}
|
||||
/>
|
||||
<EnableExtensionModal
|
||||
visible={showEnableExtensionModal}
|
||||
extension={pgCronExtension}
|
||||
onCancel={() => setShowEnableExtensionModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export default function CronSyntaxChart() {
|
||||
return (
|
||||
<div>
|
||||
<pre className="text-xs font-mono text-foreground-light">
|
||||
{/* prettier-ignore */}
|
||||
{`
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 7)
|
||||
│ │ │ │ │
|
||||
* * * * * `}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input,
|
||||
Input_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Separator,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { QUEUE_TYPES } from './Queues.constants'
|
||||
|
||||
export interface CreateQueueSheetProps {
|
||||
isClosing: boolean
|
||||
setIsClosing: (v: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const normalQueueSchema = z.object({
|
||||
type: z.literal('normal'),
|
||||
})
|
||||
|
||||
const partitionedQueueSchema = z.object({
|
||||
type: z.literal('partitioned'),
|
||||
partitionInterval: z.number(),
|
||||
retentionInterval: z.number(),
|
||||
})
|
||||
|
||||
const unloggedQueueSchema = z.object({
|
||||
type: z.literal('unlogged'),
|
||||
})
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a name for your queue')
|
||||
.max(47, "The name can't be longer than 47 characters"),
|
||||
values: z.discriminatedUnion('type', [
|
||||
normalQueueSchema,
|
||||
partitionedQueueSchema,
|
||||
unloggedQueueSchema,
|
||||
]),
|
||||
})
|
||||
|
||||
export type CreateQueueForm = z.infer<typeof FormSchema>
|
||||
export type QueueType = CreateQueueForm['values']
|
||||
|
||||
const FORM_ID = 'create-queue-sidepanel'
|
||||
|
||||
export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => {
|
||||
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
|
||||
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
|
||||
|
||||
const canToggleExtensions = useCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'extensions'
|
||||
)
|
||||
|
||||
const form = useForm<CreateQueueForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
values: {
|
||||
type: 'normal',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const isEdited = form.formState.isDirty
|
||||
|
||||
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
|
||||
if (!isEdited && isClosing) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onClosePanel = () => {
|
||||
if (isEdited) {
|
||||
setIsClosing(true)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit: SubmitHandler<CreateQueueForm> = async ({ name, values }) => {
|
||||
let query = `SELECT pgmq.create('${name}');`
|
||||
if (values.type === 'partitioned') {
|
||||
query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');`
|
||||
}
|
||||
if (values.type === 'unlogged') {
|
||||
query = `SELECT pgmq.create_unlogged('${name}');`
|
||||
}
|
||||
|
||||
createQueue(
|
||||
{
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
query,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully created queue ${name}`)
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const { data } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const pgPartmanExtension = (data ?? []).find((ext) => ext.name === 'pg_partman')
|
||||
const pgPartmanExtensionInstalled = pgPartmanExtension?.installed_version != undefined
|
||||
|
||||
const queueType = form.watch('values.type')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create a new queue</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="overflow-auto flex-grow">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
className="flex-grow overflow-auto"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="vertical" className="gap-1">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="values.type"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Type" layout="vertical" className="gap-1">
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStacked
|
||||
id="queue_type"
|
||||
name="queue_type"
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{QUEUE_TYPES.map((definition) => (
|
||||
<RadioGroupStackedItem
|
||||
key={definition.value}
|
||||
id={definition.value}
|
||||
value={definition.value}
|
||||
label=""
|
||||
disabled={
|
||||
!pgPartmanExtensionInstalled && definition.value === 'partitioned'
|
||||
}
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex items-center gap-x-5">
|
||||
<div className="text-foreground">{definition.icon}</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-x-2">
|
||||
<p className="text-foreground">{definition.label}</p>
|
||||
</div>
|
||||
<p className="text-foreground-light text-left">
|
||||
{definition.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{definition.value === 'partitioned' ? (
|
||||
<div className="pt-2 pl-10">
|
||||
<Badge variant="warning">COMING SOON</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
{/* {!pgPartmanExtensionInstalled &&
|
||||
definition.value === 'partitioned' ? (
|
||||
<div className="w-full flex gap-x-2 pl-11 py-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs">
|
||||
<code>pg_partman</code> needs to be installed to use this type
|
||||
</span>
|
||||
</div>
|
||||
) : null} */}
|
||||
</RadioGroupStackedItem>
|
||||
))}
|
||||
</RadioGroupStacked>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{/* {!pgPartmanExtensionInstalled && (
|
||||
<Admonition
|
||||
type="note"
|
||||
// @ts-ignore
|
||||
title={
|
||||
<span>
|
||||
Enable <code className="text-xs w-min">pg_partman</code> for partitioned
|
||||
queues
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<span>
|
||||
This will allow you to create partitioned queues which can handle a large
|
||||
amount of messages
|
||||
</span>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
className="w-min"
|
||||
disabled={!canToggleExtensions}
|
||||
onClick={() => setShowEnableExtensionModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'You need additional permissions to enable database extensions',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Install pg_partman extension
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)} */}
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
{queueType === 'partitioned' && (
|
||||
<>
|
||||
<SheetSection className="flex flex-col gap-3">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="values.partitionInterval"
|
||||
render={({ field: { ref, ...rest } }) => (
|
||||
<FormItemLayout label="Partition interval" className="gap-1">
|
||||
<Input
|
||||
{...rest}
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
actions={<p className="text-foreground-light pr-2">ms</p>}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="values.retentionInterval"
|
||||
render={({ field: { ref, ...rest } }) => (
|
||||
<FormItemLayout label="Retention interval" className="gap-1">
|
||||
<Input
|
||||
{...rest}
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
actions={<p className="text-foreground-light pr-2">ms</p>}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="primary"
|
||||
form={FORM_ID}
|
||||
htmlType="submit"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create queue
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
visible={isClosing}
|
||||
title="Discard changes"
|
||||
confirmLabel="Discard"
|
||||
onCancel={() => setIsClosing(false)}
|
||||
onConfirm={() => onClose()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
|
||||
lost.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
{pgPartmanExtension && (
|
||||
<EnableExtensionModal
|
||||
visible={showEnableExtensionModal}
|
||||
extension={pgPartmanExtension}
|
||||
onCancel={() => setShowEnableExtensionModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Rows2, Rows3, Rows4 } from 'lucide-react'
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
{
|
||||
value: 'basic',
|
||||
icon: <Rows4 strokeWidth={1} />,
|
||||
label: 'Basic queue',
|
||||
description: 'Create a basic queue.',
|
||||
},
|
||||
{
|
||||
value: 'unlogged',
|
||||
icon: <Rows2 strokeWidth={1} />,
|
||||
label: 'Unlogged queue',
|
||||
description:
|
||||
'Creates an unlogged queue which loses all data on database restart. Can be useful when write throughput is more important than durability.',
|
||||
},
|
||||
{
|
||||
value: 'partitioned',
|
||||
icon: <Rows3 strokeWidth={1} />,
|
||||
label: 'Partitioned queue',
|
||||
description: 'Create a partitioned queue which is optimized for large amount of messages',
|
||||
},
|
||||
] as const
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useState } from 'react'
|
||||
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export const QueuesDisabledState = () => {
|
||||
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
|
||||
const { project } = useProjectContext()
|
||||
const canToggleExtensions = useCheckPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'extensions'
|
||||
)
|
||||
|
||||
const { data } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const pgmqExtension = (data ?? []).find((ext) => ext.name === 'pgmq')
|
||||
|
||||
if (!pgmqExtension) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ProductEmptyState title="Queues">
|
||||
<div className="text-sm text-foreground-light mb-4 grid gap-2">
|
||||
<p>
|
||||
Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads,
|
||||
and enable reliable communication between different parts of your application.
|
||||
</p>
|
||||
<p>Enable queues in your project.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button type="primary" onClick={() => setShowEnableExtensionModal(true)}>
|
||||
Enable Queues
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canToggleExtensions && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-foreground">
|
||||
You need additional permissions to enable pgmq for this project
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pgmq" />
|
||||
</div>
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
|
||||
<EnableExtensionModal
|
||||
visible={showEnableExtensionModal}
|
||||
extension={pgmqExtension}
|
||||
onCancel={() => setShowEnableExtensionModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useQueuesQuery } from 'data/database-queues/database-queues-query'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { Button, Input, Sheet, SheetContent } from 'ui'
|
||||
import { CreateQueueSheet } from './CreateQueueSheet'
|
||||
import { QueuesDisabledState } from './QueuesDisabledState'
|
||||
import { QueuesRows } from './QueuesRows'
|
||||
|
||||
export const QueuesListing = () => {
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
|
||||
// used for confirmation prompt in the Create Queue Sheet
|
||||
const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false)
|
||||
const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false)
|
||||
|
||||
const {
|
||||
data: queues,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQueuesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const pgmqExtension = (extensions ?? []).find((ext) => ext.name === 'pgmq')
|
||||
// TODO: Change this to true for local development to work
|
||||
const pgmqExtensionInstalled = pgmqExtension?.installed_version
|
||||
|
||||
useEffect(() => {
|
||||
// refetch the queues after the pgmq extension has been installed
|
||||
if (pgmqExtensionInstalled && isError) {
|
||||
refetch()
|
||||
}
|
||||
}, [isError, pgmqExtensionInstalled, refetch])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useQueryState('search')
|
||||
|
||||
// this avoid showing loading screen when the extension is not installed. Otherwise, we'll have to wait for three
|
||||
// retries (which are sure to fail because the extension is not installed)
|
||||
if (isLoadingExtensions) return <GenericSkeletonLoader />
|
||||
if (!pgmqExtensionInstalled) return <QueuesDisabledState />
|
||||
if (isLoading) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to retrieve database queues" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full space-y-4">
|
||||
<FormHeader
|
||||
title="Queues"
|
||||
description="Use message queues to handle asynchronous tasks, manage workloads, and enable reliable communication between different parts of your application."
|
||||
/>
|
||||
{queues.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 queues created yet</p>
|
||||
<Button onClick={() => setCreateQueueSheetShown(true)}>Add a new queue</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap">
|
||||
<Input
|
||||
placeholder="Search for a queue"
|
||||
size="small"
|
||||
icon={<Search size={14} />}
|
||||
value={searchQuery || ''}
|
||||
className="w-64"
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button onClick={() => setCreateQueueSheetShown(true)}>Create a queue</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
className="table-fixed overflow-x-auto"
|
||||
head={
|
||||
<>
|
||||
<Table.th key="name">Name</Table.th>
|
||||
<Table.th key="arguments" className="table-cell">
|
||||
Type
|
||||
</Table.th>
|
||||
<Table.th key="created_at" className="table-cell w-60">
|
||||
Created at
|
||||
</Table.th>
|
||||
<Table.th key="queue_size" className="table-cell">
|
||||
<div className="flex justify-center">Size</div>
|
||||
</Table.th>
|
||||
<Table.th key="buttons" className="table-cell"></Table.th>
|
||||
</>
|
||||
}
|
||||
body={<QueuesRows queues={queues} filterString={searchQuery || ''} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={createQueueSheetShown} onOpenChange={() => setIsClosingCreateQueueSheet(true)}>
|
||||
<SheetContent size="default" tabIndex={undefined}>
|
||||
<CreateQueueSheet
|
||||
onClose={() => {
|
||||
setIsClosingCreateQueueSheet(false)
|
||||
setCreateQueueSheetShown(false)
|
||||
}}
|
||||
isClosing={isClosingCreateQueueSheet}
|
||||
setIsClosing={setIsClosingCreateQueueSheet}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{pgmqExtension ? (
|
||||
<EnableExtensionModal
|
||||
visible={showEnableExtensionModal}
|
||||
extension={pgmqExtension}
|
||||
onCancel={() => setShowEnableExtensionModal(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { includes, sortBy } from 'lodash'
|
||||
import { ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
|
||||
import { PostgresQueue } from 'data/database-queues/database-queues-query'
|
||||
import { DATETIME_FORMAT } from 'lib/constants'
|
||||
|
||||
interface QueuesRowsProps {
|
||||
queues: PostgresQueue[]
|
||||
filterString: string
|
||||
}
|
||||
|
||||
const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
|
||||
const router = useRouter()
|
||||
const { project: selectedProject } = useProjectContext()
|
||||
|
||||
const { data: metrics, isLoading } = useQueuesMetricsQuery(
|
||||
{
|
||||
queueName: queue.queue_name,
|
||||
projectRef: selectedProject?.ref,
|
||||
connectionString: selectedProject?.connectionString,
|
||||
},
|
||||
{
|
||||
staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute
|
||||
}
|
||||
)
|
||||
|
||||
const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
|
||||
|
||||
return (
|
||||
<Table.tr
|
||||
key={queue.queue_name}
|
||||
onClick={() => {
|
||||
router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`)
|
||||
}}
|
||||
className="hover:"
|
||||
>
|
||||
<Table.td className="truncate">
|
||||
<p title={queue.queue_name}>{queue.queue_name}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell overflow-auto">
|
||||
<p title={type.toLocaleLowerCase()} className="truncate">
|
||||
{type}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
<p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
<div className="flex justify-center">
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<p>
|
||||
{metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end">
|
||||
<ChevronRight size="18" />
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => {
|
||||
const filteredQueues = fetchedQueues.filter((x) =>
|
||||
includes(x.queue_name.toLowerCase(), filterString.toLowerCase())
|
||||
)
|
||||
const queues = sortBy(filteredQueues, (func) => func.queue_name.toLocaleLowerCase())
|
||||
|
||||
if (queues.length === 0 && filterString.length > 0) {
|
||||
return (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{queues.map((q) => (
|
||||
<QueueRow key={q.queue_name} queue={q} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseQueueDeleteMutation } from 'data/database-queues/database-queues-delete-mutation'
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface DeleteQueueProps {
|
||||
queueName: string
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const router = useRouter()
|
||||
|
||||
const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully removed queue ${queueName}`)
|
||||
router.push(`/project/${project?.ref}/integrations/queues`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
async function handleDelete() {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
deleteDatabaseQueue({
|
||||
queueName: queueName,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
}
|
||||
|
||||
if (!queueName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
variant="destructive"
|
||||
visible={visible}
|
||||
onCancel={() => onClose()}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete this queue"
|
||||
loading={isLoading}
|
||||
confirmLabel={`Delete queue ${queueName}`}
|
||||
confirmPlaceholder="Type in name of queue"
|
||||
confirmString={queueName ?? 'Unknown'}
|
||||
text={
|
||||
<>
|
||||
<span>This will delete the queue</span>{' '}
|
||||
<span className="text-bold text-foreground">{queueName}</span>
|
||||
</>
|
||||
}
|
||||
alert={{ title: 'You cannot recover this queue and its messages once deleted.' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteQueue
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown'
|
||||
import { isNil, noop } from 'lodash'
|
||||
import { Archive, Clock12, Trash2, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { MonacoEditor } from 'components/grid/components/common/MonacoEditor'
|
||||
import { RowAction, RowData } from 'components/interfaces/Auth/Users/UserOverview'
|
||||
import { useDatabaseQueueMessageArchiveMutation } from 'data/database-queues/database-queue-messages-archive-mutation'
|
||||
import { useDatabaseQueueMessageDeleteMutation } from 'data/database-queues/database-queue-messages-delete-mutation'
|
||||
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
|
||||
import { useDatabaseQueueMessageReadMutation } from 'data/database-queues/database-queue-messages-read-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { prettifyJSON } from 'lib/helpers'
|
||||
import {
|
||||
Button,
|
||||
ResizablePanel,
|
||||
Separator,
|
||||
Tabs_Shadcn_,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
export const DATE_FORMAT = 'DD MMM, YYYY HH:mm'
|
||||
|
||||
interface MessageDetailsPanelProps {
|
||||
selectedMessage: PostgresQueueMessage
|
||||
setSelectedMessage: (value: number | null) => void
|
||||
}
|
||||
|
||||
const tryFormatInitialValue = (value: string) => {
|
||||
try {
|
||||
const jsonValue = JSON.parse(value)
|
||||
return JSON.stringify(jsonValue)
|
||||
} catch (err) {
|
||||
if (typeof value === 'string') {
|
||||
return value.replaceAll(`\"`, `"`)
|
||||
} else {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageDetailsPanel = ({
|
||||
selectedMessage,
|
||||
setSelectedMessage,
|
||||
}: MessageDetailsPanelProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const { name: queueName } = useParams()
|
||||
const project = useSelectedProject()
|
||||
|
||||
useEscapeKeydown(() => setSelectedMessage(null))
|
||||
|
||||
const {
|
||||
mutate: archiveMessage,
|
||||
isLoading: isLoadingArchive,
|
||||
isSuccess: isSuccessArchive,
|
||||
} = useDatabaseQueueMessageArchiveMutation()
|
||||
|
||||
const {
|
||||
mutate: readMessage,
|
||||
isLoading: isLoadingRead,
|
||||
isSuccess: isSuccessRead,
|
||||
} = useDatabaseQueueMessageReadMutation()
|
||||
|
||||
const {
|
||||
mutate: deleteMessage,
|
||||
isLoading: isLoadingDelete,
|
||||
isSuccess: isSuccessDelete,
|
||||
} = useDatabaseQueueMessageDeleteMutation()
|
||||
|
||||
const initialValue = JSON.stringify(selectedMessage?.message)
|
||||
const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '')
|
||||
|
||||
const [view, setView] = useState<'details' | 'suggestion'>('details')
|
||||
|
||||
return (
|
||||
<ResizablePanel
|
||||
defaultSize={30}
|
||||
maxSize={45}
|
||||
minSize={30}
|
||||
collapsible
|
||||
onCollapse={() => setSelectedMessage(null)}
|
||||
className="bg-studio border-t pointer-events-auto"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
className="absolute top-3 right-3 px-1"
|
||||
icon={<X />}
|
||||
onClick={() => setSelectedMessage(null)}
|
||||
/>
|
||||
|
||||
<Tabs_Shadcn_
|
||||
value={view}
|
||||
className="flex flex-col h-full"
|
||||
onValueChange={(value: any) => {
|
||||
setView(value)
|
||||
}}
|
||||
>
|
||||
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
|
||||
<TabsTrigger_Shadcn_
|
||||
value="details"
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
<TabsContent_Shadcn_ value="details" className="w-full mt-0 overflow-y-auto grow">
|
||||
<div className="flex flex-col px-4 py-4 text-sm">
|
||||
<RowData property="Message ID" value={`${selectedMessage.msg_id}`} />
|
||||
<RowData
|
||||
property="Added at"
|
||||
value={dayjs(selectedMessage.enqueued_at).format(DATE_FORMAT)}
|
||||
/>
|
||||
<RowData
|
||||
property="Available at"
|
||||
value={dayjs(selectedMessage.vt).format(DATE_FORMAT)}
|
||||
/>
|
||||
<RowData property="Retries" value={`${selectedMessage.read_ct}`} />
|
||||
|
||||
<div>
|
||||
<h3 className="text-foreground-light py-1">Payload</h3>
|
||||
<MonacoEditor
|
||||
key={selectedMessage.msg_id}
|
||||
onChange={noop}
|
||||
width="100%"
|
||||
value={jsonString || 'NULL'}
|
||||
language="json"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col px-4 py-4 -space-y-1">
|
||||
{!selectedMessage.archived_at ? (
|
||||
<>
|
||||
<RowAction
|
||||
title="Postpone message"
|
||||
description="The message will be postponed and won't show up in reads for 60 seconds."
|
||||
button={{
|
||||
icon: <Clock12 />,
|
||||
text: 'Postpone',
|
||||
isLoading: isLoadingRead,
|
||||
onClick: () => {
|
||||
readMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queryName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
duration: 60,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessRead
|
||||
? {
|
||||
title: 'Postponed',
|
||||
description: 'The message was postponed for 60 seconds.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RowAction
|
||||
title="Archive message"
|
||||
description="The message will be marked as archived and hidden from future reads by consumers. You can still access the message later."
|
||||
button={{
|
||||
icon: <Archive />,
|
||||
text: 'Archive',
|
||||
isLoading: isLoadingArchive,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
archiveMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queryName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessArchive
|
||||
? {
|
||||
title: 'Archived',
|
||||
description: 'The message was archived successfully.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<RowAction
|
||||
title="Delete message"
|
||||
description="The message cannot be recovered afterwards."
|
||||
button={{
|
||||
icon: <Trash2 />,
|
||||
text: 'Delete',
|
||||
type: 'danger',
|
||||
isLoading: isLoadingDelete,
|
||||
onClick: () => {
|
||||
deleteMessage({
|
||||
projectRef: project!.ref,
|
||||
connectionString: project?.connectionString,
|
||||
queueName: queueName!,
|
||||
messageId: selectedMessage.msg_id,
|
||||
})
|
||||
},
|
||||
}}
|
||||
success={
|
||||
isSuccessDelete
|
||||
? {
|
||||
title: 'Deleted',
|
||||
description: 'The message was deleted successfully.',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
</ResizablePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation'
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface PurgeQueueProps {
|
||||
queueName: string
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const router = useRouter()
|
||||
|
||||
const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully purged queue ${queueName}`)
|
||||
router.push(`/project/${project?.ref}/integrations/queues`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
async function handlePurge() {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
purgeDatabaseQueue({
|
||||
queueName: queueName,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
}
|
||||
|
||||
if (!queueName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
variant="warning"
|
||||
visible={visible}
|
||||
onCancel={() => onClose()}
|
||||
onConfirm={handlePurge}
|
||||
title="Purge this queue"
|
||||
loading={isLoading}
|
||||
confirmLabel={`Purge queue ${queueName}`}
|
||||
confirmPlaceholder="Type in name of queue"
|
||||
confirmString={queueName ?? 'Unknown'}
|
||||
text={
|
||||
<>
|
||||
<span>This will purge the queue</span>{' '}
|
||||
<span className="text-bold text-foreground">{queueName}</span>
|
||||
</>
|
||||
}
|
||||
alert={{
|
||||
title:
|
||||
"This action will delete all messages from the queue. They can't be recovered afterwards.",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeQueue
|
||||
@@ -0,0 +1,9 @@
|
||||
import { capitalize } from 'lodash'
|
||||
|
||||
export const QUEUE_MESSAGE_TYPES = ['archived', 'available', 'scheduled'] as const
|
||||
export type QUEUE_MESSAGE_TYPE = (typeof QUEUE_MESSAGE_TYPES)[number]
|
||||
|
||||
export const QUEUE_MESSAGE_OPTIONS = QUEUE_MESSAGE_TYPES.map((type) => ({
|
||||
id: type,
|
||||
name: capitalize(type),
|
||||
}))
|
||||
@@ -0,0 +1,219 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { TextSearch } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsInteger, useQueryState } from 'nuqs'
|
||||
import { UIEvent, useMemo, useRef } from 'react'
|
||||
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
|
||||
|
||||
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
|
||||
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
|
||||
|
||||
interface QueueDataGridProps {
|
||||
isLoading: boolean
|
||||
messages: PostgresQueueMessage[]
|
||||
showMessageModal: () => void
|
||||
fetchNextPage: () => void
|
||||
}
|
||||
|
||||
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
|
||||
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
|
||||
}
|
||||
|
||||
const messagesCols = [
|
||||
{
|
||||
id: 'id',
|
||||
name: 'ID',
|
||||
description: undefined,
|
||||
minWidth: 50,
|
||||
width: 70,
|
||||
value: (row: PostgresQueueMessage) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="text-xs">{row.msg_id}</h3>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enqueued_at',
|
||||
name: 'Added at',
|
||||
description: undefined,
|
||||
minWidth: 160,
|
||||
width: 180,
|
||||
value: (row: PostgresQueueMessage) => (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{dayjs(row.enqueued_at).format(DATE_FORMAT)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
name: 'Status',
|
||||
description: undefined,
|
||||
minWidth: 280,
|
||||
width: 300,
|
||||
value: (row: PostgresQueueMessage) => {
|
||||
const isAvailable = new Date() > new Date(row.vt)
|
||||
|
||||
if (row.archived_at) {
|
||||
return (
|
||||
<Badge variant="default">Archived at {dayjs(row.archived_at).format(DATE_FORMAT)}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={isAvailable ? 'brand' : 'warning'}>
|
||||
{isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'retries',
|
||||
name: 'Retries',
|
||||
description: undefined,
|
||||
minWidth: 50,
|
||||
width: 70,
|
||||
value: (row: PostgresQueueMessage) => <span>{row.read_ct}</span>,
|
||||
},
|
||||
{
|
||||
id: 'payload',
|
||||
name: 'Payload',
|
||||
description: undefined,
|
||||
minWidth: 600,
|
||||
value: (row: PostgresQueueMessage) => <span>{JSON.stringify(row.message)}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
const columns = messagesCols.map((col) => {
|
||||
const result: Column<PostgresQueueMessage> = {
|
||||
key: col.id,
|
||||
name: col.name,
|
||||
resizable: true,
|
||||
minWidth: col.minWidth ?? 120,
|
||||
width: col.width,
|
||||
headerCellClass: 'first:pl-6 cursor-pointer',
|
||||
renderHeaderCell: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between font-normal text-xs w-full">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="!text-foreground">{col.name}</p>
|
||||
{col.description && <p className="text-foreground-lighter">{col.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
renderCell: (props) => {
|
||||
const value = col.value(props.row)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex flex-col justify-center font-mono text-xs',
|
||||
typeof value === 'number' ? 'text-right' : ''
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
export const QueueMessagesDataGrid = ({
|
||||
isLoading,
|
||||
messages,
|
||||
showMessageModal,
|
||||
fetchNextPage,
|
||||
}: QueueDataGridProps) => {
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger)
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (isLoading || !isAtBottom(event)) return
|
||||
fetchNextPage()
|
||||
}
|
||||
|
||||
const selectedMessage = useMemo(
|
||||
() => messages.find((m) => m.msg_id === selectedMessageId),
|
||||
[messages, selectedMessageId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<DataGrid
|
||||
ref={gridRef}
|
||||
className="h-full"
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
onScroll={handleScroll}
|
||||
rows={messages}
|
||||
rowClass={(message) => {
|
||||
const isSelected = message.msg_id === selectedMessageId
|
||||
return [
|
||||
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
|
||||
`${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`,
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-4',
|
||||
].join(' ')
|
||||
}}
|
||||
renderers={{
|
||||
renderRow(idx, props) {
|
||||
return (
|
||||
<Row
|
||||
key={props.row.msg_id}
|
||||
{...props}
|
||||
onClick={() => {
|
||||
if (typeof idx === 'number' && idx >= 0) {
|
||||
setSelectedMessageId(props.row.msg_id)
|
||||
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
||||
const { id, ...rest } = router.query
|
||||
router.push({ ...router, query: { ...rest, id: props.row.msg_id } })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
noRowsFallback: isLoading ? (
|
||||
<div className="absolute top-14 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
|
||||
<TextSearch className="text-foreground-muted" strokeWidth={1} />
|
||||
<div className="text-center">
|
||||
<p className="text-foreground">No messages found</p>
|
||||
<p className="text-foreground-light">
|
||||
The selected queue doesn't have any messages.
|
||||
</p>
|
||||
<Button className="mt-2" onClick={() => showMessageModal()}>
|
||||
Add message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="absolute inset-0 z-10 pointer-events-none"
|
||||
autoSaveId="queue-messages-layout-v1"
|
||||
>
|
||||
<ResizablePanel defaultSize={1} />
|
||||
{selectedMessage && (
|
||||
<>
|
||||
<ResizableHandle withHandle className="pointer-events-auto" />
|
||||
<MessageDetailsPanel
|
||||
selectedMessage={selectedMessage}
|
||||
setSelectedMessage={setSelectedMessageId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { QUEUE_MESSAGE_OPTIONS, QUEUE_MESSAGE_TYPE } from './Queue.utils'
|
||||
|
||||
interface QueueFiltersProps {
|
||||
selectedTypes: QUEUE_MESSAGE_TYPE[]
|
||||
setSelectedTypes: (value: QUEUE_MESSAGE_TYPE[]) => void
|
||||
}
|
||||
|
||||
export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => {
|
||||
return (
|
||||
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FilterPopover
|
||||
name={selectedTypes.length === 0 ? 'All types' : 'Types'}
|
||||
title="Select types to show"
|
||||
buttonType={selectedTypes.length === 0 ? 'dashed' : 'default'}
|
||||
options={QUEUE_MESSAGE_OPTIONS} // Ignore user image column
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
clearButtonText="Reset"
|
||||
activeOptions={selectedTypes}
|
||||
onSaveFilters={(value) => setSelectedTypes(value as any)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
interface SendMessageModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
delay: z.coerce.number().int().gte(0).default(5),
|
||||
payload: z.string().refine(
|
||||
(val) => {
|
||||
try {
|
||||
JSON.parse(val)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{
|
||||
message: 'The payload should be a JSON object',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
export type SendMessageForm = z.infer<typeof FormSchema>
|
||||
|
||||
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
|
||||
|
||||
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
|
||||
const { name: queueName } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const form = useForm<SendMessageForm>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
delay: 1,
|
||||
payload: '{}',
|
||||
},
|
||||
})
|
||||
|
||||
const { isLoading, mutate } = useDatabaseQueueMessageSendMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully added a message to the queue.`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<SendMessageForm> = (values) => {
|
||||
mutate({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
queueName: queueName!,
|
||||
payload: values.payload,
|
||||
delay: values.delay,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.reset({ delay: 1, payload: '{}' })
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="medium"
|
||||
alignFooter="right"
|
||||
header="Add a message to the queue"
|
||||
visible={visible}
|
||||
loading={isLoading}
|
||||
onCancel={onClose}
|
||||
confirmText="Add"
|
||||
onConfirm={() => {
|
||||
const values = form.getValues()
|
||||
onSubmit(values)
|
||||
}}
|
||||
>
|
||||
<Modal.Content className="flex flex-col gap-y-4">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
className="flex-grow overflow-auto gap-2 flex flex-col"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="delay"
|
||||
render={({ field: { ref, ...rest } }) => (
|
||||
<FormItemLayout
|
||||
label="Delay"
|
||||
layout="vertical"
|
||||
className="gap-1"
|
||||
description="Time in seconds before the message becomes available for reading."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
{...rest}
|
||||
type="number"
|
||||
placeholder="1"
|
||||
actions={<p className="text-foreground-light pr-2">sec</p>}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="payload"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Message payload" layout="vertical" className="gap-1">
|
||||
<FormControl_Shadcn_>
|
||||
<CodeEditor
|
||||
id="message-payload"
|
||||
language="json"
|
||||
className="!mb-0 h-32 overflow-hidden rounded border"
|
||||
onInputChange={(e: string | undefined) => field.onChange(e)}
|
||||
options={{ wordWrap: 'off', contextmenu: false }}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -11,27 +10,28 @@ import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
|
||||
import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DatePicker } from 'components/ui/DatePicker'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
import {
|
||||
AuditLog,
|
||||
useOrganizationAuditLogsQuery,
|
||||
} from 'data/organizations/organization-audit-logs-query'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
// [Joshen considerations]
|
||||
// - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible
|
||||
@@ -58,7 +58,7 @@ const AuditLogs = () => {
|
||||
const { data: projects } = useProjectsQuery()
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
const { data: rolesData } = useOrganizationRolesQuery({ slug })
|
||||
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
|
||||
const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } =
|
||||
useOrganizationAuditLogsQuery(
|
||||
{
|
||||
@@ -96,7 +96,7 @@ const AuditLogs = () => {
|
||||
return () => clearInterval(interval)
|
||||
}, [dateRange.from, dateRange.to])
|
||||
|
||||
const roles = rolesData?.roles ?? []
|
||||
const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])]
|
||||
|
||||
const retentionPeriod = data?.retention_period ?? 0
|
||||
const logs = data?.result ?? []
|
||||
@@ -229,16 +229,22 @@ const AuditLogs = () => {
|
||||
}}
|
||||
renderFooter={() => {
|
||||
return (
|
||||
<Alert title="" variant="info" className="mx-3 pl-2 pr-2 pt-1 pb-2">
|
||||
Your organization has a log retention period of{' '}
|
||||
<span className="text-brand">
|
||||
{retentionPeriod} day
|
||||
{retentionPeriod > 1 ? 's' : ''}
|
||||
</span>
|
||||
. You may only view logs from{' '}
|
||||
{dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
|
||||
earliest date.
|
||||
</Alert>
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="default"
|
||||
className="w-auto mx-2 px-3 py-2"
|
||||
>
|
||||
<div className="text-xs text-foreground-light">
|
||||
Your organization has a log retention period of{' '}
|
||||
<span className="text-brand">
|
||||
{retentionPeriod} day
|
||||
{retentionPeriod > 1 ? 's' : ''}
|
||||
</span>
|
||||
. You may only view logs from{' '}
|
||||
{dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the
|
||||
earliest date.
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
@@ -284,37 +290,24 @@ const AuditLogs = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Date</p>
|
||||
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={
|
||||
dateSortDesc ? (
|
||||
<ArrowDown strokeWidth={1.5} size={14} />
|
||||
) : (
|
||||
<ArrowUp strokeWidth={1.5} size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDateSortDesc(!dateSortDesc)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="right">
|
||||
<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">
|
||||
{dateSortDesc ? 'Sort latest first' : 'Sort earliest first'}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={
|
||||
dateSortDesc ? (
|
||||
<ArrowDown strokeWidth={1.5} size={14} />
|
||||
) : (
|
||||
<ArrowUp strokeWidth={1.5} size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDateSortDesc(!dateSortDesc)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Table.th>,
|
||||
<Table.th key="actions" className="py-2"></Table.th>,
|
||||
|
||||
+3
-8
@@ -69,17 +69,12 @@ const BillingBreakdown = () => {
|
||||
|
||||
{invoiceFeatureEnabled && (
|
||||
<>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<p className="prose text-sm">
|
||||
The table shows your upcoming invoice, excluding credits. This invoice will
|
||||
continue updating until the end of your billing period on{' '}
|
||||
{billingCycleEnd.format('MMMM DD')}. See{' '}
|
||||
<Link
|
||||
className="text-green-900 transition hover:text-green-1000"
|
||||
href={`/org/${orgSlug}/usage`}
|
||||
>
|
||||
usage page
|
||||
</Link>{' '}
|
||||
for a more detailed usage breakdown.
|
||||
<Link href={`/org/${orgSlug}/usage`}>usage page</Link> for a more detailed usage
|
||||
breakdown.
|
||||
</p>
|
||||
|
||||
<UpcomingInvoice slug={orgSlug} />
|
||||
|
||||
+34
-32
@@ -1,15 +1,20 @@
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Info } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { useOrgUpcomingInvoiceQuery } from 'data/invoices/org-invoice-upcoming-query'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Collapsible } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Collapsible,
|
||||
Tooltip_Shadcn_,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { billingMetricUnit, formatUsage } from '../helpers'
|
||||
import { ChevronRight, Info } from 'lucide-react'
|
||||
|
||||
export interface UpcomingInvoiceProps {
|
||||
slug?: string
|
||||
@@ -170,7 +175,7 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
|
||||
className="!px-1"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'transition',
|
||||
usageFeesExpanded.includes(fee.description) && 'rotate-90'
|
||||
)}
|
||||
@@ -285,32 +290,29 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
|
||||
|
||||
const InvoiceTooltip = ({ text, linkRef }: { text: string; linkRef?: string }) => {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_>
|
||||
<Info size={12} strokeWidth={2} />
|
||||
</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 min-w-[300px] max-w-[450px] max-h-[300px] overflow-y-auto">
|
||||
<span className="text-xs text-foreground">
|
||||
<p>
|
||||
{text}{' '}
|
||||
{linkRef && (
|
||||
<Link
|
||||
href={linkRef}
|
||||
target="_blank"
|
||||
className="transition text-brand hover:text-brand-600 underline"
|
||||
>
|
||||
Read more
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_
|
||||
side="bottom"
|
||||
className="min-w-[300px] max-w-[450px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<p>
|
||||
{text}{' '}
|
||||
{linkRef && (
|
||||
<Link
|
||||
href={linkRef}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition text-brand hover:text-brand-600 underline"
|
||||
>
|
||||
Read more
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user