Merge master resolve conflicts

This commit is contained in:
Joshen Lim committed 2024-11-22 16:59:50 +08:00
commit 625fc8d40d
74 files changed
+3055 -900

No files matched your search

-63
View File
@@ -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"
-28
View File
@@ -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
+10 -5
View File
@@ -11,15 +11,20 @@ jobs:
name: runner / vale
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
sparse-checkout: |
.vale.ini
vale
apps/docs/content
- name: Get changed files
id: changed-files
env:
BASE_REF: ${{ github.base_ref }}
SHA: ${{ github.sha }}
run: |
git fetch origin $BASE_REF
echo "CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/$BASE_REF $SHA | grep '^apps/docs/content/' | tr '\n' ',' | sed 's/,$//')" >> $GITHUB_OUTPUT
- uses: errata-ai/vale-action@reviewdog
with:
files: apps/docs/content
# [Charis] Temporary because we still have to go through giant backlog of existing errors
# Don't want people to worry about this (just want them looking at the specific PR comments)
# fail_on_error: true
files: ${{ steps.changed-files.outputs.CHANGED_FILES }}
@@ -219,7 +219,6 @@ hideToc: true
import { useState, useEffect } from 'react'
import { supabase } from './lib/supabase'
import Auth from './components/Auth'
import Account from './components/Account'
import { View, Text } from 'react-native'
import { Session } from '@supabase/supabase-js'
@@ -439,6 +439,8 @@ Unlike the OAuth flow which requires the use of a web browser, the native Sign i
When the user provides consent, Google issues an identity token (commonly abbreviated as ID token) that is then sent to your project's Supabase Auth server. When valid, a new user session is started by issuing an access and refresh token from Supabase Auth.
By default, Supabase Auth implements nonce validation during the authentication flow. This can be disabled in production under `Authentication > Providers > Google > Skip Nonce Check` in the Dashboard, or when developing locally by setting `auth.external.<provider>.skip_nonce_check`. Only disable this if your client libraries cannot properly handle nonce verification.
When working with Expo, you can use the [`react-native-google-signin/google-signin` library](https://github.com/react-native-google-signin/google-signin#expo-installation) library to obtain an ID token that you can pass to supabase-js [`signInWithIdToken` method](/docs/reference/javascript/auth-signinwithidtoken).
Follow the [Expo installation docs](https://react-native-google-signin.github.io/docs/setting-up/expo) for installation and configuration instructions. See the [supabase-js reference](/docs/reference/javascript/initializing?example=react-native-options-async-storage) for instructions on initializing the supabase-js client in React Native.
@@ -53,6 +53,8 @@ Some settings can only be modified by a superuser. Supabase pre-enables the [`su
| `auto_explain.log_min_duration` | Logs query plans taking longer than this duration. |
| `auto_explain.log_nested_statements` | Log nested statements' plans. |
| `log_min_messages` | Minimum severity level of messages to log. |
| `pg_net.ttl` | Sets how long the [pg_net extension](/docs/guides/database/extensions/pg_net) saves responses |
| `pg_net.batch_size` | Sets how many requests the [pg_net extension](/docs/guides/database/extensions/pg_net) can make per second |
| `pgaudit.*` | Configures the [PGAudit extension](https://supabase.com/docs/guides/database/extensions/pgaudit). The `log_parameter` is still restricted to protect secrets |
| `pgrst.*` | [`PostgREST` settings](https://docs.postgrest.org/en/stable/references/configuration.html#db-aggregates-enabled) |
| `plan_filter.*` | Configures the [pg_plan_filter extension](https://supabase.com/docs/guides/database/extensions/pg_plan_filter) |
@@ -8,6 +8,12 @@ Supabase DOES NOT RECOMMEND any new usage of [`pgsodium`](https://github.com/mic
The [`pgsodium`](https://github.com/michelp/pgsodium) extension is expected to go through a deprecation cycle in the near future. We will reach out to owners of impacted projects to assist with migrations away from [`pgsodium`](https://github.com/michelp/pgsodium) once the deprecation process begins.
<Admonition type="note">
The [Vault extension](/docs/guides/database/vault) won’t be impacted. Its internal implementation will shift away from pgsodium, but the interface and API will remain unchanged.
</Admonition>
[`pgsodium`](https://github.com/michelp/pgsodium) is a Postgres extension which provides SQL access to [`libsodium`'s](https://doc.libsodium.org/) high-level cryptographic algorithms.
Supabase previously documented two features derived from pgsodium. Namely [Server Key Management](https://github.com/michelp/pgsodium#server-key-management) and [Transparent Column Encryption](https://github.com/michelp/pgsodium#transparent-column-encryption). At this time, we do not recommend using either on the Supabase platform due to their high level of operational complexity and misconfiguration risk.
@@ -58,7 +58,7 @@ Let’s create our table. Each row represents a restaurant with its location sto
create table if not exists public.restaurants (
id int generated by default as identity primary key,
name text not null,
location geography(POINT) not null
location gis.geography(POINT) not null
);
```
@@ -34,7 +34,7 @@ jobs:
PROJECT_ID: YOUR_SUPABASE_PROJECT_ID
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
@@ -16,7 +16,7 @@ This SQL query will show the size of all databases in your Postgres cluster:
```sql
select
sum(pg_database_size(pg_database.datname)) / (1024 * 1024) as db_size_mb
pg_size_pretty(sum(pg_database_size(pg_database.datname)))
from pg_database;
```
@@ -30,6 +30,16 @@ Depending on your billing plan, your database can go into read-only mode which c
</Admonition>
### Disk space usage
Your database size is part of the disk usage for your Supabase project, there are many components to Postgres that consume additional disk space. One of the primary components, is the [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html). Postgres will store database changes in log files that are cleared away after they are applied to the database. These same files are also used by [Read Replicas](/docs/guides/platform/read-replicas) or other replication methods.
If you would like to determine the size of the WAL files stored on disk, Postgres provides `pg_ls_waldir` as a helper function; the following query can be run:
```sql
select pg_size_pretty(sum(size)) as wal_size from pg_ls_waldir();
```
### Vacuum operations
Postgres does not immediately reclaim the physical space used by dead tuples (i.e., deleted rows) in the DB. They are marked as "removed" until a [vacuum operation](https://www.postgresql.org/docs/current/routine-vacuuming.html) is executed. As a result, deleting data from your database may not immediately reduce the reported disk usage. You can use the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) `inspect db bloat` command to view all dead tuples in your database. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](https://supabase.com/dashboard/project/_/sql/)
@@ -22,6 +22,11 @@ This upgrade method is currently in Beta.
</Admonition>
<Admonition type="note">
For security purposes, passwords for custom roles are not backed up and, following a restore, they
would need to be reset. See [here](/docs/guides/platform/backups#daily-backups) for more details
</Admonition>
pg_upgrade performs an in-place upgrade on your database. For projects larger than 1GB, pg_upgrade is generally faster than a pause+restore cycle, and the speed advantage grows with the size of the database.
1. Plan for an appropriate downtime window, and ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade.
+22 -1
View File
@@ -1,7 +1,7 @@
clispec: '001'
info:
id: cli
version: 1.223.7
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
+393
View File
@@ -44,6 +44,9 @@ info:
- id: analytics
title: Analytics
description: Analytics settings.
- id: experimental
title: Experimental
description: Settings in alpha testing, subject to changes or removal at any time.
- id: local
title: Local Development
description: Local Development settings.
@@ -190,6 +193,237 @@ parameters:
- name: 'PgBouncer Configuration'
link: https://www.pgbouncer.org/config.html#default_pool_size
- id: 'db.settings.effective_cache_size'
title: 'db.settings.effective_cache_size'
tags: ['database']
required: false
description: |
Sets the planner's assumption about the effective size of the disk cache.
This is a query planner parameter that doesn't affect actual memory allocation.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE'
- id: 'db.settings.logical_decoding_work_mem'
title: 'db.settings.logical_decoding_work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by logical decoding, before writing data to local disk.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM'
- id: 'db.settings.maintenance_work_mem'
title: 'db.settings.maintenance_work_mem'
tags: ['database']
required: false
description: |
Specifies the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, and ALTER TABLE ADD FOREIGN KEY.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM'
- id: 'db.settings.max_connections'
title: 'db.settings.max_connections'
tags: ['database']
required: false
description: |
Determines the maximum number of concurrent connections to the database server.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS'
- id: 'db.settings.max_locks_per_transaction'
title: 'db.settings.max_locks_per_transaction'
tags: ['database']
required: false
description: |
Controls the average number of object locks allocated for each transaction.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION'
- id: 'db.settings.max_parallel_maintenance_workers'
title: 'db.settings.max_parallel_maintenance_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single utility command.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS'
- id: 'db.settings.max_parallel_workers'
title: 'db.settings.max_parallel_workers'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS'
- id: 'db.settings.max_parallel_workers_per_gather'
title: 'db.settings.max_parallel_workers_per_gather'
tags: ['database']
required: false
description: |
Sets the maximum number of parallel workers that can be started by a single Gather or Gather Merge node.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER'
- id: 'db.settings.max_replication_slots'
title: 'db.settings.max_replication_slots'
tags: ['database']
required: false
description: |
Specifies the maximum number of replication slots that the server can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS'
- id: 'db.settings.max_slot_wal_keep_size'
title: 'db.settings.max_slot_wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the maximum size of WAL files that replication slots are allowed to retain in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE'
- id: 'db.settings.max_standby_archive_delay'
title: 'db.settings.max_standby_archive_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing archived WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY'
- id: 'db.settings.max_standby_streaming_delay'
title: 'db.settings.max_standby_streaming_delay'
tags: ['database']
required: false
description: |
Sets the maximum delay before canceling queries when a hot standby server is processing streamed WAL data.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY'
- id: 'db.settings.max_wal_size'
title: 'db.settings.max_wal_size'
tags: ['database']
required: false
description: |
Sets the maximum size of WAL files that the system will keep in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE'
- id: 'db.settings.max_wal_senders'
title: 'db.settings.max_wal_senders'
tags: ['database']
required: false
description: |
Specifies the maximum number of concurrent connections from standby servers or streaming base backup clients.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS'
- id: 'db.settings.max_worker_processes'
title: 'db.settings.max_worker_processes'
tags: ['database']
required: false
type: 'uint'
description: |
Sets the maximum number of background processes that the system can support.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES'
- id: 'db.settings.session_replication_role'
title: 'db.settings.session_replication_role'
tags: ['database']
required: false
description: |
Controls whether triggers and rewrite rules are enabled. Valid values are: "origin", "replica", or "local".
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE'
- id: 'db.settings.shared_buffers'
title: 'db.settings.shared_buffers'
tags: ['database']
required: false
description: |
Sets the amount of memory the database server uses for shared memory buffers.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS'
- id: 'db.settings.statement_timeout'
title: 'db.settings.statement_timeout'
tags: ['database']
required: false
description: |
Abort any statement that takes more than the specified amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT'
- id: 'db.settings.track_commit_timestamp'
title: 'db.settings.track_commit_timestamp'
tags: ['database']
required: false
type: 'bool'
description: |
Record commit time of transactions.
Note: Changing this parameter requires a database restart.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP'
- id: 'db.settings.wal_keep_size'
title: 'db.settings.wal_keep_size'
tags: ['database']
required: false
description: |
Specifies the minimum size of past log file segments kept in the pg_wal directory.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE'
- id: 'db.settings.wal_sender_timeout'
title: 'db.settings.wal_sender_timeout'
tags: ['database']
required: false
description: |
Terminate replication connections that are inactive for longer than this amount of time.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT'
- id: 'db.settings.work_mem'
title: 'db.settings.work_mem'
tags: ['database']
required: false
description: |
Specifies the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files.
links:
- name: 'PostgreSQL configuration'
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM'
- id: 'db.pooler.max_client_conn'
title: 'db.pooler.max_client_conn'
tags: ['database']
@@ -463,6 +697,56 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.secure_password_change'
title: 'auth.email.secure_password_change'
tags: ['auth']
required: false
type: 'bool'
default: false
description: |
If enabled, requires the user's current password to be provided when changing to a new password.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.max_frequency'
title: 'auth.email.max_frequency'
tags: ['auth']
required: false
type: 'duration'
default: '1m'
description: |
The minimum amount of time that must pass between email requests.
Helps prevent email spam by limiting how frequently emails can be sent.
Example values: "1m", "1h", "24h"
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_length'
title: 'auth.email.otp_length'
tags: ['auth']
required: false
default: '6'
description: |
The length of the OTP code to be sent in emails.
Must be between 6 and 10 digits.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.otp_exp'
title: 'auth.email.otp_exp'
tags: ['auth']
required: false
default: '300'
description: |
The expiry time for an OTP code in seconds.
Default is 300 seconds (5 minutes).
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.email.smtp.host'
title: 'auth.email.smtp.host'
tags: ['auth']
@@ -780,6 +1064,38 @@ parameters:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.external.provider.skip_nonce_check'
title: 'auth.external.<provider>.skip_nonce_check'
tags: ['auth']
required: false
description: |
Disables nonce validation during OIDC authentication flow for the specified provider. Enable only when client libraries cannot properly handle nonce verification. Be aware that this reduces security by allowing potential replay attacks with stolen ID tokens.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.enroll_enabled'
title: 'auth.mfa.web_authn.enroll_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn enrollment for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'auth.mfa.web_authn.verify_enabled'
title: 'auth.mfa.web_authn.verify_enabled'
tags: ['auth', 'local']
required: false
default: 'false'
description: |
Enable WebAuthn verification for multi-factor authentication.
links:
- name: 'Auth Server configuration'
link: 'https://supabase.com/docs/reference/auth'
- id: 'functions.function_name.enabled'
title: 'functions.<function_name>.enabled'
tags: ['edge-functions']
@@ -821,6 +1137,20 @@ parameters:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
- id: 'functions.function_name.entrypoint'
title: 'functions.<function_name>.entrypoint'
tags: ['edge-functions']
required: false
description: |
Specify a custom entrypoint path for the function relative to the project root.
When not specified, defaults to `supabase/functions/<function_name>/index.ts`.
links:
- name: '`supabase functions` CLI subcommands'
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
usage: |
[functions.my_function]
entrypoint = "path/to/custom/function.ts"
- id: 'analytics.enabled'
title: 'analytics.enabled'
tags: ['analytics']
@@ -863,3 +1193,66 @@ parameters:
links:
- name: Self-hosted Logflare Configuration
link: https://supabase.com/docs/reference/self-hosting-analytics/list-endpoints#getting-started
- id: 'experimental.webhooks.enabled'
title: 'experimental.webhooks.enabled'
tags: ['experimental']
required: false
default: 'false'
description: |
Automatically enable webhook features on each new created branch
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.orioledb_version'
title: 'experimental.orioledb_version'
tags: ['experimental']
required: false
description: |
Configures Postgres storage engine to use OrioleDB with S3 support.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_host'
title: 'experimental.s3_host'
tags: ['experimental']
required: false
default: 'env(S3_HOST)'
description: |
Configures S3 bucket URL for OrioleDB storage.
Format example: <bucket_name>.s3-<region>.amazonaws.com
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_region'
title: 'experimental.s3_region'
tags: ['experimental']
required: false
default: 'env(S3_REGION)'
description: |
Configures S3 bucket region for OrioleDB storage.
Example: us-east-1
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_access_key'
title: 'experimental.s3_access_key'
tags: ['experimental']
required: false
default: 'env(S3_ACCESS_KEY)'
description: |
Configures AWS_ACCESS_KEY_ID for S3 bucket access.
DO NOT commit your AWS access key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
- id: 'experimental.s3_secret_key'
title: 'experimental.s3_secret_key'
tags: ['experimental']
required: false
default: 'env(S3_SECRET_KEY)'
description: |
Configures AWS_SECRET_ACCESS_KEY for S3 bucket access.
DO NOT commit your AWS secret key to git. Use environment variable substitution instead.
Note: This is an experimental feature and may change in future releases.
links: []
+42 -24
View File
@@ -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",
+1
View File
@@ -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.
@@ -28,10 +28,12 @@ interface PolicyRowProps {
policy: PostgresPolicy
onSelectEditPolicy: (policy: PostgresPolicy) => void
onSelectDeletePolicy: (policy: PostgresPolicy) => void
isLocked: boolean
}
const PolicyRow = ({
policy,
isLocked: isLockedDefault,
onSelectEditPolicy = noop,
onSelectDeletePolicy = noop,
}: PolicyRowProps) => {
@@ -42,6 +44,10 @@ const PolicyRow = ({
const { project } = useProjectContext()
const { data: authConfig } = useAuthConfigQuery({ projectRef: project?.ref })
// override islocked for Realtime messages table
const isLocked =
policy.schema === 'realtime' && policy.table === 'messages' ? false : isLockedDefault
// TODO(km): Simple check for roles that allow authenticated access.
// In the future, we'll use splinter to return proper warnings for policies that allow anonymous user access.
const appliesToAnonymousUsers =
@@ -88,50 +94,52 @@ const PolicyRow = ({
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({
open: true,
initialInput: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
})
{!isLocked && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className={cn(isAssistantV2Enabled ? 'w-52' : 'w-40')}
>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectEditPolicy(policy)}>
<Edit size={14} />
<p>Edit policy</p>
</DropdownMenuItem>
{isAssistantV2Enabled && (
<DropdownMenuItem
className="space-x-2"
onClick={() => {
setAiAssistantPanel({
open: true,
initialInput: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
})
}}
>
<Edit size={14} />
<p>Edit policy with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
>
<Edit size={14} />
<p>Edit policy with Assistant</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdatePolicies}
onClick={() => onSelectDeletePolicy(policy)}
tooltip={{
content: {
side: 'left',
text: 'You need additional permissions to delete policies',
},
}}
>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
<Trash size={14} />
<p>Delete policy</p>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</Panel.Content>
)
@@ -88,6 +88,7 @@ const PolicyTableRow = ({
{policies?.map((policy) => (
<PolicyRow
key={policy.id}
isLocked={isLocked}
policy={policy}
onSelectEditPolicy={onSelectEditPolicy}
onSelectDeletePolicy={onSelectDeletePolicy}
@@ -176,7 +176,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
<Separator />
)}
<div className={cn('flex flex-col', PANEL_PADDING)}>
<div className={cn('flex flex-col gap-1.5', PANEL_PADDING)}>
<RowData property="User UID" value={user.id} />
<RowData
property="Created at"
@@ -461,35 +461,38 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
export const RowData = ({ property, value }: { property: string; value?: string | boolean }) => {
return (
<div className="flex items-center gap-x-2 group">
<p className="w-36 text-foreground-light text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<>
<div className="flex items-center gap-x-2 group justify-between">
<p className=" text-foreground-lighter text-sm">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
{value ? (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<Check size={10} className="text-contrast" strokeWidth={4} />
</div>
) : (
<div className="rounded-full w-4 h-4 dark:bg-white bg-black flex items-center justify-center">
<X size={10} className="text-contrast" strokeWidth={4} />
</div>
)}
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
<p className="text-sm">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
type="text"
icon={<Copy />}
className="transition opacity-0 group-hover:opacity-100 px-1"
text={value}
/>
)}
</div>
)}
</div>
<Separator />
</>
)
}
@@ -3,7 +3,12 @@ import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidatio
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
name: provider.title,
value: provider.title.toLowerCase(),
value:
provider.title === 'Slack (OIDC)'
? 'slack_oidc'
: provider.title === 'LinkedIn (OIDC)'
? 'linkedin_oidc'
: provider.title.toLowerCase(),
icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`,
iconClass: provider.title === 'GitHub' ? 'dark:invert' : '',
})).concat(
@@ -1,36 +1,56 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useFlag } from 'hooks/ui/useFlag'
import Link from 'next/link'
import React from 'react'
import { useParams } from 'common'
import { NavMenu, NavMenuItem } from 'ui'
import { Badge, NavMenu, NavMenuItem } from 'ui'
type Props = {
active: 'scheduled' | 'pitr'
active: 'pitr' | 'scheduled' | 'rtnp'
}
function DatabaseBackupsNav({ active }: Props) {
const { ref } = useParams()
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
const ref = useProjectContext()?.project?.ref
const navMenuItems = [
{
enabled: true,
id: 'scheduled',
label: 'Scheduled backups',
href: `/project/${ref}/database/backups/scheduled`,
},
{
enabled: true,
id: 'pitr',
label: 'Point in time',
href: `/project/${ref}/database/backups/pitr`,
},
]
{
enabled: isCloneToNewProjectEnabled,
id: 'rtnp',
label: (
<div className="flex items-center gap-1">
Restore to new project{' '}
<Badge size="small" className="!text-[10px] px-1.5 py-0">
New
</Badge>
</div>
),
href: `/project/${ref}/database/backups/restore-to-new-project`,
},
] as const
return (
<NavMenu>
{navMenuItems.map((item) => (
<NavMenuItem key={item.label} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
{navMenuItems.map(
(item) =>
item.enabled && (
<NavMenuItem key={item.id} active={item.id === active}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
)
)}
</NavMenu>
)
}
@@ -1,23 +1,14 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import DatePicker from 'react-datepicker'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import InformationBox from 'components/ui/InformationBox'
import { useBackupsQuery } from 'data/database/backups-query'
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { PROJECT_STATUS } from 'lib/constants'
import {
Alert,
@@ -31,15 +22,9 @@ import {
import BackupsEmpty from '../BackupsEmpty'
import BackupsStorageAlert from '../BackupsStorageAlert'
import type { Timezone } from './PITR.types'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { getClientTimezone } from './PITR.utils'
import PITRStatus from './PITRStatus'
import TimeInput from './TimeInput'
import { TimezoneSelection } from './TimezoneSelection'
import { PITRForm } from './pitr-form'
const PITRSelection = () => {
const router = useRouter()
@@ -51,11 +36,11 @@ const PITRSelection = () => {
const [showConfiguration, setShowConfiguration] = useState(false)
const [showConfirmation, setShowConfirmation] = useState(false)
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const canTriggerPhysicalBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const [selectedRecoveryPoint, setSelectedRecoveryPoint] = useState<{
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}>()
const hasReadReplicas = (databases ?? []).length > 1
@@ -76,67 +61,16 @@ const PITRSelection = () => {
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
backups?.physicalBackupData ?? {}
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
const earliestAvailableBackup = dayjs
.unix(earliestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestPhysicalBackupDateUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
// This will be the actual unix timestamp for the backup
const recoveryTimeTargetUnix = selectedDate.unix()
// Formatting from the unix again just to double check correctness
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isSelectedOutOfRange =
selectedDate &&
(selectedDate.isBefore(earliestAvailableBackup) || selectedDate.isAfter(latestAvailableBackup))
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const onCancel = () => {
setShowConfiguration(false)
setSelectedTimezone(getClientTimezone())
}
const onConfirmRestore = async () => {
if (!ref) return console.error('Project ref is required')
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
if (!selectedRecoveryPoint?.recoveryTimeTargetUnix)
return console.error('Recovery time target unix is required')
restoreFromPitr({
ref,
recovery_time_target_unix: selectedRecoveryPoint.recoveryTimeTargetUnix,
})
}
return (
@@ -177,173 +111,14 @@ const PITRSelection = () => {
onSetConfiguration={() => setShowConfiguration(true)}
/>
) : (
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<ButtonTooltip
type="warning"
disabled={isSelectedOutOfRange || !selectedDate || !canTriggerPhysicalBackups}
onClick={() => setShowConfirmation(true)}
tooltip={{
content: {
side: 'bottom',
text: isSelectedOutOfRange
? 'Selected date is out of range where backups are available'
: !canTriggerPhysicalBackups
? 'You need additional permissions to trigger a restore'
: undefined,
},
}}
>
Review restore details
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Restore database to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={
isSelectedOnLatestDay ? latestAvailableBackupTime : undefined
}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups
are captured every 2 minutes, allowing you to enter a time and restore
your database to the closest backup point. We'll match the time you
enter to the closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
<PITRForm
earliestAvailableBackupUnix={earliestPhysicalBackupDateUnix}
latestAvailableBackupUnix={latestPhysicalBackupDateUnix}
onSubmit={(recoveryPoint) => {
setSelectedRecoveryPoint(recoveryPoint)
setShowConfirmation(true)
}}
/>
)}
</>
)}
@@ -379,12 +154,12 @@ const PITRSelection = () => {
</div>
<div className="py-2 flex flex-col gap-3">
<div>
<p className="text-sm font-mono text-foreground-lighter">{selectedTimezone?.text}</p>
<p className="text-2xl">{recoveryTimeString}</p>
<p className="text-sm font-mono text-foreground-lighter">Local Time</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeString}</p>
</div>
<div>
<p className="text-sm font-mono text-foreground-lighter">(UTC+00:00)</p>
<p className="text-2xl">{recoveryTimeStringUtc}</p>
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeStringUtc}</p>
</div>
</div>
</Modal.Content>
@@ -61,7 +61,7 @@ export const TimezoneSelection = ({
value={option}
onSelect={(text) => {
const selectedTimezone = ALL_TIMEZONES.find(
(option) => option.text.toLocaleLowerCase() === text
(option) => option.text === text
)
if (selectedTimezone) {
onSelectTimezone(selectedTimezone)
@@ -0,0 +1,264 @@
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import DatePicker from 'react-datepicker'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { format } from 'date-fns'
import InformationBox from 'components/ui/InformationBox'
import dayjs from 'dayjs'
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
import {
constrainDateToRange,
formatNumberToTwoDigits,
getClientTimezone,
getDatesBetweenRange,
} from './PITR.utils'
import { TimezoneSelection } from './TimezoneSelection'
import TimeInput from './TimeInput'
import { Timezone } from './PITR.types'
import { useMemo, useState } from 'react'
type Props = {
onSubmit: (data: {
recoveryTimeTargetUnix: number
recoveryTimeString: string
recoveryTimeStringUtc: string
}) => void
earliestAvailableBackupUnix: number
latestAvailableBackupUnix: number
}
export function PITRForm({
onSubmit,
earliestAvailableBackupUnix,
latestAvailableBackupUnix,
}: Props) {
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
const earliestAvailableBackup = dayjs
.unix(earliestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const latestAvailableBackup = dayjs
.unix(latestAvailableBackupUnix ?? 0)
.tz(selectedTimezone.utc[0])
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true)
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
const selectedTime = {
h: selectedDate.hour(),
m: selectedDate.minute(),
s: selectedDate.second(),
}
const earliestAvailableBackupTime = {
h: earliestAvailableBackup.hour(),
m: earliestAvailableBackup.minute(),
s: earliestAvailableBackup.second(),
}
const latestAvailableBackupTime = {
h: latestAvailableBackup.hour(),
m: latestAvailableBackup.minute(),
s: latestAvailableBackup.second(),
}
const recoveryTimeTargetUnix = selectedDate.unix()
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
const isWithinRange = useMemo(
() =>
(!!selectedDate && selectedDate.isSame(latestAvailableBackup)) ||
selectedDate.isSame(earliestAvailableBackup) ||
(selectedDate.isBefore(latestAvailableBackup) &&
selectedDate.isAfter(earliestAvailableBackup)),
[selectedDate, latestAvailableBackup, earliestAvailableBackup]
)
const onUpdateDate = (date: Date) => {
setSelectedDateRaw(
constrainDateToRange(
dayjs(date).tz(selectedTimezone.utc[0], true),
earliestAvailableBackup,
latestAvailableBackup
).toDate()
)
}
const handleSubmit = () => {
onSubmit({
recoveryTimeTargetUnix,
recoveryTimeString,
recoveryTimeStringUtc,
})
}
return (
<div>
<FormPanel
disabled={true}
footer={
<div className="flex items-center justify-end gap-3 p-6">
<ButtonTooltip
type="default"
disabled={!selectedDate || !isWithinRange}
onClick={handleSubmit}
tooltip={{
content: {
hidden: !isWithinRange,
side: 'bottom',
text: !isWithinRange
? 'Selected date is out of range where backups are available'
: undefined,
},
}}
>
Continue
</ButtonTooltip>
</div>
}
>
<div className="flex justify-between px-10 py-6 space-x-10">
<div className="w-1/3 space-y-2">
<DatePicker
inline
selected={selectedDateRaw}
onChange={onUpdateDate}
dayClassName={() => 'cursor-pointer'}
minDate={earliestAvailableBackup.toDate()}
maxDate={latestAvailableBackup.toDate()}
highlightDates={availableDates.map((date) => date.toDate())}
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex items-center justify-between px-2 py-2">
<div className="flex w-full items-center justify-between">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
type="button"
className={`
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronLeft size={16} strokeWidth={2} />
</button>
<span className="text-foreground-light text-sm">
{format(date, 'MMMM yyyy')}
</span>
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
type="button"
className={`
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
text-foreground-light hover:text-foreground focus:outline-none
`}
>
<ChevronRight size={16} strokeWidth={2} />
</button>
</div>
</div>
)}
/>
{availableDates.length > 1 && (
<div className="flex items-center space-x-2">
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
<p className="text-xs text-foreground-light">Point in time back up available</p>
</div>
)}
</div>
<div className="w-2/3">
{!selectedDate ? (
<div className="h-full flex items-center justify-center">
<div className="mx-2">
<InformationBox
defaultVisibility
hideCollapse
icon={<HelpCircle size={14} strokeWidth={2} />}
title="Select a date which you'd like to restore your database to"
/>
</div>
</div>
) : (
<div className="space-y-8 py-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Date to restore to</p>
<p className="text-3xl">
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
<span>
, {formatNumberToTwoDigits(selectedTime.h)}:
{formatNumberToTwoDigits(selectedTime.m)}:
{formatNumberToTwoDigits(selectedTime.s)}
</span>
</p>
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Time zone</p>
<div className="w-[350px]">
<TimezoneSelection
selectedTimezone={selectedTimezone}
onSelectTimezone={setSelectedTimezone}
/>
</div>
</div>
<div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Recovery time</p>
<TimeInput
defaultTime={selectedTime}
minimumTime={
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
}
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
onChange={({ h, m, s }) => {
const newDate = dayjs(selectedDateRaw)
.set('hour', h)
.set('minute', m)
.set('second', s)
setSelectedDateRaw(newDate.toDate())
}}
/>
</div>
<p className="text-sm text-foreground-light mt-8">
Enter a time within the available range to restore from. <br /> Backups are
captured every 2 minutes, allowing you to enter a time and restore your
database to the closest backup point. We'll match the time you enter to the
closest backup within the 2-minute window
</p>
</div>
<div className="!mt-4 space-y-1">
<h3 className="text-sm text-foreground-light"></h3>
{isSelectedOnEarliestDay && (
<p className="text-sm text-foreground-light">
<strong>Earliest backup available for this date</strong>:{' '}
{earliestAvailableBackup.format('HH:mm:ss')}
</p>
)}
{isSelectedOnLatestDay && (
<p className="text-sm text-foreground-light">
<strong>Latest backup available for this date</strong>:{' '}
{latestAvailableBackup.format('HH:mm:ss')}
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
</FormPanel>
</div>
)
}
@@ -0,0 +1,45 @@
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
export const AdditionalMonthlySpend = () => {
const { project } = useProjectContext()
/**
* New project will have the same compute size and disk size as the original project
*/
function getAdditionalMonthlySpend() {
const currentProjectComputeSize = project?.infra_compute_size
if (!currentProjectComputeSize) {
return null
}
if (currentProjectComputeSize === 'nano') {
return null
}
const additionalMonthlySpend = instanceSizeSpecs[currentProjectComputeSize]
return additionalMonthlySpend
}
const additionalMonthlySpend = getAdditionalMonthlySpend()
if (!additionalMonthlySpend) {
return null
}
return (
<div className="text-sm text-foreground-lighter border-t p-5">
<p>
The new project will have the same compute size and disk size as this project. You will be
able to update the compute size and disk size after the new project is created in{' '}
<span className="font-mono text-xs tracking-tighter text-foreground-light">
Project Settings &gt; Compute and Disk
</span>
</p>
<div className="flex justify-between text-foreground mt-2">
<p>Additional Monthly Compute + Disk Cost</p>
<p className="font-mono text-right text-brand">${additionalMonthlySpend.priceMonthly}</p>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import Panel from 'components/ui/Panel'
import BackupsEmpty from '../BackupsEmpty'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Badge, Button } from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface BackupsListProps {
onSelectRestore: (id: number) => void
}
export const BackupsList = ({ onSelectRestore }: BackupsListProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
return (
<Panel>
{cloneBackups?.backups.length === 0 ? (
<>
<BackupsEmpty />
</>
) : (
<div className="divide-y">
{/* <pre>{JSON.stringify({ cloneStatus }, null, 2)}</pre> */}
{cloneBackups?.backups.map((backup) => {
if (!backup.isPhysicalBackup) return null
return (
<div className="flex p-4 gap-4" key={backup.id}>
<div>
<TimestampInfo value={backup.inserted_at} />
</div>
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
{(backup.status as any) === 'COMPLETED' && (
<Button
className="ml-auto"
type="outline"
onClick={() => onSelectRestore(backup.id)}
>
Restore
</Button>
)}
</div>
)
})}
</div>
)}
</Panel>
)
}
@@ -0,0 +1,84 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
} from 'ui'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface ConfirmRestoreDialogProps {
open: boolean
onOpenChange: (value: boolean) => void
onSelectContinue: () => void
}
export const ConfirmRestoreDialog = ({
open,
onOpenChange,
onSelectContinue,
}: ConfirmRestoreDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Confirm restore to a new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<DialogSection className="prose pb-6 space-y-4 text-sm">
<ul className="space-y-2">
<li>
Project organization will stay the same: <code>{organization?.name}</code>
</li>
<li>
Project region will stay the same: <code>{project?.region || ''}</code>
</li>
<li>
A project can only be restored to another project once. <br />
<span className="text-foreground-lighter text-xs">
This is a temporary limitation. Please contact us if you need to restore a project
to multiple other projects.
</span>
</li>
</ul>
<ul>
<li>What will be transferred?</li>
<ul className="ml-4">
<li>Database schema (tables, views, procedures)</li>
<li>All data and indexes</li>
<li>Database roles, permissions and users</li>
</ul>
</ul>
<ul>
<li>What needs manual reconfiguration?</li>
<ul className="ml-4">
<li>Storage objects & settings</li>
<li>Edge Functions</li>
<li>Auth settings & API keys</li>
<li>Realtime settings</li>
<li>Database extensions and settings</li>
<li>Read replicas</li>
</ul>
</ul>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSelectContinue()}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,205 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { debounce } from 'lodash'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { passwordStrength } from 'lib/helpers'
import { generateStrongPassword } from 'lib/project'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface CreateNewProjectDialogProps {
open: boolean
selectedBackupId: number | null
recoveryTimeTarget: number | null
onOpenChange: (value: boolean) => void
onCloneSuccess: () => void
}
export const CreateNewProjectDialog = ({
open,
selectedBackupId,
recoveryTimeTarget,
onOpenChange,
onCloneSuccess,
}: CreateNewProjectDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
const FormSchema = z.object({
name: z.string().min(1),
password: z.string().min(1),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
password: '',
},
})
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
const hasPITREnabled = cloneBackups?.pitr_enabled
const { mutate: triggerClone, isLoading: cloneMutationLoading } = useProjectCloneMutation({
onError: (error) => {
console.error('error', error)
toast.error('Failed to restore to new project')
},
onSuccess: () => {
toast.success('Restoration process started')
onCloneSuccess()
},
})
const delayedCheckPasswordStrength = useRef(
debounce((value: string) => checkPasswordStrength(value), 300)
).current
async function checkPasswordStrength(value: string) {
const { message, strength } = await passwordStrength(value)
setPasswordStrengthScore(strength)
setPasswordStrengthMessage(message)
}
const generatePassword = () => {
const password = generateStrongPassword()
form.setValue('password', password)
delayedCheckPasswordStrength(password)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>Create new project</DialogTitle>
<DialogDescription>
This process will create a new project and restore your database to it.
</DialogDescription>
</DialogHeader>
<Form_Shadcn_ {...form}>
<form
id={'create-new-project-form'}
onSubmit={form.handleSubmit((data) => {
if (!project?.ref) {
toast.error('Project ref is required')
return
}
if (hasPITREnabled && recoveryTimeTarget) {
triggerClone({
projectRef: project?.ref,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: recoveryTimeTarget,
cloneBackupId: undefined,
})
} else if (selectedBackupId) {
triggerClone({
projectRef: project?.ref,
cloneBackupId: selectedBackupId,
newProjectName: data.name,
newDbPass: data.password,
recoveryTimeTarget: undefined,
})
} else {
toast.error('No backup or point in time selected')
return
}
})}
>
<DialogSection className="pb-6 space-y-4 text-sm">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="New Project Name">
<FormControl_Shadcn_>
<Input_Shadcn_ placeholder="Enter a name" type="text" {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="password"
render={({ field }) => (
<FormItemLayout>
<FormControl_Shadcn_>
<Input
id="db-password"
label="Database Password"
type="password"
placeholder="Type in a strong password"
value={field.value}
copy={field.value?.length > 0}
onChange={(e) => {
const value = e.target.value
field.onChange(value)
if (value == '') {
setPasswordStrengthScore(-1)
setPasswordStrengthMessage('')
} else delayedCheckPasswordStrength(value)
}}
descriptionText={
<PasswordStrengthBar
passwordStrengthScore={passwordStrengthScore}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generatePassword}
/>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button htmlType="reset" type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={cloneMutationLoading}>
Restore to new project
</Button>
</DialogFooter>
</form>
</Form_Shadcn_>
</DialogContent>
</Dialog>
)
}
@@ -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 (
@@ -50,7 +50,7 @@ const NewProjectPanel = () => {
<EditorIndexPageLink projectRef={ref}>Table Editor</EditorIndexPageLink>
</Button>
<Button asChild type="default" icon={<SqlEditor strokeWidth={1.5} />}>
<Link href={`/project/${ref}/sql/new`}>SQL editor</Link>
<Link href={`/project/${ref}/sql/new`}>SQL Editor</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
@@ -17,7 +17,6 @@ import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormLabel_Shadcn_,
Input_Shadcn_,
@@ -35,7 +34,13 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from './CronJobs.utils'
import {
buildCronQuery,
buildHttpRequestCommand,
cronPattern,
secondsPattern,
parseCronJobCommand,
} from './CronJobs.utils'
import { CronJobScheduleSection } from './CronJobScheduleSection'
import { EdgeFunctionSection } from './EdgeFunctionSection'
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
@@ -44,8 +49,6 @@ import { HttpRequestSection } from './HttpRequestSection'
import { SqlFunctionSection } from './SqlFunctionSection'
import { SqlSnippetSection } from './SqlSnippetSection'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
import { InfoIcon } from 'lucide-react'
export interface CreateCronJobSheetProps {
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
@@ -94,13 +97,18 @@ const FormSchema = z.object({
.trim()
.min(1)
.refine((value) => {
try {
CronToString(value)
} catch {
return false
if (cronPattern.test(value)) {
try {
CronToString(value)
return true
} catch {
return false
}
} else if (secondsPattern.test(value)) {
return true
}
return true
}, 'The schedule needs to be in a Cron format.'),
return false
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
values: z.discriminatedUnion('type', [
edgeFunctionSchema,
httpRequestSchema,
@@ -135,7 +143,7 @@ export const CreateCronJobSheet = ({
resolver: zodResolver(FormSchema),
defaultValues: {
name: selectedCronJob?.jobname || '',
schedule: selectedCronJob?.schedule || '* * * * * *',
schedule: selectedCronJob?.schedule || '*/5 * * * *',
values: cronJobValues,
},
})
@@ -26,17 +26,19 @@ import {
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateCronJobForm } from './CreateCronJobSheet'
import CronSyntaxChart from './CronSyntaxChart'
import { secondsPattern } from './CronJobs.utils'
interface CronJobScheduleSectionProps {
form: UseFormReturn<CreateCronJobForm>
}
const presets = [
{ name: 'Every minute', expression: '* * * * * *' },
const PRESETS = [
{ name: 'Every minute', expression: '* * * * *' },
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
{ name: 'Every Monday at midnight', expression: '0 0 * * 1' },
{ name: 'Every night at midnight', expression: '0 0 0 * * *' },
{ name: 'Every night at midnight', expression: '0 0 * * *' },
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
{ name: 'Every 30 seconds', expression: '30 seconds' },
] as const
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
@@ -46,7 +48,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
const [presetValue, setPresetValue] = useState<string>(initialValue)
const [inputValue, setInputValue] = useState(initialValue)
const [debouncedValue] = useDebounce(inputValue, 500)
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
const [scheduleString, setScheduleString] = useState('')
@@ -78,19 +80,31 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
}, [debouncedValue, useNaturalLanguage])
useEffect(() => {
if (!inputValue) return
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
// update the cronstrue string when the input value changes
try {
setScheduleString(CronToString(inputValue))
form.setValue('schedule', inputValue)
} catch {}
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [form, inputValue])
useEffect(() => {
if (!useNaturalLanguage) {
setPresetValue(schedule)
if (useNaturalLanguage) return
setPresetValue(schedule)
if (!schedule) {
setScheduleString('')
return
}
try {
setScheduleString(CronToString(schedule))
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [schedule])
@@ -113,6 +127,11 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
value={inputValue}
placeholder="E.g. every 5 minutes"
className={cn(!useNaturalLanguage && 'hidden')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
onChange={(e) => setInputValue(e.target.value)}
/>
) : (
@@ -136,6 +155,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
setInputValue('')
setPresetValue('')
setScheduleString('')
form.setValue('schedule', '')
}}
/>
<p className="text-sm text-foreground-light">Use natural language</p>
@@ -143,7 +163,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
<div className="mt-2">
<ul className="flex gap-2 flex-wrap mt-2">
{presets.map((preset) => (
{PRESETS.map((preset) => (
<li key={preset.name}>
<Button
type="outline"
@@ -183,46 +203,38 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
)}
>
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
) : (
<span className="text-xl font-mono text-foreground-lighter">
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
)}
{!inputValue && !isGeneratingCron && !scheduleString ? (
<span className="text-sm text-foreground-light">
Describe your schedule above
</span>
) : scheduleString ? (
) : (
<span className="text-sm text-foreground-light flex items-center gap-2">
The cron will be run {/* lowercase the first letter */}
{isGeneratingCron ? (
<span className="inline-flex items-center">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
>
.
</motion.span>
))}
</span>
<LoadingDots />
) : scheduleString === '' ? ( // set a min length before showing invalid message
'Enter a valid cron expression above'
) : scheduleString.includes('Invalid cron expression') ? (
'Invalid cron expression'
) : (
scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'
<>
The cron will be run{' '}
{secondsPattern.test(schedule)
? 'every ' + schedule
: scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
.join(' ') + '.'}
</>
)}
</span>
) : null}
)}
</div>
</FormItem_Shadcn_>
)
@@ -235,7 +247,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
const CronSyntaxLoader = () => {
return (
<div className="flex gap-2">
{['*', '*', '*', '*', '*', '*'].map((char, i) => (
{['*', '*', '*', '*', '*'].map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0.3 }}
@@ -253,3 +265,25 @@ const CronSyntaxLoader = () => {
</div>
)
}
const LoadingDots = () => {
return (
<span className="inline-flex items-center">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: 'reverse',
delay: i * 0.2,
}}
>
.
</motion.span>
))}
</span>
)
}
@@ -1,5 +1,5 @@
import { it } from 'vitest'
import { parseCronJobCommand } from './CronJobs.utils'
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
describe('parseCronJobCommand', () => {
it('should return a default object when the command is null', () => {
@@ -34,4 +34,38 @@ describe('parseCronJobCommand', () => {
type: 'edge_function',
})
})
// Array of test cases for secondsPattern
const secondsPatternTests = [
{ description: '10 seconds', command: '10 seconds' },
{ description: '30 seconds', command: '30 seconds' },
]
// Run tests for secondsPattern
secondsPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a secondsPattern with "${description}"`, () => {
expect(command).toMatch(secondsPattern)
})
})
// Array of test cases for cronPattern
const cronPatternTests = [
{ description: 'every hour', command: '0 * * * *' },
{ description: 'every day at midnight', command: '0 0 * * *' },
{ description: 'every Monday at 9 AM', command: '0 9 * * 1' },
{ description: 'every 15th of the month at noon', command: '0 12 15 * *' },
{ description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
{ description: 'every 30 minutes', command: '30 * * * *' },
{ description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
{ description: 'every quarter hour', command: '*/15 * * * *' },
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
]
// Replace the single cronPattern test with forEach
cronPatternTests.forEach(({ description, command }) => {
it(`should match the regex for a cronPattern with "${description}"`, () => {
expect(command).toMatch(cronPattern)
})
})
})
@@ -110,3 +110,8 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
return DEFAULT_CRONJOB_COMMAND
}
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
export const secondsPattern = /^\d+\s+seconds$/
export const cronPattern =
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
@@ -4,14 +4,13 @@ export default function CronSyntaxChart() {
<pre className="text-xs font-mono text-foreground-light">
{/* prettier-ignore */}
{`
┌───────────── second (0 - 59)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │ │
* * * * * * `}
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │
* * * * * `}
</pre>
</div>
)
@@ -0,0 +1,30 @@
import { useInvoicePaymentLinkGetMutation } from 'data/invoices/invoice-payment-link-mutation'
import { toast } from 'sonner'
import { Button } from 'ui'
interface InvoicePayButtonProps {
slug?: string
invoiceId: string
}
const InvoicePayButton = ({ slug, invoiceId }: InvoicePayButtonProps) => {
const { mutate, isLoading } = useInvoicePaymentLinkGetMutation({
onSuccess(data) {
toast.success('Redirecting to payment gateway...')
window.location.href = data.redirectUrl
},
})
function onPayNow() {
mutate({ slug, invoiceId })
}
return (
<Button onClick={onPayNow} loading={isLoading} disabled={isLoading}>
Pay Now
</Button>
)
}
export default InvoicePayButton
@@ -21,6 +21,7 @@ import { formatCurrency } from 'lib/helpers'
import { Button } from 'ui'
import CurrentPaymentMethod from '../BillingSettings/PaymentMethods/CurrentPaymentMethod'
import { FileText, Download, ChevronLeft, ChevronRight } from 'lucide-react'
import InvoicePayButton from './InvoicePayButton'
const PAGE_LIMIT = 10
@@ -152,15 +153,7 @@ const InvoicesSettings = () => {
InvoiceStatus.OPEN,
InvoiceStatus.ISSUED,
].includes(x.status as InvoiceStatus) && (
<Button asChild>
<Link
href={`https://redirect.revops.supabase.com/pay-invoice/${x.id}`}
target="_blank"
rel="noreferrer"
>
Pay Now
</Link>
</Button>
<InvoicePayButton slug={slug} invoiceId={x.id} />
)}
<Button
@@ -1,11 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Input, Modal } from 'ui'
import { useParams } from 'common'
import {
useIsProjectActive,
useProjectContext,
@@ -17,6 +15,8 @@ import { useDatabasePasswordResetMutation } from 'data/database/database-passwor
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { Button, Input, Modal } from 'ui'
const ResetDbPassword = ({ disabled = false }) => {
const { ref } = useParams()
@@ -80,12 +80,8 @@ const ResetDbPassword = ({ disabled = false }) => {
}
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setPassword(password)
delayedCheckPasswordStrength(password)
}
@@ -148,7 +144,7 @@ const ResetDbPassword = ({ disabled = false }) => {
passwordStrengthScore={passwordStrengthScore}
passwordStrengthMessage={passwordStrengthMessage}
password={password}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>
@@ -363,6 +363,7 @@ export enum LogsTableName {
POSTGREST = 'postgrest_logs',
SUPAVISOR = 'supavisor_logs',
WAREHOUSE = 'warehouse_logs',
CRON_JOBS = 'cron_job_run_details',
}
export const LOGS_TABLES = {
@@ -376,6 +377,7 @@ export const LOGS_TABLES = {
postgrest: LogsTableName.POSTGREST,
supavisor: LogsTableName.SUPAVISOR,
warehouse: LogsTableName.WAREHOUSE,
cron: LogsTableName.CRON_JOBS,
}
export const LOGS_SOURCE_DESCRIPTION = {
@@ -389,6 +391,7 @@ export const LOGS_SOURCE_DESCRIPTION = {
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
[LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs',
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
[LogsTableName.CRON_JOBS]: 'Logs obtained from cron job runs',
}
export const genQueryParams = (params: { [k: string]: string }) => {
@@ -88,6 +88,7 @@ export type QueryType =
| 'supavisor'
| 'postgrest'
| 'warehouse'
| 'cron'
export type Mode = 'simple' | 'custom'
@@ -211,6 +211,9 @@ limit ${limit}
${orderBy}
limit ${limit}
`
case 'cron_job_run_details':
return `select status, start_time, end_time, jobid from ${table} ${where} ${orderBy} limit ${limit}`
}
}
@@ -35,6 +35,7 @@ import {
generateColumnFieldFromPostgresColumn,
generateCreateColumnPayload,
generateUpdateColumnPayload,
getPlaceholderText,
validateFields,
} from './ColumnEditor.utils'
import ColumnForeignKey from './ColumnForeignKey'
@@ -74,6 +75,9 @@ const ColumnEditor = ({
const [errors, setErrors] = useState<Dictionary<any>>({})
const [columnFields, setColumnFields] = useState<ColumnField>()
const [fkRelations, setFkRelations] = useState<ForeignKey[]>([])
const [placeholder, setPlaceholder] = useState(
getPlaceholderText(columnFields?.format, columnFields?.name)
)
const { data: types } = useEnumeratedTypesQuery({
projectRef: project?.ref,
@@ -129,6 +133,7 @@ const ColumnEditor = ({
}
const changedName = 'name' in changes && changes.name !== columnFields.name
const changedFormat = 'format' in changes && changes.format !== columnFields.format
if (
changedName &&
@@ -144,6 +149,12 @@ const ColumnEditor = ({
)
}
if (changedName || changedFormat) {
setPlaceholder(
getPlaceholderText(changes.format || columnFields.format, changes.name || columnFields.name)
)
}
const updatedColumnFields: ColumnField = { ...columnFields, ...changes }
setColumnFields(updatedColumnFields)
updateEditorDirty()
@@ -342,7 +353,7 @@ const ColumnEditor = ({
<Input
label="CHECK Constraint"
labelOptional="Optional"
placeholder={`e.g length(${columnFields?.name || 'column_name'}) < 500`}
placeholder={placeholder}
type="text"
value={columnFields?.check ?? ''}
onChange={(event: any) => onUpdateField({ check: event.target.value })}
@@ -4,7 +4,6 @@ import type { Dictionary } from 'types'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
import type { PartitionedTable, Table } from 'data/table-editor/table-editor-types'
import { uuidv4 } from 'lib/helpers'
import {
ColumnField,
@@ -250,3 +249,50 @@ export const getForeignKeyCascadeAction = (action?: string) => {
return undefined
}
}
export const getPlaceholderText = (format?: string, columnFieldName?: string) => {
const columnName = columnFieldName || 'column_name'
switch (format) {
case 'int2':
case 'int4':
case 'int8':
case 'numeric':
return `"${columnName}" > 0`
case 'float4':
case 'float8':
return `"${columnName}" > 0.0`
case 'text':
case 'varchar':
return `length("${columnName}") <= 50`
case 'json':
case 'jsonb':
return `jsonb_typeof("${columnName}"->'active') = 'boolean'`
case 'bool':
return `"${columnName}" in (true, false)`
case 'date':
return `"${columnName}" > '2024-01-01'`
case 'time':
return `"${columnName}" between '09:00:00' and '12:00:00'`
case 'timetz':
return `"${columnName}" at time zone 'UTC' between '09:00:00+00' and '17:00:00+00'`
case 'uuid':
return `"${columnName}" '00000000-0000-0000-0000-000000000000'`
case 'timestamp':
return `"${columnName}" > '2023-01-01 00:00' and "${columnName}" < '2025-01-01 00:00'`
case 'timestamptz':
return `"${columnName}" > '2023-01-01 00:00:00+00' and "${columnName}" < '2025-01-01 00:00:00+00'`
default:
return `length("${columnName}") < 500`
}
}
@@ -164,6 +164,12 @@ export function LogsSidebarMenuV2() {
url: `/project/${ref}/logs/edge-functions-logs`,
items: [],
},
{
name: 'Cron Jobs',
key: 'cron-logs',
url: `/project/${ref}/logs/cron-logs`,
items: [],
},
]
const filteredLogs = BASE_COLLECTIONS.filter((collection) => {
@@ -1,9 +1,9 @@
import { ArrowUpRight } from 'lucide-react'
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import type { Project } from 'data/projects/project-detail-query'
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import type { Organization } from 'types'
import { ArrowUpRight } from 'lucide-react'
import { useFlag } from 'hooks/ui/useFlag'
export const generateSettingsMenu = (
ref?: string,
@@ -27,6 +27,7 @@ export const generateSettingsMenu = (
const storageEnabled = features?.storage ?? true
const warehouseEnabled = features?.warehouse ?? false
const logDrainsEnabled = features?.logDrains ?? false
const newDiskComputeEnabled = features?.diskAndCompute ?? false
return [
{
@@ -38,7 +39,7 @@ export const generateSettingsMenu = (
url: `/project/${ref}/settings/general`,
items: [],
},
...(IS_PLATFORM && features?.diskAndCompute
...(IS_PLATFORM && newDiskComputeEnabled
? [
{
name: 'Compute and Disk',
@@ -0,0 +1,65 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
import { invoicesKeys } from './keys'
export type InvoicePaymentLinkGetVariables = {
slug?: string
invoiceId?: string
}
export async function updateInvoicePaymentLink({
slug,
invoiceId,
}: InvoicePaymentLinkGetVariables) {
if (!slug) throw new Error('Org slug is required')
if (!invoiceId) throw new Error('Invoice ID is required')
const { data, error } = await get(
'/platform/organizations/{slug}/billing/invoices/{invoiceId}/payment-link',
{
params: {
path: {
slug,
invoiceId,
},
},
}
)
if (error) handleError(error)
return data
}
type InvoicePaymentLinkGetData = Awaited<ReturnType<typeof updateInvoicePaymentLink>>
export const useInvoicePaymentLinkGetMutation = ({
onError,
...options
}: Omit<
UseMutationOptions<InvoicePaymentLinkGetData, ResponseError, InvoicePaymentLinkGetVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<InvoicePaymentLinkGetData, ResponseError, InvoicePaymentLinkGetVariables>(
(vars) => updateInvoicePaymentLink(vars),
{
async onError(error, variables, context) {
// In case of an error, there is a good chance that the invoice status has changed, so we invalidate the cache to reflect the updated status
await Promise.all([
queryClient.invalidateQueries(invoicesKeys.listAndCount(variables.slug)),
])
if (onError === undefined) {
toast.error(error.message)
} else {
onError(error, variables, context)
}
},
...options,
}
)
}
+1
View File
@@ -5,6 +5,7 @@ export const invoicesKeys = {
list: (slug: string | undefined, offset: number | undefined) =>
['invoices', slug, offset] as const,
count: (slug: string | undefined) => ['invoices', slug, 'count'] as const,
listAndCount: (slug: string | undefined) => ['invoices', slug] as const,
projectInvoices: (projectRef: string | undefined, offset: number | undefined) =>
['invoices', projectRef, offset] as const,
@@ -0,0 +1,47 @@
import Link from 'next/link'
import { ComponentProps, forwardRef, useRef } from 'react'
type LinkProps = ComponentProps<typeof Link>
export interface PrefetchableLinkProps extends Omit<LinkProps, 'onMouseEnter' | 'onMouseLeave'> {
prefetcher: () => void
}
const DELAY = 75
const PrefetchableLink = forwardRef<HTMLAnchorElement, PrefetchableLinkProps>(
function PrefetchableLink({ prefetcher, children, ...props }, ref) {
const mouseOverStartRef = useRef<number | null>(null)
const prefetchTimeoutRef = useRef<number | null>(null)
function onPrefetch() {
const now = Date.now()
// setTimeout is not guaranteed to be precise, so we need to check the time again
if (mouseOverStartRef.current && now - mouseOverStartRef.current >= DELAY) {
prefetcher()
}
}
function onMouseEnter() {
mouseOverStartRef.current = Date.now()
prefetchTimeoutRef.current = window.setTimeout(onPrefetch, DELAY)
}
function onMouseLeave() {
mouseOverStartRef.current = null
if (prefetchTimeoutRef.current) {
clearTimeout(prefetchTimeoutRef.current)
prefetchTimeoutRef.current = null
}
}
return (
<Link ref={ref} {...props} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{children}
</Link>
)
}
)
export default PrefetchableLink
@@ -1,7 +1,6 @@
import { QueryClient, useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, PropsWithChildren, useCallback } from 'react'
import { PropsWithChildren, useCallback } from 'react'
import { loadTableEditorSortsAndFiltersFromLocalStorage } from 'components/grid/SupabaseGrid'
import {
@@ -17,6 +16,7 @@ import { useFlag } from 'hooks/ui/useFlag'
import { ImpersonationRole } from 'lib/role-impersonation'
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
import { TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE } from 'state/table-editor'
import PrefetchableLink, { PrefetchableLinkProps } from './PrefetchableLink'
interface PrefetchEditorTablePageArgs {
queryClient: QueryClient
@@ -96,14 +96,12 @@ export function usePrefetchEditorTablePage() {
)
}
type LinkProps = ComponentProps<typeof Link>
interface EditorTablePageLinkProps extends Omit<LinkProps, 'href'> {
interface EditorTablePageLinkProps extends Omit<PrefetchableLinkProps, 'href' | 'prefetcher'> {
projectRef?: string
id?: string
sorts?: Sort[]
filters?: Filter[]
href?: LinkProps['href']
href?: PrefetchableLinkProps['href']
}
export function EditorTablePageLink({
@@ -118,12 +116,12 @@ export function EditorTablePageLink({
const prefetch = usePrefetchEditorTablePage()
return (
<Link
<PrefetchableLink
href={href || `/project/${projectRef}/editor/${id}`}
onMouseEnter={() => prefetch({ id, sorts, filters })}
prefetcher={() => prefetch({ id, sorts, filters })}
{...props}
>
{children}
</Link>
</PrefetchableLink>
)
}
@@ -1,7 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, PropsWithChildren, useCallback } from 'react'
import { PropsWithChildren, useCallback } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { prefetchSchemas } from 'data/database/schemas-query'
@@ -9,6 +8,7 @@ import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { prefetchEntityTypes } from 'data/entity-types/entity-types-infinite-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useFlag } from 'hooks/ui/useFlag'
import PrefetchableLink, { PrefetchableLinkProps } from './PrefetchableLink'
export function usePrefetchEditorIndexPage() {
const router = useRouter()
@@ -45,11 +45,9 @@ export function usePrefetchEditorIndexPage() {
}, [entityTypesSort, project, queryClient, router])
}
type LinkProps = ComponentProps<typeof Link>
interface EditorIndexPageLinkProps extends Omit<LinkProps, 'href'> {
interface EditorIndexPageLinkProps extends Omit<PrefetchableLinkProps, 'href' | 'prefetcher'> {
projectRef?: string
href?: LinkProps['href']
href?: PrefetchableLinkProps['href']
}
export function EditorIndexPageLink({
@@ -61,8 +59,12 @@ export function EditorIndexPageLink({
const prefetch = usePrefetchEditorIndexPage()
return (
<Link href={href || `/project/${projectRef}/editor`} onMouseEnter={prefetch} {...props}>
<PrefetchableLink
href={href || `/project/${projectRef}/editor`}
prefetcher={prefetch}
{...props}
>
{children}
</Link>
</PrefetchableLink>
)
}
@@ -1,11 +1,11 @@
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, PropsWithChildren, useCallback } from 'react'
import { PropsWithChildren, useCallback } from 'react'
import { prefetchProjectLogRequestsCount } from 'data/analytics/project-log-requests-count-query'
import { prefetchProjectLogStats } from 'data/analytics/project-log-stats-query'
import { prefetchProjectDetail } from 'data/projects/project-detail-query'
import PrefetchableLink, { PrefetchableLinkProps } from './PrefetchableLink'
export function usePrefetchProjectIndexPage() {
const router = useRouter()
@@ -40,11 +40,9 @@ export function usePrefetchProjectIndexPage() {
)
}
type LinkProps = ComponentProps<typeof Link>
interface ProjectIndexPageLinkProps extends Omit<LinkProps, 'href'> {
interface ProjectIndexPageLinkProps extends Omit<PrefetchableLinkProps, 'href' | 'prefetcher'> {
projectRef?: string
href?: LinkProps['href']
href?: PrefetchableLinkProps['href']
}
export function ProjectIndexPageLink({
@@ -56,12 +54,12 @@ export function ProjectIndexPageLink({
const prefetch = usePrefetchProjectIndexPage()
return (
<Link
<PrefetchableLink
href={href || `/project/${projectRef}`}
onMouseEnter={() => prefetch({ projectRef })}
prefetcher={() => prefetch({ projectRef })}
{...props}
>
{children}
</Link>
</PrefetchableLink>
)
}
@@ -0,0 +1,65 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { projectKeys } from './keys'
export type ProjectCloneVariables = {
projectRef: string
newProjectName: string
newDbPass: string
cloneBackupId?: number
recoveryTimeTarget?: number
}
export async function triggerClone({
projectRef,
newProjectName,
cloneBackupId,
newDbPass,
recoveryTimeTarget,
}: ProjectCloneVariables) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await post('/platform/database/{ref}/clone', {
params: { path: { ref: projectRef } },
body: { cloneBackupId, newProjectName, newDbPass, recoveryTimeTarget },
})
if (error) handleError(error)
return data
}
type ProjectCloneData = Awaited<ReturnType<typeof triggerClone>>
export const useProjectCloneMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<ProjectCloneData, ResponseError, ProjectCloneVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<ProjectCloneData, ResponseError, ProjectCloneVariables>(
(vars) => triggerClone(vars),
{
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: projectKeys.listCloneBackups(variables.projectRef),
})
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
console.error(data)
toast.error(`Failed to trigger clone: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
+29
View File
@@ -0,0 +1,29 @@
import { get, handleError } from 'data/fetchers'
import { projectKeys } from './keys'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { ResponseError } from 'types'
export async function getCloneBackups(projectRef?: string) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await get(`/platform/database/{ref}/clone`, {
params: { path: { ref: projectRef } },
})
if (error) handleError(error)
return data
}
export type CloneBackupsData = Awaited<ReturnType<typeof getCloneBackups>>
export type CloneBackupsError = ResponseError
export const useCloneBackupsQuery = <TData = CloneBackupsData>(
{ projectRef }: { projectRef?: string },
options: UseQueryOptions<CloneBackupsData, CloneBackupsError, TData> = {}
) => {
return useQuery<CloneBackupsData, CloneBackupsError, TData>(
projectKeys.listCloneBackups(projectRef),
() => getCloneBackups(projectRef),
{ enabled: !!projectRef, ...options }
)
}
@@ -0,0 +1,29 @@
import { get, handleError } from 'data/fetchers'
import { projectKeys } from './keys'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { ResponseError } from 'types'
export async function getCloneStatus(projectRef?: string) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await get(`/platform/database/{ref}/clone/status`, {
params: { path: { ref: projectRef } },
})
if (error) handleError(error)
return data
}
export type CloneStatusData = Awaited<ReturnType<typeof getCloneStatus>>
export type CloneStatusError = ResponseError
export const useCloneStatusQuery = <TData = CloneStatusData>(
{ projectRef }: { projectRef?: string },
options: UseQueryOptions<CloneStatusData, CloneStatusError, TData> = {}
) => {
return useQuery<CloneStatusData, CloneStatusError, TData>(
projectKeys.listCloneStatus(projectRef),
() => getCloneStatus(projectRef),
{ enabled: !!projectRef, ...options }
)
}
+6
View File
@@ -18,4 +18,10 @@ export const projectKeys = {
['projects', projectRef, 'pause-status'] as const,
orgProjects: (slug: string | undefined) => ['projects', 'org', slug] as const,
// Clone to new project
listCloneBackups: (projectRef: string | undefined) =>
['projects', projectRef, 'clone-backups'] as const,
listCloneStatus: (projectRef: string | undefined) =>
['projects', projectRef, 'clone-status'] as const,
}
@@ -0,0 +1,97 @@
import { components } from 'api-types'
import { PROVIDERS } from 'lib/constants'
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
export const instanceSizeSpecs: Record<
DesiredInstanceSize,
{
label: string
ram: string
cpu: string
priceHourly: number
priceMonthly: number
cloud_providers: string[]
}
> = {
micro: {
label: 'Micro',
ram: '1 GB',
cpu: '2-core',
priceHourly: 0.01344,
priceMonthly: 10,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
small: {
label: 'Small',
ram: '2 GB',
cpu: '2-core',
priceHourly: 0.0206,
priceMonthly: 15,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
medium: {
label: 'Medium',
ram: '4 GB',
cpu: '2-core',
priceHourly: 0.0822,
priceMonthly: 60,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
large: {
label: 'Large',
ram: '8 GB',
cpu: '2-core',
priceHourly: 0.1517,
priceMonthly: 110,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
xlarge: {
label: 'XL',
ram: '16 GB',
cpu: '4-core',
priceHourly: 0.2877,
priceMonthly: 210,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'2xlarge': {
label: '2XL',
ram: '32 GB',
cpu: '8-core',
priceHourly: 0.562,
priceMonthly: 410,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'4xlarge': {
label: '4XL',
ram: '64 GB',
cpu: '16-core',
priceHourly: 1.32,
priceMonthly: 960,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'8xlarge': {
label: '8XL',
ram: '128 GB',
cpu: '32-core',
priceHourly: 2.562,
priceMonthly: 1870,
cloud_providers: [PROVIDERS.AWS.id],
},
'12xlarge': {
label: '12XL',
ram: '192 GB',
cpu: '48-core',
priceHourly: 3.836,
priceMonthly: 2800,
cloud_providers: [PROVIDERS.AWS.id],
},
'16xlarge': {
label: '16XL',
ram: '256 GB',
cpu: '64-core',
priceHourly: 5.12,
priceMonthly: 3730,
cloud_providers: [PROVIDERS.AWS.id],
},
}
+7 -1
View File
@@ -1,10 +1,16 @@
import { useContext } from 'react'
import FlagContext from 'components/ui/Flag/FlagContext'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
export function useFlag<T = boolean>(name: string) {
const project = useSelectedProject()
const store: any = useContext(FlagContext)
// Temporary override as Fly projects are cant seem to upgrade their compute with the new disk UI
if (name === 'diskAndComputeForm' && project?.cloud_provider === 'FLY') {
return false
}
const isObjectEmpty = (objectName: Object) => {
return Object.keys(objectName).length === 0
}
+10
View File
@@ -0,0 +1,10 @@
import generator from 'generate-password-browser'
export const generateStrongPassword = () => {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
return password
}
@@ -1,5 +1,4 @@
import { useParams } from 'common'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { useRouter } from 'next/router'
import { ChangeEvent, useRef, useState } from 'react'
@@ -21,6 +20,7 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PROVIDERS } from 'lib/constants'
import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { AWS_REGIONS } from 'shared-data'
import { useIntegrationInstallationSnapshot } from 'state/integration-installation'
import type { NextPageWithLayout } from 'types'
@@ -117,13 +117,8 @@ const CreateProject = () => {
setPasswordStrengthMessage(message)
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setDbPass(password)
delayedCheckPasswordStrength(password)
}
@@ -245,7 +240,7 @@ const CreateProject = () => {
passwordStrengthScore={passwordStrengthScore}
password={dbPass}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>
+7 -105
View File
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { ChevronRight, ExternalLink } from 'lucide-react'
import Link from 'next/link'
@@ -27,9 +26,11 @@ import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToInci
import Panel from 'components/ui/Panel'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
import { useDefaultRegionQuery } from 'data/misc/get-default-region-query'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import {
DbInstanceSize,
ProjectCreateVariables,
@@ -50,6 +51,7 @@ import {
PROVIDERS,
} from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import type { CloudProvider } from 'shared-data'
import type { NextPageWithLayout } from 'types'
import {
@@ -83,7 +85,6 @@ import { Admonition } from 'ui-patterns/admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
@@ -100,99 +101,6 @@ const sizes: DesiredInstanceSize[] = [
'16xlarge',
]
const instanceSizeSpecs: Record<
DesiredInstanceSize,
{
label: string
ram: string
cpu: string
priceHourly: number
priceMonthly: number
cloud_providers: string[]
}
> = {
micro: {
label: 'Micro',
ram: '1 GB',
cpu: '2-core',
priceHourly: 0.01344,
priceMonthly: 10,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
small: {
label: 'Small',
ram: '2 GB',
cpu: '2-core',
priceHourly: 0.0206,
priceMonthly: 15,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
medium: {
label: 'Medium',
ram: '4 GB',
cpu: '2-core',
priceHourly: 0.0822,
priceMonthly: 60,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
large: {
label: 'Large',
ram: '8 GB',
cpu: '2-core',
priceHourly: 0.1517,
priceMonthly: 110,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
xlarge: {
label: 'XL',
ram: '16 GB',
cpu: '4-core',
priceHourly: 0.2877,
priceMonthly: 210,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'2xlarge': {
label: '2XL',
ram: '32 GB',
cpu: '8-core',
priceHourly: 0.562,
priceMonthly: 410,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'4xlarge': {
label: '4XL',
ram: '64 GB',
cpu: '16-core',
priceHourly: 1.32,
priceMonthly: 960,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
},
'8xlarge': {
label: '8XL',
ram: '128 GB',
cpu: '32-core',
priceHourly: 2.562,
priceMonthly: 1870,
cloud_providers: [PROVIDERS.AWS.id],
},
'12xlarge': {
label: '12XL',
ram: '192 GB',
cpu: '48-core',
priceHourly: 3.836,
priceMonthly: 2800,
cloud_providers: [PROVIDERS.AWS.id],
},
'16xlarge': {
label: '16XL',
ram: '256 GB',
cpu: '64-core',
priceHourly: 5.12,
priceMonthly: 3730,
cloud_providers: [PROVIDERS.AWS.id],
},
}
const Wizard: NextPageWithLayout = () => {
const router = useRouter()
const { slug, projectName } = useParams()
@@ -350,15 +258,9 @@ const Wizard: NextPageWithLayout = () => {
// compute credits
10
// [Joshen] Refactor: DB Password could be a common component
// used in multiple pages with repeated logic
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
// [Refactor] DB Password could be a common component used in multiple pages with repeated logic
function generatePassword() {
const password = generateStrongPassword()
form.setValue('dbPass', password)
delayedCheckPasswordStrength(password)
}
@@ -851,7 +753,7 @@ const Wizard: NextPageWithLayout = () => {
passwordStrengthScore={form.getValues('dbPassStrength')}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
>
@@ -0,0 +1,326 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
import { Markdown } from 'components/interfaces/Markdown'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useCloneStatusQuery } from 'data/projects/clone-status-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PROJECT_STATUS } from 'lib/constants'
import { getDatabaseMajorVersion } from 'lib/helpers'
import type { NextPageWithLayout } from 'types'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { useProjectsQuery } from 'data/projects/projects-query'
const RestoreToNewProjectPage: NextPageWithLayout = () => {
return (
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<div className="space-y-6">
<FormHeader className="!mb-0" title="Database Backups" />
<DatabaseBackupsNav active="rtnp" />
<div className="space-y-8">
<RestoreToNewProject />
</div>
</div>
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
}
RestoreToNewProjectPage.getLayout = (page) => (
<DatabaseLayout title="Database">{page}</DatabaseLayout>
)
const RestoreToNewProject = () => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null)
const { data: projects } = useProjectsQuery()
const {
data: cloneBackups,
error,
isLoading: cloneBackupsLoading,
isError,
} = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
const plan = subscription?.plan?.id
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isPermissionsLoaded = usePermissionsLoaded()
const canReadPhysicalBackups = useCheckPermissions(PermissionAction.READ, 'physical_backups')
const canTriggerPhysicalBackups = useCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const hasPITREnabled = cloneBackups?.pitr_enabled
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
const IS_PG15_OR_ABOVE = dbVersion >= 15
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
const {
data: cloneStatus,
refetch: refetchCloneStatus,
isLoading: cloneStatusLoading,
} = useCloneStatusQuery(
{
projectRef: project?.ref,
},
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
const hasTransientState = data.clones.some((c) => c.status === 'IN_PROGRESS')
if (!hasTransientState) setRefetchInterval(false)
},
}
)
const lastClone = cloneStatus?.clones?.[cloneStatus?.clones.length - 1]
const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
const clonedProject = projects?.find(
(p) => p.ref === cloneStatus?.clones?.[0]?.target_project.ref
)
if (isLoading) {
return <GenericSkeletonLoader />
}
if (!canReadPhysicalBackups) {
return <NoPermission resourceText="view backups" />
}
if (!canTriggerPhysicalBackups) {
return <NoPermission resourceText="restore backups" />
}
if (!IS_PG15_OR_ABOVE) {
return (
<Admonition
type="default"
title="Restore to new project is not available for this database version"
>
<Markdown
className="max-w-full"
content={`Restore to new project is only available for Postgres 15 and above.
Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure)
to upgrade your database version.
`}
/>
</Admonition>
)
}
if (!PHYSICAL_BACKUPS_ENABLED) {
return (
<Admonition
type="default"
title="Restore to new project requires physical backups"
description={
<>
Physical backups must be enabled to restore your database to a new project.
<br /> Find out more about how backups work at supabase{' '}
<Link
target="_blank"
className="underline"
href="https://supabase.com/docs/guides/platform/backups"
>
in our docs
</Link>
.
</>
}
/>
)
}
if (plan === 'free') {
return (
<UpgradeToPro
buttonText="Upgrade"
primaryText="Restore to a new project requires a pro plan or above."
secondaryText="To restore to a new project, you need to upgrade to a Pro plan and have physical backups enabled."
/>
)
}
if (IS_CLONED_PROJECT) {
return (
<Admonition type="default" title="This project cannot be restored to a new project">
<Markdown
className="max-w-full [&>p]:!leading-normal"
content={`This is a temporary limitation whereby projects that were originally restored from another project cannot be restored to yet another project.
If you need to restore a project to multiple other projects, please reach out via [support](/support/new?ref=${project?.ref}).`}
/>
<Button asChild type="default">
<Link
href={`/dashboard/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''}`}
>
Go to original project
</Link>
</Button>
</Admonition>
)
}
if (isError) {
return <AlertError error={error} subject="Failed to retrieve backups" />
}
if (!isActiveHealthy) {
return (
<Admonition
type="default"
title="Restore to new project is not available while project is offline"
description="Your project needs to be online to restore your database to a new project"
/>
)
}
if (lastClone?.status === 'FAILED') {
return (
<Admonition type="destructive" title="Failed to restore to new project">
<Markdown content="Sorry! The new project failed to be created, please reach out to support for assistance." />
<Button asChild type="default">
<Link
target="_blank"
rel="noreferrer noopener"
href={`/support/new?category=dashboard_bug&subject=Failed%20to%20restore%20to%20new%20project&message=Target%20project%20reference:%20${clonedProject?.ref ?? 'unknown'}`}
>
Contact support
</Link>
</Button>
</Admonition>
)
}
if (lastClone?.status === 'COMPLETED') {
return (
<Admonition type="default" title="Restoration completed">
<Markdown
className="max-w-full"
content={`The new project${!!clonedProject ? ` ${clonedProject.name}` : ''} has been created. A project can only be restored to another project once.`}
/>
<Button asChild type="default">
<Link href={`/project/${lastClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</Admonition>
)
}
if (lastClone?.status === 'IN_PROGRESS') {
return (
<Alert_Shadcn_ className="[&>svg]:bg-none! [&>svg]:text-foreground-light">
<Loader2 className="animate-spin" />
<AlertTitle_Shadcn_>Restoration in progress</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
The new project{!!clonedProject ? ` ${clonedProject.name}` : ''} is currently being
created
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${lastClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
if (
!isLoading &&
hasPITREnabled &&
!cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
) {
return (
<Admonition
type="default"
title="No backups found"
description="PITR is enabled, but no backups were found. Check again in a few minutes."
/>
)
}
if (!isLoading && !hasPITREnabled && cloneBackups?.backups.length === 0) {
return <BackupsEmpty />
}
return (
<>
<ConfirmRestoreDialog
open={showConfirmationDialog}
onOpenChange={setShowConfirmationDialog}
onSelectContinue={() => {
setShowConfirmationDialog(false)
setShowNewProjectDialog(true)
}}
/>
<CreateNewProjectDialog
open={showNewProjectDialog}
selectedBackupId={selectedBackupId}
recoveryTimeTarget={recoveryTimeTarget}
onOpenChange={setShowNewProjectDialog}
onCloneSuccess={() => {
refetchCloneStatus()
setRefetchInterval(5000)
setShowNewProjectDialog(false)
}}
/>
{hasPITREnabled ? (
<>
<PITRForm
onSubmit={(v) => {
setShowConfirmationDialog(true)
setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
}}
earliestAvailableBackupUnix={
cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
}
latestAvailableBackupUnix={
cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
}
/>
</>
) : (
<BackupsList
onSelectRestore={(id) => {
setSelectedBackupId(id)
setShowConfirmationDialog(true)
}}
/>
)}
</>
)
}
export default RestoreToNewProjectPage
@@ -0,0 +1,26 @@
// select * from cron.job_run_details where jobid = '1' order by start_time desc limit 10
import { useRouter } from 'next/router'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
import type { NextPageWithLayout } from 'types'
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
export const LogPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = router.query
return (
<LogsPreviewer
projectRef={ref as string}
condensedLayout={true}
tableName={LogsTableName.CRON_JOBS}
queryType={'cron'}
/>
)
}
LogPage.getLayout = (page) => <LogsLayout title="Cron Logs">{page}</LogsLayout>
export default LogPage
+5 -10
View File
@@ -1,11 +1,9 @@
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { makeAutoObservable } from 'mobx'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { ChangeEvent, createContext, useContext, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Input, Listbox } from 'ui'
import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout'
import {
@@ -22,9 +20,11 @@ import {
PROVIDERS,
} from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { VERCEL_INTEGRATION_CONFIGS } from 'lib/vercelConfigs'
import { AWS_REGIONS } from 'shared-data'
import type { Dictionary } from 'types'
import { Button, Input, Listbox } from 'ui'
interface ISetupProjectStore {
token: string
@@ -211,13 +211,8 @@ const CreateProject = observer(() => {
setPasswordStrengthMessage(message)
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setDbPass(password)
delayedCheckPasswordStrength(password)
}
@@ -271,7 +266,7 @@ const CreateProject = observer(() => {
passwordStrengthScore={passwordStrengthScore}
password={dbPass}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>
+1 -1
View File
@@ -96,7 +96,7 @@
}
.react-datepicker__day--highlighted {
@apply bg-brand-600 text-foreground-light;
@apply bg-overlay-hover text-foreground-light;
}
.react-datepicker__aria-live {
+8 -4
View File
@@ -9,7 +9,7 @@ name: supabase
services:
studio:
container_name: supabase-studio
image: supabase/studio:20241014-c083b3b
image: supabase/studio:20241106-f29003e
restart: unless-stopped
healthcheck:
test:
@@ -77,7 +77,7 @@ services:
auth:
container_name: supabase-auth
image: supabase/gotrue:v2.158.1
image: supabase/gotrue:v2.164.0
depends_on:
db:
# Disable this if you are using an external Postgres database
@@ -119,6 +119,10 @@ services:
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
# Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile.
# GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true
# GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true
# GOTRUE_SMTP_MAX_FREQUENCY: 1s
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
@@ -302,7 +306,7 @@ services:
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.59.0
image: supabase/edge-runtime:v1.62.2
restart: unless-stopped
depends_on:
analytics:
@@ -367,7 +371,7 @@ services:
# Comment out everything below this point if you are using an external Postgres database
db:
container_name: supabase-db
image: supabase/postgres:15.1.1.78
image: supabase/postgres:15.6.1.139
healthcheck:
test: pg_isready -U postgres -h localhost
interval: 5s
+18 -18
View File
@@ -110,6 +110,24 @@ Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注
<td><a href="https://github.com/supabase/realtime-dart" target="_blank" rel="noopener noreferrer">realtime-dart</a></td>
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
<td><a href="https://github.com/supabase/functions-dart" target="_blank" rel="noopener noreferrer">functions-dart</a></td>
</tr>
<tr>
<td>Swift</td>
<td><a href="https://github.com/supabase-community/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
<td><a href="https://github.com/supabase-community/postgrest-swift" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td><a href="https://github.com/supabase-community/functions-swift" target="_blank" rel="noopener noreferrer">functions-swift</a></td>
</tr>
<tr>
<td>Python</td>
<td><a href="https://github.com/supabase-community/supabase-py" target="_blank" rel="noopener noreferrer">supabase-py</a></td>
<td><a href="https://github.com/supabase-community/postgrest-py" target="_blank" rel="noopener noreferrer">postgrest-py</a></td>
<td><a href="https://github.com/supabase-community/gotrue-py" target="_blank" rel="noopener noreferrer">gotrue-py</a></td>
<td><a href="https://github.com/supabase-community/realtime-py" target="_blank" rel="noopener noreferrer">realtime-py</a></td>
<td><a href="https://github.com/supabase-community/storage-py" target="_blank" rel="noopener noreferrer">storage-py</a></td>
<td><a href="https://github.com/supabase-community/functions-py" target="_blank" rel="noopener noreferrer">functions-py</a></td>
</tr>
<!-- /notranslate -->
<th colspan="7">💚 社区 💚</th>
@@ -150,15 +168,6 @@ Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注
<td><a href="https://github.com/supabase-community/supabase-kt/tree/master/Storage" target="_blank" rel="noopener noreferrer">storage-kt</a></td>
<td><a href="https://github.com/supabase-community/supabase-kt/tree/master/Functions" target="_blank" rel="noopener noreferrer">functions-kt</a></td>
</tr>
<tr>
<td>Python</td>
<td><a href="https://github.com/supabase-community/supabase-py" target="_blank" rel="noopener noreferrer">supabase-py</a></td>
<td><a href="https://github.com/supabase-community/postgrest-py" target="_blank" rel="noopener noreferrer">postgrest-py</a></td>
<td><a href="https://github.com/supabase-community/gotrue-py" target="_blank" rel="noopener noreferrer">gotrue-py</a></td>
<td><a href="https://github.com/supabase-community/realtime-py" target="_blank" rel="noopener noreferrer">realtime-py</a></td>
<td><a href="https://github.com/supabase-community/storage-py" target="_blank" rel="noopener noreferrer">storage-py</a></td>
<td><a href="https://github.com/supabase-community/functions-py" target="_blank" rel="noopener noreferrer">functions-py</a></td>
</tr>
<tr>
<td>Ruby</td>
<td><a href="https://github.com/supabase-community/supabase-rb" target="_blank" rel="noopener noreferrer">supabase-rb</a></td>
@@ -177,15 +186,6 @@ Supabase 是一个[托管平台](https://supabase.com/dashboard)。你可以注
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Swift</td>
<td><a href="https://github.com/supabase-community/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
<td><a href="https://github.com/supabase-community/postgrest-swift" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td><a href="https://github.com/supabase-community/functions-swift" target="_blank" rel="noopener noreferrer">functions-swift</a></td>
</tr>
<tr>
<td>Godot Engine (GDScript)</td>
<td><a href="https://github.com/supabase-community/godot-engine.supabase" target="_blank" rel="noopener noreferrer">supabase-gdscript</a></td>
+6 -12
View File
@@ -13493,14 +13493,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz",
@@ -20640,8 +20632,9 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"license": "MIT",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -34273,9 +34266,10 @@
}
},
"node_modules/npm-run-all/node_modules/cross-spawn": {
"version": "6.0.5",
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
"integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
+22 -13
View File
@@ -12,28 +12,37 @@ export async function generateCron(openai: OpenAI, prompt: string) {
You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron.
Rules for responses:
- Output cron expressions in the 6-field format supported by pg_cron (seconds minute hour day month weekday)
- The seconds field is optional - if not specified, it defaults to 0
- Provide a brief explanation of what the cron expression does
- Output cron expressions in the 5-field format supported by pg_cron
- Do not provide any explanation of what the cron expression does
- Format output as markdown with the cron expression in a code block
- If the request is unclear or impossible, ask for clarification
- If the request isn't related to cron scheduling, explain that you can only help with cron expressions
- Do not ask for clarification if you need it. Just output the cron expression.
Example input: "Every Monday at 3am"
Example output:
This cron expression runs every Monday at 3:00:00 AM:
\`\`\`
0 0 3 * * 1
0 3 * * 1
\`\`\`
Common patterns to remember:
- * means "every" for that field
- */n means "every n intervals"
- n means "exactly at n"
- n,m means "at n and m"
- n-m means "every value from n to m"
Field order: seconds (0-59), minute (0-59), hour (0-23), day (1-31), month (1-12), weekday (0-6, Sunday=0)
This cron expression runs every minute:
\`\`\`
* * * * *
\`\`\`
Additional examples:
- Every minute: \`* * * * *\`
- Every 5 minutes: \`*/5 * * * *\`
- Every first of the month, at 00:00: \`0 0 1 * *\`
- Every night at midnight: \`0 0 * * *\`
- Every Monday at 2am: \`0 2 * * 1\`
Field order:
- minute (0-59)
- hour (0-23)
- day (1-31)
- month (1-12)
- weekday (0-6, Sunday=0)
Here is the user's prompt:
${prompt}
+647 -51
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -84,12 +84,22 @@ export const TimestampInfo = ({
setCopied(false)
}, 1000)
}}
className={cn('grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1', {
'bg-surface-100': copied,
})}
className={cn(
'relative cursor-default grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1',
{
'bg-surface-100': copied,
}
)}
>
<span className="text-right truncate">{label}:</span>
<span className={copied ? 'text-brand-600' : ''}>{copied ? 'Copied!' : value}</span>
<div className="relative">
{copied && (
<span className="absolute inset-0 flex items-center text-brand-600 bg-surface-100">
Copied!
</span>
)}
<span>{value}</span>
</div>
</span>
)
}
+6 -4
View File
@@ -30,8 +30,6 @@ port = 54323
[inbucket]
# Port to use for the email testing server web interface.
port = 54324
smtp_port = 54325
pop3_port = 54326
[storage]
# The maximum file size allowed (e.g. "5MB", "500KB").
@@ -42,7 +40,11 @@ file_size_limit = "50MiB"
# in emails.
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:3000"]
additional_redirect_urls = [
"https://*-supabase.vercel.app/*/*",
"https://supabase.com/*/*",
"http://localhost:3000/*/*",
]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one
# week).
jwt_expiry = 3600
@@ -61,7 +63,7 @@ enable_confirmations = false
# Uncomment the following to use gh oAuth app locally with your own test app
# to use env vars locally, run > `source ./supabase/.env && supabase [command...]`
[auth.external.github]
enabled = true
enabled = false
client_id = "env(GITHUB_CLIENT_ID)"
secret = "env(GITHUB_SECRET)"
# Overrides the default auth redirectUrl.
-34
View File
@@ -1,34 +0,0 @@
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
*.tfvars
*.tfvars.json
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc
-41
View File
@@ -1,41 +0,0 @@
variable "git_branch" {
type = string
default = null
}
# Fetch all branches of a linked project
data "supabase_branch" "all" {
parent_project_ref = var.linked_project
}
# Import an existing branch database
import {
for_each = {
for b in data.supabase_branch.all.branches :
b.git_branch => b
if b.git_branch == var.git_branch
}
to = supabase_branch.imported[0]
id = each.value.id
}
resource "supabase_branch" "imported" {
count = length(var.git_branch[*])
parent_project_ref = var.linked_project
git_branch = var.git_branch
}
# Override auth settings for the current branch
resource "supabase_settings" "preview" {
count = length(var.git_branch[*])
project_ref = supabase_branch.imported[0].database.id
auth = jsonencode({
site_url = "http://localhost:3001"
})
}
output "branch_database" {
value = one(supabase_branch.imported[*].database)
sensitive = true
}
-15
View File
@@ -1,15 +0,0 @@
# Define a linked project variable as user input
variable "linked_project" {
type = string
}
# Configure api settings for the linked project
resource "supabase_settings" "production" {
project_ref = var.linked_project
api = jsonencode({
db_schema = "public, storage, graphql_public"
db_extra_search_path = "public, extensions"
max_rows = 1000
})
}
-10
View File
@@ -1,10 +0,0 @@
terraform {
required_providers {
supabase = {
source = "supabase/supabase"
version = "~> 1.0"
}
}
}
provider "supabase" {}