mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 14:19:11 +03:00
Merge branch 'master' into chore/table-editor-filter-sorts-hooks
This commit is contained in:
commit
d2904d9609
190 files changed
+4103
-1858
No files matched your search
@@ -17,6 +17,10 @@ on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-prod
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -70,3 +70,10 @@ describe('prod smoke test: crawler pages return correct data', () => {
|
||||
expect(headings.includes('examples')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prod smoke test: ref pages without crawler versions', async () => {
|
||||
it('returns 200', async () => {
|
||||
const result = await fetch(REFERENCE_DOCS_URL + '/api/v1-deploy-a-function')
|
||||
expect(result.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,8 @@ Your database will be available in less than a minute.
|
||||
|
||||
**Finding your credentials:**
|
||||
|
||||
You can find your project credentials inside the project [settings](https://supabase.com/dashboard/project/_/settings/), including:
|
||||
You can find your project credentials on the dashboard:
|
||||
|
||||
- [Database credentials](https://supabase.com/dashboard/project/_/settings/database): connection strings and connection pooler details.
|
||||
- [API credentials](https://supabase.com/dashboard/project/_/settings/database): your serverless API URL and `anon` / `service_role` keys.
|
||||
- [Database connection strings](https://supabase.com/dashboard/project/_/settings/api?showConnect=true): Direct and Pooler connection details including the connection string and parameters.
|
||||
- [Database password](https://supabase.com/dashboard/project/_/settings/database): Reset database password here if you do not have it.
|
||||
- [API credentials](https://supabase.com/dashboard/project/_/settings/api): your serverless API URL and `anon` / `service_role` keys.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- If you're planning to migrate a database larger than 6 GB, we recommend [upgrading to at least a Large compute add-on](/docs/guides/platform/compute-add-ons). This will ensure you have the necessary resources to handle the migration efficiently.
|
||||
|
||||
- For databases smaller than 150 GB, you can increase the size of the disk on paid projects by navigating to [Database Settings](/dashboard/project/_/settings/database).
|
||||
- For databases smaller than 150 GB, you can increase the size of the disk on paid projects by navigating to the [Compute and Disk Settings](dashboard/project/_/settings/compute-and-disk) page.
|
||||
|
||||
- If you're dealing with a database larger than 150 GB, we strongly advise you to [contact our support team](/dashboard/support/new) for assistance in provisioning the required resources and ensuring a smooth migration process.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ If you haven't already, head over to [database.new](https://database.new) and cr
|
||||
|
||||
When creating your project, make sure to note down your database password as you will need it to construct the `DB_URL` in the next step.
|
||||
|
||||
You can find the database connection string in your Supabase Dashboard [database settings](https://supabase.com/dashboard/project/_/settings/database). Select "Use connection pooling" with `Mode: Session` for a direct connection to your Postgres database. It will look something like this:
|
||||
You can find your database connection string on your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true). Use the Session pooler connection string which looks like this:
|
||||
|
||||
```txt
|
||||
postgresql://postgres.[PROJECT-REF]:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
||||
|
||||
@@ -37,7 +37,7 @@ pip install vecs
|
||||
|
||||
## Connect to your database
|
||||
|
||||
Find the Postgres pooler connection string for your Supabase project in the [database settings](https://supabase.com/dashboard/project/_/settings/database) of the dashboard. Copy the "URI" format, which should look something like `postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres`
|
||||
On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true). The connection string should look like `postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres`
|
||||
|
||||
Create a new code block below the install block (`ctrl+m b`) and add the following code using the Postgres URI you copied above:
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ DB_CONNECTION = "postgresql://<user>:<password>@<host>:<port>/<db_name>"
|
||||
vx = vecs.create_client(DB_CONNECTION)
|
||||
```
|
||||
|
||||
Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project.
|
||||
Replace the `DB_CONNECTION` with your own connection string. You can find the connection string on your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type='note'>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ DB_CONNECTION = "postgresql://<user>:<password>@<host>:<port>/<db_name>"
|
||||
vx = vecs.create_client(DB_CONNECTION)
|
||||
```
|
||||
|
||||
Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project.
|
||||
Replace the `DB_CONNECTION` with your own connection string. You can find the connection string on your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type='note'>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ DB_CONNECTION = "postgresql://<user>:<password>@<host>:<port>/<db_name>"
|
||||
vx = vecs.create_client(DB_CONNECTION)
|
||||
```
|
||||
|
||||
Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project.
|
||||
Replace the `DB_CONNECTION` with your Session pooler connection string. You can find the connection string on your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type='note'>
|
||||
|
||||
@@ -51,7 +51,7 @@ SQLAlchemy requires the connection string to start with `postgresql://` (instead
|
||||
|
||||
<Admonition type='note'>
|
||||
|
||||
You must use the "connection pooling" string (domain ending in `*.pooler.supabase.com`) with Google Colab since Colab does not support IPv6.
|
||||
You must use the Session pooler connection string with Google Colab since Colab does not support IPv6.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ DB_CONNECTION = "postgresql://<user>:<password>@<host>:<port>/<db_name>"
|
||||
vx = vecs.create_client(DB_CONNECTION)
|
||||
```
|
||||
|
||||
Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project.
|
||||
Replace the `DB_CONNECTION` with your own connection string. You can find the connection string on your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type='note'>
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ const OneTapComponent = () => {
|
||||
}
|
||||
},
|
||||
nonce: hashedNonce,
|
||||
// with chrome's removal of third-party cookiesm, we need to use FedCM instead (https://developers.google.com/identity/gsi/web/guides/fedcm-migration)
|
||||
// with chrome's removal of third-party cookies, we need to use FedCM instead (https://developers.google.com/identity/gsi/web/guides/fedcm-migration)
|
||||
use_fedcm_for_prompt: true,
|
||||
})
|
||||
google.accounts.id.prompt() // Display the One Tap UI
|
||||
|
||||
@@ -77,11 +77,7 @@ The connection string looks like this:
|
||||
postgresql://postgres:[YOUR-PASSWORD]@db.apbkobhfnmcqqzqeeqss.supabase.co:5432/postgres
|
||||
```
|
||||
|
||||
Get your project's direct string from the [Database Settings](/dashboard/project/_/settings/database) page:
|
||||
|
||||
1. Go to the `Settings` section.
|
||||
2. Click `Database`.
|
||||
3. Under `Connection string`, make sure `Display connection pooler` is unchecked. Copy the URI.
|
||||
Get your project's direct connection string from your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
## Shared pooler
|
||||
|
||||
@@ -97,11 +93,7 @@ The connection string looks like this:
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
||||
```
|
||||
|
||||
Get your project's session mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
|
||||
|
||||
1. Go to the `Settings` section.
|
||||
2. Click `Database`.
|
||||
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Session mode` is selected. Copy the URI.
|
||||
Get your project's Session pooler connection string from your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
### Supavisor transaction mode
|
||||
|
||||
@@ -119,11 +111,7 @@ The connection string looks like this:
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
||||
```
|
||||
|
||||
Get your project's transaction mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
|
||||
|
||||
1. Go to the `Settings` section.
|
||||
2. Click `Database`.
|
||||
3. Under `Connection string`, make sure `Display connection pooler` is checked and `Transaction mode` is selected. Copy the URI.
|
||||
Get your project's Transaction pooler connection string from your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
## Dedicated pooler
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ If you do not have DBeaver, you can download it from its [website](https://dbeav
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
<StepHikeCompact.Details title="Get Your Credentials">
|
||||
Inside the Dashboard's [Database Settings](https://supabase.com/dashboard/project/_/settings/database), note your session mode's:
|
||||
On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true), note your session pooler's:
|
||||
- host
|
||||
- username
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ hideToc: true
|
||||
<StepHikeCompact.Details title="Connect to Postgres">
|
||||
|
||||
Connect your Postgres server to Metabase.
|
||||
- On your project dashboard click on [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
- On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
- View parameters under "Session pooler"
|
||||
|
||||
<Admonition type="note" label="connection notice">
|
||||
|
||||
@@ -42,7 +42,7 @@ If you use special symbols in your Postgres password, you must remember to [perc
|
||||
|
||||
### Changing your project password
|
||||
|
||||
When you created your project you were also asked to enter a password. This is actually the password for the `postgres` role in your database. You can update this from the Dashboard under the [database settings](https://supabase.com/dashboard/project/_/settings/database) page. You should _never_ give this to third-party service unless you absolutely trust them. Instead, we recommend that you create a new user for every service that you want to give access too. This will also help you with debugging - you can see every query that each role is executing in your database within `pg_stat_statements`.
|
||||
When you created your project you were also asked to enter a password. This is the password for the `postgres` role in your database. You can update this from the Dashboard under the [database settings](https://supabase.com/dashboard/project/_/settings/database) page. You should _never_ give this to third-party service unless you absolutely trust them. Instead, we recommend that you create a new user for every service that you want to give access too. This will also help you with debugging - you can see every query that each role is executing in your database within `pg_stat_statements`.
|
||||
|
||||
Changing the password does not result in any downtime. All connected services, such as PostgREST, PgBouncer, and other Supabase managed services, are automatically updated to use the latest password to ensure availability. However, if you have any external services connecting to the Supabase database using hardcoded username/password credentials, a manual update will be required.
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
<StepHikeCompact.Details title="Add your connection information to your .env file">
|
||||
- Visit the [Database Settings](https://supabase.com/dashboard/project/_/settings/database)
|
||||
- Find your Supavisor Session Mode string. It should end with 5432. It will be used in your `.env` file.
|
||||
- On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
- Find your Supavisor Session pooler string. It should end with 5432. It will be used in your `.env` file.
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||
@@ -56,7 +56,7 @@ Prisma couldn't establish a connection with Postgres or Supavisor before the tim
|
||||
### Solutions: [#solution-cant-reach-database-server-at]
|
||||
|
||||
- **Check database health**: Use the [Reports Dashboard](https://supabase.com/dashboard/project/_/reports/database) to monitor CPU, memory, and I/O usage. If the database is overloaded, consider increasing your [compute size](https://supabase.com/docs/guides/platform/compute-add-ons) or [optimizing your queries](https://supabase.com/docs/guides/database/query-optimization).
|
||||
- **Verify connection string**: Double-check the connection string in your Prisma configuration to ensure it matches one in your [Database Settings](https://supabase.com/dashboard/project/_/settings/database).
|
||||
- **Verify connection string**: Double-check the connection string in your Prisma configuration to ensure it matches in your [project connect page](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
- **Increase connection timeout**: Try increasing the `connect_timeout` parameter in your Prisma configuration to give it more time to establish a connection.
|
||||
|
||||
```md
|
||||
|
||||
@@ -9,9 +9,9 @@ video: 'https://www.youtube.com/v/J9mTPY8rIXE'
|
||||
|
||||
Vault is a Postgres extension and accompanying Supabase UI that makes it safe and easy to store encrypted secrets and other data in your database. This opens up a lot of possibilities to use Postgres in ways that go beyond what is available in a stock distribution.
|
||||
|
||||
Under the hood, the Vault is a table of Secrets and Encryption Keys that are stored using [Authenticated Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption) on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged.
|
||||
Under the hood, the Vault is a table of Secrets that are stored using [Authenticated Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption) on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged.
|
||||
|
||||
Supabase provides a dashboard UI for the Vault that makes storing secrets easy. Click a button, type in your secret, and save. Optionally create your own keys you can use to encrypt your secret. Your secret will then be stored on disk encrypted using the specified key.
|
||||
Supabase provides a dashboard UI for the Vault that makes storing secrets easy. Click a button, type in your secret, and save.
|
||||
|
||||
<video width="99%" muted playsInline controls="true">
|
||||
<source
|
||||
@@ -22,19 +22,11 @@ Supabase provides a dashboard UI for the Vault that makes storing secrets easy.
|
||||
/>
|
||||
</video>
|
||||
|
||||
There are two main parts to the Vault UI, Secrets and Encryption Keys:
|
||||
You can use Vault to store secrets - everything from Environment Variables to API Keys. You can then use these secrets anywhere in your database: Postgres [Functions](/docs/guides/database/functions), Triggers, and [Webhooks](/docs/guides/database/webhooks). From a SQL perspective, accessing secrets is as easy as querying a table (or in this case, a view). The underlying secrets tables will be stored in encrypted form.
|
||||
|
||||
## Secrets
|
||||
## Using Vault
|
||||
|
||||
You can use the Vault to store secrets - everything from Environment Variables to API Keys. You can then use these secrets anywhere in your database: Postgres [Functions](/docs/guides/database/functions), Triggers, and [Webhooks](/docs/guides/database/webhooks). From a SQL perspective, accessing secrets is as easy as querying a table (or in this case, a view). The underlying secrets tables will be stored in encrypted form.
|
||||
|
||||
## Encryption keys
|
||||
|
||||
These are keys used to encrypt data inside your database. You can create different Encryption Keys for different purposes, for example: one for encrypting user-data, and another for application-data. Each key is encrypted itself using a root encryption key that lives outside of the database. See **[Encryption key location](#encryption-key-location)** for more details.
|
||||
|
||||
## Using the Vault
|
||||
|
||||
You can manage secrets and encryption keys from the UI or using SQL.
|
||||
You can manage secrets from the UI or using SQL.
|
||||
|
||||
### Adding secrets
|
||||
|
||||
@@ -71,7 +63,7 @@ id | 7095d222-efe5-4cd5-b5c6-5755b451e223
|
||||
name | unique_name
|
||||
description | This is the description
|
||||
secret | 3mMeOcoG84a5F2uOfy2ugWYDp9sdxvCTmi6kTeT97bvA8rCEsG5DWWZtTU8VVeE=
|
||||
key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354
|
||||
key_id |
|
||||
nonce | \x9f2d60954ba5eb566445736e0760b0e3
|
||||
created_at | 2022-12-14 02:34:23.85159+00
|
||||
updated_at | 2022-12-14 02:34:23.85159+00
|
||||
@@ -79,31 +71,6 @@ updated_at | 2022-12-14 02:34:23.85159+00
|
||||
|
||||
</details>
|
||||
|
||||
Alternatively, you can create a secret by inserting data into the `vault.secret` table:
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
insert into vault.secrets (secret)
|
||||
values ('s3kre3t_k3y') returning *;
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show Result</summary>
|
||||
|
||||
```sql
|
||||
-[ RECORD 1 ]-------------------------------------------------------------
|
||||
id | d91596b8-1047-446c-b9c0-66d98af6d001
|
||||
name |
|
||||
description |
|
||||
secret | S02eXS9BBY+kE3r621IS8beAytEEtj+dDHjs9/0AoMy7HTbog+ylxcS22A==
|
||||
key_id | 7f5ad44b-6bd5-4c99-9f68-4b6c7486f927
|
||||
nonce | \x3aa2e92f9808e496aa4163a59304b895
|
||||
created_at | 2022-12-14 02:29:21.3625+00
|
||||
updated_at | 2022-12-14 02:29:21.3625+00
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Viewing secrets
|
||||
|
||||
If you look in the `vault.secrets` table, you will see that your data is stored encrypted. To decrypt the data, there is an automatically created view `vault.decrypted_secrets`. This view will decrypt secret data on the fly:
|
||||
@@ -126,7 +93,7 @@ name | unique_name
|
||||
description | This is the description
|
||||
secret | 3mMeOcoG84a5F2uOfy2ugWYDp9sdxvCTmi6kTeT97bvA8rCEsG5DWWZtTU8VVeE=
|
||||
decrypted_secret | another_s3kre3t
|
||||
key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354
|
||||
key_id |
|
||||
nonce | \x9f2d60954ba5eb566445736e0760b0e3
|
||||
created_at | 2022-12-14 02:34:23.85159+00
|
||||
updated_at | 2022-12-14 02:34:23.85159+00
|
||||
@@ -136,7 +103,7 @@ name |
|
||||
description |
|
||||
secret | a1CE4vXwQ53+N9bllJj1D7fasm59ykohjb7K90PPsRFUd9IbBdxIGZNoSQLIXl4=
|
||||
decrypted_secret | another_s3kre3t
|
||||
key_id | 8c72b05e-b931-4372-abf9-a09cfad18489
|
||||
key_id |
|
||||
nonce | \x1d3b2761548c4efb2d29ca11d44aa22f
|
||||
created_at | 2022-12-14 02:32:50.58921+00
|
||||
updated_at | 2022-12-14 02:32:50.58921+00
|
||||
@@ -146,7 +113,7 @@ name |
|
||||
description |
|
||||
secret | S02eXS9BBY+kE3r621IS8beAytEEtj+dDHjs9/0AoMy7HTbog+ylxcS22A==
|
||||
decrypted_secret | s3kre3t_k3y
|
||||
key_id | 7f5ad44b-6bd5-4c99-9f68-4b6c7486f927
|
||||
key_id |
|
||||
nonce | \x3aa2e92f9808e496aa4163a59304b895
|
||||
created_at | 2022-12-14 02:29:21.3625+00
|
||||
updated_at | 2022-12-14 02:29:21.3625+00
|
||||
@@ -186,7 +153,7 @@ name | updated_unique_name
|
||||
description | This is the updated description
|
||||
secret | lhb3HBFxF+qJzp/HHCwhjl4QFb5dYDsIQEm35DaZQOovdkgp2iy6UMufTKJGH4ThMrU=
|
||||
decrypted_secret | n3w_upd@ted_s3kret
|
||||
key_id | c62da7a0-b85d-471d-8ea7-52aae21d7354
|
||||
key_id |
|
||||
nonce | \x9f2d60954ba5eb566445736e0760b0e3
|
||||
created_at | 2022-12-14 02:34:23.85159+00
|
||||
updated_at | 2022-12-14 02:51:13.938396+00
|
||||
@@ -206,7 +173,7 @@ updated_at | 2022-12-14 02:51:13.938396+00
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
As we mentioned, the Vault uses `pgsodium`'s Transparent Column Encryption (TCE) to store secrets in an authenticated encrypted form. There are some details around that you may be curious about, what does authenticated mean, and where are encryption keys store? This section explains those details.
|
||||
As we mentioned, Vault uses Transparent Column Encryption (TCE) to store secrets in an authenticated encrypted form. There are some details around that you may be curious about. What does authenticated mean? Where is the encryption key stored? This section explains those details.
|
||||
|
||||
### Authenticated encryption with associated data
|
||||
|
||||
@@ -218,34 +185,11 @@ The first important feature of TCE is that it uses an [Authenticated Encryption
|
||||
|
||||
**Associated Data** means that you can include any other columns from the same row as part of the signature computation. This doesn't encrypt those other columns - rather it ensures that your encrypted value is only associated with columns from that row. If an attacker were to copy an encrypted value from another row to the current one, the signature would be rejected (assuming you used a unique column in the associated data).
|
||||
|
||||
Another important feature of `pgsodium` is that the encryption keys are never stored in the database alongside the encrypted data. Instead, only a **Key ID** is stored, which is a reference to the key that is only accessible outside of SQL. Even if an attacker can capture a dump of your entire database, they will see only encrypted data and key IDs, _never the raw key itself_.
|
||||
Another important feature is that the encryption key is never stored in the database alongside the encrypted data. Even if an attacker can capture a dump of your entire database, they will see only encrypted data, _never the encryption key itself_.
|
||||
|
||||
This is an important safety precaution - there is little value in storing the encryption key in the database itself as this would be like locking your front door but leaving the key in the lock! Storing the key outside the database fixes this issue.
|
||||
|
||||
Where are the keys stored? Supabase creates and manages the root keys (from which all key IDs are derived) in our secured backend systems. We keep this root key safe and separate from your data. You remain in control of your keys - a separate API endpoint is available that you can use to access the key if you want to decrypt your data outside of Supabase.
|
||||
|
||||
### Internal details
|
||||
|
||||
To encrypt data, you need a _key id_. You can use the default key id created automatically for every project, or create your own key ids Using the `pgsodium.create_key()` function. Key ids are used to internally derive the encryption key used to encrypt secrets in the vault. Vault users typically do not have access to the key itself, only the key id.
|
||||
|
||||
Both `vault.create_secret()` and `vault.update_secret()` take an optional fourth `new_key_id` argument. This argument can be used to store a different key id for the secret instead of the default value.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
select vault.create_secret(
|
||||
'another_s3kre3t_key',
|
||||
'another_unique_name',
|
||||
'This is another description',
|
||||
(pgsodium.create_key()).id
|
||||
);
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```sh
|
||||
-[ RECORD 1 ]-+-------------------------------------
|
||||
create_secret | cec9e005-a44d-4b19-86e1-febf3cd40619
|
||||
```
|
||||
Where is the key stored? Supabase creates and manages the encryption key in our secured backend systems. We keep this key safe and separate from your data. You remain in control of your key - a separate API endpoint is available that you can use to access the key if you want to decrypt your data outside of Supabase.
|
||||
|
||||
Which roles should have access to the `vault.secrets` table should be carefully considered. There are two ways to grant access, the first is that the `postgres` user can explicitly grant access to the vault table itself.
|
||||
|
||||
|
||||
@@ -106,14 +106,17 @@ You can use the [Supabase CLI](/docs/guides/cli) to manage changes inside a loca
|
||||
<StepHikeCompact.Step step={2}>
|
||||
<StepHikeCompact.Details title="Pull your database migration" fullWidth>
|
||||
|
||||
Pull your database changes using `supabase db pull`. You can find your database URL in your [database settings](https://supabase.com/dashboard/project/_/settings/database), under the URI tab of the Connection String settings panel. Make sure **Use connection pooling** is checked so you can use the IPv4-enabled connection pooler. (Without connection pooling, your database is only accessible over IPv6, which isn't yet supported by all network providers.)
|
||||
Pull your database changes using `supabase db pull`. To get your database connection string, go to your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true) and look for the Session pooler connection string.
|
||||
|
||||
```markdown
|
||||
supabase db pull --db-url <db_url>
|
||||
supabase db pull --db-url <db_connection_string>
|
||||
|
||||
# Your Database URL looks something like:
|
||||
# Your Database connection string will look like this:
|
||||
# postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres
|
||||
```
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
</StepHikeCompact.Step>
|
||||
@@ -458,8 +461,10 @@ You can disable branching at any time. Navigate to the [Branches](/dashboard/pro
|
||||
|
||||
### Persistent branches
|
||||
|
||||
Persistent branches are the type of branches that will remain active even after the underlying PR is closed.
|
||||
Persistent branches are the type of branches that will remain active even after the underlying PR is closed. Any PR based on a persistent branch will also have a corresponding preview branch created automatically.
|
||||
|
||||
You can change any branch to be persistent on the [Branches](/dashboard/project/_/branches) page by clicking the triple dots icon next to the branch you want to modify, and selecting "Switch to persistent".
|
||||
|
||||
All persistent branches can be toggled back to be an ephemeral branch in the exact same way.
|
||||
|
||||
## Migration and seeding behavior
|
||||
|
||||
@@ -49,13 +49,13 @@ hideToc: true
|
||||
|
||||
Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely.
|
||||
|
||||
When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. Make sure **Use connection pooling** is checked and **Session mode** is selected. Then copy the URI. Replace the password placeholder with your saved database password.
|
||||
When your project is up and running, navigate to your project dashboard and click on [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type="tip">
|
||||
Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [database settings](https://supabase.com/dashboard/project/_/settings/database) if you do not have it.
|
||||
|
||||
If your network supports IPv6 connections, you can also use the direct connection string. Uncheck **Use connection pooling** and copy the new URI.
|
||||
|
||||
</Admonition>
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ hideToc: true
|
||||
|
||||
Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely.
|
||||
|
||||
When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. Make sure **Use connection pooling** is checked and **Session mode** is selected. Then copy the URI. Replace the password placeholder with your saved database password.
|
||||
When your project is up and running, navigate to your project dashboard and click on [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type="tip">
|
||||
Look for the Session Pooler connection string and copy the string. You will need to replace the Password with your saved database password. You can reset your database password in your [database settings](https://supabase.com/dashboard/project/_/settings/database) if you do not have it.
|
||||
|
||||
If your network supports IPv6 connections, you can also use the direct connection string. Uncheck **Use connection pooling** and copy the new URI.
|
||||
|
||||
</Admonition>
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Otherwise, you'll need to invite a user as **Owner**, and they need to accept th
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Project scoped roles are only available on the [Enterprise Plan](https://supabase.com/pricing)
|
||||
Project scoped roles are only available on the [Team and Enterprise plans](https://supabase.com/pricing).
|
||||
|
||||
</Admonition>
|
||||
|
||||
@@ -261,7 +261,7 @@ The table below shows the actions each role can take on the resources belonging
|
||||
[^1]: Available on the Team and Enterprise Plans.
|
||||
[^2]: Sending anonymous data to OpenAI is opt in and can improve Studio AI Assistant's responses.
|
||||
[^3]: Invites sent from a SSO account can only be accepted by another SSO account coming from the same identity provider. This is a security measure that prevents accidental invites to accounts not managed by your company's enterprise systems.
|
||||
[^4]: Available on the Enterprise Plan.
|
||||
[^4]: Available on the Team and Enterprise Plans.
|
||||
[^5]: Read-Only role is able to access secrets.
|
||||
[^6]: Listed permissions are for the API and Dashboard.
|
||||
[^7]: Limited to executing SELECT queries. SQL Query Snippets run by the Read-Only role are run against the database using the **supabase_read_only_user**. This role has the [predefined Postgres role pg_read_all_data](https://www.postgresql.org/docs/current/predefined-roles.html).
|
||||
@@ -72,7 +72,7 @@ nslookup db.<PROJECT_REF>.supabase.co
|
||||
|
||||
## Identifying your connections
|
||||
|
||||
The pooler and direct connection strings can be found in the [database settings](https://supabase.com/dashboard/project/_/settings/database):
|
||||
The pooler and direct connection strings can be found in the [project connect page](https://supabase.com/dashboard/project/_?showConnect=true):
|
||||
|
||||
#### Direct connection
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ Supabase's core is Postgres, enabling the use of row-level security and providin
|
||||
## Retrieve your Supabase host [#retrieve-supabase-host]
|
||||
|
||||
1. If you're new to Supabase, [create a project](https://database.new). Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`).
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click on the View parameters under the connect string. Note your Host (`$SUPABASE_HOST`).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ Supabase provides several [tools](https://github.com/supabase-community/firebase
|
||||
}
|
||||
```
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under `Connection parameters`, enable `Use connection pooling` and set the mode to `Session`. Replace the `Host` and `User` fields with the values shown.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click on the View parameters under the connect string. Replace the `Host` and `User` fields with the values shown.
|
||||
1. Enter the password you used when you created your Supabase project in the `password` entry in the `supabase-service.json` file.
|
||||
|
||||
## Generate a Firebase private key [#generate-firebase-private-key]
|
||||
|
||||
@@ -29,8 +29,8 @@ The Firestore `collection` is "flattened" and converted to a table with basic co
|
||||
}
|
||||
```
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under `Connection parameters`, enable `Use connection pooling` and set the mode to `Session`. Replace the `Host` and `User` fields with the values shown.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click on the View parameters under the connect string. Replace the `Host` and `User` fields with the values shown.
|
||||
1. Enter the password you used when you created your Supabase project in the `password` entry in the `supabase-service.json` file.
|
||||
|
||||
## Generate a Firebase private key [#generate-firebase-private-key]
|
||||
|
||||
@@ -35,8 +35,8 @@ Alternatively, use the [Heroku to Supabase migration tool](https://migrate.supab
|
||||
## Retrieve your Supabase connection string [#retrieve-supabase-connection-string]
|
||||
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password.
|
||||
1. Get your project's Session pooler connection string from your project dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
1. Replace [YOUR-PASSWORD] in the connection string with your database password. You can reset your database password on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database) if you do not have it.
|
||||
|
||||
## Export your Heroku database to a file [#export-heroku-database]
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ Before you begin the migration, you need to collect essential information about
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`).
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click on the View parameters under the connect string. Note your Host (`$SUPABASE_HOST`).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ Before you begin the migration, you need to collect essential information about
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`).
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click on the View parameters under the connect string. Note your Host (`$SUPABASE_HOST`).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -34,9 +34,8 @@ export OLD_DB_URL="postgresql://neondb_owner:xxxxxxxxxxxxxxx-random-word-yyyyyyy
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set.
|
||||
1. Click the **Copy** button to the right of your connection string to copy it to the clipboard.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click the **Copy** button to the right of your connection string to copy it to the clipboard.
|
||||
|
||||
## Set your `NEW_DB_URL` environment variable
|
||||
|
||||
|
||||
@@ -25,8 +25,13 @@ Example:
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under Session pooler, Copy the connection string and replace the password placeholder with your database password.
|
||||
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have
|
||||
the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -29,8 +29,13 @@ Example:
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under Session pooler, Copy the connection string and replace the password placeholder with your database password.
|
||||
|
||||
<Admonition type="note">
|
||||
If you're in an [IPv6 environment](https://github.com/orgs/supabase/discussions/27034) or have
|
||||
the IPv4 Add-On, you can use the direct connection string instead of Supavisor in Session mode.
|
||||
</Admonition>
|
||||
|
||||
## Migrate the database
|
||||
|
||||
|
||||
@@ -41,9 +41,8 @@ export OLD_DB_URL="postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-we
|
||||
1. If you're new to Supabase, [create a project](https://supabase.com/dashboard).
|
||||
Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard.
|
||||
1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set.
|
||||
1. Click the **Copy** button to the right of your connection string to copy it to the clipboard.
|
||||
1. On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true)
|
||||
1. Under the Session pooler, click the **Copy** button to the right of your connection string to copy it to the clipboard.
|
||||
|
||||
## Set your `NEW_DB_URL` environment variable
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ breadcrumb: 'Migrations'
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
<StepHikeCompact.Details title="Get the new database connection string" fullWidth>
|
||||
Go to the [project page](/dashboard/project/_/) and click the "**Connect**" button at the top of the page for the connection string.
|
||||
On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type="note">
|
||||
Use the Session pooler connection string by default. If your ISP supports IPv6, use the direct connection string.
|
||||
Use the Session pooler connection string by default. If your ISP supports IPv6 or you have the IPv4 add-on enabled, use the direct connection string.
|
||||
</Admonition>
|
||||
|
||||
Session pooler connection string:
|
||||
@@ -131,7 +131,7 @@ breadcrumb: 'Migrations'
|
||||
<StepHikeCompact.Step step={4}>
|
||||
|
||||
<StepHikeCompact.Details title="Get the database password" fullWidth>
|
||||
Reset the password in the [Database Settings](/dashboard/project/_/settings/database).
|
||||
Reset the password in the [project connect page](/dashboard/project/_?showConnect=true).
|
||||
|
||||
Replace ```[YOUR-PASSWORD]``` in the connection string with the database password.
|
||||
|
||||
|
||||
@@ -59,20 +59,15 @@ Here are some things that are not stored directly in your database and will requ
|
||||
- Database extensions and settings
|
||||
- Read Replicas
|
||||
|
||||
Here is a non-exhaustive list of items that are commonly asked about and will be migrated with your backup restoration:
|
||||
|
||||
- Database triggers
|
||||
- Database functions
|
||||
|
||||
## Restore backup
|
||||
|
||||
<StepHikeCompact>
|
||||
<StepHikeCompact.Step step={1}>
|
||||
<StepHikeCompact.Details title="Get the new database connection string" fullWidth>
|
||||
Go to the [project page](/dashboard/project/_/) and click the "**Connect**" button at the top of the page for the connection string.
|
||||
On your project dashboard, click [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<Admonition type="note">
|
||||
Use the Session pooler connection string by default. If your ISP supports IPv6, use the direct connection string.
|
||||
Use the Session pooler connection string by default. If your ISP supports IPv6 or you have the IPv4 add-on enabled, use the direct connection string.
|
||||
</Admonition>
|
||||
|
||||
Session pooler connection string:
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ sudo apt-get update
|
||||
sudo apt-get install postgresql-client
|
||||
```
|
||||
|
||||
Once installed, you can find your PSQL string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database). Make sure if you are using the pooler connection that it is in session mode (port 5432).
|
||||
Once installed, you can find your PSQL string on the dashboard by clicking [connect](https://supabase.com/dashboard/project/_?showConnect=true). Make sure if you are using the pooler connection that it is the Session pooler (port 5432).
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
|
||||
+4
-3
@@ -46,7 +46,7 @@ sudo apt-get update
|
||||
sudo apt-get install postgresql-client
|
||||
```
|
||||
|
||||
Once installed, you can find your PSQL string from the [Database Settings](https://supabase.com/dashboard/project/_/settings/database), which can be executed in the terminal to create a psql session.
|
||||
Once installed, you can find your PSQL string on the dashboard by clicking [connect](https://supabase.com/dashboard/project/_?showConnect=true). Make sure if you are using the pooler connection that it is the Session pooler (port 5432).
|
||||
|
||||
If your network can use IPv6, consider using the direct connection string instead of Supavisor. It's not mandatory, but for tasks that run a long time, it's best to reduce network complexity. To check if your network is compatible, use this cURL command to request your IPv6 address:
|
||||
|
||||
@@ -54,7 +54,7 @@ If your network can use IPv6, consider using the direct connection string instea
|
||||
curl -6 https://ifconfig.co/ip
|
||||
```
|
||||
|
||||
If an address is returned, you should be able to use your direct connection string found in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database):
|
||||
If an address is returned, you should use your direct connection string found on the dashboard by clicking [connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
**3. Increase memory for index creation (optional)**
|
||||
|
||||
@@ -104,7 +104,8 @@ If your task is particularly long, you can speed it up by boosting your computin
|
||||
|
||||
**7. Consider increasing disk size (optional)**
|
||||
|
||||
HSNW indexes can produce temporary files during their construction that may consume a few GBs worth of disk. Consider increasing the disk size in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) to accommodate for short-term disk stress.
|
||||
HSNW indexes can produce temporary files during their construction that may consume a few GBs worth of disk.
|
||||
Consider increasing the disk size in the [Compute and Disk settings](https://supabase.com/dashboard/project/_/settings/compute-and-disk) to accommodate for short-term disk stress.
|
||||
|
||||
<img
|
||||
width="947"
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ If a database exhibited some of these metrics for prolonged periods, then there
|
||||
- Scale the database to get more IO if possible
|
||||
- Optimize queries/tables or refactor database/app to reduce IO
|
||||
- Spin-up a [read-replica](https://supabase.com/dashboard/project/_/settings/infrastructure)
|
||||
- Modify IO configs in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database)
|
||||
- Modify IO configs in the [Compute and Disk settings](https://supabase.com/dashboard/project/_/settings/compute-and-disk)
|
||||
- [Partitions](https://supabase.com/docs/guides/database/partitions): Generally should be used on very large tables to minimize data pulled from disk
|
||||
|
||||
Other useful Supabase Grafana guides:
|
||||
|
||||
@@ -148,7 +148,7 @@ Useful Links:
|
||||
- [How to Monitor Connections and Find the Correct Pool Size](https://github.com/orgs/supabase/discussions/27141).
|
||||
- [Supavisor FAQ](https://github.com/orgs/supabase/discussions/21566)
|
||||
|
||||
Supabase provides 3 connection strings in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database). You can use all three or just the ones most relevant to your project.
|
||||
Supabase provides 3 database connection strings that can be used simultaneously if necessary. You can find them on the dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
### Direct connection:
|
||||
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@ database_id = "c89147f8-a66a-4c04-a1a7-45442fd7f2ee"
|
||||
|
||||
### Finding your database hostname
|
||||
|
||||
Your database's hostname is crucial for establishing a direct connection. It resolves to the underlying IP address of your database. To find your hostname, navigate to your [Database Settings](https://supabase.com/dashboard/project/_/settings/database). It's important to note that the pooler (connection pooler) has a different IP than your database. Therefore, to reveal the database host and direct connection string, you must hide the pooler connection string.
|
||||
Your database's hostname is crucial for establishing a direct connection. It resolves to the underlying IP address of your database.
|
||||
|
||||
To find your hostname, navigate to the dashboard and click [Connect](https://supabase.com/dashboard/project/_?showConnect=true). Look at the Direct connection string and click the View parameters under it to see the hostname.
|
||||
|
||||
Example Hostname: `db.zcjtzmeifsoteyjytnbc.supabase.co`
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ Example IPv6 Address: `2a05:d014:1c06:5f0c:d7a9:8616:bee2:30df`
|
||||
|
||||
### Identifying your connections:
|
||||
|
||||
The pooler and direct connection strings can be found in the [database settings](https://supabase.com/dashboard/project/_/settings/database):
|
||||
The pooler and direct connection strings can be found on the dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
> "Note uses an IPv6 address by default
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ They sit between the database and your client servers. They are solely optimized
|
||||
|
||||
### Where are the connection strings
|
||||
|
||||
Supabase provides 3 database connection strings that can be used simultaneously if necessary. All can be viewed in the connection string section of the [Database Settings](https://supabase.com/dashboard/project/_/settings/database)
|
||||
Supabase provides 3 database connection strings that can be used simultaneously if necessary. You can find them on the dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
<img
|
||||
width="999"
|
||||
@@ -89,7 +89,7 @@ Now, imagine the tournament organizers decide to expand the venue to house 200 p
|
||||
|
||||
### In the context of Supavisor, what does "pool size" mean?
|
||||
|
||||
"Pool size" refers to the maximum number of direct connections the pooler can maintain per unique user, database, and mode combination. You can adjust it in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) to strike a balance between efficient resource utilization and accommodating peak traffic.
|
||||
"Pool size" refers to the maximum number of direct connections the pooler can maintain per unique user, database, and mode combination. You can adjust it in the [project connect page](https://supabase.com/dashboard/project/_?showConnect=true) to strike a balance between efficient resource utilization and accommodating peak traffic.
|
||||
|
||||
### What is the "user+db+mode" combination?
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ If you are deploying to:
|
||||
- serverless functions
|
||||
- horizontally auto-scaling deployments
|
||||
|
||||
It is recommended that you connect with the pooler in transaction mode (port 6543), which can be found in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database):
|
||||
It is recommended that you connect with the pooler in transaction mode (port 6543), which can be found on the dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
```sh
|
||||
# Example transaction mode string:
|
||||
@@ -36,7 +36,7 @@ When relying on Supavisor, it's important to pick an adequate pool size. This gu
|
||||
|
||||
## Deploying to stationary servers
|
||||
|
||||
For stationary servers, such as VMs and long-running containers, it is recommended to use your direct connection string, which can be found in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database)
|
||||
For stationary servers, such as VMs and long-running containers, it is recommended to use your direct connection string, which can be found on the dashboard by clicking [Connect](https://supabase.com/dashboard/project/_?showConnect=true).
|
||||
|
||||
```
|
||||
# Example DB string:
|
||||
|
||||
+22
-26
@@ -1,15 +1,12 @@
|
||||
import { isbot } from 'isbot'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clientSdkIds } from '~/content/navigation.references'
|
||||
import { BASE_PATH } from '~/lib/constants'
|
||||
|
||||
const REFERENCE_PATH = `${(BASE_PATH ?? '') + '/'}reference`
|
||||
const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference`
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (!url.pathname.startsWith(REFERENCE_PATH)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
@@ -26,38 +23,37 @@ export function middleware(request: NextRequest) {
|
||||
if (slug.length > 0) {
|
||||
const rewriteUrl = new URL(url)
|
||||
rewriteUrl.pathname = (BASE_PATH ?? '') + '/api/crawlers'
|
||||
|
||||
return NextResponse.rewrite(rewriteUrl)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [, lib, maybeVersion] = url.pathname.replace(REFERENCE_PATH, '').split('/')
|
||||
}
|
||||
|
||||
if (lib === 'cli') {
|
||||
const rewritePath = [REFERENCE_PATH, 'cli'].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
const [, lib, maybeVersion] = url.pathname.replace(REFERENCE_PATH, '').split('/')
|
||||
|
||||
if (lib === 'api') {
|
||||
const rewritePath = [REFERENCE_PATH, 'api'].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
if (lib === 'cli') {
|
||||
const rewritePath = [REFERENCE_PATH, 'cli'].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (lib?.startsWith('self-hosting-')) {
|
||||
const rewritePath = [REFERENCE_PATH, lib].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
if (lib === 'api') {
|
||||
const rewritePath = [REFERENCE_PATH, 'api'].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (clientSdkIds.includes(lib)) {
|
||||
const version = /v\d+/.test(maybeVersion) ? maybeVersion : null
|
||||
const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
if (lib?.startsWith('self-hosting-')) {
|
||||
const rewritePath = [REFERENCE_PATH, lib].join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
if (clientSdkIds.includes(lib)) {
|
||||
const version = /v\d+/.test(maybeVersion) ? maybeVersion : null
|
||||
const rewritePath = [REFERENCE_PATH, lib, version].filter(Boolean).join('/')
|
||||
return NextResponse.rewrite(new URL(rewritePath, request.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/((?!api|_next|static|public|favicon.ico).*)',
|
||||
matcher: '/reference/:path*',
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 277 KiB |
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 2.20.12
|
||||
version: 2.22.4
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -871,7 +871,7 @@ commands:
|
||||
title: supabase services
|
||||
summary: Show versions of all Supabase services
|
||||
tags:
|
||||
- management-api
|
||||
- local-dev
|
||||
links: []
|
||||
usage: supabase services
|
||||
subcommands: []
|
||||
|
||||
@@ -1387,7 +1387,8 @@ functions:
|
||||
examples:
|
||||
- id: update-the-email-for-an-authenticated-user
|
||||
name: Update the email for an authenticated user
|
||||
description: Sends a "Confirm Email Change" email to the new email address.
|
||||
description: |
|
||||
Sends a "Confirm Email Change" email to the new address. If **Secure Email Change** is enabled (default), confirmation is also required from the **old email** before the change is applied. To skip dual confirmation and apply the change after only the new email is verified, disable **Secure Email Change** in the [Email Auth Provider settings](/dashboard/project/_/auth/providers?provider=Email).
|
||||
isSpotlight: false
|
||||
code: |
|
||||
```js
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createPortal } from 'react-dom'
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { useUrlState } from 'hooks/ui/useUrlState'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
import { EMPTY_ARR } from 'lib/void'
|
||||
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { noop } from 'lodash'
|
||||
|
||||
import { FeatureFlagContext } from 'common'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { EMPTY_OBJ } from 'lib/void'
|
||||
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react'
|
||||
@@ -15,7 +17,7 @@ export const FEATURE_PREVIEWS = [
|
||||
key: LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW,
|
||||
name: 'Layout Update for Organizations',
|
||||
content: <LayoutUpdatePreview />,
|
||||
discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038',
|
||||
discussionsUrl: 'https://github.com/orgs/supabase/discussions/33670',
|
||||
isNew: false,
|
||||
isPlatformOnly: true,
|
||||
},
|
||||
@@ -72,6 +74,19 @@ const FeaturePreviewContext = createContext<FeaturePreviewContextType>({
|
||||
export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext)
|
||||
|
||||
export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { hasLoaded } = useContext(FeatureFlagContext)
|
||||
const enableNewLayoutPreview = useFlag('newLayoutPreview')
|
||||
|
||||
// [Joshen] Similar logic to feature flagging previews, we can use flags to default opt in previews
|
||||
const isDefaultOptIn = (feature: (typeof FEATURE_PREVIEWS)[number]) => {
|
||||
switch (feature.key) {
|
||||
case LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW:
|
||||
return enableNewLayoutPreview
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const [flags, setFlags] = useState(() =>
|
||||
FEATURE_PREVIEWS.reduce((a, b) => {
|
||||
return { ...a, [b.key]: false }
|
||||
@@ -82,11 +97,16 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}
|
||||
if (typeof window !== 'undefined') {
|
||||
setFlags(
|
||||
FEATURE_PREVIEWS.reduce((a, b) => {
|
||||
return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' }
|
||||
const defaultOptIn = isDefaultOptIn(b)
|
||||
const localStorageValue = localStorage.getItem(b.key)
|
||||
return {
|
||||
...a,
|
||||
[b.key]: !localStorageValue ? defaultOptIn : localStorageValue === 'true',
|
||||
}
|
||||
}, {})
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}, [hasLoaded])
|
||||
|
||||
const value = {
|
||||
flags,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect } from 'react'
|
||||
import { useParams } from 'common'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { removeTabsByEditor } from 'state/tabs'
|
||||
@@ -19,12 +18,9 @@ const FeaturePreviewModal = () => {
|
||||
const featurePreviewContext = useFeaturePreviewContext()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const enableNewLayoutPreview = useFlag('newLayoutPreview')
|
||||
|
||||
// [Joshen] Use this if we want to feature flag previews
|
||||
function isReleasedToPublic(feature: (typeof FEATURE_PREVIEWS)[number]) {
|
||||
switch (feature.key) {
|
||||
case LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW:
|
||||
return enableNewLayoutPreview
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { ExternalLink, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Button } from 'ui'
|
||||
import { useIsNewLayoutEnabled } from './FeaturePreviewContext'
|
||||
|
||||
export const LayoutUpdatePreview = () => {
|
||||
return (
|
||||
@@ -37,3 +42,48 @@ export const LayoutUpdatePreview = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LayoutUpdateBanner = () => {
|
||||
const newLayoutPreview = useIsNewLayoutEnabled()
|
||||
const [newLayoutPreviewState] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.UI_NEW_LAYOUT_PREVIEW, '')
|
||||
const [newLayoutAcknowledged, setNewLayoutAcknowledged] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.NEW_LAYOUT_NOTICE_ACKNOWLEDGED,
|
||||
false
|
||||
)
|
||||
const isDefaultOptedInNewLayout = newLayoutPreview && newLayoutPreviewState === ''
|
||||
|
||||
if (!isDefaultOptedInNewLayout || newLayoutAcknowledged) return null
|
||||
|
||||
return (
|
||||
<Alert_Shadcn_ className="mb-4 relative">
|
||||
<AlertTitle_Shadcn_>
|
||||
<Badge variant="brand" className="mr-2">
|
||||
NEW
|
||||
</Badge>
|
||||
Dashboard layout has been updated!
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
We've updated the dashboard layout to make it easier to find and manage your organization
|
||||
settings.
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-4 flex items-center gap-x-2">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://github.com/orgs/supabase/discussions/33670"
|
||||
>
|
||||
View announcement
|
||||
</a>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="absolute top-2 right-2 px-1"
|
||||
onClick={() => setNewLayoutAcknowledged(true)}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }}
|
||||
/>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { DEFAULT_HOME, IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { useIsNewLayoutEnabled } from './FeaturePreview/FeaturePreviewContext'
|
||||
|
||||
// Ideally these could all be within a _middleware when we use Next 12
|
||||
const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const router = useRouter()
|
||||
const { ref, slug, id } = useParams()
|
||||
const newLayoutPreview = useIsNewLayoutEnabled()
|
||||
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
const snap = useAppStateSnapshot()
|
||||
@@ -31,6 +33,12 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
''
|
||||
)
|
||||
|
||||
const DEFAULT_HOME = IS_PLATFORM
|
||||
? newLayoutPreview
|
||||
? '/organizations'
|
||||
: '/projects'
|
||||
: '/project/default'
|
||||
|
||||
/**
|
||||
* Array of urls/routes that should be ignored
|
||||
*/
|
||||
|
||||
@@ -7,10 +7,10 @@ import Table from 'components/to-be-cleaned/Table'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { Button, Input, SidePanel } from 'ui'
|
||||
import MigrationsEmptyState from './MigrationsEmptyState'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Button, Input, SidePanel } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import MigrationsEmptyState from './MigrationsEmptyState'
|
||||
|
||||
const Migrations = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -55,7 +55,7 @@ const Migrations = () => {
|
||||
>
|
||||
<Button key="contact-support" asChild type="default">
|
||||
<Link
|
||||
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
|
||||
href={`/support/new?projectRef=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
|
||||
>
|
||||
Contact support
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
|
||||
interface DeleteDestinationProps {
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
onDelete: () => void
|
||||
isLoading: boolean
|
||||
name: string
|
||||
}
|
||||
|
||||
const DeleteDestination = ({
|
||||
visible,
|
||||
setVisible,
|
||||
onDelete,
|
||||
isLoading,
|
||||
name,
|
||||
}: DeleteDestinationProps) => {
|
||||
return (
|
||||
<>
|
||||
<TextConfirmModal
|
||||
variant={'warning'}
|
||||
visible={visible}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
onConfirm={onDelete}
|
||||
title="Delete this destination"
|
||||
loading={isLoading}
|
||||
confirmLabel={`Delete destination`}
|
||||
confirmPlaceholder="Type in name of destination"
|
||||
confirmString={name ?? 'Unknown'}
|
||||
text={
|
||||
<>
|
||||
<span>This will delete the destination</span>{' '}
|
||||
</>
|
||||
}
|
||||
alert={{ title: 'You cannot recover this destination once deleted.' }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteDestination
|
||||
@@ -0,0 +1,519 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useParams } from 'common'
|
||||
import { useCreatePipelineMutation } from 'data/replication/create-pipeline-mutation'
|
||||
import { useCreateSinkMutation } from 'data/replication/create-sink-mutation'
|
||||
import { useCreateSourceMutation } from 'data/replication/create-source-mutation'
|
||||
import { useReplicationPublicationsQuery } from 'data/replication/publications-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useUpdateSinkMutation } from 'data/replication/update-sink-mutation'
|
||||
import { useUpdatePipelineMutation } from 'data/replication/update-pipeline-mutation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Accordion_Shadcn_,
|
||||
AccordionContent_Shadcn_,
|
||||
AccordionItem_Shadcn_,
|
||||
AccordionTrigger_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
Switch,
|
||||
TextArea_Shadcn_,
|
||||
WarningIcon,
|
||||
Label_Shadcn_ as Label,
|
||||
} from 'ui'
|
||||
import * as z from 'zod'
|
||||
import PublicationsComboBox from './PublicationsComboBox'
|
||||
import NewPublicationPanel from './NewPublicationPanel'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useReplicationSinkByIdQuery } from 'data/replication/sink-by-id-query'
|
||||
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
interface DestinationPanelProps {
|
||||
visible: boolean
|
||||
sourceId: number | undefined
|
||||
onClose: () => void
|
||||
existingDestination?: {
|
||||
sourceId?: number
|
||||
sinkId: number
|
||||
pipelineId?: number
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const DestinationPanel = ({
|
||||
visible,
|
||||
sourceId,
|
||||
onClose,
|
||||
existingDestination,
|
||||
}: DestinationPanelProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
|
||||
const { mutateAsync: createSource, isLoading: creatingSource } = useCreateSourceMutation()
|
||||
const { mutateAsync: createSink, isLoading: creatingSink } = useCreateSinkMutation()
|
||||
const { mutateAsync: createPipeline, isLoading: creatingPipeline } = useCreatePipelineMutation()
|
||||
const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline, isLoading: stoppingPipeline } = useStopPipelineMutation()
|
||||
const { mutateAsync: updateSink, isLoading: updatingSink } = useUpdateSinkMutation()
|
||||
const { mutateAsync: updatePipeline, isLoading: updatingPipeline } = useUpdatePipelineMutation()
|
||||
const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({
|
||||
projectRef,
|
||||
sourceId,
|
||||
})
|
||||
|
||||
const { data: sinkData } = useReplicationSinkByIdQuery({
|
||||
projectRef,
|
||||
sinkId: existingDestination?.sinkId,
|
||||
})
|
||||
|
||||
const { data: pipelineData } = useReplicationPipelineByIdQuery({
|
||||
projectRef,
|
||||
pipelineId: existingDestination?.pipelineId,
|
||||
})
|
||||
|
||||
const isCreating = creatingSource || creatingSink || creatingPipeline || startingPipeline
|
||||
const isUpdating = updatingSink || updatingPipeline || stoppingPipeline || startingPipeline
|
||||
const isSubmitting = isCreating || isUpdating
|
||||
const editMode = !!existingDestination
|
||||
|
||||
const formId = 'destination-editor'
|
||||
const types = ['BigQuery'] as const
|
||||
const TypeEnum = z.enum(types)
|
||||
const FormSchema = z.object({
|
||||
type: TypeEnum,
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
projectId: z.string().min(1, 'Project id is required'),
|
||||
datasetId: z.string().min(1, 'Dataset id is required'),
|
||||
serviceAccountKey: z.string().min(1, 'Service account key is required'),
|
||||
publicationName: z.string().min(1, 'Publication is required'),
|
||||
maxSize: z.number().min(1, 'Max Size must be greater than 0').int(),
|
||||
maxFillSecs: z.number().min(1, 'Max Fill seconds should be greater than 0').int(),
|
||||
maxStalenessMins: z.number().nonnegative(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
type: TypeEnum.enum.BigQuery,
|
||||
name: sinkData?.name ?? '',
|
||||
projectId: sinkData?.config?.big_query?.project_id ?? '',
|
||||
datasetId: sinkData?.config?.big_query?.dataset_id ?? '',
|
||||
serviceAccountKey: sinkData?.config?.big_query?.service_account_key ?? '',
|
||||
publicationName: pipelineData?.publication_name ?? '',
|
||||
maxSize: pipelineData?.config?.config?.max_size ?? 1000,
|
||||
maxFillSecs: pipelineData?.config?.config?.max_fill_secs ?? 10,
|
||||
maxStalenessMins: sinkData?.config?.big_query?.max_staleness_mins ?? 5,
|
||||
enabled: existingDestination?.enabled ?? true,
|
||||
}),
|
||||
[sinkData, pipelineData, existingDestination]
|
||||
)
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
mode: 'onBlur',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
try {
|
||||
if (editMode && existingDestination) {
|
||||
if (!sourceId) {
|
||||
console.error('Source id is required')
|
||||
return
|
||||
}
|
||||
if (!existingDestination.pipelineId) {
|
||||
console.error('Pipeline id is required')
|
||||
return
|
||||
}
|
||||
// Update existing destination
|
||||
await updateSink({
|
||||
projectRef,
|
||||
sinkId: existingDestination.sinkId,
|
||||
sinkName: data.name,
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
maxStalenessMins: data.maxStalenessMins,
|
||||
})
|
||||
|
||||
if (existingDestination.pipelineId) {
|
||||
await updatePipeline({
|
||||
projectRef,
|
||||
pipelineId: existingDestination.pipelineId,
|
||||
sourceId,
|
||||
sinkId: existingDestination.sinkId,
|
||||
publicationName: data.publicationName,
|
||||
config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } },
|
||||
})
|
||||
}
|
||||
if (data.enabled) {
|
||||
await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
|
||||
} else {
|
||||
await stopPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
|
||||
}
|
||||
|
||||
toast.success('Successfully updated destination')
|
||||
} else {
|
||||
// Create new destination
|
||||
if (!sourceId) {
|
||||
console.error('Source id is required')
|
||||
return
|
||||
}
|
||||
const { id: sinkId } = await createSink({
|
||||
projectRef,
|
||||
sinkName: data.name,
|
||||
projectId: data.projectId,
|
||||
datasetId: data.datasetId,
|
||||
serviceAccountKey: data.serviceAccountKey,
|
||||
maxStalenessMins: data.maxStalenessMins,
|
||||
})
|
||||
const { id: pipelineId } = await createPipeline({
|
||||
projectRef,
|
||||
sourceId,
|
||||
sinkId,
|
||||
publicationName: data.publicationName,
|
||||
config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } },
|
||||
})
|
||||
if (data.enabled) {
|
||||
await startPipeline({ projectRef, pipelineId })
|
||||
}
|
||||
toast.success('Successfully created destination')
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${editMode ? 'update' : 'create'} destination`)
|
||||
}
|
||||
}
|
||||
const onEnableReplication = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
await createSource({ projectRef })
|
||||
}
|
||||
|
||||
const { enabled } = form.watch()
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode && sinkData && pipelineData) {
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
}, [sinkData, pipelineData, editMode, defaultValues, form])
|
||||
|
||||
return (
|
||||
<>
|
||||
{sourceId ? (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader className="flex justify-between items-center">
|
||||
<div>
|
||||
<SheetTitle>{editMode ? 'Edit Destination' : 'New Destination'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode ? null : 'Send data to a new destination'}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue('enabled', checked)
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm mx-2">Enable</Label>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-8"
|
||||
label="Name"
|
||||
layout="vertical"
|
||||
description="A name you will use to identify this destination"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<h3 className="mb-4">What data to send</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="publicationName"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Publication"
|
||||
layout="vertical"
|
||||
description="A publication is a collection of tables that you want to replicate "
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<PublicationsComboBox
|
||||
publications={publications?.map((pub) => pub.name) || []}
|
||||
loading={loadingPublications}
|
||||
field={field}
|
||||
onNewPublicationClick={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<h3 className="mb-4 mt-8">Where to send that data</h3>
|
||||
|
||||
<FormField_Shadcn_
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Type"
|
||||
layout="vertical"
|
||||
description="The type of destination to send the data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_ value={field.value}>
|
||||
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="BigQuery">
|
||||
BigQuery
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
></FormField_Shadcn_>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project Id"
|
||||
layout="vertical"
|
||||
description="Which BigQuery project to send data to"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Project id" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="datasetId"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Project's Dataset Id"
|
||||
layout="vertical"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Dataset id" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="serviceAccountKey"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Service Account Key"
|
||||
layout="vertical"
|
||||
description="The service account key for BigQuery"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<TextArea_Shadcn_
|
||||
{...field}
|
||||
rows={4}
|
||||
maxLength={5000}
|
||||
placeholder="Service account key"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion_Shadcn_ type="single" collapsible>
|
||||
<AccordionItem_Shadcn_ value="item-1" className="border-none">
|
||||
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-start mb-0 mt-8">
|
||||
Advanced Settings
|
||||
</AccordionTrigger_Shadcn_>
|
||||
<AccordionContent_Shadcn_ asChild className="!pb-0">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxSize"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Size"
|
||||
layout="vertical"
|
||||
description="The maximum size of the data to send"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxSize', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max size"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxFillSecs"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Fill Seconds"
|
||||
layout="vertical"
|
||||
description="The maximum amount of time to fill the data"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxFillSecs', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max fill seconds"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="maxStalenessMins"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
className="mb-4"
|
||||
label="Max Staleness"
|
||||
layout="vertical"
|
||||
description="Maximum staleness time allowed"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
type="number"
|
||||
{...form.register('maxStalenessMins', {
|
||||
valueAsNumber: true, // Ensure the value is handled as a number
|
||||
})}
|
||||
placeholder="Max staleness in minutes"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent_Shadcn_>
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
<div className="hidden">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout className="mb-4" layout="vertical" label="Enabled">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={isSubmitting} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
>
|
||||
{editMode ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<NewPublicationPanel
|
||||
visible={publicationPanelVisible}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setPublicationPanelVisible(false)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full" tabIndex={-1}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>New Destination</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Alert_Shadcn_>
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
{/* Pricing to be decided yet */}
|
||||
Enabling replication will cost additional $xx.xx
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
<span></span>
|
||||
<div className="flex items-center gap-x-2 mt-3">
|
||||
<Button type="default" onClick={onEnableReplication}>
|
||||
Enable replication
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button disabled={isSubmitting} type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationPanel
|
||||
@@ -0,0 +1,206 @@
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
|
||||
import { ResponseError } from 'types'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import RowMenu from './RowMenu'
|
||||
import PipelineStatus from './PipelineStatus'
|
||||
import { useParams } from 'common'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { useDeleteSinkMutation } from 'data/replication/delete-sink-mutation'
|
||||
import { useDeletePipelineMutation } from 'data/replication/delete-pipeline-mutation'
|
||||
import DeleteDestination from './DeleteDestination'
|
||||
import DestinationPanel from './DestinationPanel'
|
||||
|
||||
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
|
||||
|
||||
interface DestinationRowProps {
|
||||
sourceId: number | undefined
|
||||
sinkId: number
|
||||
sinkName: string
|
||||
type: string
|
||||
pipeline: Pipeline | undefined
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
const DestinationRow = ({
|
||||
sourceId,
|
||||
sinkId,
|
||||
sinkName,
|
||||
type,
|
||||
pipeline,
|
||||
error: pipelineError,
|
||||
isLoading: isPipelineLoading,
|
||||
isError: isPipelineError,
|
||||
isSuccess: isPipelineSuccess,
|
||||
}: DestinationRowProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
|
||||
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
|
||||
const [showEditDestinationPanel, setShowEditDestinationPanel] = useState(false)
|
||||
|
||||
const {
|
||||
data: pipelineStatusData,
|
||||
error: pipelineStatusError,
|
||||
isLoading: isPipelineStatusLoading,
|
||||
isError: isPipelineStatusError,
|
||||
isSuccess: isPipelineStatusSuccess,
|
||||
} = useReplicationPipelineStatusQuery(
|
||||
{
|
||||
projectRef,
|
||||
pipelineId: pipeline?.id,
|
||||
},
|
||||
{ refetchInterval }
|
||||
)
|
||||
const [requestStatus, setRequestStatus] = useState<
|
||||
'None' | 'EnableRequested' | 'DisableRequested'
|
||||
>('None')
|
||||
const { mutateAsync: startPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
if (
|
||||
(requestStatus === 'EnableRequested' && pipelineStatus === 'Started') ||
|
||||
(requestStatus === 'DisableRequested' && pipelineStatus === 'Stopped')
|
||||
) {
|
||||
setRefetchInterval(false)
|
||||
setRequestStatus('None')
|
||||
}
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to enable destination')
|
||||
}
|
||||
setRequestStatus('EnableRequested')
|
||||
setRefetchInterval(5000)
|
||||
}
|
||||
const onDisableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to disable destination')
|
||||
}
|
||||
setRequestStatus('DisableRequested')
|
||||
setRefetchInterval(5000)
|
||||
}
|
||||
const { mutateAsync: deleteSink } = useDeleteSinkMutation({})
|
||||
const { mutateAsync: deletePipeline } = useDeletePipelineMutation({
|
||||
onSuccess: (_res: any) => {
|
||||
toast.success('Successfully deleted destination')
|
||||
},
|
||||
})
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
await deletePipeline({ projectRef, pipelineId: pipeline.id })
|
||||
await deleteSink({ projectRef, sinkId })
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete destination')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
|
||||
)}
|
||||
{isPipelineSuccess && (
|
||||
<Table.tr>
|
||||
<Table.td>
|
||||
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : sinkName}
|
||||
</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
) : (
|
||||
<PipelineStatus
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
></PipelineStatus>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
) : (
|
||||
pipeline.publication_name
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<RowMenu
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
onEnableClick={onEnableClick}
|
||||
onDisableClick={onDisableClick}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
onEditClick={() => setShowEditDestinationPanel(true)}
|
||||
></RowMenu>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
<DeleteDestination
|
||||
visible={showDeleteDestinationForm}
|
||||
setVisible={setShowDeleteDestinationForm}
|
||||
onDelete={onDeleteClick}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
name={sinkName}
|
||||
/>
|
||||
<DestinationPanel
|
||||
visible={showEditDestinationPanel}
|
||||
onClose={() => setShowEditDestinationPanel(false)}
|
||||
sourceId={sourceId}
|
||||
existingDestination={{
|
||||
sourceId,
|
||||
sinkId,
|
||||
pipelineId: pipeline?.id,
|
||||
enabled: pipelineStatusData?.status === 'Started',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationRow
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useReplicationSinksQuery } from 'data/replication/sinks-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import DestinationRow from './DestinationRow'
|
||||
import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { useState } from 'react'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
|
||||
const Destinations = () => {
|
||||
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const {
|
||||
data: sourcesData,
|
||||
error: sourcesError,
|
||||
isLoading: isSourcesLoading,
|
||||
isError: isSourcesError,
|
||||
isSuccess: isSourcesSuccess,
|
||||
} = useReplicationSourcesQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
|
||||
|
||||
const {
|
||||
data: sinksData,
|
||||
error: sinksError,
|
||||
isLoading: isSinksLoading,
|
||||
isError: isSinksError,
|
||||
isSuccess: isSinksSuccess,
|
||||
} = useReplicationSinksQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
const {
|
||||
data: pipelinesData,
|
||||
error: pipelinesError,
|
||||
isLoading: isPipelinesLoading,
|
||||
isError: isPipelinesError,
|
||||
isSuccess: isPipelinesSuccess,
|
||||
} = useReplicationPipelinesQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
const anySinks = isSinksSuccess && sinksData.sinks.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
|
||||
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
{(isSourcesLoading || isSinksLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{(isSourcesError || isSinksError) && (
|
||||
<AlertError
|
||||
error={sourcesError || sinksError}
|
||||
subject="Failed to retrieve destinations"
|
||||
/>
|
||||
)}
|
||||
|
||||
{anySinks ? (
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="type">Type</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
body={sinksData.sinks.map((sink) => {
|
||||
const pipeline = pipelinesData?.pipelines.find((p) => p.sink_id === sink.id)
|
||||
return (
|
||||
<DestinationRow
|
||||
key={sink.id}
|
||||
sourceId={sourceId}
|
||||
sinkId={sink.id}
|
||||
sinkName={sink.name}
|
||||
type={sink.config.big_query ? 'BigQuery' : 'Other'}
|
||||
pipeline={pipeline}
|
||||
error={pipelinesError}
|
||||
isLoading={isPipelinesLoading}
|
||||
isError={isPipelinesError}
|
||||
isSuccess={isPipelinesSuccess}
|
||||
></DestinationRow>
|
||||
)
|
||||
})}
|
||||
></Table>
|
||||
) : (
|
||||
!isSourcesLoading &&
|
||||
!isSinksLoading &&
|
||||
!isSourcesError &&
|
||||
!isSinksError && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
'border border-dashed bg-surface-100 border-overlay',
|
||||
'flex flex-col px-10 rounded-lg justify-center items-center py-8 mt-4'
|
||||
)}
|
||||
>
|
||||
<h4 className="text-lg">Send data to your first destination</h4>
|
||||
<p className="prose text-sm text-center mt-2">
|
||||
Use destinations to improve performance or run analysis on your data via
|
||||
integrations like BigQuery
|
||||
</p>
|
||||
<Button
|
||||
icon={<Plus />}
|
||||
onClick={() => setShowNewDestinationPanel(true)}
|
||||
className="mt-6"
|
||||
>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
|
||||
<NewDestinationPanel
|
||||
visible={showNewDestinationPanel}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setShowNewDestinationPanel(false)}
|
||||
></NewDestinationPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Destinations
|
||||
@@ -0,0 +1,179 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useParams } from 'common'
|
||||
import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation'
|
||||
import { useReplicationTablesQuery } from 'data/replication/tables-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
cn,
|
||||
Button,
|
||||
SheetFooter,
|
||||
SheetSection,
|
||||
Form_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Card,
|
||||
CardContent,
|
||||
SheetDescription,
|
||||
} from 'ui'
|
||||
import { MultiSelector } from 'ui-patterns/multi-select'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
|
||||
interface NewPublicationPanelProps {
|
||||
visible: boolean
|
||||
sourceId?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { mutateAsync: createPublication, isLoading: creatingPublication } =
|
||||
useCreatePublicationMutation()
|
||||
const { data: tables } = useReplicationTablesQuery({
|
||||
projectRef,
|
||||
sourceId,
|
||||
})
|
||||
const formId = 'publication-editor'
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
tables: z.array(z.string()).min(1, 'At least one table is required'),
|
||||
})
|
||||
const defaultValues = {
|
||||
name: '',
|
||||
tables: [],
|
||||
}
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
mode: 'onBlur',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!sourceId) return console.error('Source id is required')
|
||||
try {
|
||||
await createPublication({
|
||||
projectRef,
|
||||
sourceId,
|
||||
name: data.name,
|
||||
tables: data.tables.map((table) => {
|
||||
const [schema, name] = table.split('.')
|
||||
return { schema, name }
|
||||
}),
|
||||
})
|
||||
toast.success('Successfully created publication')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error('Failed to create publication')
|
||||
}
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={onClose}>
|
||||
<SheetContent showClose={false} size="default">
|
||||
<div className="flex flex-col h-full">
|
||||
<SheetHeader>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<SheetTitle>New Publication</SheetTitle>
|
||||
<SheetDescription>
|
||||
Create a new publication to share table changes for replication
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<SheetClose
|
||||
className={cn(
|
||||
'text-muted hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2',
|
||||
'disabled:pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetSection className="flex-grow overflow-auto">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="vertical">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="tables"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Tables"
|
||||
description="Which tables to make available for replication"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<MultiSelector
|
||||
values={field.value}
|
||||
onValuesChange={field.onChange}
|
||||
disabled={creatingPublication}
|
||||
>
|
||||
<MultiSelector.Trigger>
|
||||
<MultiSelector.Input placeholder="Select tables" />
|
||||
</MultiSelector.Trigger>
|
||||
<MultiSelector.Content>
|
||||
<MultiSelector.List>
|
||||
{tables?.tables.map((table) => (
|
||||
<MultiSelector.Item
|
||||
key={`${table.schema}.${table.name}`}
|
||||
value={`${table.schema}.${table.name}`}
|
||||
>
|
||||
{`${table.schema}.${table.name}`}
|
||||
</MultiSelector.Item>
|
||||
))}
|
||||
</MultiSelector.List>
|
||||
</MultiSelector.Content>
|
||||
</MultiSelector>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button type="default" disabled={creatingPublication} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" disabled={creatingPublication} form={formId} htmlType="submit">
|
||||
Create publication
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPublicationPanel
|
||||
@@ -0,0 +1,57 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { cn } from 'ui'
|
||||
import { ResponseError } from 'types'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface PipelineStatusProps {
|
||||
pipelineStatus: string | undefined
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
requestStatus: 'None' | 'EnableRequested' | 'DisableRequested'
|
||||
}
|
||||
|
||||
const PipelineStatus = ({
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
requestStatus,
|
||||
}: PipelineStatusProps) => {
|
||||
const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true
|
||||
const requestInFlight = requestStatus !== 'None'
|
||||
const status =
|
||||
requestStatus === 'EnableRequested'
|
||||
? 'Enabling'
|
||||
: requestStatus === 'DisableRequested'
|
||||
? 'Disabling'
|
||||
: pipelineStatus === 'Stopped'
|
||||
? 'Disabled'
|
||||
: 'Enabled'
|
||||
return (
|
||||
<>
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
{isSuccess && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{requestInFlight ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
pipelineEnabled ? 'bg-brand' : 'bg-warning-600'
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineStatus
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
import { ControllerRenderProps } from 'react-hook-form'
|
||||
|
||||
interface PublicationsComboBoxProps {
|
||||
publications: string[]
|
||||
loading: boolean
|
||||
onNewPublicationClick: () => void
|
||||
field: ControllerRenderProps<any, 'publicationName'>
|
||||
}
|
||||
|
||||
const PublicationsComboBox = ({
|
||||
publications,
|
||||
loading,
|
||||
onNewPublicationClick,
|
||||
field,
|
||||
}: PublicationsComboBoxProps) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [selectedPublication, setSelectedPublication] = useState<string>(field?.value || '')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setSearchTerm(value)
|
||||
}
|
||||
|
||||
function handlePublicationSelect(pub: string) {
|
||||
setSelectedPublication(pub)
|
||||
setDropdownOpen(false)
|
||||
field.onChange(pub)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open)
|
||||
if (!open && field?.onBlur) {
|
||||
field.onBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="medium"
|
||||
className={`w-full [&>span]:w-full text-left`}
|
||||
iconRight={
|
||||
<ChevronsUpDown
|
||||
className="text-foreground-muted"
|
||||
strokeWidth={2}
|
||||
size={14}
|
||||
></ChevronsUpDown>
|
||||
}
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
>
|
||||
{selectedPublication || 'Select publication'}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0" sameWidthAsTrigger>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_
|
||||
placeholder="Find publication..."
|
||||
value={searchTerm}
|
||||
onValueChange={handleSearchChange}
|
||||
></CommandInput_Shadcn_>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-center justify-center">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
'No publications found'
|
||||
)}
|
||||
</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className={publications.length > 7 ? 'h-[210px]' : ''}>
|
||||
{publications.map((pub) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={pub}
|
||||
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
|
||||
onSelect={() => {
|
||||
handlePublicationSelect(pub)
|
||||
}}
|
||||
onClick={() => {
|
||||
handlePublicationSelect(pub)
|
||||
}}
|
||||
>
|
||||
<span>{pub}</span>
|
||||
{selectedPublication === pub && (
|
||||
<Check className="text-brand" strokeWidth={2} size={13} />
|
||||
)}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
<CommandSeparator_Shadcn_ />
|
||||
<CommandGroup_Shadcn_>
|
||||
<CommandItem_Shadcn_
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={onNewPublicationClick}
|
||||
onClick={onNewPublicationClick}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} className="mr-2" />
|
||||
<p>New publication</p>
|
||||
</CommandItem_Shadcn_>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublicationsComboBox
|
||||
@@ -0,0 +1,72 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
|
||||
import { ResponseError } from 'types'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
interface RowMenuProps {
|
||||
pipelineStatus: string | undefined
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
onEnableClick: () => void
|
||||
onDisableClick: () => void
|
||||
onEditClick: () => void
|
||||
onDeleteClick: () => void
|
||||
}
|
||||
|
||||
const RowMenu = ({
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
onEnableClick,
|
||||
onDisableClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: RowMenuProps) => {
|
||||
const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true
|
||||
return (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
{pipelineEnabled ? (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
|
||||
<Pause size={14} />
|
||||
<p>Disable</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
|
||||
<Play size={14} />
|
||||
<p>Enable</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
|
||||
<Edit size={14} />
|
||||
<p>Edit destination</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
|
||||
<Trash stroke="red" size={14} />
|
||||
<p>Delete destination</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowMenu
|
||||
@@ -292,7 +292,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
|
||||
)}
|
||||
label={
|
||||
<Link
|
||||
href={`/support/new?ref=${ref}&category=sales&subject=Enquiry%20about%20larger%20instance%20sizes`}
|
||||
href={`/support/new?projectRef=${ref}&category=sales&subject=Enquiry%20about%20larger%20instance%20sizes`}
|
||||
>
|
||||
<div className="w-full flex flex-col gap-3 justify-between">
|
||||
<div className="relative px-3 flex justify-between">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
|
||||
import { useCronJobRunQuery } from 'data/database-cron-jobs/database-cron-jobs-run-query'
|
||||
import { useDatabaseCronJobToggleMutation } from 'data/database-cron-jobs/database-cron-jobs-toggle-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -26,7 +27,6 @@ import { TimestampInfo } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { convertCronToString, getNextRun } from './CronJobs.utils'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
interface CronJobCardProps {
|
||||
job: CronJob
|
||||
|
||||
@@ -187,10 +187,6 @@ const supabaseIntegrations: IntegrationDefinition[] = [
|
||||
route: 'overview',
|
||||
label: 'Overview',
|
||||
},
|
||||
{
|
||||
route: 'keys',
|
||||
label: 'Keys',
|
||||
},
|
||||
{
|
||||
route: 'secrets',
|
||||
label: 'Secrets',
|
||||
@@ -208,16 +204,6 @@ const supabaseIntegrations: IntegrationDefinition[] = [
|
||||
loading: Loading,
|
||||
}
|
||||
)
|
||||
case 'keys':
|
||||
return dynamic(
|
||||
() =>
|
||||
import('../Vault/Keys/EncryptionKeysManagement').then(
|
||||
(mod) => mod.EncryptionKeysManagement
|
||||
),
|
||||
{
|
||||
loading: Loading,
|
||||
}
|
||||
)
|
||||
case 'secrets':
|
||||
return dynamic(
|
||||
() => import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement),
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { noop } from 'lodash'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
Input,
|
||||
SelectContent_Shadcn_,
|
||||
SelectGroup_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query'
|
||||
|
||||
interface EncryptionKeySelectorProps {
|
||||
nameId?: string
|
||||
label?: string
|
||||
labelOptional?: string
|
||||
selectedKeyId?: any
|
||||
error?: string
|
||||
onSelectKey: (keyId: string) => void
|
||||
onUpdateDescription?: (desc: string) => void
|
||||
}
|
||||
|
||||
const EncryptionKeySelector = ({
|
||||
nameId = 'keyName',
|
||||
label = 'Encryption key',
|
||||
labelOptional,
|
||||
selectedKeyId,
|
||||
error,
|
||||
onSelectKey = noop,
|
||||
onUpdateDescription = noop,
|
||||
}: EncryptionKeySelectorProps) => {
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const { data } = usePgSodiumKeysQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const keys = data || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<div className="flex flex-row gap-x-2 justify-between">
|
||||
<label className="block text-foreground-light text-sm break-all">{label}</label>
|
||||
{labelOptional && (
|
||||
<span className="text-foreground-lighter text-sm">{labelOptional}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Select_Shadcn_ value={selectedKeyId} onValueChange={onSelectKey}>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
{keys.find((key) => key.id === selectedKeyId)?.name || 'Select an encryption key'}
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="create-new">
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus size={16} strokeWidth={1.5} />
|
||||
Create a new Encryption Key
|
||||
</span>
|
||||
</SelectItem_Shadcn_>
|
||||
{keys.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-2" />
|
||||
{keys.map((key) => (
|
||||
<SelectItem_Shadcn_ key={key.id} value={key.id}>
|
||||
<div className="space-y-1">
|
||||
<p>{key.name || 'No name provided'}</p>
|
||||
<p className="text-xs">
|
||||
ID: <span className="font-mono">{key.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</div>
|
||||
{selectedKeyId === 'create-new' && (
|
||||
<Input
|
||||
id={nameId}
|
||||
label="Name of encryption key"
|
||||
error={error}
|
||||
onChange={(event) => onUpdateDescription(event.target.value)}
|
||||
descriptionText="Provide a name for your key for easier identification"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EncryptionKeySelector
|
||||
-310
@@ -1,310 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { sortBy } from 'lodash'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { usePgSodiumKeyCreateMutation } from 'data/pg-sodium-keys/pg-sodium-key-create-mutation'
|
||||
import { usePgSodiumKeyDeleteMutation } from 'data/pg-sodium-keys/pg-sodium-key-delete-mutation'
|
||||
import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Key, Loader, Search, Trash, X } from 'lucide-react'
|
||||
import { Button, Form, Input, Listbox, Modal, Separator } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
const DEFAULT_KEY_NAME = 'No description provided'
|
||||
|
||||
export const EncryptionKeysManagement = () => {
|
||||
const { search } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const [selectedSort, setSelectedSort] = useState<'name' | 'created'>('created')
|
||||
const [showAddKeyModal, setShowAddKeyModal] = useState(false)
|
||||
const [selectedKeyToRemove, setSelectedKeyToRemove] = useState<any>()
|
||||
const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
useEffect(() => {
|
||||
if (search !== undefined) setSearchValue(search)
|
||||
}, [search])
|
||||
|
||||
const { data, isLoading } = usePgSodiumKeysQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const { mutate: addKeyMutation, isLoading: isCreating } = usePgSodiumKeyCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully added new key')
|
||||
setShowAddKeyModal(false)
|
||||
},
|
||||
})
|
||||
const { mutate: deleteKeyMutation, isLoading: isDeleting } = usePgSodiumKeyDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully deleted encryption key`)
|
||||
setSelectedKeyToRemove(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
const allKeys = data || []
|
||||
const keys = sortBy(
|
||||
searchValue
|
||||
? allKeys.filter(
|
||||
(key) =>
|
||||
(key?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
key.id.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
: allKeys,
|
||||
(k) => {
|
||||
if (selectedSort === 'created') {
|
||||
return Number(new Date(k.created))
|
||||
} else {
|
||||
return k[selectedSort]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const addKey = async (values: any, { setSubmitting }: any) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
addKeyMutation({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
name: values.name,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDeleteKey = async () => {
|
||||
if (!selectedKeyToRemove) return
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
deleteKeyMutation({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
id: selectedKeyToRemove.id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 p-4 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
||||
<Input
|
||||
className="md:w-52 input-clear"
|
||||
size="tiny"
|
||||
placeholder="Search by name or ID"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
icon={<Search strokeWidth={2} size={16} />}
|
||||
actions={
|
||||
searchValue.length > 0
|
||||
? [
|
||||
<Button
|
||||
key="clear"
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="px-1"
|
||||
onClick={() => setSearchValue('')}
|
||||
/>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<div className="md:w-44">
|
||||
<Listbox size="tiny" value={selectedSort} onChange={setSelectedSort}>
|
||||
<Listbox.Option
|
||||
id="created"
|
||||
className="max-w-[180px]"
|
||||
value="created"
|
||||
label="Sort by created at"
|
||||
>
|
||||
Created at
|
||||
</Listbox.Option>
|
||||
<Listbox.Option
|
||||
id="name"
|
||||
className="max-w-[180px]"
|
||||
value="name"
|
||||
label="Sort by name"
|
||||
>
|
||||
Name
|
||||
</Listbox.Option>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/vault" />
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canManageKeys}
|
||||
onClick={() => setShowAddKeyModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageKeys ? 'You need additional permissions to add keys' : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add new key
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of keys */}
|
||||
<div className="border rounded">
|
||||
{isLoading ? (
|
||||
<div className="px-6 py-6 space-x-2 flex items-center justify-center">
|
||||
<Loader className="animate-spin text-foreground-light" size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm text-foreground">Loading keys from the Vault</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{keys.map((key, idx) => {
|
||||
return (
|
||||
<Fragment key={key.key_id}>
|
||||
<div className="px-6 py-4 flex items-center space-x-4">
|
||||
<Key className="text-foreground-light" strokeWidth={2} />
|
||||
<div className="space-y-1 min-w-[70%] max-w-[70%]">
|
||||
<p
|
||||
className="text-sm truncate text-foreground"
|
||||
title={key.name || DEFAULT_KEY_NAME}
|
||||
>
|
||||
{key.name || DEFAULT_KEY_NAME}
|
||||
</p>
|
||||
<p
|
||||
title={key.id}
|
||||
className="text-xs text-foreground-light font-bold truncate"
|
||||
>
|
||||
ID: <span className="font-mono">{key.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end w-[30%] space-x-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Added on {dayjs(key.created).format('MMM D, YYYY')}
|
||||
</p>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
className="py-2"
|
||||
icon={<Trash />}
|
||||
disabled={!canManageKeys}
|
||||
onClick={() => setSelectedKeyToRemove(key)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageKeys
|
||||
? 'You need additional permissions to delete keys'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{idx !== keys.length - 1 && <Separator />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{keys.length === 0 && (
|
||||
<>
|
||||
{searchValue.length === 0 ? (
|
||||
<div className="px-6 py-6 space-y-1 flex flex-col items-center justify-center">
|
||||
<p className="text-sm text-foreground">No encryption keys added yet</p>
|
||||
<p className="text-sm text-foreground-light text-center">
|
||||
Encryption keys are created by the pgsodium extension and can be used to
|
||||
encrypt your columns and secrets
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 space-y-1">
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{searchValue}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="medium"
|
||||
alignFooter="right"
|
||||
visible={selectedKeyToRemove !== undefined}
|
||||
onCancel={() => setSelectedKeyToRemove(undefined)}
|
||||
onConfirm={confirmDeleteKey}
|
||||
loading={isDeleting}
|
||||
header="Confirm to delete key"
|
||||
>
|
||||
<Modal.Content className="space-y-4">
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Deleting a key that's in use will cause any secret or column which depends on it to be
|
||||
unusable."
|
||||
description="Do ensure that the key is not currently in use to prevent any issues."
|
||||
/>
|
||||
<p className="text-sm">
|
||||
The following key will be permanently removed and cannot be recovered.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground">
|
||||
{selectedKeyToRemove?.name ?? DEFAULT_KEY_NAME}
|
||||
</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
<code className="!mx-0">ID: {selectedKeyToRemove?.id}</code>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
hideFooter
|
||||
size="medium"
|
||||
visible={showAddKeyModal}
|
||||
onCancel={() => setShowAddKeyModal(false)}
|
||||
header="Add a new key"
|
||||
>
|
||||
<Form
|
||||
id="add-new-key-form"
|
||||
validateOnBlur={false}
|
||||
initialValues={{ name: '' }}
|
||||
validate={(values: any) => {
|
||||
const errors: any = {}
|
||||
if (values.name.length === 0) errors.name = 'Please provide a name for your key'
|
||||
return errors
|
||||
}}
|
||||
onSubmit={addKey}
|
||||
>
|
||||
{() => {
|
||||
return (
|
||||
<>
|
||||
<Modal.Content className="space-y-4">
|
||||
<p className="text-sm">Provide a name for your key for easier identification.</p>
|
||||
<Input id="name" label="Key Name" />
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isCreating}
|
||||
onClick={() => setShowAddKeyModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isCreating} loading={isCreating}>
|
||||
Add key
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+43
-105
@@ -4,12 +4,9 @@ import { toast } from 'sonner'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { usePgSodiumKeyCreateMutation } from 'data/pg-sodium-keys/pg-sodium-key-create-mutation'
|
||||
import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query'
|
||||
import { useVaultSecretCreateMutation } from 'data/vault/vault-secret-create-mutation'
|
||||
import { Eye, EyeOff, HelpCircle } from 'lucide-react'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
import EncryptionKeySelector from '../Keys/EncryptionKeySelector'
|
||||
|
||||
interface AddNewSecretModalProps {
|
||||
visible: boolean
|
||||
@@ -18,35 +15,20 @@ interface AddNewSecretModalProps {
|
||||
|
||||
const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => {
|
||||
const [showSecretValue, setShowSecretValue] = useState(false)
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string>()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const { mutateAsync: addKeyMutation } = usePgSodiumKeyCreateMutation()
|
||||
const { mutateAsync: addSecret } = useVaultSecretCreateMutation()
|
||||
|
||||
const { data: keys, isLoading } = usePgSodiumKeysQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{
|
||||
enabled: visible,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && keys) {
|
||||
if (visible) {
|
||||
setShowSecretValue(false)
|
||||
setSelectedKeyId(keys[0]?.id ?? 'create-new')
|
||||
}
|
||||
}, [visible, keys])
|
||||
}, [visible])
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (values.name.length === 0) errors.name = 'Please provide a name for your secret'
|
||||
if (values.secret.length === 0) errors.secret = 'Please enter your secret value'
|
||||
if (selectedKeyId === 'create-new' && values.keyName.length === 0)
|
||||
errors.keyName = 'Please provide a name for your new key'
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -54,18 +36,9 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
setSubmitting(true)
|
||||
let encryptionKeyId = selectedKeyId
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
if (selectedKeyId === 'create-new') {
|
||||
const addKeyRes = await addKeyMutation({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.keyName || undefined,
|
||||
})
|
||||
encryptionKeyId = addKeyRes[0].id
|
||||
}
|
||||
|
||||
await addSecret({
|
||||
projectRef: project.ref,
|
||||
@@ -73,7 +46,6 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
secret: values.secret,
|
||||
key_id: encryptionKeyId,
|
||||
})
|
||||
toast.success(`Successfully added new secret ${values.name}`)
|
||||
onClose()
|
||||
@@ -86,81 +58,47 @@ const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => {
|
||||
|
||||
return (
|
||||
<Modal hideFooter size="medium" visible={visible} onCancel={onClose} header="Add new secret">
|
||||
{isLoading ? (
|
||||
<Modal.Content>
|
||||
<GenericSkeletonLoader />
|
||||
</Modal.Content>
|
||||
) : (
|
||||
<Form
|
||||
id="add-new-secret-form"
|
||||
initialValues={{ name: '', description: '', secret: '', keyId: '', keyName: '' }}
|
||||
validate={validate}
|
||||
validateOnBlur={false}
|
||||
onSubmit={onAddNewSecret}
|
||||
>
|
||||
{({ isSubmitting }: any) => {
|
||||
return (
|
||||
<>
|
||||
<Modal.Content className="space-y-4">
|
||||
<Input id="name" label="Name" />
|
||||
<Input id="description" label="Description" labelOptional="Optional" />
|
||||
<Input
|
||||
id="secret"
|
||||
type={showSecretValue ? 'text' : 'password'}
|
||||
label="Secret value"
|
||||
actions={
|
||||
<div className="mr-1">
|
||||
<Button
|
||||
type="default"
|
||||
icon={showSecretValue ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setShowSecretValue(!showSecretValue)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="space-y-4">
|
||||
<EncryptionKeySelector
|
||||
nameId="keyName"
|
||||
label="Select a key to encrypt your secret with"
|
||||
labelOptional="Optional"
|
||||
selectedKeyId={selectedKeyId}
|
||||
onSelectKey={setSelectedKeyId}
|
||||
/>
|
||||
<InformationBox
|
||||
icon={<HelpCircle size={18} strokeWidth={2} />}
|
||||
url="https://github.com/supabase/vault"
|
||||
urlLabel="Vault documentation"
|
||||
title="What is a key?"
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Keys are used to encrypt data inside your database, and every secret in
|
||||
the Vault is encrypted with a key.
|
||||
</p>
|
||||
<p>
|
||||
You may create different keys for different purposes, such as one for
|
||||
encrypting user data, and another for application data.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center justify-end space-x-2">
|
||||
<Button type="default" disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
Add secret
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
<Form
|
||||
id="add-new-secret-form"
|
||||
initialValues={{ name: '', description: '', secret: '' }}
|
||||
validate={validate}
|
||||
validateOnBlur={false}
|
||||
onSubmit={onAddNewSecret}
|
||||
>
|
||||
{({ isSubmitting }: any) => {
|
||||
return (
|
||||
<>
|
||||
<Modal.Content className="space-y-4">
|
||||
<Input id="name" label="Name" />
|
||||
<Input id="description" label="Description" labelOptional="Optional" />
|
||||
<Input
|
||||
id="secret"
|
||||
type={showSecretValue ? 'text' : 'password'}
|
||||
label="Secret value"
|
||||
actions={
|
||||
<div className="mr-1">
|
||||
<Button
|
||||
type="default"
|
||||
icon={showSecretValue ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setShowSecretValue(!showSecretValue)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center justify-end space-x-2">
|
||||
<Button type="default" disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
Add secret
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const DeleteSecretModal = ({ selectedSecret, onClose }: DeleteSecretModalProps)
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">{selectedSecret?.description}</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
ID: <span className="font-mono">{selectedSecret?.key_id}</span>
|
||||
ID: <span className="font-mono">{selectedSecret?.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -4,12 +4,10 @@ import { toast } from 'sonner'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { usePgSodiumKeyCreateMutation } from 'data/pg-sodium-keys/pg-sodium-key-create-mutation'
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { useVaultSecretUpdateMutation } from 'data/vault/vault-secret-update-mutation'
|
||||
import type { VaultSecret } from 'types'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
import EncryptionKeySelector from '../Keys/EncryptionKeySelector'
|
||||
import { EyeOff, Eye } from 'lucide-react'
|
||||
|
||||
interface EditSecretModalProps {
|
||||
@@ -18,11 +16,9 @@ interface EditSecretModalProps {
|
||||
}
|
||||
|
||||
const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string>()
|
||||
const [showSecretValue, setShowSecretValue] = useState(false)
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const { mutateAsync: addKeyMutation } = usePgSodiumKeyCreateMutation()
|
||||
const { mutateAsync: updateSecret } = useVaultSecretUpdateMutation()
|
||||
|
||||
let INITIAL_VALUES = {
|
||||
@@ -34,7 +30,6 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
useEffect(() => {
|
||||
if (selectedSecret !== undefined) {
|
||||
setShowSecretValue(false)
|
||||
setSelectedKeyId(selectedSecret.key_id)
|
||||
}
|
||||
}, [selectedSecret])
|
||||
|
||||
@@ -53,23 +48,6 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
if (values.name !== selectedSecret?.name) payload.name = values.name
|
||||
if (values.description !== selectedSecret?.description)
|
||||
payload.description = values.description
|
||||
if (selectedKeyId !== selectedSecret?.key_id) {
|
||||
let encryptionKeyId = selectedKeyId
|
||||
if (encryptionKeyId === 'create-new') {
|
||||
const addKeyRes = await addKeyMutation({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.keyName || undefined,
|
||||
})
|
||||
if (addKeyRes.error) {
|
||||
return toast.error(`Failed to create new key: ${addKeyRes.error.message}`)
|
||||
} else {
|
||||
encryptionKeyId = addKeyRes[0].id
|
||||
}
|
||||
}
|
||||
|
||||
payload.key_id = encryptionKeyId
|
||||
}
|
||||
payload.secret = values.secret
|
||||
|
||||
if (!isEmpty(payload) && selectedSecret) {
|
||||
@@ -162,16 +140,6 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="space-y-4">
|
||||
<EncryptionKeySelector
|
||||
nameId="keyName"
|
||||
label="Select a key to encrypt your secret with"
|
||||
labelOptional="Optional"
|
||||
selectedKeyId={selectedKeyId}
|
||||
onSelectKey={setSelectedKeyId}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center justify-end space-x-2">
|
||||
<Button type="default" disabled={isSubmitting} onClick={() => onClose()}>
|
||||
Cancel
|
||||
|
||||
@@ -61,18 +61,10 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) =>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 group">
|
||||
<Key
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className="text-foreground-light transition group-hover:text-brand"
|
||||
/>
|
||||
<Link
|
||||
href={`/project/${ref}/integrations/vault/keys?search=${secret.key_id}`}
|
||||
className="text-foreground-light font-mono text-xs cursor-pointer transition group-hover:text-brand"
|
||||
title={secret.key_id}
|
||||
>
|
||||
{secret.key_id}
|
||||
</Link>
|
||||
<Key size={14} strokeWidth={2} className="text-foreground-light transition" />
|
||||
<p className="text-foreground-light font-mono text-xs transition" title={secret.id}>
|
||||
{secret.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 w-[40%]">
|
||||
|
||||
@@ -39,10 +39,8 @@ export const SecretsManagement = () => {
|
||||
const allSecrets = data || []
|
||||
const secrets = sortBy(
|
||||
searchValue.length > 0
|
||||
? allSecrets.filter(
|
||||
(secret) =>
|
||||
(secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
secret.key_id.toLowerCase().includes(searchValue.toLowerCase())
|
||||
? allSecrets.filter((secret) =>
|
||||
(secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
: allSecrets,
|
||||
(s) => {
|
||||
@@ -99,14 +97,6 @@ export const SecretsManagement = () => {
|
||||
>
|
||||
Name
|
||||
</Listbox.Option>
|
||||
<Listbox.Option
|
||||
id="key_id"
|
||||
className="max-w-[180px]"
|
||||
value="key_id"
|
||||
label="Sort by key ID"
|
||||
>
|
||||
Key ID
|
||||
</Listbox.Option>
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-1
@@ -6,7 +6,9 @@ import { toast } from 'sonner'
|
||||
import { useIsNewLayoutEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useOrganizationDeleteMutation } from 'data/organizations/organization-delete-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
|
||||
const DeleteOrganizationButton = () => {
|
||||
@@ -18,11 +20,21 @@ const DeleteOrganizationButton = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
|
||||
const { mutate: deleteOrganization, isLoading: isDeleting } = useOrganizationDeleteMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully deleted ${orgName}`)
|
||||
router.push(newLayoutPreview ? '/organizations' : '/projects')
|
||||
if (newLayoutPreview) {
|
||||
setLastVisitedOrganization('')
|
||||
router.push('/organizations')
|
||||
} else {
|
||||
router.push('/projects')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -16,10 +17,12 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
@@ -29,6 +32,10 @@ import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSetti
|
||||
|
||||
const TeamSettings = () => {
|
||||
const newLayoutPreview = useIsNewLayoutEnabled()
|
||||
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const {
|
||||
organizationMembersCreate: organizationMembersCreationEnabled,
|
||||
@@ -36,11 +43,13 @@ const TeamSettings = () => {
|
||||
} = useIsFeatureEnabled(['organization_members:create', 'organization_members:delete'])
|
||||
|
||||
const { slug } = useParams()
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const isOwner = selectedOrganization?.is_owner
|
||||
|
||||
const { data: permissions } = usePermissionsQuery()
|
||||
const { refetch: refetchOrganizations } = useOrganizationsQuery()
|
||||
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
|
||||
@@ -59,10 +68,19 @@ const TeamSettings = () => {
|
||||
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
|
||||
|
||||
const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
setIsLeaving(false)
|
||||
setIsLeaveTeamModalOpen(false)
|
||||
window?.location.replace(BASE_PATH) // Force reload to clear Store
|
||||
|
||||
await refetchOrganizations()
|
||||
toast.success(`Successfully left ${selectedOrganization?.name}`)
|
||||
|
||||
if (newLayoutPreview) {
|
||||
setLastVisitedOrganization('')
|
||||
router.push('/organizations')
|
||||
} else {
|
||||
router.push('/projects')
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLeaving(false)
|
||||
|
||||
-2
@@ -142,14 +142,12 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP
|
||||
|
||||
if (isApplyAllProjects) {
|
||||
if (originalConfigurationType === 'org-scope') {
|
||||
console.log('Reset: Org scoped')
|
||||
setProjectsRoleConfiguration(originalConfiguration)
|
||||
} else {
|
||||
setProjectsRoleConfiguration([{ ref: undefined, roleId: roleIdToApply }])
|
||||
}
|
||||
} else {
|
||||
if (originalConfigurationType === 'project-scope') {
|
||||
console.log('Reset: Project scoped')
|
||||
setProjectsRoleConfiguration(originalConfiguration)
|
||||
} else {
|
||||
setProjectsRoleConfiguration(
|
||||
|
||||
@@ -36,6 +36,8 @@ import SecondLevelNav from './SecondLevelNav'
|
||||
const ProjectAPIDocs = () => {
|
||||
const { ref } = useParams()
|
||||
const snap = useAppStateSnapshot()
|
||||
const isIntroduction =
|
||||
snap.activeDocsSection.length === 1 && snap.activeDocsSection[0] === 'introduction'
|
||||
const isEntityDocs =
|
||||
snap.activeDocsSection.length === 2 && snap.activeDocsSection[0] === 'entities'
|
||||
|
||||
@@ -76,9 +78,11 @@ const ProjectAPIDocs = () => {
|
||||
<h4>API Docs</h4>
|
||||
<div className="flex items-center space-x-1">
|
||||
{!isEntityDocs && <LanguageSelector simplifiedVersion />}
|
||||
<Button type="default" onClick={() => setShowKeys(!showKeys)}>
|
||||
{showKeys ? 'Hide keys' : 'Show keys'}
|
||||
</Button>
|
||||
{isIntroduction && (
|
||||
<Button type="default" onClick={() => setShowKeys(!showKeys)}>
|
||||
{showKeys ? 'Hide keys' : 'Show keys'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ const Reports = () => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mb-16 flex-grow h-64">
|
||||
<div className="relative mb-16 flex-grow">
|
||||
{config && startDate && endDate && (
|
||||
<GridResize
|
||||
startDate={startDate}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { makeActiveTabPermanent } from 'state/tabs'
|
||||
import { cn } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { untitledSnippetTitle } from './SQLEditor.constants'
|
||||
import type { IStandaloneCodeEditor } from './SQLEditor.types'
|
||||
import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
|
||||
import { makeActiveTabPermanent } from 'state/tabs'
|
||||
|
||||
export type MonacoEditorProps = {
|
||||
id: string
|
||||
@@ -25,6 +25,7 @@ export type MonacoEditorProps = {
|
||||
autoFocus?: boolean
|
||||
executeQuery: () => void
|
||||
onHasSelection: (value: boolean) => void
|
||||
onMount?: (editor: IStandaloneCodeEditor) => void
|
||||
onPrompt?: (value: {
|
||||
selection: string
|
||||
beforeSelection: string
|
||||
@@ -43,6 +44,7 @@ const MonacoEditor = ({
|
||||
executeQuery,
|
||||
onHasSelection,
|
||||
onPrompt,
|
||||
onMount,
|
||||
}: MonacoEditorProps) => {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
@@ -143,6 +145,8 @@ const MonacoEditor = ({
|
||||
if (editor.getValue().length === 1) editor.setPosition({ lineNumber: 1, column: 2 })
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
onMount?.(editor)
|
||||
}
|
||||
|
||||
const debouncedSetSql = debounce((id, value) => snapV2.setSql(id, value), 1000)
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { createTabId, renameTab } from 'state/tabs'
|
||||
import { createTabId, updateTab } from 'state/tabs'
|
||||
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
|
||||
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
|
||||
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
|
||||
@@ -111,7 +111,7 @@ const RenameQueryModal = ({
|
||||
snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput })
|
||||
if (isSQLEditorTabsEnabled && ref) {
|
||||
const tabId = createTabId('sql', { id })
|
||||
renameTab(ref, tabId, nameInput)
|
||||
updateTab(ref, tabId, { label: nameInput })
|
||||
}
|
||||
toast.success('Successfully renamed snippet!')
|
||||
if (onComplete) onComplete()
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useGetImpersonatedRoleState,
|
||||
} from 'state/role-impersonation-state'
|
||||
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { createTabId, getTabsStore, updateTab } from 'state/tabs'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
|
||||
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
|
||||
import { RunQueryWarningModal } from './RunQueryWarningModal'
|
||||
@@ -81,11 +83,14 @@ export const SQLEditor = () => {
|
||||
const os = detectOS()
|
||||
const router = useRouter()
|
||||
const { ref, id: urlId } = useParams()
|
||||
const org = useSelectedOrganization()
|
||||
|
||||
const { profile } = useProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const project = useSelectedProject()
|
||||
const organization = useSelectedOrganization()
|
||||
const org = useSelectedOrganization()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const store = getTabsStore(ref)
|
||||
const tabs = useSnapshot(store)
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
@@ -111,6 +116,8 @@ export const SQLEditor = () => {
|
||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null)
|
||||
const monacoRef = useRef<Monaco | null>(null)
|
||||
const diffEditorRef = useRef<IStandaloneDiffEditor | null>(null)
|
||||
const scrollTopRef = useRef<number>(0)
|
||||
|
||||
const [hasSelection, setHasSelection] = useState<boolean>(false)
|
||||
const [lineHighlights, setLineHighlights] = useState<string[]>([])
|
||||
const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
|
||||
@@ -136,7 +143,7 @@ export const SQLEditor = () => {
|
||||
useAddDefinitions(id, monacoRef.current)
|
||||
|
||||
/** React query data fetching */
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug })
|
||||
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
|
||||
|
||||
const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery({
|
||||
@@ -362,6 +369,19 @@ export const SQLEditor = () => {
|
||||
[profile?.id, project?.id, ref, router, snapV2]
|
||||
)
|
||||
|
||||
const onMount = (editor: IStandaloneCodeEditor) => {
|
||||
const tabId = createTabId('sql', { id })
|
||||
const tabData = tabs.tabsMap[tabId]
|
||||
|
||||
// [Joshen] Tiny timeout to give a bit of time for the content to load before scrolling
|
||||
setTimeout(() => {
|
||||
if (tabData?.metadata?.scrollTop) {
|
||||
editor.setScrollTop(tabData.metadata.scrollTop)
|
||||
}
|
||||
}, 20)
|
||||
editor.onDidScrollChange((e) => (scrollTopRef.current = e.scrollTop))
|
||||
}
|
||||
|
||||
const onDebug = useCallback(async () => {
|
||||
try {
|
||||
const snippet = snapV2.snippets[id]
|
||||
@@ -511,6 +531,12 @@ export const SQLEditor = () => {
|
||||
closeDiff()
|
||||
setPromptState((prev) => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
return () => {
|
||||
if (ref) {
|
||||
const tabId = createTabId('sql', { id })
|
||||
updateTab(ref, tabId, { scrollTop: scrollTopRef.current })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [closeDiff, id])
|
||||
|
||||
@@ -718,6 +744,7 @@ export const SQLEditor = () => {
|
||||
monacoRef={monacoRef}
|
||||
executeQuery={executeQuery}
|
||||
onHasSelection={setHasSelection}
|
||||
onMount={onMount}
|
||||
onPrompt={({
|
||||
selection,
|
||||
beforeSelection,
|
||||
|
||||
@@ -479,7 +479,7 @@ const Addons = () => {
|
||||
</p>
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/support/new?ref=${projectRef}&category=sales&subject=Project%20too%20old%20old%20for%20PITR`}
|
||||
href={`/support/new?projectRef=${projectRef}&category=sales&subject=Project%20too%20old%20old%20for%20PITR`}
|
||||
>
|
||||
<a>Contact support</a>
|
||||
</Link>
|
||||
|
||||
@@ -118,7 +118,7 @@ const DiskSizeConfigurationModal = ({
|
||||
</p>
|
||||
<Button asChild type="default" className="mt-3">
|
||||
<Link
|
||||
href={`/support/new?ref=${projectRef}&category=${SupportCategories.PERFORMANCE_ISSUES}&subject=Increase%20disk%20size%20beyond%20200GB`}
|
||||
href={`/support/new?projectRef=${projectRef}&category=${SupportCategories.PERFORMANCE_ISSUES}&subject=Increase%20disk%20size%20beyond%20200GB`}
|
||||
>
|
||||
Contact support
|
||||
</Link>
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ const CustomDomainConfig = () => {
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm text-foreground-light">
|
||||
Failed to retrieve custom domain configuration. Please try again later or{' '}
|
||||
<Link href={`/support/new?ref=${ref}&category=sales`} className="underline">
|
||||
<Link href={`/support/new?projectRef=${ref}&category=sales`} className="underline">
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {
|
||||
type="default"
|
||||
onClick={onCancelCustomDomain}
|
||||
loading={isDeleting}
|
||||
disabled={isReverifyLoading || isValidating}
|
||||
disabled={isValidating}
|
||||
className="self-end"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { MousePointerClick, X, Clipboard, Check } from 'lucide-react'
|
||||
import { Check, Clipboard, MousePointerClick, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import {
|
||||
Button,
|
||||
CodeBlock,
|
||||
@@ -9,11 +12,9 @@ import {
|
||||
cn,
|
||||
copyToClipboard,
|
||||
} from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import DefaultPreviewSelectionRenderer from './LogSelectionRenderers/DefaultPreviewSelectionRenderer'
|
||||
import type { LogData, QueryType } from './Logs.types'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export interface LogSelectionProps {
|
||||
log?: LogData
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import type { PreviewLogData } from '../Logs.types'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
ResponseCodeFormatter,
|
||||
SelectionDetailedRow,
|
||||
SelectionDetailedTimestampRow,
|
||||
SeverityFormatter,
|
||||
} from '../LogsFormatters'
|
||||
|
||||
const AuthSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
|
||||
return (
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-6`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-foreground-lighter text-sm">Event Message</h3>
|
||||
<div className="text-xs text-wrap font-mono text-foreground whitespace-pre-wrap overflow-x-auto">
|
||||
{log.metadata?.msg || log.event_message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectionDetailedTimestampRow value={log.timestamp} />
|
||||
{log.metadata?.status && (
|
||||
<SelectionDetailedRow
|
||||
label="Status"
|
||||
value={String(log.metadata?.status)}
|
||||
valueRender={<ResponseCodeFormatter value={log.metadata?.status} />}
|
||||
/>
|
||||
)}
|
||||
{log.metadata?.level && (
|
||||
<SelectionDetailedRow
|
||||
label="Severity"
|
||||
value={log.metadata?.level}
|
||||
valueRender={<SeverityFormatter value={log.metadata?.level} />}
|
||||
/>
|
||||
)}
|
||||
{log.metadata?.path && (
|
||||
<SelectionDetailedRow label="Request Path" value={log.metadata?.path} />
|
||||
)}
|
||||
{log.metadata?.error && (
|
||||
<SelectionDetailedRow label="Error Message" value={log.metadata?.error} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-foreground-lighter text-sm">Metadata</h3>
|
||||
<pre
|
||||
className=" className={`text-foreground text-sm col-span-8 overflow-x-auto text-xs font-mono`}"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: jsonSyntaxHighlight(log.metadata!),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthSelectionRenderer
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import React from 'react'
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import LogsDivider from '../Logs.Divider'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
ResponseCodeFormatter,
|
||||
SelectionDetailedRow,
|
||||
SelectionDetailedTimestampRow,
|
||||
} from '../LogsFormatters'
|
||||
|
||||
const DatabaseApiSelectionRender = ({ log }: any) => {
|
||||
const request = log?.metadata[0]?.request?.[0]
|
||||
const response = log?.metadata[0]?.response?.[0]
|
||||
const method = request?.method
|
||||
const status = response?.status_code
|
||||
const ipAddress = request?.headers?.[0]?.cf_connecting_ip
|
||||
const countryOrigin = request?.headers?.[0]?.cf_ipcountry
|
||||
const clientInfo = request?.headers?.[0]?.x_client_info
|
||||
const referer = request?.headers?.[0]?.referer
|
||||
const redirectIdentifier = log?.metadata[0]?.load_balancer_redirect_identifier
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
|
||||
<SelectionDetailedRow
|
||||
label="Status"
|
||||
value={status}
|
||||
valueRender={<ResponseCodeFormatter value={status} />}
|
||||
/>
|
||||
<SelectionDetailedRow label="Method" value={method} />
|
||||
<SelectionDetailedTimestampRow value={log.timestamp} />
|
||||
<SelectionDetailedRow label="IP Address" value={ipAddress} />
|
||||
<SelectionDetailedRow label="Origin Country" value={countryOrigin} />
|
||||
{clientInfo && <SelectionDetailedRow label="Client" value={clientInfo} />}
|
||||
{referer && <SelectionDetailedRow label="Referer" value={referer} />}
|
||||
{redirectIdentifier && (
|
||||
<SelectionDetailedRow label="Redirect Identifier" value={redirectIdentifier} />
|
||||
)}
|
||||
</div>
|
||||
<LogsDivider />
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<h3 className="text-foreground-light mb-4 font-mono text-sm uppercase">Request Metadata</h3>
|
||||
<pre className="text-sm syntax-highlight overflow-x-auto mb-4">
|
||||
<div
|
||||
className="text-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: request ? jsonSyntaxHighlight(log.metadata[0].request[0]) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
<LogsDivider />
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<h3 className="text-foreground-light mb-4 font-mono text-sm uppercase">
|
||||
Response Metadata
|
||||
</h3>
|
||||
<pre className="text-sm syntax-highlight overflow-x-auto mb-4">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: response ? jsonSyntaxHighlight(log.metadata[0].response[0]) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatabaseApiSelectionRender
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { Alert } from 'ui'
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import LogsDivider from '../Logs.Divider'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
SelectionDetailedRow,
|
||||
SelectionDetailedTimestampRow,
|
||||
SeverityFormatter,
|
||||
} from '../LogsFormatters'
|
||||
|
||||
const DatabasePostgresSelectionRender = ({ log }: any) => {
|
||||
const postgresUsername = log?.metadata[0]?.parsed[0]?.user_name
|
||||
const sessionId = log?.metadata[0]?.parsed[0]?.session_id
|
||||
const hint = log?.metadata[0]?.parsed[0]?.hint
|
||||
const errorSeverity = log?.metadata[0]?.parsed[0]?.error_severity
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={LOGS_TAILWIND_CLASSES.log_selection_x_padding}>
|
||||
<span className="col-span-4 text-sm text-foreground-lighter">Event message</span>
|
||||
|
||||
<div className="text-wrap mt-2 overflow-x-auto whitespace-pre-wrap font-mono text-xs text-foreground">
|
||||
{log.event_message}
|
||||
</div>
|
||||
</div>
|
||||
<LogsDivider />
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
|
||||
<SelectionDetailedRow
|
||||
label="Severity"
|
||||
value={errorSeverity}
|
||||
valueRender={<SeverityFormatter value={errorSeverity} />}
|
||||
/>
|
||||
<SelectionDetailedTimestampRow value={log.timestamp} />
|
||||
<SelectionDetailedRow label="Postgres Username" value={postgresUsername} />
|
||||
<SelectionDetailedRow label="Session ID" value={sessionId} />
|
||||
</div>
|
||||
{hint && (
|
||||
<div className={`mt-4 ${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<Alert variant="warning" withIcon title={hint} />
|
||||
</div>
|
||||
)}
|
||||
<LogsDivider />
|
||||
<div className={LOGS_TAILWIND_CLASSES.log_selection_x_padding}>
|
||||
<h3 className="mb-4 text-lg text-foreground">Metadata</h3>
|
||||
<pre className="syntax-highlight overflow-x-auto text-sm">
|
||||
<div
|
||||
className="text-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: log.metadata ? jsonSyntaxHighlight(log.metadata[0]) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const DatabaseApiSelectionHeaderRender = (log: any) => {
|
||||
const method = log?.metadata[0]?.request[0]?.method
|
||||
const path = log?.metadata[0]?.request[0]?.path
|
||||
|
||||
return `${method} ${path}`
|
||||
}
|
||||
|
||||
export default DatabasePostgresSelectionRender
|
||||
+9
-7
@@ -1,3 +1,6 @@
|
||||
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -8,12 +11,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
} from 'ui'
|
||||
import type { PreviewLogData, LogSearchCallback } from '../Logs.types'
|
||||
import { toast } from 'sonner'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { LogSearchCallback, PreviewLogData } from '../Logs.types'
|
||||
import { ResponseCodeFormatter } from '../LogsFormatters'
|
||||
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
|
||||
|
||||
const LogRowCodeBlock = ({ value, className }: { value: string; className?: string }) => (
|
||||
<pre
|
||||
@@ -37,7 +37,7 @@ const PropertyRow = ({
|
||||
value: any
|
||||
dataTestId?: string
|
||||
}) => {
|
||||
const { search, setSearch } = useLogsUrlState()
|
||||
const { setSearch } = useLogsUrlState()
|
||||
const handleSearch: LogSearchCallback = async (event: string, { query }: { query?: string }) => {
|
||||
setSearch(query || '')
|
||||
}
|
||||
@@ -175,6 +175,7 @@ const PropertyRow = ({
|
||||
|
||||
const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
|
||||
const { timestamp, event_message, metadata, id, status, ...rest } = log
|
||||
const log_file = log?.metadata?.[0]?.log_file
|
||||
|
||||
return (
|
||||
<div data-testid="log-selection" className={`p-2 flex flex-col`}>
|
||||
@@ -193,9 +194,10 @@ const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
|
||||
})}
|
||||
|
||||
{log?.event_message && (
|
||||
<PropertyRow key={'event_message'} keyName={'event_message'} value={log.event_message} />
|
||||
<PropertyRow key="event_message" keyName="event_message" value={log.event_message} />
|
||||
)}
|
||||
{log?.metadata && <PropertyRow key={'metadata'} keyName={'metadata'} value={log.metadata} />}
|
||||
{!!log_file && <PropertyRow key="log_file" keyName="log_file" value={log_file} />}
|
||||
{log?.metadata && <PropertyRow key="metadata" keyName="metadata" value={log.metadata} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { filterFunctionsRequestResponse } from 'lib/logs'
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import type { PreviewLogData } from '../Logs.types'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
ResponseCodeFormatter,
|
||||
SelectionDetailedRow,
|
||||
SelectionDetailedTimestampRow,
|
||||
} from '../LogsFormatters'
|
||||
|
||||
const FunctionInvocationSelectionRender = ({ log }: { log: PreviewLogData }) => {
|
||||
const metadata = log.metadata?.[0]
|
||||
const request = metadata?.request?.[0]
|
||||
const response = metadata?.response?.[0]
|
||||
const method = request?.method
|
||||
const status = response?.status_code
|
||||
const requestUrl = request?.url !== undefined ? new URL(request?.url) : undefined
|
||||
const executionTimeMs = metadata.execution_time_ms
|
||||
const deploymentId = metadata.deployment_id
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
|
||||
<SelectionDetailedRow
|
||||
label="Status"
|
||||
value={status}
|
||||
valueRender={<ResponseCodeFormatter value={status} />}
|
||||
/>
|
||||
<SelectionDetailedRow label="Method" value={method} />
|
||||
<SelectionDetailedTimestampRow value={log.timestamp} />
|
||||
<SelectionDetailedRow label="Execution Time" value={`${executionTimeMs}ms`} />
|
||||
<SelectionDetailedRow label="Execution ID" value={metadata.execution_id} />
|
||||
<SelectionDetailedRow label="Deployment ID" value={deploymentId} />
|
||||
<SelectionDetailedRow label="Log ID" value={log.id} />
|
||||
{requestUrl !== undefined && (
|
||||
<SelectionDetailedRow
|
||||
label="Request Path"
|
||||
value={requestUrl.pathname + requestUrl.search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<h3 className="text-foreground-light mb-4 font-mono text-sm uppercase">Request Metadata</h3>
|
||||
<pre className="text-sm syntax-highlight overflow-x-auto mb-4">
|
||||
<div
|
||||
className="text-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: request ? jsonSyntaxHighlight(filterFunctionsRequestResponse(request)) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<h3 className="text-foreground-light mb-4 font-mono text-sm uppercase">
|
||||
Response Metadata
|
||||
</h3>
|
||||
<pre className="text-sm syntax-highlight overflow-x-auto mb-4">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: response ? jsonSyntaxHighlight(filterFunctionsRequestResponse(response)) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FunctionInvocationSelectionRender
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import {
|
||||
jsonSyntaxHighlight,
|
||||
SelectionDetailedRow,
|
||||
SelectionDetailedTimestampRow,
|
||||
SeverityFormatter,
|
||||
} from '../LogsFormatters'
|
||||
|
||||
const FunctionLogsSelectionRender = ({ log }: any) => {
|
||||
const metadata = log.metadata[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<span className="text-foreground-lighter text-sm col-span-4">Event message</span>
|
||||
<div className="text-xs text-wrap font-mono text-foreground mt-2 whitespace-pre-wrap overflow-x-auto">
|
||||
{log.event_message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px w-full bg-panel-border-interior-light [[data-theme*=dark]_&]:bg-panel-border-interior-dark"></div>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding} space-y-2`}>
|
||||
<SelectionDetailedRow
|
||||
label="Severity"
|
||||
value={metadata.level}
|
||||
valueRender={<SeverityFormatter value={metadata.level} />}
|
||||
/>
|
||||
<SelectionDetailedRow label="Deployment version" value={metadata.version} />
|
||||
<SelectionDetailedTimestampRow value={log.timestamp} />
|
||||
<SelectionDetailedRow label="Execution ID" value={metadata.execution_id} />
|
||||
<SelectionDetailedRow label="Deployment ID" value={metadata.deployment_id} />
|
||||
</div>
|
||||
<div className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}>
|
||||
<h3 className="text-lg text-foreground mb-4">Metadata</h3>
|
||||
<pre className="text-sm syntax-highlight overflow-x-auto">
|
||||
<div
|
||||
className="text-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: metadata ? jsonSyntaxHighlight(metadata) : '',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FunctionLogsSelectionRender
|
||||
@@ -350,6 +350,9 @@ export const SQL_FILTER_TEMPLATES: any = {
|
||||
..._SQL_FILTER_COMMON,
|
||||
database: (value: string) => `m.project like '${value}%'`,
|
||||
},
|
||||
pg_upgrade_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
},
|
||||
pg_cron_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
},
|
||||
@@ -367,6 +370,7 @@ export enum LogsTableName {
|
||||
SUPAVISOR = 'supavisor_logs',
|
||||
PGBOUNCER = 'pgbouncer_logs',
|
||||
WAREHOUSE = 'warehouse_logs',
|
||||
PG_UPGRADE = 'pg_upgrade_logs',
|
||||
PG_CRON = 'pg_cron_logs',
|
||||
}
|
||||
|
||||
@@ -381,6 +385,7 @@ export const LOGS_TABLES = {
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
supavisor: LogsTableName.SUPAVISOR,
|
||||
warehouse: LogsTableName.WAREHOUSE,
|
||||
pg_upgrade: LogsTableName.PG_UPGRADE,
|
||||
pg_cron: LogsTableName.POSTGRES,
|
||||
pgbouncer: LogsTableName.PGBOUNCER,
|
||||
}
|
||||
@@ -397,6 +402,7 @@ export const LOGS_SOURCE_DESCRIPTION = {
|
||||
[LogsTableName.SUPAVISOR]: 'Shared connection pooler logs for PostgreSQL',
|
||||
[LogsTableName.PGBOUNCER]: 'Dedicated connection pooler for PostgreSQL',
|
||||
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
|
||||
[LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process',
|
||||
[LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs',
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export type QueryType =
|
||||
| 'supavisor'
|
||||
| 'postgrest'
|
||||
| 'warehouse'
|
||||
| 'pg_upgrade'
|
||||
| 'pg_cron'
|
||||
| 'pgbouncer'
|
||||
|
||||
|
||||
@@ -205,6 +205,9 @@ limit ${limit}
|
||||
case 'supavisor_logs':
|
||||
return `select id, ${table}.timestamp, event_message from ${table} ${joins} ${where} ${orderBy} limit ${limit}`
|
||||
|
||||
case 'pg_upgrade_logs':
|
||||
return `select id, ${table}.timestamp, event_message from ${table} ${joins} ${where} ${orderBy} limit 100`
|
||||
|
||||
default:
|
||||
return `select id, ${table}.timestamp, event_message from ${table}
|
||||
${where}
|
||||
|
||||
@@ -18,7 +18,13 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { Button } from 'ui'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
import LogTable from './LogTable'
|
||||
import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants'
|
||||
import { DatePickerValue } from './Logs.DatePickers'
|
||||
import {
|
||||
LOGS_TABLES,
|
||||
LOG_ROUTES_WITH_REPLICA_SUPPORT,
|
||||
LogsTableName,
|
||||
PREVIEWER_DATEPICKER_HELPERS,
|
||||
} from './Logs.constants'
|
||||
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
|
||||
import { maybeShowUpgradePrompt } from './Logs.utils'
|
||||
import UpgradePrompt from './UpgradePrompt'
|
||||
@@ -54,17 +60,42 @@ export const LogsPreviewer = ({
|
||||
}: PropsWithChildren<LogsPreviewerProps>) => {
|
||||
const router = useRouter()
|
||||
const { db } = useParams()
|
||||
const [showChart, setShowChart] = useState(true)
|
||||
|
||||
const organization = useSelectedOrganization()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
const [showChart, setShowChart] = useState(true)
|
||||
const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
|
||||
getDefaultDatePickerValue()
|
||||
)
|
||||
|
||||
const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } =
|
||||
useLogsUrlState()
|
||||
|
||||
const [selectedLogId, setSelectedLogId] = useSelectedLog()
|
||||
|
||||
const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef })
|
||||
|
||||
// TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi
|
||||
function getDefaultDatePickerValue() {
|
||||
const iso_timestamp_start = router.query.iso_timestamp_start as string
|
||||
const iso_timestamp_end = router.query.iso_timestamp_end as string
|
||||
|
||||
if (iso_timestamp_start && iso_timestamp_end) {
|
||||
return {
|
||||
to: iso_timestamp_end,
|
||||
from: iso_timestamp_start,
|
||||
text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
|
||||
isHelper: false,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default)
|
||||
return {
|
||||
to: defaultDatePickerValue!.calcTo(),
|
||||
from: defaultDatePickerValue!.calcFrom(),
|
||||
text: defaultDatePickerValue!.text,
|
||||
isHelper: true,
|
||||
}
|
||||
}
|
||||
|
||||
const table = !tableName ? LOGS_TABLES[queryType] : tableName
|
||||
|
||||
const {
|
||||
@@ -96,13 +127,18 @@ export const LogsPreviewer = ({
|
||||
setFilters({ ...filters, search_query: template.searchString })
|
||||
}
|
||||
|
||||
// [Joshen 180425] This logic here all seems unnecessary IMO? handleRefresh just should call refresh?
|
||||
// [Joshen] For helper date picker values, reset the timestamp start to prevent data caching
|
||||
// Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result
|
||||
// Whereas if a specific range is selected, you'd not expect new data to show up
|
||||
const handleRefresh = () => {
|
||||
if (timestampStart) {
|
||||
const newTimestampStart = dayjs(timestampStart).toISOString()
|
||||
setTimeRange(newTimestampStart, timestampEnd)
|
||||
} else {
|
||||
setTimeRange('', '')
|
||||
if (selectedDatePickerValue.isHelper) {
|
||||
const helper = PREVIEWER_DATEPICKER_HELPERS.find(
|
||||
(x) => x.text === selectedDatePickerValue.text
|
||||
)
|
||||
if (helper) {
|
||||
const newTimestampStart = helper.calcFrom()
|
||||
setTimeRange(newTimestampStart, timestampEnd)
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
@@ -180,6 +216,8 @@ export const LogsPreviewer = ({
|
||||
query: id !== projectRef ? { ...router.query, db: id } : params,
|
||||
})
|
||||
}}
|
||||
selectedDatePickerValue={selectedDatePickerValue}
|
||||
setSelectedDatePickerValue={setSelectedDatePickerValue}
|
||||
/>
|
||||
{children}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
@@ -42,6 +41,8 @@ interface PreviewFilterPanelProps {
|
||||
filters: Filters
|
||||
onSelectedDatabaseChange: (id: string) => void
|
||||
className?: string
|
||||
selectedDatePickerValue: DatePickerValue
|
||||
setSelectedDatePickerValue: (value: DatePickerValue) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,8 @@ const PreviewFilterPanel = ({
|
||||
table,
|
||||
onSelectedDatabaseChange,
|
||||
className,
|
||||
selectedDatePickerValue,
|
||||
setSelectedDatePickerValue,
|
||||
}: PreviewFilterPanelProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
@@ -79,30 +82,6 @@ const PreviewFilterPanel = ({
|
||||
|
||||
const hasEdits = search !== defaultSearchValue
|
||||
|
||||
function getDefaultDatePickerValue() {
|
||||
// if we have values in the URL, use them
|
||||
const iso_timestamp_start = router.query.iso_timestamp_start as string
|
||||
const iso_timestamp_end = router.query.iso_timestamp_end as string
|
||||
if (iso_timestamp_start && iso_timestamp_end) {
|
||||
return {
|
||||
to: iso_timestamp_end,
|
||||
from: iso_timestamp_start,
|
||||
text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
|
||||
isHelper: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
to: PREVIEWER_DATEPICKER_HELPERS[2].calcTo(),
|
||||
from: PREVIEWER_DATEPICKER_HELPERS[2].calcFrom(),
|
||||
text: 'Last hour',
|
||||
isHelper: true,
|
||||
}
|
||||
}
|
||||
|
||||
const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
|
||||
getDefaultDatePickerValue()
|
||||
)
|
||||
|
||||
const handleInputSearch = (query: string) => onSearch('search-input-change', { query })
|
||||
|
||||
// Sync local state with provided default value
|
||||
|
||||
@@ -102,10 +102,7 @@ export const Sidebar = ({ className, ...props }: SidebarProps) => {
|
||||
{!hideSideBar && (
|
||||
<SidebarMotion
|
||||
{...props}
|
||||
transition={{
|
||||
delay: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
overflowing={sidebarBehaviour === 'expandable'}
|
||||
collapsible="icon"
|
||||
variant="sidebar"
|
||||
@@ -122,7 +119,7 @@ export const Sidebar = ({ className, ...props }: SidebarProps) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
className="w-min px-1.5 mx-0.5 group-data-[state=expanded]:px-2"
|
||||
className={`w-min px-1.5 mx-0.5 ${sidebarBehaviour === 'open' ? '!px-2' : ''}`}
|
||||
icon={<PanelLeftDashed size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -154,14 +151,6 @@ export const SidebarContent = ({ footer }: { footer?: ReactNode }) => {
|
||||
const newLayoutPreview = useIsNewLayoutEnabled()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
// temporary logic to show settings route in sidebar footer
|
||||
// this will be removed once we move to an updated org/project nav
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const settingsRoutes = generateSettingsRoutes(ref, project)
|
||||
const activeRoute = router.pathname.split('/')[3]
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -208,7 +197,7 @@ export function SideBarNavLink({
|
||||
const buttonProps = {
|
||||
tooltip: sidebarBehaviour === 'closed' ? route.label : '',
|
||||
isActive: active,
|
||||
className: 'text-sm',
|
||||
className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''),
|
||||
size: 'default' as const,
|
||||
onClick: onClick,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Button } from 'ui'
|
||||
import { MessageSquare, Send, X } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
|
||||
interface AIAssistantOptionProps {
|
||||
projectRef: string
|
||||
organizationSlug: string
|
||||
}
|
||||
|
||||
export const AIAssistantOption = ({ projectRef, organizationSlug }: AIAssistantOptionProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { data: projects } = useProjectsQuery()
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 800)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (!organizationSlug || organizationSlug === 'no-org') {
|
||||
return null
|
||||
}
|
||||
|
||||
const getProjectRef = () => {
|
||||
if (projectRef !== 'no-project') {
|
||||
return projectRef
|
||||
}
|
||||
// If no specific project selected, use first project from the org
|
||||
const orgProjects = projects?.filter((p) => p.organization_slug === organizationSlug)
|
||||
return orgProjects?.[0]?.ref || '_'
|
||||
}
|
||||
|
||||
const aiLink = `/project/${getProjectRef()}?aiAssistantPanelOpen=true`
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="w-full overflow-hidden border rounded-md relative"
|
||||
>
|
||||
<div className="flex flex-col xl:flex-row py-8 px-10">
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="absolute top-3 right-3 hover:bg-background-surface-300 z-[2] px-1"
|
||||
icon={<X size={14} strokeWidth={2} />}
|
||||
onClick={() => setIsVisible(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 z-[2] flex-shrink-0">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Try the AI Assistant</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Ask the AI assistant to help you with your support issue
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={aiLink}>
|
||||
<Button size="tiny" type="default">
|
||||
Ask AI assistant
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute z-[1] scale-75 -right-24 top-0">
|
||||
<div className="relative grow flex flex-col gap-3 w-[400px]">
|
||||
<div className="flex items-start gap-3 pl-12">
|
||||
<div className="w-8 h-8 rounded-full bg-background-surface-300 flex items-center justify-center">
|
||||
<MessageSquare size={16} className="text-foreground-lighter" />
|
||||
</div>
|
||||
<div className="flex-1 bg-background-surface-200 rounded-lg p-4 max-w-[280px]">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Hi! I'm your AI assistant. How can I help you today?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-3 pr-10">
|
||||
<div className="bg-background-surface-200 rounded-lg p-4 max-w-[280px]">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
I can help you with database queries, API endpoints, or any other technical
|
||||
questions you might have.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -inset-2 bg-gradient-to-l from-transparent via-background-200 via-[95%] to-background-200 to-[100%] z-[1]" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -58,16 +58,21 @@ const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive']
|
||||
const CONTAINER_CLASSES = 'px-6'
|
||||
|
||||
interface SupportFormV2Props {
|
||||
onProjectSelected: (value: string) => void
|
||||
onOrganizationSelected: (value: string) => void
|
||||
setSentCategory: (value: string) => void
|
||||
setSelectedProject: (value: string) => void
|
||||
}
|
||||
|
||||
// [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view
|
||||
// This is a rewrite of the old SupportForm to use the new form components
|
||||
export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFormV2Props) => {
|
||||
export const SupportFormV2 = ({
|
||||
onProjectSelected: setSelectedProject,
|
||||
onOrganizationSelected: setSelectedOrganization,
|
||||
setSentCategory,
|
||||
}: SupportFormV2Props) => {
|
||||
const { profile } = useProfile()
|
||||
const {
|
||||
ref,
|
||||
projectRef: ref,
|
||||
slug,
|
||||
category: urlCategory,
|
||||
subject: urlSubject,
|
||||
@@ -295,6 +300,18 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo
|
||||
if (urlMessage) form.setValue('message', urlMessage)
|
||||
}, [urlMessage])
|
||||
|
||||
// Sync organization selection with parent state
|
||||
// Initialized as 'no-org' in parent if no org is selected
|
||||
useEffect(() => {
|
||||
setSelectedOrganization(organizationSlug)
|
||||
}, [organizationSlug, setSelectedOrganization])
|
||||
|
||||
// Sync project selection with parent state
|
||||
// Initialized as 'no-project' in parent if no project is selected
|
||||
useEffect(() => {
|
||||
setSelectedProject(projectRef)
|
||||
}, [projectRef, setSelectedProject])
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface NotFoundStateProps {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
const NotFoundState = ({ id }: NotFoundStateProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="w-[400px]">
|
||||
<InformationBox
|
||||
icon={<AlertCircle strokeWidth={2} />}
|
||||
title={`Unable to find your table with ID ${id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFoundState
|
||||
+2
-2
@@ -25,7 +25,7 @@ import { getTables } from 'data/tables/tables-query'
|
||||
import { useUrlState } from 'hooks/ui/useUrlState'
|
||||
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { createTabId, renameTab } from 'state/tabs'
|
||||
import { createTabId, updateTab } from 'state/tabs'
|
||||
import type { Dictionary } from 'types'
|
||||
import { SonnerProgress } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
@@ -489,7 +489,7 @@ const SidePanelEditor = ({
|
||||
if (isTableEditorTabsEnabled && ref && payload.name) {
|
||||
// [Joshen] Only table entities can be updated via the dashboard
|
||||
const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id })
|
||||
renameTab(ref, tabId, payload.name)
|
||||
updateTab(ref, tabId, { label: payload.name })
|
||||
}
|
||||
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
|
||||
}
|
||||
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user