mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
Merge branch 'master' into feat/library-md-pages
This commit is contained in:
commit
6830cfe6d4
301 files changed
+25767
-4710
No files matched your search
@@ -15,7 +15,8 @@ Supabase branches create separate environments that spin off from your main proj
|
||||
- **Preview Branches**: Preview branches are ephemeral and best suited for focused testing. They are automatically deleted when a PR is merged or closed.
|
||||
- **Persistent Branches**: Persistent branches are long-lived and recommended for environments like staging, QA, or development. They aren't automatically paused or deleted due to inactivity or when a PR is merged or closed.
|
||||
- **Managing Branches**: You can create, review, and merge branches either automatically via our [GitHub integration](/docs/guides/deployment/branching/github-integration) or directly [through the dashboard](/docs/guides/deployment/branching/dashboard) (currently in beta). All branches show up in the branches page in the dashboard, regardless of how they were created.
|
||||
- **Data-less**: New branches do not start with any data from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration.
|
||||
- **Cloned from your main project**: Each new branch is created as a clone of your main project. It starts with that project's Edge Functions deployed and the configuration set.
|
||||
- **Data-less by default**: By default, new branches do not start with any data or storage objects from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration, or the [Include data](/docs/guides/deployment/branching/dashboard#include-production-data) option if you create the branch from the dashboard.
|
||||
|
||||
## Deploying to production
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ Once you've enabled the feature, you can create a new branch:
|
||||
1. Click the arrows next to the branch name in the top menu bar. (The top menu bar has the format `YOUR_ORGANIZATION / YOUR_PROJECT / CURRENT_BRANCH_NAME`.)
|
||||
2. Click `Create branch`.
|
||||
|
||||
The new branch is a clone of your base project. It starts with the project's schema, Edge Functions, and the configuration set.
|
||||
|
||||
### Include production data
|
||||
|
||||
By default, a branch starts without any of your production data or storage objects. If your project has the Point-in-Time Recovery add-on, you can turn on **Include data** when you create the branch to copy your production data into it.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
A branch created with **Include data** holds a copy of your production data, treat it with the same care as production.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
A branch uses a larger disk and matches the compute size of your project, which increases its cost.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Making changes to a branch
|
||||
|
||||
Use the branch selector in the top bar to change to your branch. Any changes you make (including SQL run in the SQL editor, table editor changes, and configuration changes) are now made against the currently selected branch.
|
||||
|
||||
@@ -83,19 +83,25 @@ Enable the **Automatic branching** option in your GitHub Integration configurati
|
||||
|
||||
When a new branch is created in GitHub, a corresponding branch is created in Supabase. (You can enable the **Supabase changes only** option to only create Supabase branches when Supabase files change.)
|
||||
|
||||
Every Supabase branch, preview or persistent, is created as a clone of your base project. The new branch starts with the Edge Functions and configuration of that project. Its database schema is not cloned. Instead, it is built from the migrations you commit to your repository.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can test configuration changes on your Preview Branch by configuring the `config.toml` file in your Supabase directory. See the [Configuration docs](/docs/guides/deployment/branching/configuration) for more information.
|
||||
|
||||
Your branch starts with the configuration of your base project. The settings in your `config.toml` file are applied on top of that clone.
|
||||
|
||||
A comment is added to your PR with the deployment status of your preview branch.
|
||||
|
||||
### Migrations
|
||||
|
||||
The migrations in the `migrations` subdirectory of your Supabase directory are automatically run.
|
||||
The migrations in the `migrations` subdirectory of your Supabase directory are automatically run when the branch is created. Each later commit runs only the migrations that haven't been applied yet.
|
||||
|
||||
If you want to rerun existing migrations, reset the branch from the Supabase dashboard to start from scratch. Note that existing data on your branch will also be dropped by a reset.
|
||||
|
||||
### Seeding
|
||||
|
||||
No production data is copied to your Preview branch. This is meant to protect your sensitive production data.
|
||||
Cloning your base project copies its Edge Functions and configuration, but not its data or storage objects. This is meant to protect your sensitive production data. Your branch starts with the tables your migrations create, and the only rows in them are the ones your seed files add.
|
||||
|
||||
You can seed your Preview Branch with sample data using the `seed.sql` file in your Supabase directory. See the [Seeding docs](/docs/guides/local-development/seeding-your-database) for more information.
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ You might want to roll back changes you've made in an earlier migration change.
|
||||
|
||||
To fix this, push the latest changes, then delete the preview branch in Supabase and reopen it.
|
||||
|
||||
The new preview branch is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost. This is equivalent to running `supabase db reset` locally. All migrations are rerun in sequential order.
|
||||
The new preview branch is a fresh clone of your base project and is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost.
|
||||
|
||||
To rerun migrations that your base project has already applied, reset the branch from the Supabase dashboard instead. A reset reruns all migrations in sequential order and drops existing data on the branch.
|
||||
|
||||
### Deployment failures
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ After completing the steps above, you should receive a Slack message whenever an
|
||||
|
||||
Migrations are run in sequential order. Each migration builds upon the previous one.
|
||||
|
||||
The preview branch has a record of which migrations have been applied, and only applies new migrations for each commit. This can create an issue when rolling back migrations.
|
||||
The preview branch inherits the migration history of your base project, so it only applies migrations that haven't been run yet. This can create an issue when rolling back migrations.
|
||||
|
||||
### Using ORM or custom seed scripts
|
||||
|
||||
@@ -237,7 +237,9 @@ You might want to roll back changes you've made in an earlier migration change.
|
||||
|
||||
To fix this, push the latest changes, then delete the preview branch in Supabase and reopen it.
|
||||
|
||||
The new preview branch is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost. This is equivalent to running `supabase db reset` locally. All migrations are rerun in sequential order.
|
||||
The new preview branch is a fresh clone of your base project and is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost.
|
||||
|
||||
To rerun migrations that your base project has already applied, reset the branch from the Supabase dashboard instead. A reset reruns all migrations in sequential order and drops existing data on the branch.
|
||||
|
||||
### Seeding behavior
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ Existing projects on pg_graphql 1.5.x are not impacted unless they choose to upg
|
||||
|
||||
### Ltree indexes require reindexing after upgrade
|
||||
|
||||
_Applies when upgrading to Postgres 15.18 or 17.10._
|
||||
_Applies when upgrading to Postgres 15.19 or 17.11._
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
@@ -221,29 +221,115 @@ To mitigate this issue:
|
||||
2. If `reindex_required` is `true`, find the affected indexes:
|
||||
|
||||
```sql
|
||||
select schemaname, tablename, indexname
|
||||
from pg_indexes
|
||||
select distinct
|
||||
n.nspname as schema_name,
|
||||
cls.relname as table_name,
|
||||
ic.relname as index_name
|
||||
from pg_index idx
|
||||
join pg_class ic on idx.indexrelid = ic.oid
|
||||
join pg_class cls on idx.indrelid = cls.oid
|
||||
join pg_namespace n on ic.relnamespace = n.oid
|
||||
join lateral unnest(idx.indclass::oid[]) with ordinality as k(opclass, pos) on true
|
||||
join pg_opclass oc on oc.oid = k.opclass
|
||||
join pg_type ty on ty.oid = oc.opcintype
|
||||
where
|
||||
indexname in (
|
||||
select c.relname
|
||||
from
|
||||
pg_index as i
|
||||
join pg_class as c on i.indexrelid = c.oid
|
||||
join pg_attribute as a on a.attrelid = i.indrelid and a.attnum = ANY(i.indkey)
|
||||
join pg_type as t on a.atttypid = t.oid
|
||||
where t.typname in ('ltree', '_ltree')
|
||||
);
|
||||
k.pos <= idx.indnkeyatts -- key columns only, excludes INCLUDE
|
||||
and ty.typname in ('ltree', '_ltree');
|
||||
```
|
||||
|
||||
3. Reindex each affected index. `REINDEX INDEX CONCURRENTLY` runs online with no downtime:
|
||||
3. Reindex each affected index using its schema-qualified name. `REINDEX INDEX CONCURRENTLY` runs online with no downtime, but cannot run inside a transaction block:
|
||||
|
||||
```sql
|
||||
REINDEX INDEX CONCURRENTLY <index_name>;
|
||||
REINDEX INDEX CONCURRENTLY <schema_name>.<index_name>;
|
||||
```
|
||||
|
||||
Separately from the encoding case above, this release also fixes an integer overflow in `ltree` comparisons: values with more than about 14,653 labels could compare incorrectly, which can corrupt B-tree indexes built over them, regardless of your database encoding. The following query lists only the B-tree indexes whose `ltree` column or expression actually contains such values, so they are the ones to reindex (an empty result means no action is needed):
|
||||
|
||||
```sql
|
||||
SELECT s.schema_name || '.' || s.index_name AS index_to_reindex
|
||||
FROM (
|
||||
SELECT
|
||||
n.nspname AS schema_name,
|
||||
c.relname AS table_name,
|
||||
ic.relname AS index_name,
|
||||
min(pg_get_expr(i.indpred, i.indrelid)) AS pred, -- partial-index predicate, if any
|
||||
string_agg('nlevel(' || pg_get_indexdef(i.indexrelid, k.pos::int, true) || ') > 14653', ' OR ') AS keys_cond
|
||||
FROM pg_index i
|
||||
JOIN pg_class ic ON ic.oid = i.indexrelid
|
||||
JOIN pg_class c ON c.oid = i.indrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_am am ON am.oid = ic.relam
|
||||
JOIN LATERAL generate_series(1, i.indnkeyatts) AS k(pos) ON true
|
||||
JOIN pg_attribute ia ON ia.attrelid = i.indexrelid AND ia.attnum = k.pos
|
||||
JOIN pg_type t ON t.oid = ia.atttypid
|
||||
WHERE am.amname = 'btree'
|
||||
AND t.typname = 'ltree'
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
GROUP BY n.nspname, c.relname, ic.relname
|
||||
) s
|
||||
WHERE (xpath(
|
||||
'/row/cnt/text()',
|
||||
query_to_xml(
|
||||
format('SELECT count(*) AS cnt FROM %I.%I WHERE %s(%s)',
|
||||
s.schema_name, s.table_name,
|
||||
CASE WHEN s.pred IS NOT NULL THEN '(' || s.pred || ') AND ' ELSE '' END,
|
||||
s.keys_cond),
|
||||
false, true, ''
|
||||
)
|
||||
))[1]::text::bigint > 0
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Reindex each index it returns, using the schema-qualified name. `REINDEX INDEX CONCURRENTLY` runs online with no downtime, but cannot run inside a transaction block:
|
||||
|
||||
```sql
|
||||
REINDEX INDEX CONCURRENTLY <schema_name>.<index_name>;
|
||||
```
|
||||
|
||||
### Btree_gist indexes on float columns require reindexing after upgrade
|
||||
|
||||
_Applies when upgrading to Postgres 15.19 or 17.11._
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
You are affected only if you have `btree_gist` indexes on `float4` or `float8` columns that may contain `NaN` values.
|
||||
|
||||
</Admonition>
|
||||
|
||||
This release fixes `NaN` handling in `btree_gist`'s `float4` and `float8` operator classes. Indexes on those columns built under the previous version can return wrong results for rows containing `NaN` until the index is rebuilt.
|
||||
|
||||
To mitigate this issue:
|
||||
|
||||
1. Find `btree_gist` indexes on float columns:
|
||||
|
||||
```sql
|
||||
select distinct
|
||||
n.nspname as schema_name,
|
||||
cls.relname as table_name,
|
||||
ic.relname as index_name
|
||||
from pg_index idx
|
||||
join pg_class ic on idx.indexrelid = ic.oid
|
||||
join pg_am am on ic.relam = am.oid
|
||||
join pg_class cls on idx.indrelid = cls.oid
|
||||
join pg_namespace n on ic.relnamespace = n.oid
|
||||
join lateral unnest(idx.indclass::oid[]) with ordinality as k(opclass, pos) on true
|
||||
join pg_opclass oc on oc.oid = k.opclass
|
||||
join pg_type ty on ty.oid = oc.opcintype
|
||||
where
|
||||
am.amname = 'gist'
|
||||
and k.pos <= idx.indnkeyatts
|
||||
and ty.typname in ('float4', 'float8');
|
||||
```
|
||||
|
||||
2. If any indexes are returned and those columns may contain `NaN` values, reindex them using the schema-qualified name. `REINDEX INDEX CONCURRENTLY` runs online with no downtime, but cannot run inside a transaction block:
|
||||
|
||||
```sql
|
||||
REINDEX INDEX CONCURRENTLY <schema_name>.<index_name>;
|
||||
```
|
||||
|
||||
### Custom operator selectivity estimators
|
||||
|
||||
_Applies when upgrading to Postgres 15.18 or 17.10._
|
||||
_Applies when upgrading to Postgres 15.19 or 17.11._
|
||||
|
||||
Attaching a non-built-in (extension- or user-provided) selectivity estimator function to an operator now requires superuser. Existing operators continue to work — the check only fires when an operator is (re)created, most commonly during `pg_dump` / `pg_restore`, a logical restore, or a branch.
|
||||
|
||||
@@ -256,7 +342,9 @@ ERROR: must be superuser to specify a non-built-in restriction estimator functio
|
||||
Most projects are not affected. To check whether your database has any user-defined operators that reference a non-built-in estimator:
|
||||
|
||||
```sql
|
||||
SELECT n.nspname AS schema, o.oprname AS operator
|
||||
SELECT n.nspname AS schema, o.oprname AS operator,
|
||||
o.oprrest::regproc AS restrict_estimator,
|
||||
o.oprjoin::regproc AS join_estimator
|
||||
FROM pg_operator o
|
||||
JOIN pg_namespace n ON o.oprnamespace = n.oid
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title = "Realtime: Postgres Changes Troubleshooting"
|
||||
topics = [
|
||||
"database",
|
||||
"realtime",
|
||||
]
|
||||
keywords = [ "postgres changes", "rls", "replica identity", "subscription", "websocket" ] # any strings (topics are automatically added so no need to duplicate)
|
||||
---
|
||||
|
||||
A Realtime subscription connects, a row changes in the database, and nothing arrives on the client. No error, no event — silence. This is one of the more common issues developers run into with Supabase Realtime, and the cause is almost always one of a handful of things.
|
||||
|
||||
The order below reflects how often each one turns out to be the actual problem. Check RLS early, even if the subscription code looks fine — it's the single biggest source of "silently missing" events, by a wide margin.
|
||||
|
||||
For a broader index of Realtime-specific issues, see the [Troubleshooting](/docs/guides/troubleshooting) directory.
|
||||
|
||||
## Step 1: Is the table in the Realtime publication?
|
||||
|
||||
Realtime doesn't watch every table by default. Each one has to be added to a publication called `supabase_realtime`. If it isn't, Realtime has no visibility into that table at all — no matter how the client subscription is set up.
|
||||
|
||||
```sql
|
||||
select * from pg_publication_tables where pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
If the table's missing from the results:
|
||||
|
||||
```sql
|
||||
alter publication supabase_realtime add table your_table;
|
||||
```
|
||||
|
||||
Or toggle it on from **Database → Replication** in the dashboard.
|
||||
|
||||
This gets overlooked on a table created recently — creating the table and enabling Realtime on it are two separate steps:
|
||||
|
||||
```sql
|
||||
alter publication supabase_realtime add table messages;
|
||||
```
|
||||
|
||||
## Step 2: Is RLS quietly blocking the row?
|
||||
|
||||
This is the most common cause, and it's the one that wastes the most time, because nothing errors. The event doesn't show up.
|
||||
|
||||
Realtime enforces RLS the same way a normal query would — as the subscribing client's role. `SUBSCRIBED` only means the WebSocket connected. It says nothing about whether that client is allowed to see the data.
|
||||
|
||||
Test it directly with either options:
|
||||
|
||||
Using the same credentials the client uses, try to select the row that changed:
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.from('messages').select('*').eq('id', theRowIdThatChanged)
|
||||
|
||||
console.log({ data, error })
|
||||
```
|
||||
|
||||
Or on the dashboard, open Table Editor, and try to view the row as the subscribing user's role.
|
||||
|
||||
Empty `data`? That's the answer. The policy is blocking this row for this user, and Realtime is doing exactly what it's supposed to.
|
||||
|
||||
A fix might look like:
|
||||
|
||||
```sql
|
||||
create policy "Users can view messages in their rooms"
|
||||
on messages for select
|
||||
using (
|
||||
exists (
|
||||
select 1 from room_members
|
||||
where room_members.room_id = messages.room_id
|
||||
and room_members.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
One trap specific to `UPDATE` events: RLS generally has to allow both the old and new row state. If a policy only permits `status = 'active'`, and an update flips the status to `archived`, the event can fail to deliver — the row was visible a second ago, but the new state no longer passes the check.
|
||||
|
||||
See [Row Level Security](/docs/guides/database/postgres/row-level-security) for the underlying model, and [why a select can return an empty data array](./why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx), which is directly relevant to the test above.
|
||||
|
||||
## Step 3: Missing fields in `payload.old`?
|
||||
|
||||
If events are arriving but `payload.old` is mostly empty or `null`, that's a replica identity problem, not a delivery problem.
|
||||
|
||||
By default, Postgres only sends the primary key in the "old row" for `UPDATE` and `DELETE`. If your code compares old vs. new values, that's not enough:
|
||||
|
||||
```sql
|
||||
alter table messages replica identity full;
|
||||
```
|
||||
|
||||
If the table has Row Level Security enabled, `replica identity full` isn't enough by itself: the `old` record still contains only the primary key. There's no way around this while RLS is on — don't rely on other `payload.old` fields for a policy-protected table.
|
||||
|
||||
A typical case: a `status` column moves from `pending` to `completed`, and the client checks
|
||||
|
||||
```js
|
||||
if (payload.old.status !== payload.new.status) {
|
||||
notifyUser()
|
||||
}
|
||||
```
|
||||
|
||||
Without `replica identity full` (or with RLS enabled), `payload.old.status` is `undefined`, and `undefined !== 'completed'` is `true` — so the check fires `notifyUser()` on every update, not just the ones where status actually changed.
|
||||
|
||||
## Step 4: Check the subscription code itself
|
||||
|
||||
Once publication and RLS are ruled out, look at the subscription config. Table name, schema, filter syntax — small mismatches here are common.
|
||||
|
||||
- Table name matches exactly, including case
|
||||
- Schema is correct (`public`, unless you're using a custom one)
|
||||
- Filter syntax is right: `room_id=eq.abc123`, not `room_id = abc123`
|
||||
|
||||
```js
|
||||
const channel = supabase
|
||||
.channel('room-messages')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'messages',
|
||||
filter: `room_id=eq.${roomId}`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Got an update:', payload.new)
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
console.log('Subscription status:', status)
|
||||
})
|
||||
```
|
||||
|
||||
Log the status callback. Don't assume `.subscribe()` worked.
|
||||
|
||||
- `SUBSCRIBED` — connected
|
||||
- `CHANNEL_ERROR` — check the error payload
|
||||
- `CLOSED` — channel got shut down
|
||||
- `TIMED_OUT` — connection issue; see [Realtime connections giving `TIMED_OUT` errors](./realtime-connections-timed_out-status)
|
||||
|
||||
A channel stuck at `CHANNEL_ERROR` is a different problem than one that connects fine but never fires. Figure out which one you're dealing with before going further.
|
||||
|
||||
## Step 5: Stale or duplicate subscriptions
|
||||
|
||||
This one is almost always a React/Next.js problem.
|
||||
|
||||
A component re-renders, `roomId` changes, and the old channel doesn't get cleaned up. Now you've got two subscriptions running, or one listening against a stale parameter. It doesn't always look broken — sometimes it looks like duplicate events, or events for the wrong room.
|
||||
|
||||
```js
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel(`room:${roomId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
|
||||
(payload) => console.log(payload.new)
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [roomId])
|
||||
```
|
||||
|
||||
That cleanup line is the part people skip. Without it, the old channel keeps running against the previous `roomId` in the background, and nothing in the UI tells you it's happening. See [Next.js 13/14 stale data when changing RLS or table data](./nextjs-1314-stale-data-when-changing-rls-or-table-data-85b8oQ) for an adjacent version of the same bug.
|
||||
|
||||
## Step 6: Writing right after `SUBSCRIBED`
|
||||
|
||||
There's a confirmed timing gap between the client reporting `SUBSCRIBED` and the backend's replication listener being ready to stream. A write made in that gap can get missed.
|
||||
|
||||
This shows up most in automated tests, where subscribe and write happen back-to-back:
|
||||
|
||||
```js
|
||||
// Risky — writing immediately after subscribing
|
||||
const channel = supabase.channel('test-channel').on(/* ... */).subscribe()
|
||||
await supabase.from('messages').insert({ text: 'hello' })
|
||||
```
|
||||
|
||||
The reliable fix isn't a fixed delay — it's waiting for the backend to confirm the `postgres_changes` extension is listening, via the `system` message it emits:
|
||||
|
||||
```js
|
||||
supabase
|
||||
.channel('room1')
|
||||
.on('system', '*', (payload) => {
|
||||
if (payload.extension === 'postgres_changes' && payload.status === 'ok') {
|
||||
console.log('changes are ready', payload) // safe to write now
|
||||
}
|
||||
})
|
||||
.on('postgres_changes', { event: '*', schema: '*' }, (payload) => {
|
||||
console.log('Change received!', payload)
|
||||
})
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
No need to `await` anything here — react to the message inside the handler (e.g. set a flag) before triggering the write. This `system` message isn't formally documented yet.
|
||||
|
||||
If you can't wire this up right now, a short fixed delay after `SUBSCRIBED` is a weaker fallback:
|
||||
|
||||
```js
|
||||
channel.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await supabase.from('messages').insert({ text: 'hello' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Step 7: Is Realtime enabled?
|
||||
|
||||
Sometimes the answer is straightforward. Check:
|
||||
|
||||
- **Project Settings → Realtime** — enabled at the project level?
|
||||
- **Database → Replication** — toggle on for this specific table?
|
||||
|
||||
Check this again after a project's been paused and restarted — the replication slot can need to reconnect. If the project's under heavier load than usual, also check the Realtime "Concurrent Peak Connections" quota — hitting that limit can look a lot like a broken subscription.
|
||||
|
||||
## Step 8: Read the Realtime logs
|
||||
|
||||
Still stuck? **Logs → Realtime** in the dashboard. Look for connection drops, replication errors, rate limiting.
|
||||
|
||||
For more detail than the default logs give you, see [Debug Realtime with Logger and Log Levels](./realtime-debugging-with-logger). If the connection seems to drop intermittently rather than failing outright, check [Realtime: Handling Silent Disconnections in Background Applications](./realtime-handling-silent-disconnections-in-backgrounded-applications-592794) and [Understanding and Monitoring Realtime Heartbeats](./realtime-heartbeat-messages).
|
||||
|
||||
One thing to design around regardless: Realtime doesn't guarantee every message gets delivered. Network blips and reconnects can drop an event here and there. If a missed update matters, treat the Realtime event as a signal to re-fetch state — not as the only source of truth. Then a dropped event is an inconvenience, not a bug.
|
||||
|
||||
## Still nothing?
|
||||
|
||||
Rule out the network layer:
|
||||
|
||||
- Corporate firewall or proxy blocking WebSocket connections
|
||||
- Outdated `supabase-js` version — check recent release notes
|
||||
|
||||
Hopefully this guide is helpful to you in resolving these types of issues.
|
||||
@@ -381,6 +381,14 @@ interface ApiOperationRequestBodyDetailsInternalProps extends HTMLAttributes<HTM
|
||||
schema: ISchema
|
||||
}
|
||||
|
||||
// Some specs have allOf/anyOf/oneOf as a single schema object instead of an
|
||||
// array. Wrap it so it still renders instead of crashing or disappearing.
|
||||
function asSchemaArray(value: unknown): Array<any> {
|
||||
if (Array.isArray(value)) return value
|
||||
if (value && typeof value === 'object') return [value]
|
||||
return []
|
||||
}
|
||||
|
||||
function ApiOperationRequestBodyDetailsInternal({
|
||||
schema,
|
||||
...props
|
||||
@@ -389,7 +397,7 @@ function ApiOperationRequestBodyDetailsInternal({
|
||||
return (
|
||||
<>
|
||||
<span className="font-mono text-sm font-medium text-foreground">All of the following:</span>
|
||||
{schema.allOf.map((option, index) => (
|
||||
{asSchemaArray(schema.allOf).map((option, index) => (
|
||||
<ApiSchemaParamSubdetails key={index} schema={option} />
|
||||
))}
|
||||
</>
|
||||
@@ -398,7 +406,7 @@ function ApiOperationRequestBodyDetailsInternal({
|
||||
return (
|
||||
<>
|
||||
<span className="font-mono text-sm font-medium text-foreground">Any of the following:</span>
|
||||
{schema.anyOf.map((option, index) => (
|
||||
{asSchemaArray(schema.anyOf).map((option, index) => (
|
||||
<ApiSchemaParamSubdetails key={index} schema={option} />
|
||||
))}
|
||||
</>
|
||||
@@ -407,7 +415,7 @@ function ApiOperationRequestBodyDetailsInternal({
|
||||
return (
|
||||
<>
|
||||
<span className="font-mono text-sm font-medium text-foreground">One of the following:</span>
|
||||
{schema.oneOf.map((option, index) => (
|
||||
{asSchemaArray(schema.oneOf).map((option, index) => (
|
||||
<ApiSchemaParamSubdetails key={index} schema={option} />
|
||||
))}
|
||||
</>
|
||||
@@ -431,10 +439,11 @@ function ApiOperationRequestBodyDetailsInternal({
|
||||
return (
|
||||
<>
|
||||
<span className="font-mono text-sm font-medium text-foreground">{`Array of ${displayName}`}</span>
|
||||
{!(
|
||||
'type' in schema.items &&
|
||||
['string', 'boolean', 'number', 'integer'].includes(schema.items.type)
|
||||
) && <ApiSchemaParamSubdetails className="mt-4" schema={schema.items} />}
|
||||
{schema.items &&
|
||||
!(
|
||||
'type' in schema.items &&
|
||||
['string', 'boolean', 'number', 'integer'].includes(schema.items.type)
|
||||
) && <ApiSchemaParamSubdetails className="mt-4" schema={schema.items} />}
|
||||
</>
|
||||
)
|
||||
} else if (schema.type === 'object') {
|
||||
@@ -469,13 +478,14 @@ export function ApiSchemaParamSubdetails({
|
||||
(schema.type === 'string' &&
|
||||
!('minLength' in schema || 'maxLength' in schema || 'pattern' in schema)) ||
|
||||
(schema.type === 'array' &&
|
||||
schema.items &&
|
||||
'type' in schema.items &&
|
||||
['boolean', 'number', 'integer', 'string', 'file'].includes(schema.items.type)))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const subContent =
|
||||
const rawSubContent =
|
||||
'enum' in schema
|
||||
? schema.enum
|
||||
: 'anyOf' in schema
|
||||
@@ -492,6 +502,7 @@ export function ApiSchemaParamSubdetails({
|
||||
value: schema[key],
|
||||
}))
|
||||
: []
|
||||
const subContent = asSchemaArray(rawSubContent)
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
@@ -530,8 +541,24 @@ export function ApiSchemaParamSubdetails({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{'type' in schema && schema.type === 'object' ? (
|
||||
<div className={cn('border-b border-x border-fault', 'rounded-b-lg', 'p-5')}>
|
||||
<ApiSchema schema={schema} />
|
||||
<div className={cn('border-b border-x border-default', 'rounded-b-lg')}>
|
||||
<div className="p-5 border-b border-default">
|
||||
<ApiSchema schema={schema} />
|
||||
</div>
|
||||
<ApiOperationRequestBodyDetailsInternal schema={schema} className="px-5" />
|
||||
</div>
|
||||
) : 'type' in schema &&
|
||||
schema.type === 'array' &&
|
||||
'items' in schema &&
|
||||
schema.items &&
|
||||
typeof schema.items === 'object' &&
|
||||
'type' in schema.items &&
|
||||
schema.items.type === 'object' ? (
|
||||
<div className={cn('border-b border-x border-default', 'rounded-b-lg')}>
|
||||
<div className="p-5 border-b border-default">
|
||||
<ApiSchema schema={schema} />
|
||||
</div>
|
||||
<ApiOperationRequestBodyDetailsInternal schema={schema.items} className="px-5" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className={cn('border-b border-x border-default', 'rounded-b-lg')}>
|
||||
|
||||
@@ -119,6 +119,8 @@ export function CodeCopyButton({ className, content }: { className?: string; con
|
||||
className
|
||||
)}
|
||||
aria-label="Copy code"
|
||||
// Tooltip repeats the label; the description would read the name twice
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-lighter" />
|
||||
@@ -135,29 +137,33 @@ export function CodeCopyButton({ className, content }: { className?: string; con
|
||||
|
||||
export function CodeBlockControls({ content }: { content: string }) {
|
||||
const [isWrapped, setIsWrapped] = useState(false)
|
||||
// Empty until the first toggle, so nothing is announced on mount
|
||||
const [wrapStatus, setWrapStatus] = useState('')
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleWrap = useCallback(() => {
|
||||
setIsWrapped((prev) => {
|
||||
const newValue = !prev
|
||||
// Find the parent code block and toggle the wrap data attribute
|
||||
const codeBlock = wrapperRef.current?.closest('.shiki')
|
||||
if (codeBlock) {
|
||||
if (newValue) {
|
||||
codeBlock.setAttribute('data-wrapped', 'true')
|
||||
} else {
|
||||
codeBlock.removeAttribute('data-wrapped')
|
||||
}
|
||||
const newValue = !isWrapped
|
||||
setIsWrapped(newValue)
|
||||
setWrapStatus(newValue ? 'Word wrap enabled' : 'Word wrap disabled')
|
||||
|
||||
const codeBlock = wrapperRef.current?.closest('.shiki')
|
||||
if (codeBlock) {
|
||||
if (newValue) {
|
||||
codeBlock.setAttribute('data-wrapped', 'true')
|
||||
} else {
|
||||
codeBlock.removeAttribute('data-wrapped')
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
}, [isWrapped])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="opacity-0 flex group-hover:opacity-100 focus-within:opacity-100 absolute top-2 right-2 gap-1"
|
||||
className="opacity-0 flex group-hover:opacity-100 group-focus-within:opacity-100 absolute top-2 right-2 gap-1"
|
||||
>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{wrapStatus}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -165,6 +171,8 @@ export function CodeBlockControls({ content }: { content: string }) {
|
||||
onClick={toggleWrap}
|
||||
className={cn('cursor-pointer border rounded-md p-1', 'hover:bg-selection transition')}
|
||||
aria-label={isWrapped ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
// Tooltip repeats the label; the description would read the name twice
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isWrapped ? (
|
||||
<ArrowRightFromLine size={14} className="text-lighter" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createTwoslasher, type ExtraFiles, type NodeHover } from 'twoslash'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { AnnotatedSpan, CodeBlockControls } from './CodeBlock.client'
|
||||
import { getFontStyle } from './CodeBlock.utils'
|
||||
import { getCodeBlockLabel, getFontStyle } from './CodeBlock.utils'
|
||||
import theme from './supabase-2.json' with { type: 'json' }
|
||||
import denoTypes from './types/lib.deno.d.ts.include'
|
||||
|
||||
@@ -46,11 +46,7 @@ export async function CodeBlock({
|
||||
twoslashed = annotationsByLine(hoverNodes)
|
||||
code = editedCode
|
||||
} catch (_err) {
|
||||
// Silently ignore, if imports aren't defined type compilation fails
|
||||
// Uncomment lines below to debug in dev
|
||||
// console.log('\n==========CODE==========\n')
|
||||
// console.log(code)
|
||||
// console.error(_err.recommendation)
|
||||
// Type compilation fails when imports aren't defined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,52 +62,64 @@ export async function CodeBlock({
|
||||
'group',
|
||||
'relative',
|
||||
'not-prose',
|
||||
'w-full overflow-x-auto',
|
||||
'w-full',
|
||||
'border border-default rounded-lg',
|
||||
'bg-200',
|
||||
'text-sm',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-roledescription="code block"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'code-scroll',
|
||||
'w-full overflow-x-auto rounded-lg',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
role="group"
|
||||
aria-roledescription="code block"
|
||||
aria-label={getCodeBlockLabel(lang, tokens.length)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<pre>
|
||||
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
|
||||
{lineNumbers ? (
|
||||
<>
|
||||
{tokens.map((line, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'select-none text-right text-muted bg-control px-2 min-h-5 leading-5',
|
||||
idx === 0 && 'pt-6',
|
||||
idx === tokens.length - 1 && 'pb-6'
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'code-content min-h-5 leading-5 pl-6 pr-6',
|
||||
idx === 0 && 'pt-6',
|
||||
idx === tokens.length - 1 && 'pb-6'
|
||||
)}
|
||||
>
|
||||
<CodeLine tokens={line} twoslash={twoslashed?.get(idx)} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="code-content p-6">
|
||||
{tokens.map((line, idx) => (
|
||||
<CodeLine key={idx} tokens={line} twoslash={twoslashed?.get(idx)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
{/* After the code so the block is named before its controls, and outside the scroller so they stay pinned */}
|
||||
{!hideControls && <CodeBlockControls content={code.trim()} />}
|
||||
<pre>
|
||||
<code className={lineNumbers ? 'grid grid-cols-[auto_1fr]' : ''}>
|
||||
{lineNumbers ? (
|
||||
<>
|
||||
{tokens.map((line, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={cn(
|
||||
'select-none text-right text-muted bg-control px-2 min-h-5 leading-5',
|
||||
idx === 0 && 'pt-6',
|
||||
idx === tokens.length - 1 && 'pb-6'
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'code-content min-h-5 leading-5 pl-6 pr-6',
|
||||
idx === 0 && 'pt-6',
|
||||
idx === tokens.length - 1 && 'pb-6'
|
||||
)}
|
||||
>
|
||||
<CodeLine tokens={line} twoslash={twoslashed?.get(idx)} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="code-content p-6">
|
||||
{tokens.map((line, idx) => (
|
||||
<CodeLine key={idx} tokens={line} twoslash={twoslashed?.get(idx)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
/*
|
||||
* As defined in @shikijs/core/dist/chunk-tokens.d.mts
|
||||
*/
|
||||
// As defined in @shikijs/core/dist/chunk-tokens.d.mts
|
||||
enum FontStyle {
|
||||
NotSet = -1,
|
||||
None = 0,
|
||||
@@ -28,3 +26,26 @@ export function getFontStyle(styleFlags: number): CSSProperties {
|
||||
|
||||
return style
|
||||
}
|
||||
|
||||
// Fence aliases a screen reader would otherwise read letter by letter
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
c: 'C',
|
||||
html: 'HTML',
|
||||
js: 'JavaScript',
|
||||
json: 'JSON',
|
||||
jsx: 'JavaScript',
|
||||
py: 'Python',
|
||||
sh: 'Shell',
|
||||
shell: 'Shell',
|
||||
sql: 'SQL',
|
||||
toml: 'TOML',
|
||||
ts: 'TypeScript',
|
||||
tsx: 'TypeScript',
|
||||
yaml: 'YAML',
|
||||
}
|
||||
|
||||
export function getCodeBlockLabel(lang: string | null, lineCount: number): string {
|
||||
const lines = `${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`
|
||||
if (!lang) return lines
|
||||
return `${LANGUAGE_LABELS[lang] ?? lang}, ${lines}`
|
||||
}
|
||||
@@ -283,6 +283,7 @@ Shane E
|
||||
Shaun Newman
|
||||
Shardul Borhade
|
||||
Shreekar Shetty
|
||||
Simon Tomlinson
|
||||
Sreyas Udayavarman
|
||||
Stephanie Jackson (stejacks)
|
||||
Stephen Morgan
|
||||
|
||||
@@ -468,7 +468,7 @@
|
||||
"summary": "List all projects",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["projects_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
"summary": "Create a project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["organization_projects_create"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -596,7 +596,7 @@
|
||||
"summary": "List all organizations",
|
||||
"tags": ["Organizations"],
|
||||
"x-badges": [{ "name": "OAuth scope: organizations:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organizations_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
},
|
||||
@@ -628,7 +628,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Create an organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api", "billing"],
|
||||
"x-endpoint-owners": ["control-plane", "billing"],
|
||||
"x-fga-permissions": [["organizations_create"]]
|
||||
}
|
||||
},
|
||||
@@ -725,7 +725,7 @@
|
||||
"responses": { "204": { "description": "" } },
|
||||
"summary": "[Beta] Authorize user through oauth",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/token": {
|
||||
@@ -753,7 +753,7 @@
|
||||
},
|
||||
"summary": "[Beta] Exchange auth code for user's access and refresh token",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/revoke": {
|
||||
@@ -771,7 +771,7 @@
|
||||
"responses": { "204": { "description": "" } },
|
||||
"summary": "[Beta] Revoke oauth app authorization and it's corresponding tokens",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/authorize/project-claim": {
|
||||
@@ -853,7 +853,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Authorize user through oauth and claim a project",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]]
|
||||
}
|
||||
},
|
||||
@@ -910,7 +910,7 @@
|
||||
"summary": "Lists SQL snippets for the logged in user",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["snippets_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -947,7 +947,7 @@
|
||||
"summary": "Gets a specific SQL snippet",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["snippets_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -967,7 +967,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Gets the user's profile",
|
||||
"tags": ["Profile"],
|
||||
"x-endpoint-owners": ["management-api"]
|
||||
"x-endpoint-owners": ["control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/projects/{ref}/actions": {
|
||||
@@ -1256,7 +1256,7 @@
|
||||
"summary": "Get project api keys",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -1305,7 +1305,7 @@
|
||||
"summary": "Creates a new API key for the project",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -1345,7 +1345,7 @@
|
||||
"summary": "Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found.",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -1390,7 +1390,7 @@
|
||||
"summary": "Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found.",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -1452,7 +1452,7 @@
|
||||
"summary": "Updates an API key for the project",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
},
|
||||
@@ -1506,7 +1506,7 @@
|
||||
"summary": "Get API key",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -1573,7 +1573,7 @@
|
||||
"summary": "Deletes an API key for the project",
|
||||
"tags": ["Secrets"],
|
||||
"x-badges": [{ "name": "OAuth scope: secrets:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -1773,7 +1773,7 @@
|
||||
"summary": "[Beta] Gets project's custom hostname config",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_read"]],
|
||||
"x-oauth-scope": "domains:read"
|
||||
},
|
||||
@@ -1812,7 +1812,7 @@
|
||||
"summary": "[Beta] Deletes a project's custom hostname configuration",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -1861,7 +1861,7 @@
|
||||
"summary": "[Beta] Updates project's custom hostname configuration",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -1902,7 +1902,7 @@
|
||||
"summary": "[Beta] Attempts to verify the DNS configuration for project's custom hostname configuration",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -1943,7 +1943,7 @@
|
||||
"summary": "[Beta] Activates a custom hostname for a project.",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -2013,7 +2013,7 @@
|
||||
"summary": "[Beta] Get project's temporary access configuration.",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["security", "management-api"],
|
||||
"x-endpoint-owners": ["security", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -2089,7 +2089,7 @@
|
||||
"summary": "[Beta] Update project's temporary access configuration.",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["security", "management-api"],
|
||||
"x-endpoint-owners": ["security", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -2130,7 +2130,7 @@
|
||||
"summary": "[Beta] Gets project's network bans",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -2171,7 +2171,7 @@
|
||||
"summary": "[Beta] Gets project's network bans with additional information about which databases they affect",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -2213,7 +2213,7 @@
|
||||
"summary": "[Beta] Remove network bans.",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -2254,7 +2254,7 @@
|
||||
"summary": "[Beta] Gets project's network restrictions",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -2301,7 +2301,7 @@
|
||||
"summary": "[Alpha] Updates project's network restrictions by adding or removing CIDRs",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -2350,7 +2350,7 @@
|
||||
"summary": "[Beta] Updates project's network restrictions",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -2479,7 +2479,7 @@
|
||||
"summary": "Gets project's postgrest config",
|
||||
"tags": ["Rest"],
|
||||
"x-badges": [{ "name": "OAuth scope: rest:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["data_api_config_read"]],
|
||||
"x-oauth-scope": "rest:read"
|
||||
},
|
||||
@@ -2526,7 +2526,7 @@
|
||||
"summary": "Updates project's postgrest config",
|
||||
"tags": ["Rest"],
|
||||
"x-badges": [{ "name": "OAuth scope: rest:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["data_api_config_write"]],
|
||||
"x-oauth-scope": "rest:write"
|
||||
}
|
||||
@@ -2567,7 +2567,7 @@
|
||||
"summary": "Gets a specific project that belongs to the authenticated user",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -2605,7 +2605,7 @@
|
||||
"summary": "Deletes the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra", "dev-workflows"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "dev-workflows"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
},
|
||||
@@ -2650,7 +2650,7 @@
|
||||
"summary": "Updates the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -2814,7 +2814,7 @@
|
||||
"summary": "[Beta] Get project's SSL enforcement configuration.",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_ssl_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -2861,7 +2861,7 @@
|
||||
"summary": "[Beta] Update project's SSL enforcement configuration.",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_ssl_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -2960,7 +2960,7 @@
|
||||
{ "name": "OAuth scope: domains:read", "position": "after" },
|
||||
{ "name": "Only available on Pro, Team, Enterprise", "position": "before" }
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_read"]],
|
||||
"x-oauth-scope": "domains:read"
|
||||
},
|
||||
@@ -2992,7 +2992,7 @@
|
||||
"summary": "[Beta] Deletes a project's vanity subdomain configuration",
|
||||
"tags": ["Domains"],
|
||||
"x-badges": [{ "name": "OAuth scope: domains:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -3049,7 +3049,7 @@
|
||||
{ "name": "OAuth scope: domains:write", "position": "after" },
|
||||
{ "name": "Only available on Pro, Team, Enterprise", "position": "before" }
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -3106,7 +3106,7 @@
|
||||
{ "name": "OAuth scope: domains:write", "position": "after" },
|
||||
{ "name": "Only available on Pro, Team, Enterprise", "position": "before" }
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -3153,7 +3153,7 @@
|
||||
"summary": "[Beta] Upgrades the project's Postgres version",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write", "database_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -3194,7 +3194,7 @@
|
||||
"summary": "[Beta] Returns the project's eligibility for upgrades",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read", "database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -3241,7 +3241,7 @@
|
||||
"summary": "[Beta] Gets the latest status of the project's upgrade",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read", "database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -3282,7 +3282,7 @@
|
||||
"summary": "Returns project's readonly mode status",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra", "support-tooling"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "support-tooling"],
|
||||
"x-fga-permissions": [["database_readonly_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -3316,7 +3316,7 @@
|
||||
"summary": "Disables project's readonly mode for the next 15 minutes",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra", "support-tooling"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "support-tooling"],
|
||||
"x-fga-permissions": [["database_readonly_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -3365,7 +3365,7 @@
|
||||
"tags": ["Database"],
|
||||
"x-allowed-plans": ["Pro", "Team", "Enterprise"],
|
||||
"x-badges": [{ "name": "Only available on Pro, Team, Enterprise", "position": "before" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_read_replicas_write"]]
|
||||
}
|
||||
},
|
||||
@@ -3405,7 +3405,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "[Beta] Remove a read replica",
|
||||
"tags": ["Database"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_read_replicas_write"]]
|
||||
}
|
||||
},
|
||||
@@ -3488,7 +3488,7 @@
|
||||
"summary": "Gets project's service health status",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -4111,7 +4111,7 @@
|
||||
"summary": "Pauses the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4144,7 +4144,7 @@
|
||||
"summary": "Restarts the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4186,7 +4186,7 @@
|
||||
"summary": "Lists available restore versions for the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -4217,7 +4217,7 @@
|
||||
"summary": "Restores the given project",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4250,7 +4250,7 @@
|
||||
"summary": "Cancels the given project restoration",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4437,7 +4437,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Gets project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -4474,7 +4474,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Creates project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -4504,7 +4504,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Revokes project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]],
|
||||
"x-internal": true
|
||||
}
|
||||
@@ -4546,7 +4546,7 @@
|
||||
"summary": "Gets project performance advisors.",
|
||||
"tags": ["Advisors"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["advisors_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -4594,7 +4594,7 @@
|
||||
"summary": "Gets project security advisors.",
|
||||
"tags": ["Advisors"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["advisors_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -4602,7 +4602,7 @@
|
||||
"/v1/projects/{ref}/analytics/endpoints/logs.all": {
|
||||
"get": {
|
||||
"deprecated": true,
|
||||
"description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources.\n",
|
||||
"description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#logs-explorer) for all available sources.\n",
|
||||
"operationId": "v1-get-project-logs-all",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4622,7 +4622,7 @@
|
||||
"name": "sql",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details.",
|
||||
"schema": {
|
||||
"example": "select event_message from edge_logs limit 10",
|
||||
"type": "string"
|
||||
@@ -4695,7 +4695,7 @@
|
||||
"name": "sql",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details.",
|
||||
"schema": {
|
||||
"example": "select event_message from edge_logs limit 10",
|
||||
"type": "string"
|
||||
@@ -4913,6 +4913,9 @@
|
||||
"application/openmetrics-text": { "schema": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Project must be active and healthy, or metrics are not available for this project"
|
||||
},
|
||||
"401": { "description": "Unauthorized" },
|
||||
"403": { "description": "Forbidden action" },
|
||||
"429": { "description": "Rate limit exceeded" },
|
||||
@@ -5315,7 +5318,7 @@
|
||||
"summary": "[Beta] Run sql query",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"], ["database_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -5356,7 +5359,7 @@
|
||||
"summary": "[Beta] Run a sql query as supabase_read_only_user",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -5390,7 +5393,7 @@
|
||||
"summary": "[Beta] Enables Database Webhooks on the project",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_webhooks_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -5432,7 +5435,7 @@
|
||||
"summary": "Gets database metadata for the given project.",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -5481,7 +5484,7 @@
|
||||
"summary": "Updates the database password",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -5874,7 +5877,7 @@
|
||||
"summary": "Get PostgREST OpenAPI spec",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -6450,7 +6453,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Get database disk attributes",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
},
|
||||
"post": {
|
||||
@@ -6486,7 +6489,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Modify database disk",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_write"]]
|
||||
}
|
||||
},
|
||||
@@ -6525,7 +6528,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Get disk utilization",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
}
|
||||
},
|
||||
@@ -6564,7 +6567,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Gets project disk autoscale config",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
}
|
||||
},
|
||||
@@ -6680,7 +6683,7 @@
|
||||
"summary": "Get project's pgbouncer config",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -6812,7 +6815,7 @@
|
||||
"summary": "Gets project's Postgres config",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -6859,7 +6862,7 @@
|
||||
"summary": "Updates project's Postgres config",
|
||||
"tags": ["Database"],
|
||||
"x-badges": [{ "name": "OAuth scope: database:write", "position": "after" }],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -7658,7 +7661,7 @@
|
||||
"summary": "List members of an organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-badges": [{ "name": "OAuth scope: organizations:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["members_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
}
|
||||
@@ -7696,7 +7699,7 @@
|
||||
"summary": "Gets information about the organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-badges": [{ "name": "OAuth scope: organizations:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
}
|
||||
@@ -7739,7 +7742,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Gets project details for the specified organization and claim token",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -7773,7 +7776,7 @@
|
||||
"security": [{ "bearer": [] }],
|
||||
"summary": "Claims project for the specified organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write"]],
|
||||
"x-internal": true
|
||||
}
|
||||
@@ -7865,7 +7868,7 @@
|
||||
"summary": "Gets all projects for the given organization",
|
||||
"tags": ["Projects"],
|
||||
"x-badges": [{ "name": "OAuth scope: projects:read", "position": "after" }],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_projects_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -13106,6 +13109,13 @@
|
||||
"description": "Sets connection pool size for Realtime Authorization",
|
||||
"nullable": true
|
||||
},
|
||||
"postgres_changes_pool": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size used to create Postgres Changes subscriptions",
|
||||
"nullable": true
|
||||
},
|
||||
"max_concurrent_users": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -13165,6 +13175,7 @@
|
||||
"required": [
|
||||
"private_only",
|
||||
"connection_pool",
|
||||
"postgres_changes_pool",
|
||||
"max_concurrent_users",
|
||||
"max_events_per_second",
|
||||
"max_bytes_per_second",
|
||||
@@ -13189,6 +13200,12 @@
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size for Realtime Authorization"
|
||||
},
|
||||
"postgres_changes_pool": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size used to create Postgres Changes subscriptions"
|
||||
},
|
||||
"max_concurrent_users": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -13870,6 +13887,7 @@
|
||||
"project_restore_after_expiry",
|
||||
"assistant.advance_model",
|
||||
"integrations.github_connections",
|
||||
"integrations.github_push_webhooks_limit",
|
||||
"dedicated_pooler",
|
||||
"observability.dashboard_advanced_metrics",
|
||||
"api.members.invitations",
|
||||
|
||||
+1511
-134
File diff suppressed because it is too large.
Load diff
@@ -732,6 +732,72 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Organization webhooks",
|
||||
"items": [
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-deliveries-id-get",
|
||||
"title": "Organizations slug webhooks deliveries id get",
|
||||
"slug": "v2-organizations-slug-webhooks-deliveries-id-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-deliveries-id-retry-post",
|
||||
"title": "Organizations slug webhooks deliveries id retry post",
|
||||
"slug": "v2-organizations-slug-webhooks-deliveries-id-retry-post",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-delete",
|
||||
"title": "Organizations slug webhooks endpoints delete",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-delete",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-get",
|
||||
"title": "Organizations slug webhooks endpoints get",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-id-delete",
|
||||
"title": "Organizations slug webhooks endpoints id delete",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-id-delete",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-id-deliveries-get",
|
||||
"title": "Organizations slug webhooks endpoints id deliveries get",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-id-deliveries-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-id-get",
|
||||
"title": "Organizations slug webhooks endpoints id get",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-id-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-id-patch",
|
||||
"title": "Organizations slug webhooks endpoints id patch",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-id-patch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-id-test-post",
|
||||
"title": "Organizations slug webhooks endpoints id test post",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-id-test-post",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-organizations-slug-webhooks-endpoints-post",
|
||||
"title": "Organizations slug webhooks endpoints post",
|
||||
"slug": "v2-organizations-slug-webhooks-endpoints-post",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Organizations",
|
||||
@@ -828,6 +894,72 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Project webhooks",
|
||||
"items": [
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-deliveries-id-get",
|
||||
"title": "Projects ref webhooks deliveries id get",
|
||||
"slug": "v2-projects-ref-webhooks-deliveries-id-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-deliveries-id-retry-post",
|
||||
"title": "Projects ref webhooks deliveries id retry post",
|
||||
"slug": "v2-projects-ref-webhooks-deliveries-id-retry-post",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-delete",
|
||||
"title": "Projects ref webhooks endpoints delete",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-delete",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-get",
|
||||
"title": "Projects ref webhooks endpoints get",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-id-delete",
|
||||
"title": "Projects ref webhooks endpoints id delete",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-id-delete",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-id-deliveries-get",
|
||||
"title": "Projects ref webhooks endpoints id deliveries get",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-id-deliveries-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-id-get",
|
||||
"title": "Projects ref webhooks endpoints id get",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-id-get",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-id-patch",
|
||||
"title": "Projects ref webhooks endpoints id patch",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-id-patch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-id-test-post",
|
||||
"title": "Projects ref webhooks endpoints id test post",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-id-test-post",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-projects-ref-webhooks-endpoints-post",
|
||||
"title": "Projects ref webhooks endpoints post",
|
||||
"slug": "v2-projects-ref-webhooks-endpoints-post",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Projects",
|
||||
@@ -922,6 +1054,12 @@
|
||||
"slug": "v1-get-project",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-get-project-config",
|
||||
"title": "Get project config",
|
||||
"slug": "v2-get-project-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-disk-autoscale-config",
|
||||
"title": "Get project disk autoscale config",
|
||||
@@ -1169,5 +1307,41 @@
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Workers",
|
||||
"items": [
|
||||
{
|
||||
"id": "v2-create-worker-upload",
|
||||
"title": "Create worker upload",
|
||||
"slug": "v2-create-worker-upload",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-delete-a-worker",
|
||||
"title": "Delete a worker",
|
||||
"slug": "v2-delete-a-worker",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-deploy-a-worker",
|
||||
"title": "Deploy a worker",
|
||||
"slug": "v2-deploy-a-worker",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-get-a-worker",
|
||||
"title": "Get a worker",
|
||||
"slug": "v2-get-a-worker",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v2-list-all-workers",
|
||||
"title": "List all workers",
|
||||
"slug": "v2-list-all-workers",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -685,7 +685,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["projects_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -736,7 +736,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["organization_projects_create"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -871,7 +871,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organizations_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
},
|
||||
@@ -919,7 +919,7 @@
|
||||
],
|
||||
"summary": "Create an organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api", "billing"],
|
||||
"x-endpoint-owners": ["control-plane", "billing"],
|
||||
"x-fga-permissions": [["organizations_create"]]
|
||||
}
|
||||
},
|
||||
@@ -1041,7 +1041,7 @@
|
||||
},
|
||||
"summary": "[Beta] Authorize user through oauth",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/token": {
|
||||
@@ -1073,7 +1073,7 @@
|
||||
},
|
||||
"summary": "[Beta] Exchange auth code for user's access and refresh token",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/revoke": {
|
||||
@@ -1097,7 +1097,7 @@
|
||||
},
|
||||
"summary": "[Beta] Revoke oauth app authorization and it's corresponding tokens",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["auth", "management-api"]
|
||||
"x-endpoint-owners": ["auth", "control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/oauth/authorize/project-claim": {
|
||||
@@ -1207,7 +1207,7 @@
|
||||
],
|
||||
"summary": "Authorize user through oauth and claim a project",
|
||||
"tags": ["OAuth"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]]
|
||||
}
|
||||
},
|
||||
@@ -1302,7 +1302,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["snippets_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -1360,7 +1360,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["snippets_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -1388,7 +1388,7 @@
|
||||
],
|
||||
"summary": "Gets the user's profile",
|
||||
"tags": ["Profile"],
|
||||
"x-endpoint-owners": ["management-api"]
|
||||
"x-endpoint-owners": ["control-plane"]
|
||||
}
|
||||
},
|
||||
"/v1/projects/{ref}/actions": {
|
||||
@@ -1821,7 +1821,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -1896,7 +1896,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -1953,7 +1953,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -2018,7 +2018,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -2106,7 +2106,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
},
|
||||
@@ -2182,7 +2182,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_read"]],
|
||||
"x-oauth-scope": "secrets:read"
|
||||
},
|
||||
@@ -2277,7 +2277,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["auth", "management-api"],
|
||||
"x-endpoint-owners": ["auth", "control-plane"],
|
||||
"x-fga-permissions": [["api_gateway_keys_write"]],
|
||||
"x-oauth-scope": "secrets:write"
|
||||
}
|
||||
@@ -2565,7 +2565,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_read"]],
|
||||
"x-oauth-scope": "domains:read"
|
||||
},
|
||||
@@ -2625,7 +2625,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -2695,7 +2695,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -2755,7 +2755,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -2815,7 +2815,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["custom_domain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -2910,7 +2910,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["security", "management-api"],
|
||||
"x-endpoint-owners": ["security", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -3013,7 +3013,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["security", "management-api"],
|
||||
"x-endpoint-owners": ["security", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -3073,7 +3073,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -3133,7 +3133,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -3196,7 +3196,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_network_bans_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -3324,7 +3324,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -3394,7 +3394,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_network_restrictions_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -3582,7 +3582,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["data_api_config_read"]],
|
||||
"x-oauth-scope": "rest:read"
|
||||
},
|
||||
@@ -3650,7 +3650,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["data_api_config_write"]],
|
||||
"x-oauth-scope": "rest:write"
|
||||
}
|
||||
@@ -3710,7 +3710,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -3765,7 +3765,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra", "dev-workflows"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "dev-workflows"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
},
|
||||
@@ -3833,7 +3833,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4081,7 +4081,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_ssl_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -4149,7 +4149,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_ssl_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -4295,7 +4295,7 @@
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_read"]],
|
||||
"x-oauth-scope": "domains:read"
|
||||
},
|
||||
@@ -4346,7 +4346,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -4431,7 +4431,7 @@
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -4516,7 +4516,7 @@
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["vanity_subdomain_write"]],
|
||||
"x-oauth-scope": "domains:write"
|
||||
}
|
||||
@@ -4586,7 +4586,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write", "database_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -4646,7 +4646,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read", "database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -4715,7 +4715,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read", "database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -4775,7 +4775,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra", "support-tooling"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "support-tooling"],
|
||||
"x-fga-permissions": [["database_readonly_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -4828,7 +4828,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra", "support-tooling"],
|
||||
"x-endpoint-owners": ["control-plane", "infra", "support-tooling"],
|
||||
"x-fga-permissions": [["database_readonly_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -4902,7 +4902,7 @@
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_read_replicas_write"]]
|
||||
}
|
||||
},
|
||||
@@ -4958,7 +4958,7 @@
|
||||
],
|
||||
"summary": "[Beta] Remove a read replica",
|
||||
"tags": ["Database"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_read_replicas_write"]]
|
||||
}
|
||||
},
|
||||
@@ -5065,7 +5065,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -5939,7 +5939,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -5989,7 +5989,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -6046,7 +6046,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
},
|
||||
@@ -6094,7 +6094,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -6144,7 +6144,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["project_admin_write"]],
|
||||
"x-oauth-scope": "projects:write"
|
||||
}
|
||||
@@ -6396,7 +6396,7 @@
|
||||
],
|
||||
"summary": "Gets project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["project_admin_read"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -6445,7 +6445,7 @@
|
||||
],
|
||||
"summary": "Creates project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -6487,7 +6487,7 @@
|
||||
],
|
||||
"summary": "Revokes project claim token",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write", "project_admin_write"]],
|
||||
"x-internal": true
|
||||
}
|
||||
@@ -6546,7 +6546,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["advisors_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -6615,7 +6615,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["advisors_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -6623,7 +6623,7 @@
|
||||
"/v1/projects/{ref}/analytics/endpoints/logs.all": {
|
||||
"get": {
|
||||
"deprecated": true,
|
||||
"description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources.\n",
|
||||
"description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#logs-explorer) for all available sources.\n",
|
||||
"operationId": "v1-get-project-logs-all",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -6643,7 +6643,7 @@
|
||||
"name": "sql",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details.",
|
||||
"schema": {
|
||||
"example": "select event_message from edge_logs limit 10",
|
||||
"type": "string"
|
||||
@@ -6737,7 +6737,7 @@
|
||||
"name": "sql",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.",
|
||||
"description": "Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details.",
|
||||
"schema": {
|
||||
"example": "select event_message from edge_logs limit 10",
|
||||
"type": "string"
|
||||
@@ -7031,6 +7031,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Project must be active and healthy, or metrics are not available for this project"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
@@ -7651,7 +7654,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"], ["database_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -7715,7 +7718,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -7768,7 +7771,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_webhooks_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -7827,7 +7830,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -7897,7 +7900,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["database_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -8448,7 +8451,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -9294,7 +9297,7 @@
|
||||
],
|
||||
"summary": "Get database disk attributes",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
},
|
||||
"post": {
|
||||
@@ -9348,7 +9351,7 @@
|
||||
],
|
||||
"summary": "Modify database disk",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_write"]]
|
||||
}
|
||||
},
|
||||
@@ -9401,7 +9404,7 @@
|
||||
],
|
||||
"summary": "Get disk utilization",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
}
|
||||
},
|
||||
@@ -9454,7 +9457,7 @@
|
||||
],
|
||||
"summary": "Gets project disk autoscale config",
|
||||
"tags": ["Projects"],
|
||||
"x-endpoint-owners": ["management-api", "infra"],
|
||||
"x-endpoint-owners": ["control-plane", "infra"],
|
||||
"x-fga-permissions": [["infra_disk_config_read"]]
|
||||
}
|
||||
},
|
||||
@@ -9615,7 +9618,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
}
|
||||
@@ -9806,7 +9809,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_config_read"]],
|
||||
"x-oauth-scope": "database:read"
|
||||
},
|
||||
@@ -9874,7 +9877,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["infra", "management-api"],
|
||||
"x-endpoint-owners": ["infra", "control-plane"],
|
||||
"x-fga-permissions": [["database_config_write"]],
|
||||
"x-oauth-scope": "database:write"
|
||||
}
|
||||
@@ -11019,7 +11022,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["members_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
}
|
||||
@@ -11074,7 +11077,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_read"]],
|
||||
"x-oauth-scope": "organizations:read"
|
||||
}
|
||||
@@ -11132,7 +11135,7 @@
|
||||
],
|
||||
"summary": "Gets project details for the specified organization and claim token",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write"]],
|
||||
"x-internal": true
|
||||
},
|
||||
@@ -11181,7 +11184,7 @@
|
||||
],
|
||||
"summary": "Claims project for the specified organization",
|
||||
"tags": ["Organizations"],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_admin_write"]],
|
||||
"x-internal": true
|
||||
}
|
||||
@@ -11298,7 +11301,7 @@
|
||||
"position": "after"
|
||||
}
|
||||
],
|
||||
"x-endpoint-owners": ["management-api"],
|
||||
"x-endpoint-owners": ["control-plane"],
|
||||
"x-fga-permissions": [["organization_projects_read"]],
|
||||
"x-oauth-scope": "projects:read"
|
||||
}
|
||||
@@ -19487,6 +19490,13 @@
|
||||
"description": "Sets connection pool size for Realtime Authorization",
|
||||
"nullable": true
|
||||
},
|
||||
"postgres_changes_pool": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size used to create Postgres Changes subscriptions",
|
||||
"nullable": true
|
||||
},
|
||||
"max_concurrent_users": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -19549,6 +19559,7 @@
|
||||
"required": [
|
||||
"private_only",
|
||||
"connection_pool",
|
||||
"postgres_changes_pool",
|
||||
"max_concurrent_users",
|
||||
"max_events_per_second",
|
||||
"max_bytes_per_second",
|
||||
@@ -19573,6 +19584,12 @@
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size for Realtime Authorization"
|
||||
},
|
||||
"postgres_changes_pool": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Sets connection pool size used to create Postgres Changes subscriptions"
|
||||
},
|
||||
"max_concurrent_users": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
@@ -20536,6 +20553,7 @@
|
||||
"project_restore_after_expiry",
|
||||
"assistant.advance_model",
|
||||
"integrations.github_connections",
|
||||
"integrations.github_push_webhooks_limit",
|
||||
"dedicated_pooler",
|
||||
"observability.dashboard_advanced_metrics",
|
||||
"api.members.invitations",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -452,7 +452,7 @@ th code {
|
||||
}
|
||||
|
||||
/* Word wrap styles for code blocks */
|
||||
.shiki[data-wrapped='true'] {
|
||||
.shiki[data-wrapped='true'] .code-scroll {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
### Project shell — `/workers/*`
|
||||
|
||||
- [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx`
|
||||
- [x] A `routes/project/$ref/workers/$name.tsx` ← `pages/project/[ref]/workers/[name].tsx`
|
||||
|
||||
### Project shell — `/functions/*`
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { constants, permissions } from '@supabase/shared-types'
|
||||
|
||||
import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const { OAuthScope } = constants
|
||||
|
||||
type OAuthScopeValue = (typeof OAuthScope)[keyof typeof OAuthScope]
|
||||
|
||||
// Manually extracted from platform mcp controller code. Each tool maps to alternative OAuth-scope
|
||||
// groups with the same semantics as ScopeGroupAlternatives: a token can call the tool when it
|
||||
// holds ALL scopes of at least ONE group (OR between groups, AND within a group). Most tools
|
||||
// assert a single scope; execute_sql asserts database:read OR database:write depending on the
|
||||
// session's read_only mode (mcp.controller.ts), so it carries two alternatives.
|
||||
const MCPToolOAuthScopeMapping: Record<string, OAuthScopeValue[][]> = {
|
||||
apply_migration: [[OAuthScope.DATABASE_WRITE]],
|
||||
// Computes a local confirmation hash without calling the platform — no scope gates it.
|
||||
confirm_cost: [[]],
|
||||
create_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
create_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
delete_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
deploy_edge_function: [[OAuthScope.EDGE_FUNCTIONS_WRITE]],
|
||||
execute_sql: [[OAuthScope.DATABASE_READ], [OAuthScope.DATABASE_WRITE]],
|
||||
generate_typescript_types: [[OAuthScope.DATABASE_READ]],
|
||||
get_advisors: [[OAuthScope.DATABASE_READ]],
|
||||
// Calls getOrganization + listProjects to price a project, so it needs both read scopes.
|
||||
// (The type=branch path returns a constant with no platform call; gating on the project
|
||||
// path's scopes fails closed for branch-only pricing, which is fine for advisory display.)
|
||||
get_cost: [[OAuthScope.ORGANIZATIONS_READ, OAuthScope.PROJECTS_READ]],
|
||||
get_edge_function: [[OAuthScope.EDGE_FUNCTIONS_READ]],
|
||||
get_logs: [[OAuthScope.ANALYTICS_READ]],
|
||||
get_organization: [[OAuthScope.ORGANIZATIONS_READ]],
|
||||
get_project: [[OAuthScope.PROJECTS_READ]],
|
||||
get_project_url: [[OAuthScope.PROJECTS_READ]],
|
||||
get_publishable_keys: [[OAuthScope.SECRETS_READ]],
|
||||
get_storage_config: [[OAuthScope.STORAGE_READ]],
|
||||
list_branches: [[OAuthScope.ENVIRONMENT_READ]],
|
||||
list_edge_functions: [[OAuthScope.EDGE_FUNCTIONS_READ]],
|
||||
// Runs through executeSql with read_only forced true.
|
||||
list_extensions: [[OAuthScope.DATABASE_READ]],
|
||||
list_migrations: [[OAuthScope.DATABASE_READ]],
|
||||
list_organizations: [[OAuthScope.ORGANIZATIONS_READ]],
|
||||
list_projects: [[OAuthScope.PROJECTS_READ]],
|
||||
list_storage_buckets: [[OAuthScope.STORAGE_READ]],
|
||||
// Runs through executeSql with read_only forced true.
|
||||
list_tables: [[OAuthScope.DATABASE_READ]],
|
||||
merge_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
pause_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
rebase_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
reset_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
|
||||
restore_project: [[OAuthScope.PROJECTS_WRITE]],
|
||||
// Queries the public content API — no scope gates it.
|
||||
search_docs: [[]],
|
||||
update_storage_config: [[OAuthScope.STORAGE_WRITE]],
|
||||
}
|
||||
|
||||
type ExtractIds<T> = {
|
||||
[K in keyof T]: {
|
||||
[P in keyof T[K]]: T[K][P] extends { id: infer I } ? I : never
|
||||
}
|
||||
}
|
||||
const FGA_PERMISSIONS = Object.fromEntries(
|
||||
Object.entries(permissions.FgaPermissions).map(([group, permissions]) => [
|
||||
group,
|
||||
Object.fromEntries(Object.entries(permissions).map(([key, { id }]) => [key, id])),
|
||||
])
|
||||
) as ExtractIds<typeof permissions.FgaPermissions>
|
||||
|
||||
// Duplicated from platform (packages/api-core/src/lib/permissions/fga-permissions.ts)
|
||||
// Ideally, this could be exported from @supabase/shared-types
|
||||
export const legacyOauthScopeToFgaPermissionMap: Record<string, string[]> = {
|
||||
'analytics:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_LOGS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_USAGE_READ,
|
||||
],
|
||||
'analytics:write': [],
|
||||
'analytics_config:read': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_READ],
|
||||
'analytics_config:write': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_WRITE],
|
||||
'auth:read': [FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_READ],
|
||||
// Note(Hieu) Auth:write scope grants access to all auth config endpoints.
|
||||
// However, one endpoint requires minimum administrator role, so this oauth scope must also include the FGA PROJECT.ADMIN_WRITE permission
|
||||
'auth:write': [FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_WRITE],
|
||||
'database:read': [
|
||||
FGA_PERMISSIONS.USER.SNIPPETS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADVISORS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_JIT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.SNIPPETS_READ,
|
||||
],
|
||||
'database:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_WRITE,
|
||||
// Note(Hieu): Include database read permission here to align with the project query endpoint.
|
||||
// RLS and FGA guard this endpoint with database read first, then perform an additional check for write queries.
|
||||
// The OAuth guard requires database write directly, which causes a discrepancy error if we don't include read here.
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WEBHOOKS_CONFIG_WRITE,
|
||||
],
|
||||
'domains:read': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_READ,
|
||||
],
|
||||
'domains:write': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_WRITE,
|
||||
],
|
||||
'edge_functions:read': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_READ],
|
||||
'edge_functions:write': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_WRITE],
|
||||
'environment:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_READ,
|
||||
],
|
||||
'environment:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_WRITE,
|
||||
],
|
||||
'organizations:read': [
|
||||
FGA_PERMISSIONS.USER.ORGANIZATIONS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.MEMBERS_READ,
|
||||
],
|
||||
'organizations:write': [],
|
||||
'projects:read': [
|
||||
FGA_PERMISSIONS.USER.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_READ,
|
||||
],
|
||||
'projects:write': [
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_WRITE,
|
||||
],
|
||||
'rest:read': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_READ],
|
||||
'rest:write': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_WRITE],
|
||||
'secrets:read': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_READ,
|
||||
],
|
||||
'secrets:write': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_WRITE,
|
||||
],
|
||||
'storage:read': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_READ,
|
||||
],
|
||||
'storage:write': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_WRITE,
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA
|
||||
* permissions it implies:
|
||||
* {
|
||||
* execute_sql: [["snippets_read", "database_read", ...], ["project_admin_write", ...]]
|
||||
* }
|
||||
* Groups are expanded independently, preserving the OR-of-AND structure. A group is an AND, so it
|
||||
* is kept only when every one of its scopes maps to at least one FGA permission — a partial
|
||||
* expansion would weaken the requirement (e.g. [ORGANIZATIONS_READ, PROJECTS_READ] shrinking to
|
||||
* projects_read alone). A group with any unmapped scope is dropped whole, so the tool stays gated
|
||||
* rather than becoming ungated; an explicitly empty group ([]) is the deliberate ungated marker
|
||||
* and is vacuously kept.
|
||||
* The code is duplicated from platform until we find a better way to share those mappings
|
||||
*/
|
||||
export const expandOAuthScopeGroups = (
|
||||
oAuthScopeGroups: string[][],
|
||||
fgaPermissionMap: Record<string, string[]>
|
||||
): string[][] =>
|
||||
oAuthScopeGroups
|
||||
.filter((group) => group.every((oAuthScope) => (fgaPermissionMap[oAuthScope] ?? []).length > 0))
|
||||
.map((group) => group.flatMap((oAuthScope) => fgaPermissionMap[oAuthScope] ?? []))
|
||||
|
||||
export const MCPToolScopeMappings = Object.entries(MCPToolOAuthScopeMapping).reduce(
|
||||
(acc, [mcpTool, oAuthScopeGroups]) => {
|
||||
acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap)
|
||||
return acc
|
||||
},
|
||||
{} as McpMap
|
||||
)
|
||||
+36
-110
@@ -6,7 +6,6 @@ import {
|
||||
buildAPIPermissionScopeMap,
|
||||
getScopesAndEndpointsForAPI,
|
||||
} from './buildAPIPermissionScopeMap'
|
||||
import { expandOAuthScopeGroups, MCPToolScopeMappings } from './MCPToolScopeMappings'
|
||||
import { type ScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
import { mswServer } from '@/tests/lib/msw'
|
||||
|
||||
@@ -135,115 +134,24 @@ describe('addMCPToolsToScopes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCPToolScopeMappings', () => {
|
||||
// Platform gates execute_sql on database:read OR database:write depending on the MCP session's
|
||||
// read_only mode (mcp.controller.ts), so the derived requirement must be two alternatives — a
|
||||
// single conjunctive group would hide the tool from read-only tokens the platform accepts.
|
||||
test('execute_sql derives the database:read bundle OR the database:write bundle', () => {
|
||||
expect(MCPToolScopeMappings.execute_sql).toHaveLength(2)
|
||||
const [readGroup, writeGroup] = MCPToolScopeMappings.execute_sql
|
||||
expect(readGroup).toContain('database_read')
|
||||
expect(readGroup).not.toContain('database_write')
|
||||
expect(writeGroup).toContain('database_write')
|
||||
})
|
||||
|
||||
test('single-scope tools derive a single conjunctive group', () => {
|
||||
expect(MCPToolScopeMappings.apply_migration).toHaveLength(1)
|
||||
expect(MCPToolScopeMappings.apply_migration[0]).toContain('database_write')
|
||||
})
|
||||
|
||||
test('tools without a platform scope gate stay ungated ([[]]), not disabled ([])', () => {
|
||||
expect(MCPToolScopeMappings.confirm_cost).toEqual([[]])
|
||||
expect(MCPToolScopeMappings.search_docs).toEqual([[]])
|
||||
})
|
||||
|
||||
// A group is an AND: expanding only its mapped scopes would weaken the requirement (e.g.
|
||||
// [organizations:read, projects:read] shrinking to projects_read alone) and report the tool
|
||||
// enabled for an incomplete grant.
|
||||
test('a group with any unmapped scope is dropped whole, not partially expanded', () => {
|
||||
const map = { 'projects:read': ['projects_read'] }
|
||||
|
||||
expect(expandOAuthScopeGroups([['organizations:read', 'projects:read']], map)).toEqual([])
|
||||
// Other alternatives and the ungated marker survive the drop untouched.
|
||||
expect(expandOAuthScopeGroups([['organizations:read'], ['projects:read'], []], map)).toEqual([
|
||||
['projects_read'],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
// Guards the OAuth-scope -> legacy-map join: a scope key drifting out of the legacy map must
|
||||
// not inject undefined into the payload (flatMap doesn't flatten it) or silently disable a
|
||||
// gated tool by dropping all its groups.
|
||||
test('every derived group is non-empty strings, and only the ungated tools lack scopes', () => {
|
||||
const ungated = ['confirm_cost', 'search_docs']
|
||||
for (const [tool, groups] of Object.entries(MCPToolScopeMappings)) {
|
||||
expect(groups.length, `${tool} lost all its alternatives`).toBeGreaterThan(0)
|
||||
for (const group of groups) {
|
||||
if (!ungated.includes(tool))
|
||||
expect(group.length, `${tool} has an empty group`).toBeGreaterThan(0)
|
||||
for (const scope of group)
|
||||
expect(typeof scope, `${tool} leaked a non-string scope`).toBe('string')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('get_cost requires both organization and project read bundles together', () => {
|
||||
expect(MCPToolScopeMappings.get_cost).toHaveLength(1)
|
||||
expect(MCPToolScopeMappings.get_cost[0]).toEqual(
|
||||
expect.arrayContaining(['organizations_read', 'projects_read'])
|
||||
)
|
||||
})
|
||||
|
||||
test('covers exactly the tool registry of the deployed MCP server', () => {
|
||||
expect(Object.keys(MCPToolScopeMappings).sort()).toEqual([
|
||||
'apply_migration',
|
||||
'confirm_cost',
|
||||
'create_branch',
|
||||
'create_project',
|
||||
'delete_branch',
|
||||
'deploy_edge_function',
|
||||
'execute_sql',
|
||||
'generate_typescript_types',
|
||||
'get_advisors',
|
||||
'get_cost',
|
||||
'get_edge_function',
|
||||
'get_logs',
|
||||
'get_organization',
|
||||
'get_project',
|
||||
'get_project_url',
|
||||
'get_publishable_keys',
|
||||
'get_storage_config',
|
||||
'list_branches',
|
||||
'list_edge_functions',
|
||||
'list_extensions',
|
||||
'list_migrations',
|
||||
'list_organizations',
|
||||
'list_projects',
|
||||
'list_storage_buckets',
|
||||
'list_tables',
|
||||
'merge_branch',
|
||||
'pause_project',
|
||||
'rebase_branch',
|
||||
'reset_branch',
|
||||
'restore_project',
|
||||
'search_docs',
|
||||
'update_storage_config',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAPIPermissionScopeMap', () => {
|
||||
// vitestSetup starts mswServer with `onUnhandledRequest: 'error'` and resets handlers between
|
||||
// tests, so mocking here keeps that guard instead of replacing global fetch.
|
||||
const stubSpecs = (v1: Record<string, unknown>, v2: Record<string, unknown>) => {
|
||||
// tests, so mocking here keeps that guard instead of replacing global fetch. All three live
|
||||
// sources (v1 spec, v2 spec, the MCP tool-permissions endpoint) are stubbed.
|
||||
const stubSources = (
|
||||
v1: Record<string, unknown>,
|
||||
v2: Record<string, unknown>,
|
||||
mcpTools: Record<string, string[][]> = { execute_sql: [['database_read']] }
|
||||
) => {
|
||||
mswServer.use(
|
||||
http.get('*/api/v1-json', () => HttpResponse.json(v1)),
|
||||
http.get('*/api/v2-json', () => HttpResponse.json(v2))
|
||||
http.get('*/api/v2-json', () => HttpResponse.json(v2)),
|
||||
http.get('*/platform/mcp-tools-permissions', () => HttpResponse.json(mcpTools))
|
||||
)
|
||||
}
|
||||
|
||||
test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => {
|
||||
stubSpecs(
|
||||
stubSources(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}/database/query': {
|
||||
@@ -274,7 +182,7 @@ describe('buildAPIPermissionScopeMap', () => {
|
||||
// Path items may legally carry non-operation members; the specs are fetched live, so a benign
|
||||
// upstream swagger change must not start 500ing this route.
|
||||
test('tolerates path items with non-method OpenAPI members', async () => {
|
||||
stubSpecs(
|
||||
stubSources(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}': {
|
||||
@@ -293,15 +201,33 @@ describe('buildAPIPermissionScopeMap', () => {
|
||||
expect(Object.keys(map.endpoints)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('returns a copy of the tool mapping so callers cannot corrupt the module singleton', async () => {
|
||||
stubSpecs({ paths: {} }, { paths: {} })
|
||||
test('returns the MCP tool map fetched from the endpoint', async () => {
|
||||
stubSources(
|
||||
{ paths: {} },
|
||||
{ paths: {} },
|
||||
{
|
||||
apply_migration: [['database_migrations_write']],
|
||||
search_docs: [[]],
|
||||
}
|
||||
)
|
||||
|
||||
const map = await buildAPIPermissionScopeMap()
|
||||
expect(map.mcp_tools).toEqual(MCPToolScopeMappings)
|
||||
expect(map.mcp_tools).not.toBe(MCPToolScopeMappings)
|
||||
|
||||
const before = structuredClone(MCPToolScopeMappings.execute_sql)
|
||||
map.mcp_tools.execute_sql.push(['tampered'])
|
||||
expect(MCPToolScopeMappings.execute_sql).toEqual(before)
|
||||
expect(map.mcp_tools).toEqual({
|
||||
apply_migration: [['database_migrations_write']],
|
||||
search_docs: [[]],
|
||||
})
|
||||
// The gated tool is indexed under its permission; the ungated one is not.
|
||||
expect(map.scopes.database_migrations_write.mcp_tools).toEqual(['apply_migration'])
|
||||
})
|
||||
|
||||
test('throws when the MCP tool-permissions endpoint is unavailable', async () => {
|
||||
mswServer.use(
|
||||
http.get('*/api/v1-json', () => HttpResponse.json({ paths: {} })),
|
||||
http.get('*/api/v2-json', () => HttpResponse.json({ paths: {} })),
|
||||
http.get('*/platform/mcp-tools-permissions', () => new HttpResponse(null, { status: 503 }))
|
||||
)
|
||||
|
||||
await expect(buildAPIPermissionScopeMap()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,5 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
import z from 'zod'
|
||||
|
||||
// We don't have an OpenAPI that describes mcp tools security requirements so
|
||||
// we have this hard coded file that must be updated when they change
|
||||
import { MCPToolScopeMappings } from './MCPToolScopeMappings'
|
||||
import {
|
||||
EndpointMap,
|
||||
McpMap,
|
||||
@@ -13,32 +9,29 @@ import {
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
|
||||
/*
|
||||
* Builds the permissions/endpoint mapping by fetching the OpenAPI specs for our v1 and v2 APIs.
|
||||
* The two specs are indexed together rather than merged afterwards: every v1 path starts with
|
||||
* `/v1/` and every v2 path with `/v2/`, so they can't collide, and one pass de-duplicates a
|
||||
* scope's endpoint list by construction.
|
||||
* @throws InternalServerError when it can't fetch the OpenAPI specs
|
||||
* Builds the permissions/endpoint mapping from three live sources: the v1 and v2 OpenAPI specs
|
||||
* (endpoint -> FGA via `x-fga-permissions`) and the mgmt-api MCP-tool-permissions endpoint
|
||||
* (tool -> FGA). The MCP map is owned by Control Plane — it's projected from the same MCP_TOOL_AUTH
|
||||
* descriptor that drives enforcement — so Studio fetches it exactly like the OpenAPI spec instead of
|
||||
* hand-maintaining or importing a copy.
|
||||
* @throws InternalServerError when it can't fetch the specs or the MCP map
|
||||
*/
|
||||
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
|
||||
const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([
|
||||
const [apiV1SpecsJSON, apiV2SpecsJSON, mcpToolsJSON] = await Promise.all([
|
||||
fetchAPIPermissionScope('v1'),
|
||||
fetchAPIPermissionScope('v2'),
|
||||
fetchMcpToolPermissions(),
|
||||
])
|
||||
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
|
||||
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
|
||||
const mcpTools = MCP_TOOLS_SCHEMA.parse(mcpToolsJSON)
|
||||
|
||||
const { scopes, endpoints } = getScopesAndEndpointsForAPI({
|
||||
paths: { ...apiV1Specs.paths, ...apiV2Specs.paths },
|
||||
})
|
||||
addMCPToolsToScopes(scopes, MCPToolScopeMappings)
|
||||
addMCPToolsToScopes(scopes, mcpTools)
|
||||
|
||||
return {
|
||||
scopes,
|
||||
endpoints,
|
||||
// Deep copy so a caller mutating the response can't corrupt the module-level mapping, which
|
||||
// outlives every request in a long-running server.
|
||||
mcp_tools: cloneDeep(MCPToolScopeMappings),
|
||||
}
|
||||
return { scopes, endpoints, mcp_tools: mcpTools }
|
||||
}
|
||||
|
||||
// OPEN API specs look like this (only kept the parts we're interested in):
|
||||
@@ -63,7 +56,7 @@ export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap>
|
||||
// KNOWN DIVERGENCE: annotations are trusted verbatim, and the one on
|
||||
// POST /v1/projects/{ref}/database/query overstates access — the spec publishes
|
||||
// `[[database_read], [database_write]]`, but the route's guard requires database_read outright and
|
||||
// the write group is doc-only (see the execute_sql entry in MCPToolScopeMappings.ts). A token
|
||||
// the write group is doc-only (the MCP endpoint reports execute_sql under database_read only). A token
|
||||
// granted only database_write is therefore shown this endpoint as callable when the guard would
|
||||
// reject it. Studio-created tokens can't hit this (write mode always grants the read scopes too),
|
||||
// so this stays a display inaccuracy for API-created tokens; the fix is correcting the annotation
|
||||
@@ -147,6 +140,38 @@ const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => {
|
||||
}
|
||||
}
|
||||
|
||||
// The mgmt-api endpoint that projects the MCP_TOOL_AUTH descriptor (which also drives enforcement)
|
||||
// to tool -> FGA permission groups. Fetched like the OpenAPI spec above.
|
||||
const fetchMcpToolPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch(`${NEXT_PUBLIC_API_DOMAIN}/platform/mcp-tools-permissions`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
}
|
||||
const responseText = await response.text()
|
||||
|
||||
const retryAfter = response.headers.get('Retry-After') ?? undefined
|
||||
throw new InternalServerError(`MCP tool permissions responded with ${response.status}`, {
|
||||
status: response.status,
|
||||
body: responseText,
|
||||
...(retryAfter !== undefined && { retryAfter }),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof InternalServerError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new InternalServerError(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens
|
||||
|
||||
const OPEN_API_PATH_METHOD_SCHEMA = z.object({
|
||||
@@ -172,3 +197,6 @@ const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess(
|
||||
const API_SPECS_SCHEMA = z.object({
|
||||
paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA),
|
||||
})
|
||||
|
||||
// tool name -> OR-of-AND FGA permission groups, as served by GET /platform/mcp-tools-permissions.
|
||||
const MCP_TOOLS_SCHEMA: z.ZodType<McpMap> = z.record(z.string(), z.array(z.array(z.string())))
|
||||
@@ -2,6 +2,10 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
SELECT_26_STUDIO_DISMISSAL_KEY,
|
||||
useSelect26PromotionActive,
|
||||
} from 'ui-patterns/Banners/Select26Promotion'
|
||||
|
||||
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
|
||||
import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils'
|
||||
@@ -9,6 +13,11 @@ import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
|
||||
import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner'
|
||||
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
|
||||
import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation'
|
||||
import { BannerSelect2026 } from '@/components/ui/BannerStack/Banners/BannerSelect2026'
|
||||
import {
|
||||
SELECT_26_BANNER_PRIORITY,
|
||||
shouldShowSelect26Banner,
|
||||
} from '@/components/ui/BannerStack/Banners/BannerSelect2026.utils'
|
||||
import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUpdate'
|
||||
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
@@ -36,6 +45,38 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
false
|
||||
)
|
||||
|
||||
const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] =
|
||||
useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false)
|
||||
const isSelect26PromotionActive = useSelect26PromotionActive()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelect26DismissalLoaded) return
|
||||
|
||||
const shouldShow = shouldShowSelect26Banner({
|
||||
isPlatform: IS_PLATFORM,
|
||||
dismissalLoaded: isSelect26DismissalLoaded,
|
||||
isActive: isSelect26PromotionActive,
|
||||
isDismissed: isSelect26BannerDismissed,
|
||||
})
|
||||
|
||||
if (shouldShow) {
|
||||
addBanner({
|
||||
id: BANNER_ID.SELECT_26,
|
||||
isDismissed: false,
|
||||
content: <BannerSelect2026 />,
|
||||
priority: SELECT_26_BANNER_PRIORITY,
|
||||
})
|
||||
} else {
|
||||
dismissBanner(BANNER_ID.SELECT_26)
|
||||
}
|
||||
}, [
|
||||
isSelect26DismissalLoaded,
|
||||
isSelect26PromotionActive,
|
||||
isSelect26BannerDismissed,
|
||||
addBanner,
|
||||
dismissBanner,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (Date.now() >= TOSUpdateExpiry.getTime()) return
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { components } from 'api-types'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { Button } from 'ui'
|
||||
import { useCurrentPage, useSetPage } from 'ui-patterns/CommandMenu'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useApiKeysCommands } from './ApiKeys'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
type ApiKeyResponse = components['schemas']['ApiKeyResponse']
|
||||
|
||||
const { mockUseAsyncCheckPermissions, mockUseHighAvailability, mockUseSelectedProjectQuery } =
|
||||
vi.hoisted(() => ({
|
||||
mockUseAsyncCheckPermissions: vi.fn(),
|
||||
mockUseHighAvailability: vi.fn(),
|
||||
mockUseSelectedProjectQuery: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
||||
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useHighAvailability', () => ({
|
||||
useHighAvailability: mockUseHighAvailability,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
useSelectedProjectQuery: mockUseSelectedProjectQuery,
|
||||
}))
|
||||
|
||||
const API_KEYS: ApiKeyResponse[] = [
|
||||
{ api_key: 'anon-key', name: 'anon', type: 'legacy' },
|
||||
{ api_key: 'service-key', name: 'service_role', type: 'legacy' },
|
||||
{
|
||||
api_key: 'publishable-key',
|
||||
hash: 'hash',
|
||||
id: 'publishable-id',
|
||||
inserted_at: '2025-02-16T22:24:42.115195Z',
|
||||
name: 'default',
|
||||
type: 'publishable',
|
||||
},
|
||||
{
|
||||
api_key: 'secret-key',
|
||||
hash: 'hash',
|
||||
id: 'secret-id',
|
||||
inserted_at: '2025-02-16T22:24:42.115195Z',
|
||||
name: 'sb_secret',
|
||||
type: 'secret',
|
||||
},
|
||||
]
|
||||
|
||||
/** Renders the API keys command page so its commands can be asserted on. */
|
||||
const CommandPageHarness = () => {
|
||||
useApiKeysCommands()
|
||||
const setPage = useSetPage()
|
||||
const page = useCurrentPage()
|
||||
const commands =
|
||||
page && 'sections' in page ? page.sections.flatMap((section) => section.commands) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setPage('API Keys')}>Open API keys page</Button>
|
||||
<ul>
|
||||
{commands.map((command) => (
|
||||
<li key={command.id}>{command.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderCommandPage() {
|
||||
customRender(<CommandPageHarness />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open API keys page' }))
|
||||
}
|
||||
|
||||
describe('useApiKeysCommands', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseAsyncCheckPermissions.mockReturnValue({ can: true })
|
||||
mockUseSelectedProjectQuery.mockReturnValue({
|
||||
data: { id: 1, ref: 'default', name: 'default' },
|
||||
})
|
||||
mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false })
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/v1/projects/:ref/api-keys',
|
||||
response: () => HttpResponse.json<ApiKeyResponse[]>(API_KEYS),
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the legacy key commands on High Availability projects', async () => {
|
||||
mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false })
|
||||
|
||||
await renderCommandPage()
|
||||
|
||||
expect(await screen.findByText('Copy publishable key')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy secret key (sb_secret)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Copy anonymous API key')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Copy service API key')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes the legacy key commands on other projects', async () => {
|
||||
await renderCommandPage()
|
||||
|
||||
expect(await screen.findByText('Copy anonymous API key')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy service API key')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy publishable key')).toBeInTheDocument()
|
||||
expect(screen.getByText('Copy secret key (sb_secret)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils'
|
||||
import { orderCommandSectionsByPriority } from './ordering'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
const API_KEYS_PAGE_NAME = 'API Keys'
|
||||
@@ -30,15 +31,22 @@ export function useApiKeysCommands() {
|
||||
const ref = project?.ref || '_'
|
||||
|
||||
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
|
||||
const { isHighAvailability } = useHighAvailability()
|
||||
|
||||
const { data: apiKeysData } = useAPIKeys(
|
||||
{ projectRef: project?.ref, reveal: true },
|
||||
{ enabled: canReadAPIKeys }
|
||||
)
|
||||
const commands = useMemo(() => {
|
||||
const { anonKey, serviceKey, publishableKey, allSecretKeys } = canReadAPIKeys
|
||||
? (apiKeysData ?? {})
|
||||
: {}
|
||||
const {
|
||||
anonKey: legacyAnonKey,
|
||||
serviceKey: legacyServiceKey,
|
||||
publishableKey,
|
||||
allSecretKeys,
|
||||
} = canReadAPIKeys ? (apiKeysData ?? {}) : {}
|
||||
|
||||
const anonKey = isHighAvailability ? undefined : legacyAnonKey
|
||||
const serviceKey = isHighAvailability ? undefined : legacyServiceKey
|
||||
|
||||
return [
|
||||
project &&
|
||||
@@ -127,7 +135,7 @@ export function useApiKeysCommands() {
|
||||
icon: () => <Key />,
|
||||
},
|
||||
].filter(Boolean) as ICommand[]
|
||||
}, [canReadAPIKeys, apiKeysData, project, ref, resetCommandMenu, setIsOpen])
|
||||
}, [canReadAPIKeys, apiKeysData, isHighAvailability, project, ref, resetCommandMenu, setIsOpen])
|
||||
|
||||
useRegisterPage(
|
||||
API_KEYS_PAGE_NAME,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useParams } from 'common'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { useIsExplorerEnabled } from './FeaturePreviewContext'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
export const ExplorerPreview = () => {
|
||||
const { ref } = useParams()
|
||||
const isExplorerEnabled = useIsExplorerEnabled()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
The Explorer is a new unified workspace for querying your data and chatting with the
|
||||
Assistant, and is an early preview of where we're heading with the SQL Editor.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Notebooks are the first new feature of the Explorer — mix query cells and markdown notes in
|
||||
a single document, so your queries and context stay together. Use them to write runbooks,
|
||||
document incidents, build reusable reports, and more!
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src={`${BASE_PATH}/img/previews/explorer-preview.png`}
|
||||
width={1296}
|
||||
height={900}
|
||||
alt="explorer-preview"
|
||||
className="rounded-sm border"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Enabling this preview will:</p>
|
||||
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
|
||||
<li>
|
||||
Replace the existing SQL Editor with the new{' '}
|
||||
<InlineLink
|
||||
href={isExplorerEnabled ? `/project/${ref}/explorer` : `/project/${ref}/sql/new`}
|
||||
>
|
||||
Explorer
|
||||
</InlineLink>
|
||||
.
|
||||
<ul className="list-disc pl-6 text-sm text-foreground-light">
|
||||
<li>
|
||||
We're looking to replace the SQL Editor with the Explorer in the long term, but for
|
||||
now it lives alongside the SQL Editor, toggleable via this feature preview.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Enable managing of Notebooks through both the dashboard and the Assistant.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -165,6 +165,12 @@ export const useIsDatabaseConnectionsEnabled = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsExplorerEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
const isExplorerEnabled = useFlag('explorer')
|
||||
return isExplorerEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]
|
||||
}
|
||||
|
||||
export const useFeaturePreviewModal = () => {
|
||||
const featurePreviews = useFeaturePreviews()
|
||||
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { AdvisorRulesPreview } from './AdvisorRulesPreview'
|
||||
import { CLSPreview } from './CLSPreview'
|
||||
import { DatabaseConnectionsPreview } from './DatabaseConnectionsPreview'
|
||||
import { ExplorerPreview } from './ExplorerPreview'
|
||||
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
|
||||
import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview'
|
||||
import { JitDbAccessPreview } from './JitDbAccessPreview'
|
||||
@@ -59,6 +60,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: <SqlEditorManualSavePreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <IntegrationsLayoutPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS]: <DatabaseConnectionsPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: <ExplorerPreview />,
|
||||
}
|
||||
|
||||
export const FeaturePreviewModal = () => {
|
||||
@@ -148,12 +150,8 @@ export const FeaturePreviewModal = () => {
|
||||
? allFeaturePreviews.filter((x) => x.category === undefined)
|
||||
: allFeaturePreviews.filter((x) => x.category === category)
|
||||
return (
|
||||
<AccordionItem
|
||||
key={category}
|
||||
value={category}
|
||||
className="data-[state=open]:border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="text-xs font-mono uppercase tracking-tight px-4 text-foreground-lighter py-2">
|
||||
<AccordionItem key={category} value={category}>
|
||||
<AccordionTrigger className="text-xs font-mono uppercase tracking-tight px-4 text-foreground-lighter py-2 bg-tertiary dark:bg-transparent">
|
||||
{category}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="[&>div]:pb-0">
|
||||
@@ -318,8 +316,10 @@ const FeaturePreviewItem = ({
|
||||
key={feature.key}
|
||||
onClick={() => selectFeaturePreview(feature.key)}
|
||||
className={cn(
|
||||
'w-full! flex-1 flex items-center justify-between p-4 border-b cursor-pointer bg transition',
|
||||
selectedFeature?.key === feature.key ? 'bg-accent' : 'bg-card',
|
||||
'w-full! flex-1 flex items-center justify-between p-4 cursor-pointer bg transition',
|
||||
selectedFeature?.key === feature.key
|
||||
? 'bg-muted dark:bg-accent text-foreground'
|
||||
: 'bg-card text-foreground-light',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ export type FeaturePreview = {
|
||||
*/
|
||||
isForced?: boolean
|
||||
/** Optional category that the feature preview falls under, defaults to "Others" in the UI otherwise */
|
||||
category?: 'observability' | 'database'
|
||||
category?: 'observability' | 'database' | 'editors'
|
||||
/**
|
||||
* Where to send the user after enabling, to try the feature out. Omit if the
|
||||
* feature has no single destination (e.g. a global layout change).
|
||||
@@ -35,11 +35,24 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
const jitDbAccessEnabled = useFlag('jitDbAccess')
|
||||
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
|
||||
const isDatabaseConnectionsEnabled = useFlag('topForPostgres')
|
||||
const isExplorerEnabled = useFlag('explorer')
|
||||
|
||||
const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced')
|
||||
|
||||
return useMemo(() => {
|
||||
const previews: FeaturePreview[] = [
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER,
|
||||
name: 'Explorer & Notebooks',
|
||||
category: 'editors',
|
||||
// [Joshen TODO] Update with proper URL once discussion is up
|
||||
discussionsUrl: undefined,
|
||||
enabled: isExplorerEnabled,
|
||||
isNew: true,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: false,
|
||||
getRoute: (ref?: string) => `/project/${ref}/explorer`,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
|
||||
name: 'Updated Logs interface',
|
||||
@@ -114,6 +127,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
|
||||
category: 'editors',
|
||||
name: 'Disable snippet auto-saving',
|
||||
discussionsUrl: undefined,
|
||||
isNew: true,
|
||||
@@ -146,5 +160,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
jitDbAccessEnabled,
|
||||
isMarketplaceEnabled,
|
||||
isDatabaseConnectionsEnabled,
|
||||
isExplorerEnabled,
|
||||
])
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type ListGitHubConnectionsResponse = platformComponents['schemas']['ListGitHubCo
|
||||
type GetGitHubConnectionConfigResponse =
|
||||
platformComponents['schemas']['GetGitHubConnectionConfigResponse']
|
||||
type BranchResponse = apiV1Components['schemas']['BranchResponse']
|
||||
type V2ProjectConfigResponse = apiV2Components['schemas']['V2ProjectConfigResponse']
|
||||
type V2ProjectConfigResponse = apiV2Components['schemas']['V2ProjectConfigResponse_Output']
|
||||
|
||||
const PROJECT_REF = 'default'
|
||||
const ORGANIZATION_ID = 1
|
||||
@@ -120,6 +120,7 @@ function createProjectConfigResponse(auth: Record<string, unknown>): V2ProjectCo
|
||||
},
|
||||
auth,
|
||||
database: {
|
||||
major_version: 17,
|
||||
network_restrictions: {
|
||||
allowed_cidrs: [],
|
||||
entitlement: 'disallowed',
|
||||
|
||||
@@ -386,6 +386,8 @@ export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT =
|
||||
'Purchase IPv4 add-on or use Shared Pooler if on a IPv4 network'
|
||||
export const IPV4_ADDON_TEXT = 'Connections are IPv4 proxied with IPv4 add-on'
|
||||
|
||||
export const CONNECTION_SOURCE_LOAD_BALANCER = 'load-balancer'
|
||||
|
||||
export type ConnectionStringMethod = 'direct' | 'transaction' | 'session'
|
||||
|
||||
export const connectionStringMethodOptions: Record<
|
||||
|
||||
@@ -126,12 +126,14 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={`connect-${field.id}`}
|
||||
>
|
||||
<Select
|
||||
value={String(value ?? '')}
|
||||
onValueChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`connect-${field.id}`}
|
||||
size="small"
|
||||
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
|
||||
>
|
||||
@@ -167,6 +169,7 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={field.id}
|
||||
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
|
||||
>
|
||||
<Switch
|
||||
@@ -185,12 +188,14 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={`connect-${field.id}`}
|
||||
>
|
||||
<MultiSelector
|
||||
values={Array.isArray(value) ? value : []}
|
||||
onValuesChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
id={`connect-${field.id}`}
|
||||
className="w-full"
|
||||
label="Select features"
|
||||
badgeLimit="wrap"
|
||||
|
||||
@@ -210,6 +210,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS
|
||||
const { ref } = useParams()
|
||||
const stepsContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const deploymentMode = useDeploymentMode()
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
const connectionStringPooler = useConnectionStringPooler(deploymentMode)
|
||||
|
||||
const { data: ipv4Addon } = useProjectAddonsQuery(
|
||||
@@ -227,6 +228,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS
|
||||
connectionMethod: state.connectionMethod,
|
||||
useSharedPooler: state.useSharedPooler,
|
||||
hasIpv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
})
|
||||
const showSessionPoolerNotice = shouldShowSessionPoolerNotice({
|
||||
isPlatform: deploymentMode.isPlatform,
|
||||
|
||||
@@ -28,14 +28,17 @@ export function shouldShowIpv4AddonNotice({
|
||||
connectionMethod,
|
||||
useSharedPooler,
|
||||
hasIpv4Addon,
|
||||
isHighAvailability,
|
||||
}: {
|
||||
isPlatform: boolean
|
||||
mode: ConnectMode
|
||||
connectionMethod: FieldValue
|
||||
useSharedPooler: FieldValue
|
||||
hasIpv4Addon: boolean
|
||||
isHighAvailability: boolean
|
||||
}): boolean {
|
||||
if (!isPlatform || mode !== 'direct' || hasIpv4Addon) return false
|
||||
// The IPv4 add-on does not apply to Multigres connections
|
||||
if (!isPlatform || mode !== 'direct' || hasIpv4Addon || isHighAvailability) return false
|
||||
return connectionMethod === 'direct' || (connectionMethod === 'transaction' && !useSharedPooler)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,27 @@ export const buildJdbcString = (params: ConnectionParams) => {
|
||||
return `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}${extraParams}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a connection string's query params carry `sslmode=require` without
|
||||
* dropping params the URI already has (e.g. `options=reference%3D...` or
|
||||
* `sslnegotiation=direct`).
|
||||
*/
|
||||
export const withRequiredSslmode = (search: string) => {
|
||||
if (!search) return '?sslmode=require'
|
||||
if (search.includes('sslmode=')) return search
|
||||
return `${search}&sslmode=require`
|
||||
}
|
||||
|
||||
export const buildDotnetConnectionString = (params: ConnectionParams) => {
|
||||
// Multigres only accepts direct SSL negotiation; Npgsql (9+) spells it
|
||||
// `SSL Negotiation=Direct` and throws on the parameter in older versions,
|
||||
// so only emit it when the resolved URI carries the param.
|
||||
const sslNegotiation = params.search.includes('sslnegotiation=direct')
|
||||
? ';SSL Negotiation=Direct'
|
||||
: ''
|
||||
return `Host=${params.host};Port=${params.port};Database=${params.database};Username=${params.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true${sslNegotiation}`
|
||||
}
|
||||
|
||||
export const buildConnectionStringWithPassword = (
|
||||
connectionString: string,
|
||||
password: string
|
||||
|
||||
@@ -17,6 +17,18 @@ export const appendHighAvailabilitySslParams = (uri: string) =>
|
||||
? uri
|
||||
: appendConnectionStringParams(uri, HIGH_AVAILABILITY_SSL_PARAMS)
|
||||
|
||||
/**
|
||||
* The Multigres read-only load balancer listens on this port on the same host
|
||||
* as the primary database.
|
||||
*/
|
||||
export const HIGH_AVAILABILITY_LOAD_BALANCER_PORT = 5433
|
||||
|
||||
export const getHighAvailabilityLoadBalancerConnectionInfo = <
|
||||
T extends { db_port: number | string },
|
||||
>(
|
||||
connectionInfo: T
|
||||
): T => ({ ...connectionInfo, db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT })
|
||||
|
||||
type ConnectionStrings = {
|
||||
psql: string
|
||||
uri: string
|
||||
|
||||
@@ -55,6 +55,10 @@ func main() {
|
||||
},
|
||||
dotnet: {
|
||||
installCommands: [
|
||||
// SSL Negotiation=Direct in the generated connection string requires Npgsql 9.0+.
|
||||
// Concrete version: quoting a floating version breaks on Windows cmd, and
|
||||
// floating versions fail under Central Package Management (NU1011).
|
||||
'dotnet add package Npgsql --version 9.0.5',
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION',
|
||||
],
|
||||
},
|
||||
|
||||
+5
@@ -132,6 +132,7 @@ describe('shouldShowIpv4AddonNotice', () => {
|
||||
connectionMethod: 'direct',
|
||||
useSharedPooler: false,
|
||||
hasIpv4Addon: false,
|
||||
isHighAvailability: false,
|
||||
}
|
||||
|
||||
test('returns true for a direct connection with no IPv4 addon', () => {
|
||||
@@ -169,6 +170,10 @@ describe('shouldShowIpv4AddonNotice', () => {
|
||||
test('returns false when self-hosted (not platform)', () => {
|
||||
expect(shouldShowIpv4AddonNotice({ ...BASE, isPlatform: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for high-availability projects even with a direct connection and no addon', () => {
|
||||
expect(shouldShowIpv4AddonNotice({ ...BASE, isHighAvailability: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowSessionPoolerNotice', () => {
|
||||
|
||||
+48
@@ -4,6 +4,7 @@ import {
|
||||
appendConnectionStringParams,
|
||||
buildConnectionParameters,
|
||||
buildConnectionStringWithPassword,
|
||||
buildDotnetConnectionString,
|
||||
buildJdbcString,
|
||||
buildPsqlCommand,
|
||||
buildSafeConnectionString,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseConnectionParams,
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
withRequiredSslmode,
|
||||
} from '../ConnectionString.utils'
|
||||
|
||||
describe('parseConnectionParams', () => {
|
||||
@@ -236,6 +238,52 @@ describe('buildJdbcString', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRequiredSslmode', () => {
|
||||
test('returns ?sslmode=require for an empty search', () => {
|
||||
expect(withRequiredSslmode('')).toBe('?sslmode=require')
|
||||
})
|
||||
|
||||
test('leaves a search that already sets sslmode unchanged', () => {
|
||||
expect(withRequiredSslmode('?sslmode=require&sslnegotiation=direct')).toBe(
|
||||
'?sslmode=require&sslnegotiation=direct'
|
||||
)
|
||||
})
|
||||
|
||||
test('appends sslmode=require to existing params without it', () => {
|
||||
expect(withRequiredSslmode('?options=reference%3Dproj')).toBe(
|
||||
'?options=reference%3Dproj&sslmode=require'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDotnetConnectionString', () => {
|
||||
const params = {
|
||||
host: 'db.proj.supabase.co',
|
||||
port: '5432',
|
||||
user: 'postgres',
|
||||
database: 'postgres',
|
||||
search: '',
|
||||
}
|
||||
|
||||
test('builds the base Npgsql string without SSL negotiation', () => {
|
||||
expect(buildDotnetConnectionString(params)).toBe(
|
||||
`Host=db.proj.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true`
|
||||
)
|
||||
})
|
||||
|
||||
test('appends SSL Negotiation=Direct when the URI requires direct negotiation', () => {
|
||||
expect(
|
||||
buildDotnetConnectionString({
|
||||
...params,
|
||||
port: '5433',
|
||||
search: '?sslmode=require&sslnegotiation=direct',
|
||||
})
|
||||
).toBe(
|
||||
`Host=db.proj.supabase.co;Port=5433;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true;SSL Negotiation=Direct`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildConnectionParameters', () => {
|
||||
test('produces host/port/database/user rows in display order', () => {
|
||||
expect(
|
||||
|
||||
+45
@@ -3,8 +3,11 @@ import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
appendHighAvailabilitySslParams,
|
||||
buildConnectionStringPooler,
|
||||
getConnectionStrings,
|
||||
getHighAvailabilityLoadBalancerConnectionInfo,
|
||||
getSelfHostedDirectStrings,
|
||||
getSelfHostedPoolerStrings,
|
||||
HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
||||
HIGH_AVAILABILITY_SSL_PARAMS,
|
||||
} from '../DatabaseSettings.utils'
|
||||
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
@@ -239,3 +242,45 @@ describe('appendHighAvailabilitySslParams', () => {
|
||||
expect(appendHighAvailabilitySslParams('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHighAvailabilityLoadBalancerConnectionInfo', () => {
|
||||
test('swaps the port for the load balancer port and preserves the other fields', () => {
|
||||
const connectionInfo = {
|
||||
db_user: 'postgres',
|
||||
db_port: 5432,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
}
|
||||
|
||||
expect(getHighAvailabilityLoadBalancerConnectionInfo(connectionInfo)).toEqual({
|
||||
db_user: 'postgres',
|
||||
db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
})
|
||||
})
|
||||
|
||||
test('builds a read-only load balancer connection string on port 5433 with SSL params', () => {
|
||||
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo({
|
||||
db_user: 'postgres',
|
||||
db_port: 5432,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
})
|
||||
|
||||
const result = buildConnectionStringPooler({
|
||||
deploymentMode: platform,
|
||||
connectionInfo: loadBalancerInfo,
|
||||
connectionStringsShared: getConnectionStrings({
|
||||
connectionInfo: loadBalancerInfo,
|
||||
metadata: { projectRef: 'proj' },
|
||||
}),
|
||||
ipv4Addon: false,
|
||||
isHighAvailability: true,
|
||||
})
|
||||
|
||||
expect(result.direct).toBe(
|
||||
`postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5433/postgres?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,11 @@ vi.mock('common', () => ({
|
||||
useParams: () => ({ ref: 'test-ref' }),
|
||||
}))
|
||||
|
||||
const readReplicasMock: {
|
||||
data: Array<{ identifier: string; region: string; inserted_at: string }>
|
||||
} = { data: [] }
|
||||
vi.mock('@/data/read-replicas/replicas-query', () => ({
|
||||
useReadReplicasQuery: () => ({ data: [] }),
|
||||
useReadReplicasQuery: () => readReplicasMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
|
||||
@@ -498,9 +501,67 @@ describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
|
||||
describe('high availability projects', () => {
|
||||
const PRIMARY_DATABASE = {
|
||||
identifier: 'test-ref',
|
||||
region: 'ap-southeast-1',
|
||||
inserted_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
const REPLICA_DATABASE = {
|
||||
identifier: 'test-ref-rr-abcdefgh',
|
||||
region: 'ap-southeast-1',
|
||||
inserted_at: '2024-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(false)
|
||||
readReplicasMock.data = []
|
||||
})
|
||||
|
||||
test('should offer only the primary and the load balancer as sources for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
|
||||
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionSource')
|
||||
expect(options).toEqual([
|
||||
{ value: 'test-ref', label: 'Primary database' },
|
||||
{ value: 'load-balancer', label: 'Replica (read-only)' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should keep replicas as sources and omit the load balancer for non-HA projects', () => {
|
||||
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
|
||||
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionSource')
|
||||
expect(options.map((o) => o.value)).toEqual(['test-ref', 'test-ref-rr-abcdefgh'])
|
||||
expect(options.some((o) => o.value === 'load-balancer')).toBe(false)
|
||||
})
|
||||
|
||||
test('should coerce a replica connection source to the primary for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionSource: 'test-ref-rr-abcdefgh' })
|
||||
)
|
||||
|
||||
expect(result.current.state.connectionSource).toBe('test-ref')
|
||||
})
|
||||
|
||||
test('should preserve the load balancer as a valid connection source for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionSource: 'load-balancer' })
|
||||
)
|
||||
|
||||
expect(result.current.state.connectionSource).toBe('load-balancer')
|
||||
})
|
||||
|
||||
test('should hide connectionMethod field for HA projects', async () => {
|
||||
|
||||
+19
-108
@@ -1,19 +1,17 @@
|
||||
import { useParams } from 'common'
|
||||
import { Check, KeyRound } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { buildConnectionStringPooler, getConnectionStrings } from '../../../DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
DATABASE_CONNECTION_TYPES,
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
import type {
|
||||
ConnectionStringPooler,
|
||||
DeploymentMode,
|
||||
StepContentProps,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
|
||||
@@ -28,114 +26,14 @@ import {
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
import { useConnectionStringDatabases } from '@/components/interfaces/ConnectSheet/useConnectionStringDatabases'
|
||||
import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
// Multigres has no pooler, so the pooler config endpoints don't apply
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
// Memoized so the per-database pooler bag (consumed by resolveConnectionString
|
||||
// downstream) keeps a stable identity across renders. Without this the inner
|
||||
// pluckObjectFields/getConnectionStrings calls would mint fresh objects every
|
||||
// render and ripple through the resolveConnectionString useMemo below.
|
||||
return useMemo(() => {
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
return Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo,
|
||||
connectionStringsShared,
|
||||
connectionStringsDedicated,
|
||||
ipv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
}, [
|
||||
databases,
|
||||
pgbouncerConfig,
|
||||
supavisorConfig,
|
||||
allowPgBouncerSelection,
|
||||
ipv4Addon,
|
||||
projectRef,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
])
|
||||
}
|
||||
|
||||
const CONNECTION_METHOD_TO_TELEMETRY: Record<
|
||||
ConnectionStringMethod,
|
||||
'direct' | 'transaction_pooler' | 'session_pooler'
|
||||
@@ -156,6 +54,8 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
const [temporaryDatabasePassword, setTemporaryDatabasePassword] = useState('')
|
||||
|
||||
const connectionSource = state.connectionSource
|
||||
const isLoadBalancerSelected =
|
||||
isHighAvailability && connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
@@ -239,17 +139,19 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
const showPasswordPlaceholder = connectionString.includes(PASSWORD_PLACEHOLDER)
|
||||
const showSelfHostedDirectNotice = deploymentMode.isSelfHosted && connectionMethod === 'direct'
|
||||
const showPoolerTitle = deploymentMode.isPlatform && !!poolerBadge && !isHighAvailability
|
||||
const titleBadge = isLoadBalancerSelected ? 'Read-only' : poolerBadge
|
||||
const showTitleBadge = isLoadBalancerSelected || showPoolerTitle
|
||||
const showResetInTitle =
|
||||
deploymentMode.isPlatform && showPasswordPlaceholder && !temporaryDatabasePassword
|
||||
const showStringTitleRow = showPoolerTitle || showResetInTitle
|
||||
const showStringTitleRow = showTitleBadge || showResetInTitle
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="overflow-hidden rounded-lg border bg-surface-75">
|
||||
{showStringTitleRow && (
|
||||
<div className="flex items-center justify-between gap-2 border-b bg-surface-100 py-2 pl-4 pr-2">
|
||||
{showPoolerTitle ? (
|
||||
<span className="text-xs text-foreground-light">{poolerBadge}</span>
|
||||
{showTitleBadge ? (
|
||||
<span className="text-xs text-foreground-light">{titleBadge}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
@@ -282,6 +184,15 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
)}
|
||||
</div>
|
||||
{showPasswordPlaceholder && <PasswordEncodingNote />}
|
||||
{/* Persistent live region so screen readers announce the read-only state
|
||||
when the load balancer is selected */}
|
||||
<p
|
||||
role="status"
|
||||
className={cn('text-sm text-foreground-lighter', !isLoadBalancerSelected && 'sr-only')}
|
||||
>
|
||||
{isLoadBalancerSelected &&
|
||||
'Replica connections are read-only. Connect to the primary database for writes.'}
|
||||
</p>
|
||||
{showSelfHostedDirectNotice && (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Manually{' '}
|
||||
|
||||
+46
-4
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
@@ -10,12 +12,16 @@ import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Conn
|
||||
import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
|
||||
import {
|
||||
buildConnectionParameters,
|
||||
buildDotnetConnectionString,
|
||||
buildSafeConnectionString,
|
||||
parseConnectionParams,
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
withRequiredSslmode,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
import { useConnectionStringDatabases } from '@/components/interfaces/ConnectSheet/useConnectionStringDatabases'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
type DirectFilesConfig = {
|
||||
files: {
|
||||
@@ -28,11 +34,20 @@ type DirectFilesConfig = {
|
||||
passwordInUrl?: boolean
|
||||
}
|
||||
|
||||
function DirectFilesContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
function DirectFilesContent({ state, deploymentMode }: StepContentProps) {
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const connectionSource = state.connectionSource
|
||||
const isLoadBalancerSelected =
|
||||
isHighAvailability && connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
const connectionStrings = useConnectionStringDatabases(deploymentMode)
|
||||
const connectionStringPooler =
|
||||
connectionStrings[connectionSource as keyof typeof connectionStrings]
|
||||
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
resolveConnectionString({
|
||||
@@ -125,7 +140,7 @@ func main() {
|
||||
language: 'json',
|
||||
code: `{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=${connectionParams.host};Database=${connectionParams.database};Username=${connectionParams.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true"
|
||||
"DefaultConnection": "${buildDotnetConnectionString(connectionParams)}"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
@@ -180,7 +195,7 @@ PORT = os.getenv("port")
|
||||
DBNAME = os.getenv("dbname")
|
||||
|
||||
# Construct the SQLAlchemy connection string
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}${withRequiredSslmode(connectionParams.search)}"
|
||||
|
||||
# Create the SQLAlchemy engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
@@ -235,10 +250,37 @@ except Exception as e:
|
||||
return null
|
||||
}
|
||||
|
||||
const codeBlock = (
|
||||
<MultipleCodeBlock
|
||||
files={config.files}
|
||||
value={activeFile}
|
||||
onValueChange={setActiveFile}
|
||||
className={isLoadBalancerSelected ? 'rounded-none border-0' : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<MultipleCodeBlock files={config.files} value={activeFile} onValueChange={setActiveFile} />
|
||||
{isLoadBalancerSelected ? (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="flex items-center border-b bg-surface-100 py-2 pl-4 pr-2">
|
||||
<span className="text-xs text-foreground-light">Read-only</span>
|
||||
</div>
|
||||
{codeBlock}
|
||||
</div>
|
||||
) : (
|
||||
codeBlock
|
||||
)}
|
||||
{config.passwordInUrl && <PasswordEncodingNote />}
|
||||
{/* Persistent live region so screen readers announce the read-only state
|
||||
when the load balancer is selected */}
|
||||
<p
|
||||
role="status"
|
||||
className={cn('text-sm text-foreground-lighter', !isLoadBalancerSelected && 'sr-only')}
|
||||
>
|
||||
{isLoadBalancerSelected &&
|
||||
'Replica connections are read-only. Connect to the primary database for writes.'}
|
||||
</p>
|
||||
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
||||
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
connectionStringMethodOptions,
|
||||
DATABASE_CONNECTION_TYPES,
|
||||
FRAMEWORKS,
|
||||
@@ -50,11 +51,15 @@ function getFieldOptionsFromSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
}: {
|
||||
source: string
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
deploymentMode: DeploymentMode
|
||||
isHighAvailability: boolean
|
||||
projectRef?: string
|
||||
}): FieldOption[] {
|
||||
switch (source) {
|
||||
case 'frameworks':
|
||||
@@ -135,15 +140,23 @@ function getFieldOptionsFromSource({
|
||||
}))
|
||||
}
|
||||
|
||||
case 'connectionSources':
|
||||
return databases.map((db) => {
|
||||
case 'connectionSources': {
|
||||
const options = databases.map((db) => {
|
||||
const region = formatDatabaseRegion(db?.region ?? '')
|
||||
const id = formatDatabaseID(db.identifier ?? '')
|
||||
const label = db.identifier.includes('-rr-')
|
||||
? `Read Replica (${region} - ${id})`
|
||||
: 'Primary Database'
|
||||
? `Read replica (${region} - ${id})`
|
||||
: 'Primary database'
|
||||
return { value: db.identifier, label }
|
||||
})
|
||||
if (!isHighAvailability) return options
|
||||
// Multigres replicas are only reachable through the read-only load
|
||||
// balancer, so the sources are the primary and the load balancer.
|
||||
return [
|
||||
...options.filter((option) => option.value === projectRef),
|
||||
{ value: CONNECTION_SOURCE_LOAD_BALANCER, label: 'Replica (read-only)' },
|
||||
]
|
||||
}
|
||||
|
||||
case 'connectionTypes':
|
||||
return DATABASE_CONNECTION_TYPES.map((t) => ({
|
||||
@@ -185,11 +198,15 @@ function resolveFieldOptionsWithSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
}: {
|
||||
field: ResolvedField
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
deploymentMode: DeploymentMode
|
||||
isHighAvailability: boolean
|
||||
projectRef?: string
|
||||
}): FieldOption[] {
|
||||
// If already resolved (from conditional resolution)
|
||||
if (field.resolvedOptions.length > 0) {
|
||||
@@ -204,6 +221,8 @@ function resolveFieldOptionsWithSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -361,12 +380,21 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
|
||||
// Multigres has no pooler, so pooler-flavored selections restored from the
|
||||
// URL or localStorage (shared across projects) must never leak into an HA
|
||||
// project — every consumer sees the direct connection method.
|
||||
const resolvedState = useMemo(
|
||||
() =>
|
||||
isHighAvailability ? { ...state, connectionMethod: 'direct', useSharedPooler: false } : state,
|
||||
[state, isHighAvailability]
|
||||
)
|
||||
// project — every consumer sees the direct connection method. Likewise a
|
||||
// stale connection source (e.g. a replica identifier restored from the URL)
|
||||
// falls back to the primary, since HA sources are only the primary and the
|
||||
// load balancer.
|
||||
const resolvedState = useMemo(() => {
|
||||
if (!isHighAvailability) return state
|
||||
|
||||
const next: ConnectState = { ...state, connectionMethod: 'direct', useSharedPooler: false }
|
||||
const hasValidSource =
|
||||
state.connectionSource === undefined ||
|
||||
state.connectionSource === projectRef ||
|
||||
state.connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
if (!hasValidSource) next.connectionSource = projectRef ?? '_'
|
||||
return next
|
||||
}, [state, isHighAvailability, projectRef])
|
||||
|
||||
const activeFields = useMemo(() => {
|
||||
let fields = getActiveFields(connectSchema, resolvedState)
|
||||
@@ -394,9 +422,11 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
state: resolvedState,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
})
|
||||
},
|
||||
[activeFields, resolvedState, databases, deploymentMode]
|
||||
[activeFields, resolvedState, databases, deploymentMode, isHighAvailability, projectRef]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { CONNECTION_SOURCE_LOAD_BALANCER } from './Connect.constants'
|
||||
import type { DeploymentMode } from './Connect.types'
|
||||
import {
|
||||
buildConnectionStringPooler,
|
||||
getConnectionStrings,
|
||||
getHighAvailabilityLoadBalancerConnectionInfo,
|
||||
} from './DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
export const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
// Multigres has no pooler, so the pooler config endpoints don't apply
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
// Memoized so the per-database pooler bag (consumed by resolveConnectionString
|
||||
// downstream) keeps a stable identity across renders. Without this the inner
|
||||
// pluckObjectFields/getConnectionStrings calls would mint fresh objects every
|
||||
// render and ripple through the resolveConnectionString useMemo below.
|
||||
return useMemo(() => {
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
const connectionStringsByIdentifier = Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo,
|
||||
connectionStringsShared,
|
||||
connectionStringsDedicated,
|
||||
ipv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
// The Multigres load balancer is not a database row — it shares the
|
||||
// primary's host on a dedicated read-only port.
|
||||
const primaryDatabase = isHighAvailability
|
||||
? databases.find((db) => db.identifier === projectRef)
|
||||
: undefined
|
||||
if (primaryDatabase) {
|
||||
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo(
|
||||
pluckObjectFields(primaryDatabase, DB_FIELDS)
|
||||
)
|
||||
connectionStringsByIdentifier[CONNECTION_SOURCE_LOAD_BALANCER] = buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo: loadBalancerInfo,
|
||||
connectionStringsShared: getConnectionStrings({
|
||||
connectionInfo: loadBalancerInfo,
|
||||
metadata: { projectRef },
|
||||
}),
|
||||
ipv4Addon: false,
|
||||
isHighAvailability,
|
||||
})
|
||||
}
|
||||
|
||||
return connectionStringsByIdentifier
|
||||
}, [
|
||||
databases,
|
||||
pgbouncerConfig,
|
||||
supavisorConfig,
|
||||
allowPgBouncerSelection,
|
||||
ipv4Addon,
|
||||
projectRef,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
])
|
||||
}
|
||||
+11
-5
@@ -28,6 +28,11 @@ import {
|
||||
} from './DestinationForm.constants'
|
||||
import { type DestinationPanelSchemaType } from './DestinationForm.schema'
|
||||
|
||||
const INVALIDATED_SLOT_BEHAVIOR_LABELS = {
|
||||
error: 'Block startup',
|
||||
recreate: 'Recreate slot',
|
||||
}
|
||||
|
||||
export const AdvancedSettings = ({
|
||||
type,
|
||||
form,
|
||||
@@ -153,16 +158,18 @@ export const AdvancedSettings = ({
|
||||
>
|
||||
<FormControl>
|
||||
<Select value={field.value ?? 'error'} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="capitalize">{field.value ?? 'error'}</SelectTrigger>
|
||||
<SelectTrigger>
|
||||
{INVALIDATED_SLOT_BEHAVIOR_LABELS[field.value ?? 'error']}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="error" className="[&>span]:top-2.5">
|
||||
<p>Error</p>
|
||||
<p>Block startup</p>
|
||||
<p className="text-foreground-lighter">
|
||||
Blocks startup for manual recovery.
|
||||
</p>
|
||||
</SelectItem>
|
||||
<SelectItem value="recreate" className="[&>span]:top-2.5">
|
||||
<p>Recreate</p>
|
||||
<p>Recreate slot</p>
|
||||
<p className="text-foreground-lighter">
|
||||
Replaces destination tables and runs a new, billable initial sync.
|
||||
</p>
|
||||
@@ -222,7 +229,7 @@ export const AdvancedSettings = ({
|
||||
</div>
|
||||
}
|
||||
layout="horizontal"
|
||||
description="How old query results can be while BigQuery applies ongoing changes."
|
||||
description="Set the maximum age of query results while BigQuery applies ongoing changes, or leave blank for the freshest results."
|
||||
>
|
||||
<FormControl>
|
||||
<InputGroup>
|
||||
@@ -233,7 +240,6 @@ export const AdvancedSettings = ({
|
||||
step={1}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field)}
|
||||
placeholder="Default: None (Freshest results)"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>minutes</InputGroupText>
|
||||
|
||||
+7
-7
@@ -21,9 +21,9 @@ type AnalyticsBucketValidationOptions = {
|
||||
|
||||
// Fields that are always required regardless of the namespace / access key selections.
|
||||
const ANALYTICS_BUCKET_REQUIRED_FIELDS: AnalyticsBucketValidationIssue[] = [
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
]
|
||||
|
||||
export const getAnalyticsBucketValidationIssues = (
|
||||
@@ -43,8 +43,8 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
if (!hasValidNamespace) {
|
||||
issues.push(
|
||||
isCreatingNewNamespace
|
||||
? { path: 'newNamespaceName', message: 'Namespace name is required' }
|
||||
: { path: 'namespace', message: 'Namespace is required' }
|
||||
? { path: 'newNamespaceName', message: 'Namespace name is required.' }
|
||||
: { path: 'namespace', message: 'Namespace is required.' }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
data.s3SecretAccessKey?.trim().length &&
|
||||
!data.s3AccessKeyId?.trim().length
|
||||
) {
|
||||
issues.push({ path: 's3AccessKeyId', message: 'S3 access key ID is required' })
|
||||
issues.push({ path: 's3AccessKeyId', message: 'S3 access key ID is required.' })
|
||||
}
|
||||
|
||||
const currentS3AccessKeyId = data.s3AccessKeyId?.trim()
|
||||
@@ -69,7 +69,7 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
(!options.secretsOptional || hasChangedStoredS3AccessKey) &&
|
||||
!data.s3SecretAccessKey?.trim().length
|
||||
) {
|
||||
issues.push({ path: 's3SecretAccessKey', message: 'S3 secret access key is required' })
|
||||
issues.push({ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' })
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
+3
-3
@@ -7,11 +7,11 @@ export const BigQueryFormSchema = z.object({
|
||||
connectionPoolSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Connection pool size must be greater than 0')
|
||||
.min(1, 'Connection pool size must be greater than 0.')
|
||||
.optional(),
|
||||
maxStalenessMins: z
|
||||
.number()
|
||||
.int('Maximum staleness must be a whole number of minutes')
|
||||
.min(0, 'Maximum staleness must be 0 or greater')
|
||||
.int('Maximum staleness must be a whole number of minutes.')
|
||||
.min(0, 'Maximum staleness must be 0 or greater.')
|
||||
.optional(),
|
||||
})
|
||||
+43
-10
@@ -7,18 +7,51 @@ export type BigQueryValidationIssue = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const BIGQUERY_REQUIRED_FIELDS: { path: BigQueryFieldPath; message: string }[] = [
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required' },
|
||||
export const BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE = 'Service account key must be valid JSON.'
|
||||
|
||||
const BIGQUERY_REQUIRED_FIELDS: {
|
||||
path: Exclude<BigQueryFieldPath, 'serviceAccountKey'>
|
||||
message: string
|
||||
}[] = [
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
]
|
||||
|
||||
const isValidJsonString = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getBigQueryValidationIssues = (
|
||||
data: Pick<DestinationPanelSchemaType, BigQueryFieldPath>,
|
||||
options: { secretsOptional?: boolean } = {}
|
||||
): BigQueryValidationIssue[] =>
|
||||
BIGQUERY_REQUIRED_FIELDS.filter(({ path }) => {
|
||||
if (options.secretsOptional && path === 'serviceAccountKey') return false
|
||||
options: { secretsOptional?: boolean; validateJson?: boolean } = {}
|
||||
): BigQueryValidationIssue[] => {
|
||||
const { secretsOptional = false, validateJson = true } = options
|
||||
const issues: BigQueryValidationIssue[] = BIGQUERY_REQUIRED_FIELDS.filter(
|
||||
({ path }) => !data[path]?.trim().length
|
||||
).map(({ path, message }) => ({ path, message }))
|
||||
|
||||
return !data[path]?.trim().length
|
||||
})
|
||||
const serviceAccountKey = data.serviceAccountKey?.trim() ?? ''
|
||||
|
||||
if (!serviceAccountKey) {
|
||||
if (!secretsOptional) {
|
||||
issues.push({ path: 'serviceAccountKey', message: 'Service account key is required.' })
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// JSON shape is checked on submit only. Live onChange validation would fail on every
|
||||
// keystroke while the user is still pasting or typing a key.
|
||||
if (validateJson && !isValidJsonString(serviceAccountKey)) {
|
||||
issues.push({
|
||||
path: 'serviceAccountKey',
|
||||
message: BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE,
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Form } from 'ui'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
|
||||
import { BigQueryFields } from './Fields'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => {
|
||||
const form = useForm<DestinationPanelSchemaType>({
|
||||
defaultValues: { projectId: '', datasetId: '', serviceAccountKey },
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<BigQueryFields form={form} editMode={false} />
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BigQueryFields', () => {
|
||||
it('imports a JSON file into an editable service account key field', async () => {
|
||||
const { container } = customRender(<TestForm />)
|
||||
const contents = '{"type":"service_account"}'
|
||||
const file = new File([contents], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(fileInput).not.toBeNull()
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'{"type": "service_account", "project_id": "...", ...}'
|
||||
)
|
||||
await waitFor(() => expect(textarea).toHaveValue(contents))
|
||||
|
||||
fireEvent.change(textarea, { target: { value: `${contents}\n` } })
|
||||
expect(textarea).toHaveValue(`${contents}\n`)
|
||||
expect(textarea).toHaveClass('max-h-[calc(13lh+1rem)]')
|
||||
})
|
||||
|
||||
it('imports a dropped JSON file into the service account key field', async () => {
|
||||
customRender(<TestForm />)
|
||||
const contents = '{"type":"service_account"}'
|
||||
const file = new File([contents], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'{"type": "service_account", "project_id": "...", ...}'
|
||||
)
|
||||
fireEvent.drop(textarea.closest('div')!, {
|
||||
dataTransfer: { files: [file] },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(textarea).toHaveValue(contents))
|
||||
})
|
||||
|
||||
it('rejects an oversized file without replacing the existing key', async () => {
|
||||
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
|
||||
const file = new File(['x'.repeat(5001)], 'service-account.json', {
|
||||
type: 'application/json',
|
||||
})
|
||||
const readFile = vi.fn()
|
||||
Object.defineProperty(file, 'text', { value: readFile })
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
expect(
|
||||
await screen.findByText('Service account key must be 5,000 characters or fewer.')
|
||||
).toBeInTheDocument()
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the existing key when the selected file cannot be read', async () => {
|
||||
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
|
||||
const file = new File(['{}'], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: vi.fn().mockRejectedValue(new Error('File read failed')),
|
||||
})
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
expect(await screen.findByText('Could not read the selected JSON file.')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+130
-16
@@ -1,10 +1,54 @@
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { FormControl, FormField, Input, TextArea } from 'ui'
|
||||
import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
|
||||
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
|
||||
|
||||
const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000
|
||||
|
||||
const readServiceAccountFile = async (
|
||||
file: File,
|
||||
form: UseFormReturn<DestinationPanelSchemaType>,
|
||||
isCurrentRequest: () => boolean
|
||||
) => {
|
||||
if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
|
||||
if (isCurrentRequest()) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Service account key must be 5,000 characters or fewer.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await file.text()
|
||||
if (!isCurrentRequest()) return
|
||||
|
||||
if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Service account key must be 5,000 characters or fewer.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('serviceAccountKey', contents, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
form.clearErrors('serviceAccountKey')
|
||||
} catch {
|
||||
if (isCurrentRequest()) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Could not read the selected JSON file.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BigQueryFields = ({
|
||||
form,
|
||||
editMode,
|
||||
@@ -12,6 +56,41 @@ export const BigQueryFields = ({
|
||||
form: UseFormReturn<DestinationPanelSchemaType>
|
||||
editMode: boolean
|
||||
}) => {
|
||||
const serviceAccountFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileReadRequestIdRef = useRef(0)
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false)
|
||||
|
||||
const handleServiceAccountFile = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
const requestId = ++fileReadRequestIdRef.current
|
||||
await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current)
|
||||
}
|
||||
|
||||
const handleServiceAccountFileInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
await handleServiceAccountFile(file)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(false)
|
||||
await handleServiceAccountFile(event.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-6 p-5">
|
||||
<p className="text-sm font-medium text-foreground">BigQuery settings</p>
|
||||
@@ -57,23 +136,58 @@ export const BigQueryFields = ({
|
||||
label="Service account key"
|
||||
description={
|
||||
editMode
|
||||
? 'Stored credentials are hidden. Enter new credentials to replace them.'
|
||||
: 'Service account credentials JSON for authenticating with BigQuery'
|
||||
? 'Stored credentials are hidden. Paste or upload new credentials to replace them.'
|
||||
: 'Paste or upload your service account credentials JSON file for authenticating with BigQuery.'
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<TextArea
|
||||
{...field}
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
placeholder={
|
||||
editMode
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: '{"type": "service_account", "project_id": "...", ...}'
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className="relative"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className={cn('space-y-2 transition-opacity', isDraggingFile && 'opacity-40')}>
|
||||
<FormControl>
|
||||
<TextArea
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
fileReadRequestIdRef.current += 1
|
||||
field.onChange(event)
|
||||
}}
|
||||
rows={5}
|
||||
maxLength={MAX_SERVICE_ACCOUNT_KEY_LENGTH}
|
||||
placeholder={
|
||||
editMode
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: '{"type": "service_account", "project_id": "...", ...}'
|
||||
}
|
||||
className="max-h-[calc(13lh+1rem)] font-mono text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<input
|
||||
ref={serviceAccountFileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleServiceAccountFileInputChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<Upload size={14} />}
|
||||
onClick={() => serviceAccountFileInputRef.current?.click()}
|
||||
>
|
||||
Upload JSON file
|
||||
</Button>
|
||||
</div>
|
||||
{isDraggingFile ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-md ring-2 ring-brand ring-offset-2 ring-offset-background"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
+6
-6
@@ -64,26 +64,26 @@ export const getClickHouseValidationIssues = (
|
||||
const issues: ClickHouseValidationIssue[] = []
|
||||
|
||||
if (!data.clickhouseUrl?.length) {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'URL is required' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'URL is required.' })
|
||||
} else {
|
||||
let parsed: URL | undefined
|
||||
|
||||
try {
|
||||
parsed = new URL(data.clickhouseUrl)
|
||||
} catch {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must be a valid URL' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must be a valid URL.' })
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
if (parsed.protocol !== 'https:') {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must use https://' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must use HTTPS.' })
|
||||
} else {
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '')
|
||||
|
||||
if (isClickHouseHostInternal(host)) {
|
||||
issues.push({
|
||||
path: 'clickhouseUrl',
|
||||
message: 'ClickHouse URL must not target an internal address',
|
||||
message: 'ClickHouse URL must not target an internal address.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,11 +91,11 @@ export const getClickHouseValidationIssues = (
|
||||
}
|
||||
|
||||
if (!data.clickhouseUser?.length) {
|
||||
issues.push({ path: 'clickhouseUser', message: 'User is required' })
|
||||
issues.push({ path: 'clickhouseUser', message: 'User is required.' })
|
||||
}
|
||||
|
||||
if (!data.clickhouseDatabase?.length) {
|
||||
issues.push({ path: 'clickhouseDatabase', message: 'Database is required' })
|
||||
issues.push({ path: 'clickhouseDatabase', message: 'Database is required.' })
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
+7
-7
@@ -7,8 +7,8 @@ import { DuckLakeFormSchema } from './DuckLake/DuckLake.schema'
|
||||
import { SnowflakeFormSchema } from './Snowflake/Snowflake.schema'
|
||||
|
||||
const CommonFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
publicationName: z.string().min(1, 'Publication is required'),
|
||||
name: z.string().min(1, 'Name is required.'),
|
||||
publicationName: z.string().min(1, 'Publication is required.'),
|
||||
tableSyncCopyMode: z.enum([
|
||||
'include_all_tables',
|
||||
'skip_all_tables',
|
||||
@@ -18,18 +18,18 @@ const CommonFormSchema = z.object({
|
||||
tableSyncCopyTableIds: z.array(z.string()),
|
||||
maxFillMs: z
|
||||
.number()
|
||||
.int('Batch wait time must be a whole number of milliseconds')
|
||||
.min(0, 'Batch wait time must be 0 or greater')
|
||||
.int('Batch wait time must be a whole number of milliseconds.')
|
||||
.min(0, 'Batch wait time must be 0 or greater.')
|
||||
.optional(),
|
||||
maxTableSyncWorkers: z
|
||||
.number()
|
||||
.min(1, 'Max table sync workers must be greater than 0')
|
||||
.int('Max table sync workers must be a whole number')
|
||||
.min(1, 'Max table sync workers must be greater than 0.')
|
||||
.int('Max table sync workers must be a whole number.')
|
||||
.optional(),
|
||||
maxCopyConnectionsPerTable: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Max copy connections per table must be greater than 0')
|
||||
.min(1, 'Max copy connections per table must be greater than 0.')
|
||||
.optional(),
|
||||
invalidatedSlotBehavior: z.enum(['error', 'recreate']).optional(),
|
||||
})
|
||||
|
||||
+101
-49
@@ -306,12 +306,12 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -363,12 +363,12 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -386,19 +386,19 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
expect(issues).toEqual([
|
||||
{
|
||||
path: 'ducklakeCatalogUrl',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeDataPath',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeS3Endpoint',
|
||||
message: 'S3 endpoint must not contain the protocol scheme',
|
||||
message: 'S3 endpoint must not contain the protocol scheme.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeMetadataSchema',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores.',
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -487,9 +487,9 @@ describe('DestinationForm.utils DuckLake (Use Supabase)', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required' },
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required.' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required.' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -574,11 +574,11 @@ describe('DestinationForm.utils Snowflake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required' },
|
||||
{ path: 'snowflakeUser', message: 'User is required' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required' },
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required.' },
|
||||
{ path: 'snowflakeUser', message: 'User is required.' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required.' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required.' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -653,9 +653,9 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'URL is required' },
|
||||
{ path: 'clickhouseUser', message: 'User is required' },
|
||||
{ path: 'clickhouseDatabase', message: 'Database is required' },
|
||||
{ path: 'clickhouseUrl', message: 'URL is required.' },
|
||||
{ path: 'clickhouseUser', message: 'User is required.' },
|
||||
{ path: 'clickhouseDatabase', message: 'Database is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -666,7 +666,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseUser: 'default',
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([{ path: 'clickhouseUrl', message: 'ClickHouse URL must use https://' }])
|
||||
).toEqual([{ path: 'clickhouseUrl', message: 'ClickHouse URL must use HTTPS.' }])
|
||||
|
||||
expect(
|
||||
getClickHouseValidationIssues({
|
||||
@@ -675,7 +675,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' },
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -711,7 +711,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' },
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -739,9 +739,9 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required' },
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -753,9 +753,9 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required' },
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -769,6 +769,58 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
expect(issues).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects invalid JSON in the service account key', () => {
|
||||
const issues = getBigQueryValidationIssues({
|
||||
projectId: 'my-project',
|
||||
datasetId: 'my_dataset',
|
||||
serviceAccountKey: '{ "type": "service_account" ',
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips JSON shape checks when validateJson is false', () => {
|
||||
const issues = getBigQueryValidationIssues(
|
||||
{
|
||||
projectId: 'my-project',
|
||||
datasetId: 'my_dataset',
|
||||
serviceAccountKey: '{ "type": "service_account" ',
|
||||
},
|
||||
{ validateJson: false }
|
||||
)
|
||||
|
||||
expect(issues).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects non-JSON text in the service account key', () => {
|
||||
const issues = getBigQueryValidationIssues({
|
||||
projectId: 'my-project',
|
||||
datasetId: 'my_dataset',
|
||||
serviceAccountKey: 'not-json',
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('validates JSON when replacing credentials in edit mode', () => {
|
||||
const issues = getBigQueryValidationIssues(
|
||||
{
|
||||
projectId: 'my-project',
|
||||
datasetId: 'my_dataset',
|
||||
serviceAccountKey: '{ invalid',
|
||||
},
|
||||
{ secretsOptional: true }
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows an omitted BigQuery service account key in edit mode', () => {
|
||||
const issues = getBigQueryValidationIssues(
|
||||
{
|
||||
@@ -795,11 +847,11 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'namespace', message: 'Namespace is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'namespace', message: 'Namespace is required.' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -814,11 +866,11 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'namespace', message: 'Namespace is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'namespace', message: 'Namespace is required.' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -832,7 +884,7 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
s3SecretAccessKey: 'secret',
|
||||
})
|
||||
|
||||
expect(issues).toEqual([{ path: 'newNamespaceName', message: 'Namespace name is required' }])
|
||||
expect(issues).toEqual([{ path: 'newNamespaceName', message: 'Namespace name is required.' }])
|
||||
})
|
||||
|
||||
it('skips the S3 secret when creating a new access key', () => {
|
||||
@@ -894,7 +946,7 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -172,7 +172,7 @@ export const buildTableSyncCopyConfig = ({
|
||||
if (mode === 'include_all_tables' || mode === 'skip_all_tables') return { type: mode }
|
||||
|
||||
if (selectedTableIds.length === 0) {
|
||||
throw new Error('Select at least one table for the initial sync')
|
||||
throw new Error('Select at least one table for the initial sync.')
|
||||
}
|
||||
|
||||
const tableIds = selectedTableIds.map(Number)
|
||||
@@ -343,7 +343,7 @@ export const buildDestinationConfigForValidation = ({
|
||||
} else if (selectedType === 'ClickHouse') {
|
||||
return { clickHouse: buildClickHouseConfig(data) }
|
||||
} else {
|
||||
throw new Error('Invalid destination type')
|
||||
throw new Error('Invalid destination type.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -14,9 +14,21 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => {
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="horizontal">
|
||||
<FormItemLayout
|
||||
label="Name"
|
||||
layout="horizontal"
|
||||
description="Used to identify this pipeline in Supabase."
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="My destination" />
|
||||
<Input
|
||||
{...field}
|
||||
autoFocus
|
||||
placeholder="My destination"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ export const DuckLakeFormSchema = z.object({
|
||||
ducklakePoolSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Pool size must be greater than 0')
|
||||
.max(6, 'Pool size must be 6 or less')
|
||||
.min(1, 'Pool size must be greater than 0.')
|
||||
.max(6, 'Pool size must be 6 or less.')
|
||||
.optional(),
|
||||
ducklakeS3AccessKeyId: z.string().optional(),
|
||||
ducklakeS3SecretAccessKey: z.string().optional(),
|
||||
|
||||
+15
-15
@@ -55,18 +55,18 @@ type DucklakeValidationData = Pick<
|
||||
// Required fields per mode. "Use Supabase" only needs project refs + a bucket; the catalog URL and
|
||||
// S3 credentials are resolved by the platform API.
|
||||
const DUCKLAKE_SUPABASE_REQUIRED_FIELDS: DucklakeValidationIssue[] = [
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required' },
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required.' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required.' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required.' },
|
||||
]
|
||||
|
||||
const DUCKLAKE_CUSTOM_REQUIRED_FIELDS: DucklakeValidationIssue[] = [
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
]
|
||||
|
||||
const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set<DucklakeFieldPath>([
|
||||
@@ -79,7 +79,7 @@ const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set<DucklakeFieldPath>([
|
||||
const METADATA_SCHEMA_PATTERN = /^[A-Za-z0-9_]+$/
|
||||
const METADATA_SCHEMA_ISSUE: DucklakeValidationIssue = {
|
||||
path: 'ducklakeMetadataSchema',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores.',
|
||||
}
|
||||
|
||||
const getMissingRequiredFieldIssues = (
|
||||
@@ -113,7 +113,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3AccessKeyId',
|
||||
message: 'S3 access key ID is required',
|
||||
message: 'S3 access key ID is required.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3SecretAccessKey',
|
||||
message: 'S3 secret access key is required',
|
||||
message: 'S3 secret access key is required.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeCatalogUrl',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeDataPath',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3Endpoint',
|
||||
message: 'S3 endpoint must not contain the protocol scheme',
|
||||
message: 'S3 endpoint must not contain the protocol scheme.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ const DuckLakeSupabaseFields = ({ form }: { form: UseFormReturn<DestinationPanel
|
||||
const name = newBucketName.trim()
|
||||
if (!name || !ducklakeStorageProjectRef) return
|
||||
if (name.includes('/')) {
|
||||
return toast.error('Bucket name cannot contain "/"')
|
||||
return toast.error('Bucket name cannot contain "/".')
|
||||
}
|
||||
|
||||
createBucket({
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
getPipelineRegionDescription,
|
||||
PIPELINE_REGION,
|
||||
PipelineRegionField,
|
||||
} from './PipelineRegionField'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
describe('getPipelineRegionDescription', () => {
|
||||
test('includes the fixed-region note and destination-specific hint', () => {
|
||||
expect(getPipelineRegionDescription('BigQuery')).toBe(
|
||||
'All pipelines run from this region. Choose a nearby BigQuery dataset where possible.'
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to a generic destination hint', () => {
|
||||
expect(getPipelineRegionDescription()).toBe(
|
||||
'All pipelines run from this region. Choose a nearby destination region where possible.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PipelineRegionField', () => {
|
||||
test('shows the managed region as read-only information, not a combobox', () => {
|
||||
customRender(<PipelineRegionField destinationType="BigQuery" />)
|
||||
|
||||
expect(screen.getByText('Pipeline region')).toBeInTheDocument()
|
||||
expect(screen.getByText(PIPELINE_REGION.displayName)).toBeInTheDocument()
|
||||
expect(screen.getByText(PIPELINE_REGION.code)).toBeInTheDocument()
|
||||
expect(screen.getByText(/All pipelines run from this region/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/nearby BigQuery dataset/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { AWS_REGIONS } from 'shared-data'
|
||||
import { cn } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import type { DestinationType } from '../DestinationPanel.types'
|
||||
import { RegionFlag } from '@/components/ui/RegionFlag'
|
||||
import { IS_STAGING_OR_LOCAL } from '@/lib/constants'
|
||||
|
||||
// Pipelines always run from a single fixed region per environment, regardless of the source
|
||||
// project's region.
|
||||
export const PIPELINE_REGION = IS_STAGING_OR_LOCAL
|
||||
? AWS_REGIONS.SOUTHEAST_ASIA
|
||||
: AWS_REGIONS.CENTRAL_EU
|
||||
|
||||
const DESTINATION_REGION_HINT: Record<DestinationType, string> = {
|
||||
BigQuery: 'Choose a nearby BigQuery dataset where possible.',
|
||||
'Analytics Bucket': 'Keep this bucket in the same region where possible.',
|
||||
DuckLake: 'Keep catalog and object storage close to this region where possible.',
|
||||
Snowflake: 'Choose a nearby Snowflake account region where possible.',
|
||||
ClickHouse: 'Choose a nearby ClickHouse cluster where possible.',
|
||||
}
|
||||
|
||||
export const getPipelineRegionDescription = (destinationType?: DestinationType) => {
|
||||
const destinationHint = destinationType
|
||||
? DESTINATION_REGION_HINT[destinationType]
|
||||
: 'Choose a nearby destination region where possible.'
|
||||
|
||||
return `All pipelines run from this region. ${destinationHint}`
|
||||
}
|
||||
|
||||
export const PipelineRegionField = ({
|
||||
destinationType,
|
||||
className,
|
||||
}: {
|
||||
destinationType?: DestinationType
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
label="Pipeline region"
|
||||
description={getPipelineRegionDescription(destinationType)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 min-w-0 items-center gap-x-2 rounded-md border bg-surface-200 px-3 text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<RegionFlag className="w-5 shrink-0" region={PIPELINE_REGION.code} />
|
||||
<span className="min-w-0 truncate text-foreground">{PIPELINE_REGION.displayName}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-foreground-lighter">
|
||||
{PIPELINE_REGION.code}
|
||||
</span>
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -23,11 +23,11 @@ export type SnowflakeValidationIssue = {
|
||||
}
|
||||
|
||||
const SNOWFLAKE_REQUIRED_FIELDS: { path: SnowflakeFieldPath; message: string }[] = [
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required' },
|
||||
{ path: 'snowflakeUser', message: 'User is required' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required' },
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required.' },
|
||||
{ path: 'snowflakeUser', message: 'User is required.' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required.' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required.' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required.' },
|
||||
]
|
||||
|
||||
export const getSnowflakeValidationIssues = (
|
||||
|
||||
+1
@@ -71,6 +71,7 @@ vi.mock('ui', () => ({
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
DialogSectionSeparator: () => null,
|
||||
Form: ({ children }: PropsWithChildren) => children,
|
||||
Select: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
|
||||
+23
-66
@@ -5,25 +5,9 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { RefObject, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { AWS_REGIONS } from 'shared-data'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
DialogSectionSeparator,
|
||||
Form,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SheetFooter,
|
||||
SheetSection,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Button, DialogSectionSeparator, Form, SheetFooter, SheetSection } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
@@ -37,7 +21,10 @@ import { type DestinationType, type ExistingDestination } from '../DestinationPa
|
||||
import { AdvancedSettings } from './AdvancedSettings'
|
||||
import { getAnalyticsBucketValidationIssues } from './AnalyticsBucket/AnalyticsBucket.utils'
|
||||
import { AnalyticsBucketFields } from './AnalyticsBucket/Fields'
|
||||
import { getBigQueryValidationIssues } from './BigQuery/BigQuery.utils'
|
||||
import {
|
||||
BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE,
|
||||
getBigQueryValidationIssues,
|
||||
} from './BigQuery/BigQuery.utils'
|
||||
import { BigQueryFields } from './BigQuery/Fields'
|
||||
import { getClickHouseValidationIssues } from './ClickHouse/ClickHouse.utils'
|
||||
import { ClickHouseFields } from './ClickHouse/Fields'
|
||||
@@ -54,6 +41,7 @@ import { DuckLakeFields } from './DuckLake/Fields'
|
||||
import { NewPublicationPanel } from './NewPublicationPanel'
|
||||
import { NoDestinationsAvailable } from './NoDestinationsAvailable'
|
||||
import { PipelineCostDialog } from './PipelineCostDialog'
|
||||
import { PipelineRegionField } from './PipelineRegionField'
|
||||
import { PublicationSelection } from './PublicationSelection'
|
||||
import { SnowflakeFields } from './Snowflake/Fields'
|
||||
import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils'
|
||||
@@ -62,8 +50,6 @@ import { useDestinationForm } from './useDestinationForm'
|
||||
import { ValidationFailuresSection } from './ValidationFailuresSection'
|
||||
import { ValidationWarningsDialog } from './ValidationWarningsDialog'
|
||||
import { CreateAnalyticsBucketSheet } from '@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet'
|
||||
import { InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { RegionFlag } from '@/components/ui/RegionFlag'
|
||||
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query'
|
||||
@@ -71,14 +57,9 @@ import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-
|
||||
import { useReplicationPublicationsQuery } from '@/data/replication/publications-query'
|
||||
import { useReplicationSourceId } from '@/data/replication/sources-query'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { IS_STAGING_OR_LOCAL } from '@/lib/constants'
|
||||
|
||||
const formId = 'destination-editor'
|
||||
|
||||
// Pipelines always run out of a single fixed region per environment, regardless of the source
|
||||
// project's region.
|
||||
const PIPELINE_REGION = IS_STAGING_OR_LOCAL ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.CENTRAL_EU
|
||||
|
||||
interface DestinationFormProps {
|
||||
selectedType: DestinationType
|
||||
visible: boolean
|
||||
@@ -232,11 +213,12 @@ export const DestinationForm = ({
|
||||
}
|
||||
|
||||
if (selectedType === 'BigQuery') {
|
||||
getBigQueryValidationIssues(data, { secretsOptional: editMode }).forEach(
|
||||
({ path, message }) => {
|
||||
addRequiredFieldError(path, message)
|
||||
}
|
||||
)
|
||||
getBigQueryValidationIssues(data, {
|
||||
secretsOptional: editMode,
|
||||
validateJson: false,
|
||||
}).forEach(({ path, message }) => {
|
||||
addRequiredFieldError(path, message)
|
||||
})
|
||||
} else if (selectedType === 'Analytics Bucket') {
|
||||
getAnalyticsBucketValidationIssues(data, {
|
||||
secretsOptional: editMode,
|
||||
@@ -342,6 +324,16 @@ export const DestinationForm = ({
|
||||
}),
|
||||
}
|
||||
|
||||
if (selectedType === 'BigQuery') {
|
||||
const jsonIssue = getBigQueryValidationIssues(data, { secretsOptional: editMode }).find(
|
||||
(issue) => issue.message === BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE
|
||||
)
|
||||
if (jsonIssue) {
|
||||
form.setError(jsonIssue.path, { message: jsonIssue.message })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline prerequisite validation models a new pipeline and cannot
|
||||
// account for resources already owned by an existing pipeline. Edits keep
|
||||
// the established direct-update flow after pruning stale table ids.
|
||||
@@ -469,42 +461,7 @@ export const DestinationForm = ({
|
||||
onSelectNewPublication={() => setPublicationPanelVisible(true)}
|
||||
/>
|
||||
<TableCopySelection form={form} editMode={editMode} />
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
label="Region"
|
||||
description={
|
||||
<span className="text-foreground-lighter">
|
||||
Pipelines run in{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger className={InlineLinkClassName}>
|
||||
{PIPELINE_REGION.displayName}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{PIPELINE_REGION.code}</TooltipContent>
|
||||
</Tooltip>
|
||||
. In your destination provider, choose the closest available region.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Select disabled value={PIPELINE_REGION.code}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PIPELINE_REGION.code}>
|
||||
<div className="flex gap-x-3 items-center">
|
||||
<RegionFlag className="w-5" region={PIPELINE_REGION.code} />
|
||||
<p className="flex items-center gap-x-2">
|
||||
<span>{PIPELINE_REGION.displayName}</span>
|
||||
<span className="text-xs text-foreground-lighter font-mono">
|
||||
{PIPELINE_REGION.code}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
<PipelineRegionField destinationType={selectedType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
|
||||
// Helper function to handle namespace creation if needed
|
||||
const resolveNamespace = async (data: z.infer<typeof FormSchema>) => {
|
||||
if (data.namespace === CREATE_NEW_NAMESPACE) {
|
||||
if (!data.newNamespaceName) throw new Error('New namespace name is required')
|
||||
if (!data.newNamespaceName) throw new Error('New namespace name is required.')
|
||||
|
||||
await createNamespace({
|
||||
projectRef,
|
||||
@@ -224,7 +224,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
|
||||
resolveNamespace,
|
||||
})
|
||||
|
||||
if (!destinationConfig) throw new Error('Destination configuration is missing')
|
||||
if (!destinationConfig) throw new Error('Destination configuration is missing.')
|
||||
|
||||
const shouldSendBatch =
|
||||
!editMode || data.maxFillMs !== (existingBatch?.max_fill_ms ?? DEFAULT_MAX_FILL_MS)
|
||||
|
||||
+9
-4
@@ -76,10 +76,10 @@ describe('DestinationTypeSelection', () => {
|
||||
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders the Pipelines group with BigQuery when the flag is enabled', async () => {
|
||||
test('groups destinations by release stage', async () => {
|
||||
mockBigQueryEnabled.mockReturnValue(true)
|
||||
mockIcebergEnabled.mockReturnValue(false)
|
||||
mockDucklakeEnabled.mockReturnValue(false)
|
||||
mockIcebergEnabled.mockReturnValue(true)
|
||||
mockDucklakeEnabled.mockReturnValue(true)
|
||||
mockSnowflakeEnabled.mockReturnValue(false)
|
||||
mockClickHouseEnabled.mockReturnValue(false)
|
||||
addBackgroundMocks()
|
||||
@@ -88,8 +88,13 @@ describe('DestinationTypeSelection', () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole('combobox'))
|
||||
|
||||
expect(await screen.findByText('Pipelines')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Public Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Early Access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated')).toBeInTheDocument()
|
||||
expect(screen.getByText('BigQuery')).toBeInTheDocument()
|
||||
expect(screen.getByText('DuckLake')).toBeInTheDocument()
|
||||
expect(screen.getByText('Analytics Bucket')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pipelines')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('hides destinations behind disabled feature flags', async () => {
|
||||
|
||||
+77
-88
@@ -1,11 +1,11 @@
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import {
|
||||
Badge,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from '../useIsETLPrivateAlpha'
|
||||
import { DestinationType } from './DestinationPanel.types'
|
||||
import { ReadReplicasMovedCallout } from './ReadReplicasMovedCallout'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
|
||||
interface DestinationTypeOption {
|
||||
value: DestinationType
|
||||
@@ -31,13 +30,9 @@ interface DestinationTypeOption {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const STAGE_BADGE_VARIANT: Record<
|
||||
NonNullable<DestinationTypeOption['stage']>,
|
||||
'warning' | 'destructive' | 'default'
|
||||
> = {
|
||||
'Early Access': 'warning',
|
||||
Deprecated: 'destructive',
|
||||
'Public Alpha': 'default',
|
||||
interface DestinationTypeGroup {
|
||||
label: NonNullable<DestinationTypeOption['stage']>
|
||||
options: DestinationTypeOption[]
|
||||
}
|
||||
|
||||
export const DestinationTypeSelection = () => {
|
||||
@@ -73,67 +68,74 @@ export const DestinationTypeSelection = () => {
|
||||
const isOptionVisible = (value: DestinationType, hasAccess: boolean) =>
|
||||
editMode ? destinationType === value : hasAccess
|
||||
|
||||
const options: DestinationTypeOption[] = (
|
||||
[
|
||||
{
|
||||
value: 'Analytics Bucket',
|
||||
label: 'Analytics Bucket',
|
||||
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
|
||||
stage: 'Deprecated',
|
||||
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
|
||||
},
|
||||
{
|
||||
value: 'BigQuery',
|
||||
label: 'BigQuery',
|
||||
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
|
||||
stage: 'Public Alpha',
|
||||
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
|
||||
},
|
||||
{
|
||||
value: 'DuckLake',
|
||||
label: 'DuckLake',
|
||||
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
|
||||
},
|
||||
{
|
||||
value: 'Snowflake',
|
||||
label: 'Snowflake',
|
||||
description:
|
||||
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
|
||||
},
|
||||
{
|
||||
value: 'ClickHouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
|
||||
},
|
||||
] satisfies DestinationTypeOption[]
|
||||
).filter((option) => option.enabled)
|
||||
const groups: DestinationTypeGroup[] = [
|
||||
{
|
||||
label: 'Public Alpha',
|
||||
options: [
|
||||
{
|
||||
value: 'BigQuery',
|
||||
label: 'BigQuery',
|
||||
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
|
||||
stage: 'Public Alpha',
|
||||
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Early Access',
|
||||
options: [
|
||||
{
|
||||
value: 'DuckLake',
|
||||
label: 'DuckLake',
|
||||
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
|
||||
},
|
||||
{
|
||||
value: 'Snowflake',
|
||||
label: 'Snowflake',
|
||||
description:
|
||||
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
|
||||
},
|
||||
{
|
||||
value: 'ClickHouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deprecated',
|
||||
options: [
|
||||
{
|
||||
value: 'Analytics Bucket',
|
||||
label: 'Analytics Bucket',
|
||||
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
|
||||
stage: 'Deprecated',
|
||||
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleGroups = groups
|
||||
.map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) }))
|
||||
.filter((group) => group.options.length > 0)
|
||||
const options = visibleGroups.flatMap((group) => group.options)
|
||||
|
||||
const selectedOption = options.find((option) => option.value === destinationType)
|
||||
|
||||
const stageDescription =
|
||||
selectedOption?.stage === 'Public Alpha' ? (
|
||||
<>
|
||||
In public alpha and may change.{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
|
||||
Leave feedback
|
||||
</InlineLink>
|
||||
</>
|
||||
) : selectedOption?.stage === 'Early Access' ? (
|
||||
<>
|
||||
In early access and may change.{' '}
|
||||
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
|
||||
Leave feedback
|
||||
</InlineLink>
|
||||
</>
|
||||
) : selectedOption?.stage === 'Deprecated' ? (
|
||||
'This destination type is deprecated.'
|
||||
) : null
|
||||
const STAGE_DESCRIPTIONS: Record<NonNullable<DestinationTypeOption['stage']>, string> = {
|
||||
'Public Alpha': 'In public alpha and may change.',
|
||||
'Early Access': 'In early access and may change.',
|
||||
Deprecated: 'This destination type is deprecated.',
|
||||
}
|
||||
|
||||
const stageDescription = selectedOption?.stage ? STAGE_DESCRIPTIONS[selectedOption.stage] : null
|
||||
|
||||
const typeDescription =
|
||||
!editMode || stageDescription ? (
|
||||
@@ -166,24 +168,18 @@ export const DestinationTypeSelection = () => {
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[selectedOption.stage]}>
|
||||
{selectedOption.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Select a destination type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{options.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Pipelines</SelectLabel>
|
||||
{options.map((option) => (
|
||||
{visibleGroups.map((group, index) => (
|
||||
<SelectGroup key={group.label}>
|
||||
{index > 0 && <SelectSeparator />}
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<DestinationIcon
|
||||
@@ -192,14 +188,7 @@ export const DestinationTypeSelection = () => {
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
{option.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[option.stage]}>
|
||||
{option.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
@@ -208,7 +197,7 @@ export const DestinationTypeSelection = () => {
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
|
||||
import { MessageSquare, MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -29,6 +29,7 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
|
||||
import { EnablePipelinesModal } from './EnablePipelinesCallout'
|
||||
import { PIPELINES_FEEDBACK_URL } from './Replication.constants'
|
||||
import {
|
||||
useIsETLBigQueryPrivateAlpha,
|
||||
useIsETLClickHousePrivateAlpha,
|
||||
@@ -243,6 +244,11 @@ export const Destinations = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button asChild variant="default" icon={<MessageSquare />}>
|
||||
<a href={PIPELINES_FEEDBACK_URL} target="_blank" rel="noreferrer noopener">
|
||||
Leave feedback
|
||||
</a>
|
||||
</Button>
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />
|
||||
|
||||
<Shortcut
|
||||
|
||||
@@ -8,3 +8,5 @@ export enum PipelineStatusName {
|
||||
STOPPING = 'stopping',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export const PIPELINES_FEEDBACK_URL = 'https://github.com/orgs/supabase/discussions/39416'
|
||||
+17
-91
@@ -1,11 +1,14 @@
|
||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
|
||||
import { useParams } from 'common'
|
||||
import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react'
|
||||
import { useParams, useReducedMotion } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { getStatusName } from '../Pipeline.utils'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
import {
|
||||
EdgeVisualChip,
|
||||
getEdgeVisual,
|
||||
type ReplicationState,
|
||||
} from '@/components/ui/ReactFlow/EdgeVisual'
|
||||
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
|
||||
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
|
||||
import {
|
||||
@@ -19,67 +22,6 @@ type EdgeData = {
|
||||
shiftEdgeEnd: boolean
|
||||
}
|
||||
|
||||
interface ReplicationState {
|
||||
isComingUp: boolean
|
||||
isReplicating: boolean
|
||||
isFailed: boolean
|
||||
}
|
||||
|
||||
interface EdgeVisual {
|
||||
Icon: LucideIcon
|
||||
// CSS color shared by the icon and the connecting line so they always match.
|
||||
color: string
|
||||
opacity: number
|
||||
dashArray: string
|
||||
shouldAnimate: boolean
|
||||
shouldSpin?: boolean
|
||||
isFilled?: boolean
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
// Picks the icon + line appearance for a replication state. Both the icon and the line are derived
|
||||
// here from the same state so they always stay in sync. We deliberately don't surface lag: the line
|
||||
// just communicates whether data is moving, stopped, starting, or broken.
|
||||
const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState): EdgeVisual => {
|
||||
if (isFailed) {
|
||||
return {
|
||||
Icon: X,
|
||||
color: 'hsl(var(--destructive-default))',
|
||||
opacity: 1,
|
||||
dashArray: '5 5',
|
||||
shouldAnimate: false,
|
||||
strokeWidth: 4,
|
||||
}
|
||||
}
|
||||
if (isComingUp) {
|
||||
return {
|
||||
Icon: Loader2,
|
||||
color: 'var(--foreground-light)',
|
||||
opacity: 1,
|
||||
dashArray: '5',
|
||||
shouldAnimate: true,
|
||||
shouldSpin: true,
|
||||
}
|
||||
}
|
||||
if (isReplicating) {
|
||||
return {
|
||||
Icon: ArrowRight,
|
||||
color: 'hsl(var(--brand-default))',
|
||||
opacity: 1,
|
||||
dashArray: '5',
|
||||
shouldAnimate: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
Icon: Square,
|
||||
color: 'var(--foreground-lighter)',
|
||||
opacity: 0.5,
|
||||
dashArray: '5 5',
|
||||
shouldAnimate: false,
|
||||
isFilled: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const SmoothstepEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -92,6 +34,7 @@ export const SmoothstepEdge = ({
|
||||
data,
|
||||
}: EdgeProps) => {
|
||||
const { ref: projectRef = 'default' } = useParams()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData
|
||||
|
||||
const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef })
|
||||
@@ -126,16 +69,7 @@ export const SmoothstepEdge = ({
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const {
|
||||
Icon,
|
||||
color,
|
||||
opacity,
|
||||
dashArray,
|
||||
shouldAnimate,
|
||||
shouldSpin,
|
||||
isFilled,
|
||||
strokeWidth = 2,
|
||||
} = getEdgeVisual(replicationState)
|
||||
const visual = getEdgeVisual(replicationState)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -144,11 +78,14 @@ export const SmoothstepEdge = ({
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
...style,
|
||||
stroke: color,
|
||||
strokeWidth,
|
||||
opacity,
|
||||
strokeDasharray: dashArray,
|
||||
animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined,
|
||||
stroke: visual.color,
|
||||
strokeWidth: visual.strokeWidth,
|
||||
opacity: visual.opacity,
|
||||
strokeDasharray: visual.dashArray,
|
||||
animation:
|
||||
visual.shouldAnimate && !prefersReducedMotion
|
||||
? 'dashdraw 0.5s linear infinite'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
@@ -160,18 +97,7 @@ export const SmoothstepEdge = ({
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100'
|
||||
)}
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={cn(shouldSpin && 'animate-spin')}
|
||||
style={{ color, fill: isFilled ? color : undefined }}
|
||||
/>
|
||||
</div>
|
||||
<EdgeVisualChip visual={visual} />
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
|
||||
@@ -150,6 +150,9 @@ export function DiskSection({
|
||||
<DocsButton href={`${DOCS_URL}/guides/platform/database-size`} />
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
<HighAvailabilityDisabledSectionNotice title="Disk management is unavailable for High Availability projects" />
|
||||
|
||||
<PageSectionContent ref={settingsRef} className="flex flex-col gap-4 scroll-mt-24">
|
||||
{isAws && <DiskSpaceBar form={form} />}
|
||||
|
||||
@@ -255,7 +258,7 @@ export function AdvancedSection({
|
||||
<PageSectionContent className="flex flex-col gap-4">
|
||||
<Card ref={autoscaleSettingsRef} className="scroll-mt-24">
|
||||
<CardContent className="flex flex-col gap-y-8">
|
||||
<AutoScaleFields form={form} />
|
||||
<AutoScaleFields form={form} disableInput={disableDiskInputs && disableDiskSizeInput} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ import { AddonVariantId } from '@/data/subscriptions/types'
|
||||
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
useIsAwsCloudProvider,
|
||||
useIsAwsK8sCloudProvider,
|
||||
useIsAwsNimbusCloudProvider,
|
||||
useIsHighAvailability,
|
||||
useSelectedProjectQuery,
|
||||
} from '@/hooks/misc/useSelectedProject'
|
||||
import { GB, PROJECT_STATUS } from '@/lib/constants'
|
||||
@@ -102,7 +102,7 @@ export function DiskManagementForm({
|
||||
const isAws = useIsAwsCloudProvider()
|
||||
const isAwsK8s = useIsAwsK8sCloudProvider()
|
||||
const isAwsNimbus = useIsAwsNimbusCloudProvider()
|
||||
const { isHighAvailability } = useHighAvailability()
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } =
|
||||
useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
|
||||
@@ -209,9 +209,10 @@ export function DiskManagementForm({
|
||||
const usedPercentage = (usedSize / totalSize) * 100
|
||||
|
||||
const disableIopsThroughputConfig =
|
||||
modifiedComputeSize &&
|
||||
!isSpendCapEnabled &&
|
||||
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)
|
||||
isHighAvailability ||
|
||||
(modifiedComputeSize &&
|
||||
!isSpendCapEnabled &&
|
||||
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize))
|
||||
|
||||
const watchedTotalSize = useWatch({ control: form.control, name: 'totalSize' }) ?? 0
|
||||
const watchedStorageType = useWatch({ control: form.control, name: 'storageType' })
|
||||
@@ -231,10 +232,11 @@ export function DiskManagementForm({
|
||||
isRequestingChanges ||
|
||||
isPlanUpgradeRequired ||
|
||||
isWithinCooldownWindow ||
|
||||
isHighAvailability ||
|
||||
!canUpdateDiskConfiguration ||
|
||||
!isAws
|
||||
|
||||
const disableDiskInputs = disableDiskSizeInput || isSpendCapEnabled
|
||||
const disableDiskInputs = disableDiskSizeInput || isSpendCapEnabled || isHighAvailability
|
||||
|
||||
// Compute resizing is not supported for High Availability projects during Alpha
|
||||
const disableComputeInputs = isPlanUpgradeRequired || isHighAvailability
|
||||
|
||||
@@ -17,9 +17,10 @@ import { useDiskAutoscaleCustomConfigQuery } from '@/data/config/disk-autoscale-
|
||||
|
||||
type AutoScaleFieldProps = {
|
||||
form: UseFormReturn<DiskStorageSchemaType>
|
||||
disableInput?: boolean
|
||||
}
|
||||
|
||||
export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => {
|
||||
export const AutoScaleFields = ({ form, disableInput = false }: AutoScaleFieldProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
control,
|
||||
@@ -76,7 +77,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => {
|
||||
id={field.name}
|
||||
type="number"
|
||||
value={field.value ?? undefined}
|
||||
disabled={isError}
|
||||
disabled={disableInput || isError}
|
||||
onChange={(e) => {
|
||||
setValue(
|
||||
'growthPercent',
|
||||
@@ -123,7 +124,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => {
|
||||
id={field.name}
|
||||
type="number"
|
||||
value={field.value ?? undefined}
|
||||
disabled={isError}
|
||||
disabled={disableInput || isError}
|
||||
onChange={(e) => {
|
||||
setValue(
|
||||
'minIncrementGb',
|
||||
@@ -165,7 +166,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => {
|
||||
id={field.name}
|
||||
type="number"
|
||||
value={field.value ?? undefined}
|
||||
disabled={isError}
|
||||
disabled={disableInput || isError}
|
||||
onChange={(e) => {
|
||||
setValue('maxSizeGb', e.target.value === '' ? null : e.target.valueAsNumber, {
|
||||
shouldDirty: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { acceptUntrustedSql } from '@supabase/pg-meta'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import {
|
||||
Check,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
Trash,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
@@ -59,7 +60,10 @@ import { type QueryEditorHandle } from './QueryEditor'
|
||||
import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
|
||||
import { hasDiscardableChanges } from '@/data/content/notebooks/notebook-cache'
|
||||
import {
|
||||
evictNotebookFromCaches,
|
||||
hasDiscardableChanges,
|
||||
} from '@/data/content/notebooks/notebook-cache'
|
||||
import {
|
||||
isQueryCell,
|
||||
WritableCell,
|
||||
@@ -80,6 +84,7 @@ export const ExplorerNotebookTab = () => {
|
||||
const { id, ref } = useParams()
|
||||
const tabs = useTabsStateSnapshot()
|
||||
const snap = useNotebooksStateSnapshot()
|
||||
const queryClient = useQueryClient()
|
||||
const { createChat, isCreating } = useCreateChat()
|
||||
|
||||
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
|
||||
@@ -96,12 +101,14 @@ export const ExplorerNotebookTab = () => {
|
||||
const [isRunningNotebook, setIsRunningNotebook] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false)
|
||||
const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false)
|
||||
const [pendingMutationCells, setPendingMutationCells] = useState<
|
||||
{ id: string; title: string }[] | null
|
||||
>(null)
|
||||
const [skipMutatingCells, setSkipMutatingCells] = useState(false)
|
||||
const queryCellRefs = useRef(new Map<string, QueryEditorHandle>())
|
||||
const savedContentRef = useRef<typeof content>(undefined)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({
|
||||
onSuccess: () => {
|
||||
@@ -199,7 +206,7 @@ export const ExplorerNotebookTab = () => {
|
||||
runNotebook({ cellIdsToRun, force: true })
|
||||
}
|
||||
|
||||
const handleSaveNotebook = () => {
|
||||
const persistNotebook = () => {
|
||||
const notebookId = currentNotebook?.notebook.id
|
||||
if (!ref || !notebookId || !name || !content) return
|
||||
|
||||
@@ -231,6 +238,10 @@ export const ExplorerNotebookTab = () => {
|
||||
}),
|
||||
}
|
||||
|
||||
if (snap.serverDivergedWhileDirty.get(notebookId) === 'deleted') {
|
||||
writableContent.cells = writableContent.cells.map(({ _id: _, ...cell }) => cell)
|
||||
}
|
||||
|
||||
// [Joshen] For tracking if a notebook is updated while being saved, so that we do not
|
||||
// incorrectly show the saved toast if it's subsequently then saved once again while
|
||||
// the initial save is midflight
|
||||
@@ -245,6 +256,32 @@ export const ExplorerNotebookTab = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveNotebook = () => {
|
||||
const notebookId = currentNotebook?.notebook.id
|
||||
if (notebookId && snap.serverDivergedWhileDirty.get(notebookId)) {
|
||||
setIsSaveConflictOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
persistNotebook()
|
||||
}
|
||||
|
||||
const handleSaveAnyway = () => {
|
||||
setIsSaveConflictOpen(false)
|
||||
persistNotebook()
|
||||
}
|
||||
|
||||
const handleDiscardNotebookChanges = async () => {
|
||||
if (!ref || !id) return
|
||||
|
||||
const wasDeletedOnServer = snap.serverDivergedWhileDirty.get(id) === 'deleted'
|
||||
setIsSaveConflictOpen(false)
|
||||
const evicted = await evictNotebookFromCaches({ queryClient, projectRef: ref, id })
|
||||
if (wasDeletedOnServer && evicted) {
|
||||
tabs.removeTab(createTabId('notebook', { id }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyze = () => {
|
||||
createChat({
|
||||
name: `Analyze ${name} notebook`,
|
||||
@@ -286,6 +323,17 @@ export const ExplorerNotebookTab = () => {
|
||||
snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell })
|
||||
}
|
||||
|
||||
const scrollToBottomIfPending = useEffectEvent(() => {
|
||||
if (!id || snap.pendingScrollToBottom !== id || !scrollContainerRef.current) return
|
||||
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
})
|
||||
snap.clearPendingScrollToBottom()
|
||||
})
|
||||
|
||||
useEffect(() => scrollToBottomIfPending(), [id, snap.pendingScrollToBottom, content])
|
||||
|
||||
if (isNotFound) {
|
||||
return (
|
||||
<div className="p-4 h-full bg-surface-100">
|
||||
@@ -318,18 +366,12 @@ export const ExplorerNotebookTab = () => {
|
||||
<ExplorerToolbarAction
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
loading={isCreating}
|
||||
disabled={cells.length === 0}
|
||||
tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined}
|
||||
onClick={handleClickAnalyze}
|
||||
>
|
||||
Analyze
|
||||
</ExplorerToolbarAction>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Run notebook"
|
||||
icon={<Play />}
|
||||
tooltip="Run notebook"
|
||||
loading={isRunningNotebook}
|
||||
disabled={queryCellIds.length === 0}
|
||||
onClick={handleRunNotebook}
|
||||
/>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Save changes"
|
||||
icon={<Save />}
|
||||
@@ -361,10 +403,20 @@ export const ExplorerNotebookTab = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ExplorerToolbarActions>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Run notebook"
|
||||
icon={<Play />}
|
||||
tooltip="Run notebook"
|
||||
loading={isRunningNotebook}
|
||||
disabled={queryCellIds.length === 0}
|
||||
onClick={handleRunNotebook}
|
||||
>
|
||||
Run
|
||||
</ExplorerToolbarAction>
|
||||
</ExplorerToolbarActions>
|
||||
</ExplorerToolbar>
|
||||
|
||||
<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
|
||||
<div ref={scrollContainerRef} className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
|
||||
<div className="p-4 pb-10">
|
||||
{cells.length === 0 && (
|
||||
<EmptyStatePresentational
|
||||
@@ -463,6 +515,25 @@ export const ExplorerNotebookTab = () => {
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
size="small"
|
||||
visible={isSaveConflictOpen}
|
||||
title="Assistant changes detected"
|
||||
additionalActionLabel="Discard changes"
|
||||
confirmLabel={
|
||||
id && snap.serverDivergedWhileDirty.get(id) === 'deleted' ? 'Recreate' : 'Save anyway'
|
||||
}
|
||||
onAdditionalAction={handleDiscardNotebookChanges}
|
||||
onCancel={() => setIsSaveConflictOpen(false)}
|
||||
onConfirm={handleSaveAnyway}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{id && snap.serverDivergedWhileDirty.get(id) === 'deleted'
|
||||
? 'An assistant deleted this notebook after your local changes. Saving will recreate it.'
|
||||
: "An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update."}
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
size="small"
|
||||
visible={pendingMutationCells !== null}
|
||||
|
||||
@@ -1,25 +1,69 @@
|
||||
import { useParams } from 'common'
|
||||
import { Loader2, SquareCode } from 'lucide-react'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { Check, Keyboard, Loader2, MoreVertical, Save, SquareCode } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ExplorerToolbarAction } from './ExplorerToolbar'
|
||||
import { useCreateNotebook } from './hooks'
|
||||
import { QueryEditor, type ExplorerQueryModel } from './QueryEditor'
|
||||
import { type QueryDisplay, type QueryResult } from './types'
|
||||
import { createQueryCellSkeleton } from './utils'
|
||||
import { getNotebook } from '@/data/content/notebooks/notebook-query'
|
||||
import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query'
|
||||
import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { useControlledRoleImpersonationState } from '@/state/role-impersonation-state'
|
||||
import { createTabId, TabsStateContext } from '@/state/tabs'
|
||||
|
||||
/** Query-tab lifecycle adapter around the shared QueryEditor. */
|
||||
export const ExplorerQueryTab = () => {
|
||||
const { id, ref } = useParams()
|
||||
const router = useRouter()
|
||||
const { id, ref } = useParams()
|
||||
const tabs = useContext(TabsStateContext)
|
||||
const querySnap = useExplorerQueryStateSnapshot()
|
||||
|
||||
const { createNotebook } = useCreateNotebook()
|
||||
const notebooksSnap = useNotebooksStateSnapshot()
|
||||
|
||||
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
||||
true
|
||||
)
|
||||
|
||||
const [restoredQueryKey, setRestoredQueryKey] = useState<string>()
|
||||
const [showQuery, setShowQuery] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const { data: notebooksData, isPending } = useNotebooksInfiniteQuery({
|
||||
projectRef: ref,
|
||||
limit: 100,
|
||||
name: search.length === 0 ? search : debouncedSearch,
|
||||
})
|
||||
const notebooks = useMemo(() => {
|
||||
const items = notebooksData?.pages.flatMap((page) => page.content) ?? []
|
||||
return items
|
||||
}, [notebooksData?.pages])
|
||||
|
||||
const stateDraft = id ? querySnap.drafts[id] : undefined
|
||||
const draft = stateDraft?.projectRef === ref ? stateDraft : undefined
|
||||
@@ -94,6 +138,32 @@ export const ExplorerQueryTab = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const onAddToNewNotebook = () => {
|
||||
createNotebook({
|
||||
cells: [createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql })],
|
||||
})
|
||||
}
|
||||
|
||||
const onAddToExistingNotebook = async (notebookId: string) => {
|
||||
if (!ref) return
|
||||
try {
|
||||
if (!notebooksSnap.notebooks[notebookId]?.notebook.content) {
|
||||
const notebook = await getNotebook({ projectRef: ref, id: notebookId })
|
||||
notebooksSnap.setNotebook({ projectRef: ref, notebook })
|
||||
}
|
||||
|
||||
notebooksSnap.insertCellAfter({
|
||||
id: notebookId,
|
||||
cell: createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql }),
|
||||
})
|
||||
notebooksSnap.requestScrollToBottom(notebookId)
|
||||
|
||||
router.push(`/project/${ref}/explorer/notebook/${notebookId}`)
|
||||
} catch (error) {
|
||||
toast.error('Failed to add query to notebook')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryEditor
|
||||
id={id}
|
||||
@@ -128,6 +198,75 @@ export const ExplorerQueryTab = () => {
|
||||
persistTab()
|
||||
explorerQueryState.setDisplay({ id, display })
|
||||
}}
|
||||
toolbarActions={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ExplorerToolbarAction icon={<Save />} tooltip="Save query" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-52" align="end">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Add to existing notebook</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search notebooks..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{isPending ? (
|
||||
<div className="flex flex-col p-1 gap-y-1">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
</div>
|
||||
) : !notebooks?.length ? (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No notebooks found
|
||||
</p>
|
||||
) : null}
|
||||
{notebooks?.map((notebook) => (
|
||||
<CommandItem
|
||||
key={notebook.id}
|
||||
value={notebook.id}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => onAddToExistingNotebook(notebook.id)}
|
||||
>
|
||||
{notebook.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onClick={onAddToNewNotebook}>
|
||||
Create a new notebook
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ExplorerToolbarAction icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
onClick={() => setIsIntellisenseEnabled(!isIntellisenseEnabled)}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Keyboard size={14} />
|
||||
<span>Intellisense enabled</span>
|
||||
</div>
|
||||
{isIntellisenseEnabled && <Check className="text-brand" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMonaco } from '@monaco-editor/react'
|
||||
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
|
||||
import { useFlag } from 'common'
|
||||
import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import type { editor as monacoEditor, Selection } from 'monaco-editor'
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
@@ -46,6 +46,7 @@ import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefin
|
||||
import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget'
|
||||
import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { getEditorValueOrSelection } from '@/components/ui/CodeEditor/CodeEditor.utils'
|
||||
import { DiffEditor } from '@/components/ui/DiffEditor'
|
||||
import {
|
||||
type DatabaseSourceParameters,
|
||||
@@ -195,6 +196,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
const [promptState, setPromptState] = useState<(EditorSelection & { isOpen: boolean }) | null>(
|
||||
null
|
||||
)
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
|
||||
const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres'
|
||||
const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect })
|
||||
@@ -237,13 +239,6 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
const isExecuting = isExecutingSql || isExecutingLogs
|
||||
const isBusy = isLoadingProject || isResolvingDatabase || isExecuting
|
||||
|
||||
/**
|
||||
* The user's run gesture, and therefore the promotion point for this query's SQL. The
|
||||
* raw text comes straight off the editor, so it is (re)branded untrusted here — the
|
||||
* editor boundary — and promoted in the same handler. Which pair of helpers applies is
|
||||
* decided by `query._tag`, the same discriminant that picks the execution endpoint, so
|
||||
* Postgres SQL cannot reach the analytics wire or vice versa.
|
||||
*/
|
||||
const handleRunQuery = async ({
|
||||
rawSql = sql,
|
||||
shouldForce = false,
|
||||
@@ -371,13 +366,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
return (
|
||||
<>
|
||||
<Shell className={cn(variant === 'embedded' && 'mx-auto max-w-6xl', className)}>
|
||||
<ExplorerToolbar>
|
||||
<ExplorerToolbar className={cn(variant === 'viewport' && 'px-4')}>
|
||||
<ExplorerToolbarIcon>
|
||||
<CodeSquare size={14} />
|
||||
</ExplorerToolbarIcon>
|
||||
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
|
||||
<ExplorerToolbarActions>
|
||||
{toolbarActions}
|
||||
{onSourceChange && (
|
||||
<QuerySourceMenu
|
||||
disabled={pendingProposal !== null}
|
||||
@@ -391,6 +385,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
roleImpersonationState={roleImpersonationState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{display && onDisplayChange && (
|
||||
<DisplaySettingsButton
|
||||
result={result}
|
||||
@@ -406,16 +401,23 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
tooltip={showQuery ? 'Hide query' : 'Show query'}
|
||||
onClick={() => onShowQueryChange(!showQuery)}
|
||||
/>
|
||||
|
||||
{toolbarActions}
|
||||
|
||||
<ExplorerToolbarAction
|
||||
loading={isExecuting}
|
||||
icon={<Play />}
|
||||
tooltip="Run query"
|
||||
loading={isExecuting}
|
||||
tooltip={hasSelection ? 'Run selected query' : 'Run query'}
|
||||
disabled={
|
||||
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
|
||||
}
|
||||
onClick={() => handleRunQuery()}
|
||||
onClick={() => {
|
||||
const editorInstance = editorInstanceRef.current
|
||||
const rawSql = editorInstance ? getEditorValueOrSelection(editorInstance) : sql
|
||||
handleRunQuery({ rawSql })
|
||||
}}
|
||||
>
|
||||
Run
|
||||
{hasSelection ? 'Run selected' : 'Run'}
|
||||
</ExplorerToolbarAction>
|
||||
</ExplorerToolbarActions>
|
||||
</ExplorerToolbar>
|
||||
@@ -447,7 +449,10 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
placeholderClassName="top-[13px]"
|
||||
className={variant === 'embedded' ? 'h-44' : undefined}
|
||||
actions={{
|
||||
runQuery: { enabled: !isRunDisabled, callback: handleRunQuery },
|
||||
runQuery: {
|
||||
enabled: !isRunDisabled,
|
||||
callback: (rawSql: string) => handleRunQuery({ rawSql }),
|
||||
},
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
@@ -458,6 +463,23 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
|
||||
editorInstanceRef.current = editor
|
||||
|
||||
const updateHasSelection = (selection: Selection | null | undefined) => {
|
||||
const noSelection =
|
||||
!selection ||
|
||||
(selection.startLineNumber === selection.endLineNumber &&
|
||||
selection.startColumn === selection.endColumn)
|
||||
setHasSelection(!noSelection)
|
||||
}
|
||||
|
||||
// A remount (e.g. toggling "Show query" off then on) creates a fresh
|
||||
// editor with no listener history, so `hasSelection` must be read from
|
||||
// this instance directly rather than left at whatever the previous
|
||||
// editor instance last reported.
|
||||
updateHasSelection(editor.getSelection())
|
||||
editor.onDidChangeCursorSelection(({ selection }) =>
|
||||
updateHasSelection(selection)
|
||||
)
|
||||
|
||||
editor.addAction({
|
||||
id: 'generate-sql',
|
||||
label: 'Generate SQL',
|
||||
|
||||
+241
-5
@@ -1,7 +1,8 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExplorerNotebookTab } from '../ExplorerNotebookTab'
|
||||
import type { components } from '@/data/api'
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '@/lib/ai/test-fixtures'
|
||||
import { notebooksState } from '@/state/notebooks/notebooks-state'
|
||||
import type { Notebook } from '@/state/notebooks/types'
|
||||
import { createTabsState, TabsStateContext } from '@/state/tabs'
|
||||
import { createTabId, createTabsState, TabsStateContext } from '@/state/tabs'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
|
||||
@@ -62,14 +63,18 @@ const seedNotebook = () => {
|
||||
notebooksState.setNotebook({ projectRef: PROJECT_REF, notebook })
|
||||
}
|
||||
|
||||
const renderNotebookTab = (queryClient: QueryClient) =>
|
||||
const renderNotebookTab = (queryClient: QueryClient, tabsState = createTabsState(PROJECT_REF)) =>
|
||||
customRender(
|
||||
<TabsStateContext.Provider value={createTabsState(PROJECT_REF)}>
|
||||
<TabsStateContext.Provider value={tabsState}>
|
||||
<ExplorerNotebookTab />
|
||||
</TabsStateContext.Provider>,
|
||||
{ queryClient }
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
notebooksState.serverDivergedWhileDirty.clear()
|
||||
})
|
||||
|
||||
describe('ExplorerNotebookTab — assistant cache invalidation', () => {
|
||||
it('refetches and renders the updated cells after an assistant update_notebook tool call completes', async () => {
|
||||
setupSqlEditorMocks()
|
||||
@@ -205,4 +210,235 @@ describe('ExplorerNotebookTab — assistant cache invalidation', () => {
|
||||
expect(await screen.findByText('Notebook not found')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Original content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves normally without a conflict dialog when the notebook has not diverged', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
const queryClient = new QueryClient()
|
||||
let mutationCount = 0
|
||||
addAPIMock({
|
||||
method: 'put',
|
||||
path: '/platform/projects/:ref/content',
|
||||
response: () => {
|
||||
mutationCount += 1
|
||||
return HttpResponse.json({ id: NOTEBOOK_ID })
|
||||
},
|
||||
})
|
||||
|
||||
renderNotebookTab(queryClient)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
|
||||
await waitFor(() => expect(mutationCount).toBe(1))
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Assistant changes detected' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
type: 'updated' as const,
|
||||
description:
|
||||
"An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update.",
|
||||
saveLabel: 'Save anyway',
|
||||
},
|
||||
{
|
||||
type: 'deleted' as const,
|
||||
description:
|
||||
'An assistant deleted this notebook after your local changes. Saving will recreate it.',
|
||||
saveLabel: 'Recreate',
|
||||
},
|
||||
])('shows the $type conflict copy before saving', async ({ type, description, saveLabel }) => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type })
|
||||
|
||||
renderNotebookTab(new QueryClient())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Assistant changes detected' })
|
||||
expect(dialog).toHaveTextContent(description)
|
||||
expect(dialog).toHaveTextContent(saveLabel)
|
||||
expect(dialog).toHaveTextContent('Discard changes')
|
||||
})
|
||||
|
||||
it('saves exactly once and clears the conflict only after a successful save', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' })
|
||||
const queryClient = new QueryClient()
|
||||
let mutationCount = 0
|
||||
let resolveSave: (() => void) | undefined
|
||||
const savePromise = new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
addAPIMock({
|
||||
method: 'put',
|
||||
path: '/platform/projects/:ref/content',
|
||||
response: async () => {
|
||||
mutationCount += 1
|
||||
await savePromise
|
||||
return HttpResponse.json({ id: NOTEBOOK_ID })
|
||||
},
|
||||
})
|
||||
|
||||
renderNotebookTab(queryClient)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated')
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save anyway' }))
|
||||
|
||||
await waitFor(() => expect(mutationCount).toBe(1))
|
||||
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated')
|
||||
resolveSave?.()
|
||||
await waitFor(() =>
|
||||
expect(notebooksState.serverDivergedWhileDirty.has(NOTEBOOK_ID)).toBe(false)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the conflict marker when saving anyway fails', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' })
|
||||
let mutationCount = 0
|
||||
addAPIMock({
|
||||
method: 'put',
|
||||
path: '/platform/projects/:ref/content',
|
||||
response: () => {
|
||||
mutationCount += 1
|
||||
return HttpResponse.json({ message: 'Save failed' }, { status: 500 })
|
||||
},
|
||||
})
|
||||
|
||||
renderNotebookTab(new QueryClient())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save anyway' }))
|
||||
|
||||
await waitFor(() => expect(mutationCount).toBe(1))
|
||||
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated')
|
||||
})
|
||||
|
||||
it('omits existing cell IDs when saving anyway recreates an assistant-deleted notebook', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'deleted' })
|
||||
let sentBody: Record<string, unknown> | undefined
|
||||
addAPIMock({
|
||||
method: 'put',
|
||||
path: '/platform/projects/:ref/content',
|
||||
response: async ({ request }) => {
|
||||
sentBody = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({ id: NOTEBOOK_ID })
|
||||
},
|
||||
})
|
||||
|
||||
renderNotebookTab(new QueryClient())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Recreate' }))
|
||||
|
||||
await waitFor(() => expect(sentBody).toBeDefined())
|
||||
const content = sentBody?.content as { cells: Array<Record<string, unknown>> }
|
||||
content.cells.forEach((cell) => expect(cell).not.toHaveProperty('_id'))
|
||||
})
|
||||
|
||||
it('discards local changes without mutating and evicts the conflicted notebook', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' })
|
||||
const queryClient = new QueryClient()
|
||||
queryClient.setQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID), { id: NOTEBOOK_ID })
|
||||
let mutationCount = 0
|
||||
addAPIMock({
|
||||
method: 'put',
|
||||
path: '/platform/projects/:ref/content',
|
||||
response: () => {
|
||||
mutationCount += 1
|
||||
return HttpResponse.json({ id: NOTEBOOK_ID })
|
||||
},
|
||||
})
|
||||
|
||||
renderNotebookTab(queryClient)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Discard changes' }))
|
||||
|
||||
await waitFor(() => expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeUndefined())
|
||||
expect(notebooksState.serverDivergedWhileDirty.has(NOTEBOOK_ID)).toBe(false)
|
||||
expect(queryClient.getQueryData(contentKeys.resource(PROJECT_REF, NOTEBOOK_ID))).toBeUndefined()
|
||||
expect(mutationCount).toBe(0)
|
||||
})
|
||||
|
||||
it('closes the notebook tab when discarding edits after an assistant deletion', async () => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'deleted' })
|
||||
const tabsState = createTabsState(PROJECT_REF)
|
||||
const tabId = createTabId('notebook', { id: NOTEBOOK_ID })
|
||||
tabsState.addTab({ id: tabId, type: 'notebook', metadata: { notebookId: NOTEBOOK_ID } })
|
||||
|
||||
renderNotebookTab(new QueryClient(), tabsState)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Discard changes' }))
|
||||
|
||||
await waitFor(() => expect(tabsState.tabsMap[tabId]).toBeUndefined())
|
||||
})
|
||||
|
||||
it.each(['the close button', 'the backdrop'])(
|
||||
'dismisses the conflict with %s without changing notebook state',
|
||||
async (dismissal) => {
|
||||
setupSqlEditorMocks()
|
||||
seedNotebook()
|
||||
notebooksState.updateCells({
|
||||
id: NOTEBOOK_ID,
|
||||
cells: notebooksState.notebooks[NOTEBOOK_ID]!.notebook.content!.cells,
|
||||
})
|
||||
notebooksState.markServerDivergence({ id: NOTEBOOK_ID, type: 'updated' })
|
||||
|
||||
renderNotebookTab(new QueryClient())
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save changes' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Assistant changes detected' })
|
||||
if (dismissal === 'the close button') {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
} else {
|
||||
fireEvent.pointerDown(dialog.parentElement!, { button: 0, ctrlKey: false })
|
||||
}
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Assistant changes detected' })
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(notebooksState.notebooks[NOTEBOOK_ID]).toBeDefined()
|
||||
expect(notebooksState.serverDivergedWhileDirty.get(NOTEBOOK_ID)).toBe('updated')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExplorerQueryTab } from '../ExplorerQueryTab'
|
||||
@@ -14,6 +15,8 @@ import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
|
||||
const testContext = vi.hoisted(() => ({
|
||||
flags: { otelLegacyLogs: true } as Record<string, boolean>,
|
||||
params: { ref: 'default', id: 'query-test' } as { ref?: string; id?: string },
|
||||
/** Simulated editor selection — the mocked CodeEditor's fake editor reads this. */
|
||||
selectedText: undefined as string | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
@@ -30,16 +33,51 @@ vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({
|
||||
CodeEditor: ({
|
||||
value,
|
||||
onInputChange,
|
||||
onMount,
|
||||
}: {
|
||||
value: string
|
||||
onInputChange?: (value: string | undefined) => void
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="SQL editor"
|
||||
value={value}
|
||||
onChange={(e) => onInputChange?.(e.target.value)}
|
||||
/>
|
||||
),
|
||||
onMount?: (editor: any, monaco: any) => void
|
||||
}) => {
|
||||
// Kept fresh via a ref (rather than closed over) so the fake editor's
|
||||
// `getValue` reflects edits made after mount, same as the real editor would.
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
useEffect(() => {
|
||||
const hasSelection = testContext.selectedText !== undefined
|
||||
// Matches this instance's selection at the moment it mounts — a fresh editor
|
||||
// instance (e.g. after "Show query" remounts it) has no memory of a prior
|
||||
// instance's selection, so this must be read fresh rather than carried over.
|
||||
const selection = hasSelection
|
||||
? { startLineNumber: 1, endLineNumber: 2, startColumn: 1, endColumn: 5 }
|
||||
: null
|
||||
|
||||
onMount?.(
|
||||
{
|
||||
getValue: () => valueRef.current,
|
||||
getSelection: () => selection,
|
||||
getModel: () => ({ getValueInRange: () => testContext.selectedText }),
|
||||
onDidBlurEditorWidget: () => () => {},
|
||||
// Intentionally never invoked here — the real editor only fires this on a
|
||||
// subsequent user-driven selection change, not eagerly on mount. Tests that
|
||||
// need to simulate a live selection change should call this directly.
|
||||
onDidChangeCursorSelection: () => ({ dispose: () => {} }),
|
||||
addAction: () => {},
|
||||
},
|
||||
{ KeyMod: { CtrlCmd: 1, Shift: 2 }, KeyCode: { KeyK: 3, Enter: 4 } }
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
aria-label="SQL editor"
|
||||
value={value}
|
||||
onChange={(e) => onInputChange?.(e.target.value)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../QueryEditor/QuerySourceMenu', () => ({
|
||||
@@ -132,6 +170,7 @@ beforeEach(() => {
|
||||
setupSqlEditorMocks()
|
||||
testContext.flags.otelLegacyLogs = true
|
||||
testContext.params = { ref: 'default', id: 'query-test' }
|
||||
testContext.selectedText = undefined
|
||||
explorerQueryState.removeDraft({ id: 'query-test', projectRef: 'default' })
|
||||
})
|
||||
|
||||
@@ -389,4 +428,75 @@ describe('QueryTab execution', () => {
|
||||
expect(executedQueries[0]).toContain('create table foo (id int)')
|
||||
expect(executedQueries[0]).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;')
|
||||
})
|
||||
|
||||
it('runs only the selected text, not the full editor content, when there is an active selection', async () => {
|
||||
createDraft({ _tag: 'database' }, 'select 1;\nselect 2;')
|
||||
testContext.selectedText = 'select 2;'
|
||||
|
||||
// Background prefetches (intellisense keywords/functions/schemas/table-columns, event
|
||||
// triggers) hit this same generic pg-meta query endpoint, distinguished from an actual
|
||||
// run by their non-empty `key` search param — an executed query's `key` is `''`.
|
||||
const executedQueries: string[] = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/pg-meta/:ref/query',
|
||||
response: async ({ request }) => {
|
||||
const key = new URL(request.url).searchParams.get('key')
|
||||
if (key !== '') return HttpResponse.json([])
|
||||
const { query } = (await request.json()) as { query: string }
|
||||
executedQueries.push(query)
|
||||
return HttpResponse.json([])
|
||||
},
|
||||
})
|
||||
|
||||
renderQueryTab()
|
||||
const runButton = await screen.findByRole('button', { name: 'Run selected' })
|
||||
await waitFor(() => expect(runButton).toBeEnabled())
|
||||
await userEvent.click(runButton)
|
||||
|
||||
await waitFor(() => expect(executedQueries).toHaveLength(1))
|
||||
expect(executedQueries[0]).toContain('select 2')
|
||||
expect(executedQueries[0]).not.toContain('select 1')
|
||||
})
|
||||
|
||||
it('drops the stale "Run selected" state once the query panel is hidden and shown again', async () => {
|
||||
createDraft({ _tag: 'database' }, 'select 1;\nselect 2;')
|
||||
testContext.selectedText = 'select 2;'
|
||||
|
||||
const executedQueries: string[] = []
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/pg-meta/:ref/query',
|
||||
response: async ({ request }) => {
|
||||
const key = new URL(request.url).searchParams.get('key')
|
||||
if (key !== '') return HttpResponse.json([])
|
||||
const { query } = (await request.json()) as { query: string }
|
||||
executedQueries.push(query)
|
||||
return HttpResponse.json([])
|
||||
},
|
||||
})
|
||||
|
||||
renderQueryTab()
|
||||
expect(await screen.findByRole('button', { name: 'Run selected' })).toBeInTheDocument()
|
||||
|
||||
// Hiding the query panel unmounts CodeEditor entirely. Simulate the selection being
|
||||
// gone by the time it's shown again (a fresh editor instance has no selection yet).
|
||||
const hideQueryButton = document.querySelector('.lucide-eye-off')?.closest('button')
|
||||
expect(hideQueryButton).toBeInstanceOf(HTMLButtonElement)
|
||||
await userEvent.click(hideQueryButton as HTMLButtonElement)
|
||||
testContext.selectedText = undefined
|
||||
|
||||
const showQueryButton = document.querySelector('.lucide-eye')?.closest('button')
|
||||
expect(showQueryButton).toBeInstanceOf(HTMLButtonElement)
|
||||
await userEvent.click(showQueryButton as HTMLButtonElement)
|
||||
|
||||
const runButton = await screen.findByRole('button', { name: 'Run' })
|
||||
expect(screen.queryByRole('button', { name: 'Run selected' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(runButton)
|
||||
|
||||
await waitFor(() => expect(executedQueries).toHaveLength(1))
|
||||
expect(executedQueries[0]).toContain('select 1')
|
||||
expect(executedQueries[0]).toContain('select 2')
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
|
||||
const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = {
|
||||
title: 'Role Impersonation',
|
||||
icon: () => <IconUser />,
|
||||
content: () => <RoleImpersonationSelector orientation="vertical" />,
|
||||
content: () => <RoleImpersonationSelector />,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
data: { ref: 'default', connectionString: 'postgres://localhost' },
|
||||
}),
|
||||
useIsOrioleDb: () => false,
|
||||
useIsHighAvailability: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('common', async (importOriginal) => {
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
useSelectedProjectQuery: () => ({
|
||||
data: { ref: 'default', connectionString: 'postgres://localhost' },
|
||||
}),
|
||||
useIsHighAvailability: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useProtectedSchemas', () => ({
|
||||
|
||||
+8
-18
@@ -1,7 +1,6 @@
|
||||
import { useParams } from 'common'
|
||||
import { Check, ChevronDown, Plus, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { HTMLAttributes } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from 'ui'
|
||||
|
||||
import { Project, type ForeignProject, type ProjectLinkerProps } from './VercelGithub.types'
|
||||
import { CommandItemLink } from '@/components/ui/CommandItemLink'
|
||||
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
@@ -161,7 +161,6 @@ export const SupabaseProjectSelector = ({
|
||||
setOpen: (val: boolean) => void
|
||||
setSelectedSupabaseProject: (project: Project) => void
|
||||
} & Pick<ProjectLinkerProps, 'slug' | 'variant' | 'defaultSupabaseProject'>) => {
|
||||
const router = useRouter()
|
||||
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
||||
const projectCreationEnabled = useIsFeatureEnabled('projects:create')
|
||||
|
||||
@@ -226,23 +225,14 @@ export const SupabaseProjectSelector = ({
|
||||
return (
|
||||
projectCreationEnabled && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
router.push(`/new/${selectedOrganization?.slug}`)
|
||||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
<CommandItemLink
|
||||
href={`/new/${selectedOrganization?.slug}`}
|
||||
className="cursor-pointer w-full gap-2"
|
||||
onSelect={() => setOpen(false)}
|
||||
>
|
||||
<Link
|
||||
href={`/new/${selectedOrganization?.slug}`}
|
||||
className="w-full flex items-center gap-2"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
<p>Create a new project</p>
|
||||
</Link>
|
||||
</CommandItem>
|
||||
<Plus size={14} strokeWidth={1.5} />
|
||||
<p>Create a new project</p>
|
||||
</CommandItemLink>
|
||||
</CommandGroup>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -251,11 +251,7 @@ export const RegionSelector = ({
|
||||
<div className="flex items-center gap-x-3">
|
||||
{isLoading && <Loader2 size={14} className="animate-spin" />}
|
||||
{selectedRegion?.code && (
|
||||
<RegionFlag
|
||||
role="presentation"
|
||||
className="w-5"
|
||||
region={selectedRegion.code}
|
||||
/>
|
||||
<RegionFlag className="w-5" region={selectedRegion.code} />
|
||||
)}
|
||||
<span className="text-foreground">{triggerLabel}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'common'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastr
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
/** The label a database row/summary shows: the primary, or a replica by region + id. */
|
||||
function databaseLabel(identifier: string, region: string, projectRef: string | undefined) {
|
||||
@@ -28,6 +29,7 @@ export const DatabaseParametersSubMenu = ({
|
||||
onIdentifierChange: (identifier: string) => void
|
||||
}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
||||
|
||||
const { data } = useReadReplicasQuery({ projectRef })
|
||||
@@ -68,7 +70,7 @@ export const DatabaseParametersSubMenu = ({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
{infrastructureReadReplicas && (
|
||||
{IS_PLATFORM && infrastructureReadReplicas && !isHighAvailability && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="gap-x-2">
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export interface IconProps {
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
export const ServiceRoleIcon = ({ isSelected = false }: IconProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<svg width="53" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={isSelected ? '1' : '.5'}>
|
||||
<rect
|
||||
x="37.161"
|
||||
y=".53"
|
||||
width="15"
|
||||
height="15"
|
||||
rx="5.5"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<path d="M1 10.53h32.214" stroke="#33A7E9" strokeLinecap="round" strokeDasharray="2 2" />
|
||||
<rect
|
||||
x="15.964"
|
||||
y=".53"
|
||||
width="9"
|
||||
height="15"
|
||||
rx="4.5"
|
||||
stroke={resolvedTheme === 'light' ? '#7E868C' : '#7E7E7E'}
|
||||
/>
|
||||
<path d="M1 5.53h32.214" stroke="#33A7E9" strokeLinecap="round" strokeDasharray="2 2" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const AnonIcon = ({ isSelected = false }: IconProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<svg width="53" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={isSelected ? '1' : '.5'}>
|
||||
<path
|
||||
d="M1 4.994a.5.5 0 0 0 0 1v-1Zm16.218 1h.5v-1h-.5v1ZM1 5.994h1.014v-1H1v1Zm3.04 0h2.028v-1H4.041v1Zm4.056 0h2.027v-1H8.096v1Zm4.054 0h2.027v-1H12.15v1Zm4.055 0h1.013v-1h-1.013v1Z"
|
||||
fill={resolvedTheme === 'light' ? '#7E868C' : '#7E7E7E'}
|
||||
/>
|
||||
<path
|
||||
d="m15.92 12.56 9.04-9.04"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<rect
|
||||
x="15.964"
|
||||
y=".494"
|
||||
width="9"
|
||||
height="15"
|
||||
rx="4.5"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<rect
|
||||
x="37.161"
|
||||
y=".744"
|
||||
width="15"
|
||||
height="15"
|
||||
rx="5.5"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<path d="M1 10.494h32.214" stroke="#33A7E9" strokeLinecap="round" strokeDasharray="2 2" />
|
||||
<path
|
||||
d="M15.96 7.562 22.568.956M19.049 14.885l5.957-5.958"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthenticatedIcon = ({ isSelected = false }: IconProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<svg width="68" height="17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={isSelected ? '1' : '.5'}>
|
||||
<path
|
||||
d="M15.63 5.156a.5.5 0 1 0 0 1v-1Zm16.218 1h.5v-1h-.5v1Zm-16.218 0h1.013v-1H15.63v1Zm3.04 0h2.028v-1h-2.027v1Zm4.055 0h2.028v-1h-2.028v1Zm4.055 0h2.027v-1H26.78v1Zm4.055 0h1.013v-1h-1.013v1Z"
|
||||
fill={resolvedTheme === 'light' ? '#7E868C' : '#7E7E7E'}
|
||||
/>
|
||||
<path
|
||||
d="m30.55 12.722 9.04-9.04"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<rect
|
||||
x="30.594"
|
||||
y=".656"
|
||||
width="9"
|
||||
height="15"
|
||||
rx="4.5"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<rect
|
||||
x="51.791"
|
||||
y=".906"
|
||||
width="15"
|
||||
height="15"
|
||||
rx="5.5"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<path
|
||||
d="M15.63 10.656h32.214"
|
||||
stroke="#33A7E9"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="m30.59 7.724 6.607-6.606M33.679 15.047l5.957-5.958"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
/>
|
||||
<path
|
||||
d="M12.666 14.281v-1.333A2.667 2.667 0 0 0 10 10.28H6a2.667 2.667 0 0 0-2.667 2.667v1.333M8 7.615A2.667 2.667 0 1 0 8 2.28a2.667 2.667 0 0 0 0 5.334Z"
|
||||
stroke={resolvedTheme === 'light' ? '#11181C' : '#EDEDED'}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import { Check, Minus } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export interface RoleImpersonationRadioProps<T extends string> {
|
||||
label?: string
|
||||
description?: string
|
||||
value: T
|
||||
isSelected: boolean | 'partially'
|
||||
onSelectedChange: (value: T) => void
|
||||
icon?: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export function RoleImpersonationRadio<T extends string>({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
isSelected,
|
||||
onSelectedChange,
|
||||
icon,
|
||||
fullWidth = false,
|
||||
}: RoleImpersonationRadioProps<T>) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'border border-default rounded-md bg-surface-200 hover:bg-overlay-hover hover:border-control-hover px-4 py-3 cursor-pointer transition-colors',
|
||||
fullWidth ? 'w-full' : 'w-44',
|
||||
isSelected && 'border-control-hover hover:border-control-hover bg-surface-300'
|
||||
)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelectedChange(value)
|
||||
}
|
||||
}}
|
||||
htmlFor={`role-${value}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{icon && <div>{icon}</div>}
|
||||
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center p-0.5 bg-foreground text-background rounded-full">
|
||||
{typeof isSelected === 'boolean' && (
|
||||
<Check size={12} strokeWidth="4" className="text-background" />
|
||||
)}
|
||||
{isSelected === 'partially' && (
|
||||
<Minus size={12} strokeWidth="4" className="text-background" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
id={`role-${value}`}
|
||||
type="radio"
|
||||
name="role"
|
||||
value={value}
|
||||
checked={Boolean(isSelected)}
|
||||
onChange={(e) => {
|
||||
onSelectedChange(e.target.value as T)
|
||||
}}
|
||||
className="invisible h-0 w-0 border-0"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm text-foreground-light whitespace-nowrap select-none transition-colors',
|
||||
isSelected && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{label ?? value}
|
||||
</span>
|
||||
|
||||
{description && <p className="text-foreground-lighter text-xs">{description}</p>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { ImpersonationRole, PostgrestRole } from '@/lib/role-impersonation'
|
||||
|
||||
export function getSelectedRoleOption(role: ImpersonationRole | undefined): PostgrestRole {
|
||||
if (role?.type === 'postgrest' && (role.role === 'anon' || role.role === 'authenticated')) {
|
||||
return role.role
|
||||
}
|
||||
|
||||
return 'service_role'
|
||||
}
|
||||
|
||||
type RoleSelectionUpdate =
|
||||
| { shouldSetRole: false }
|
||||
| { shouldSetRole: true; role: ImpersonationRole | undefined }
|
||||
|
||||
export function getRoleSelectionUpdate(value: PostgrestRole): RoleSelectionUpdate {
|
||||
if (value === 'service_role') {
|
||||
return { shouldSetRole: true, role: undefined }
|
||||
}
|
||||
|
||||
if (value === 'anon') {
|
||||
return { shouldSetRole: true, role: { type: 'postgrest', role: 'anon' } }
|
||||
}
|
||||
|
||||
return { shouldSetRole: false }
|
||||
}
|
||||
+375
-462
@@ -1,62 +1,75 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react'
|
||||
import { ChevronsUpDown, User as IconUser, Loader2, X } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
DropdownMenuSeparator,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
ScrollArea,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
|
||||
import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils'
|
||||
import { getDisplayName } from '../Auth/Users/Users.utils'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
|
||||
import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTokenHookDetails'
|
||||
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { type RoleImpersonationController } from '@/state/role-impersonation-state'
|
||||
import type { RoleImpersonationController } from '@/state/role-impersonation-state'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
type AuthenticatorAssuranceLevels = 'aal1' | 'aal2'
|
||||
type UserSource = 'user' | 'external'
|
||||
|
||||
export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationController }) => {
|
||||
type UserImpersonationSelectorProps = {
|
||||
state: RoleImpersonationController
|
||||
disabled?: boolean
|
||||
onUserImpersonationCleared?: () => void
|
||||
}
|
||||
|
||||
export const UserImpersonationSelector = ({
|
||||
state,
|
||||
disabled = false,
|
||||
onUserImpersonationCleared,
|
||||
}: UserImpersonationSelectorProps) => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [aal, setAal] = useState<AuthenticatorAssuranceLevels>('aal1')
|
||||
const [aal, setAal] = useState<AuthenticatorAssuranceLevels>(() =>
|
||||
state.role?.type === 'postgrest' && state.role.role === 'authenticated'
|
||||
? (state.role.aal ?? 'aal1')
|
||||
: 'aal1'
|
||||
)
|
||||
const [externalUserId, setExternalUserId] = useState('')
|
||||
const [additionalClaims, setAdditionalClaims] = useState('')
|
||||
|
||||
const { id: tableId } = useParams()
|
||||
const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user')
|
||||
|
||||
const [previousSearches, setPreviousSearches] = useLocalStorage<User[]>(
|
||||
LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!),
|
||||
[]
|
||||
const [isUserComboboxOpen, setIsUserComboboxOpen] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<UserSource>(() =>
|
||||
state.role?.type === 'postgrest' &&
|
||||
state.role.role === 'authenticated' &&
|
||||
state.role.userType === 'external'
|
||||
? 'external'
|
||||
: 'user'
|
||||
)
|
||||
const [isImpersonateLoading, setIsImpersonateLoading] = useState(false)
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 300)
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
data,
|
||||
isSuccess,
|
||||
@@ -71,10 +84,9 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC
|
||||
connectionString: project?.connectionString,
|
||||
keywords: debouncedSearchText.trim().toLocaleLowerCase(),
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
|
||||
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
||||
const isSearching = isPlaceholderData && isFetching
|
||||
const impersonatingUser =
|
||||
@@ -82,29 +94,26 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC
|
||||
state.role.role === 'authenticated' &&
|
||||
state.role.userType === 'native' &&
|
||||
state.role.user
|
||||
|
||||
// Check if we're currently impersonating an external auth user (e.g. OAuth, SAML)
|
||||
// This is used to show the correct UI state and impersonation details
|
||||
const isExternalAuthImpersonating =
|
||||
? state.role.user
|
||||
: undefined
|
||||
const impersonatingExternalUser =
|
||||
state.role?.type === 'postgrest' &&
|
||||
state.role.role === 'authenticated' &&
|
||||
state.role.userType === 'external' &&
|
||||
state.role.externalAuth
|
||||
|
||||
? state.role.externalAuth
|
||||
: undefined
|
||||
const displayName = impersonatingUser
|
||||
? getDisplayName(
|
||||
impersonatingUser,
|
||||
impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown'
|
||||
)
|
||||
: impersonatingExternalUser?.sub
|
||||
const isUserSelected = Boolean(displayName)
|
||||
const customAccessTokenHookDetails = useCustomAccessTokenHookDetails(project?.ref)
|
||||
|
||||
const [isImpersonateLoading, setIsImpersonateLoading] = useState(false)
|
||||
|
||||
async function impersonateUser(user: User) {
|
||||
setIsImpersonateLoading(true)
|
||||
setPreviousSearches((prev) => {
|
||||
// Remove if already present
|
||||
const filtered = prev.filter((u) => u.id !== user.id)
|
||||
// Add new user to the start of the list (last used first)
|
||||
const updated = [user, ...filtered]
|
||||
// Keep only the last 6
|
||||
return updated.slice(0, 5)
|
||||
})
|
||||
|
||||
if (customAccessTokenHookDetails?.type === 'https') {
|
||||
toast.info(
|
||||
@@ -125,459 +134,363 @@ export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationC
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
|
||||
} finally {
|
||||
setIsImpersonateLoading(false)
|
||||
}
|
||||
|
||||
setIsImpersonateLoading(false)
|
||||
}
|
||||
|
||||
// Impersonates an external auth user (e.g. OAuth, SAML) by setting the sub and any additional claims
|
||||
// This allows testing RLS policies for external auth users without needing to set up the full OAuth/SAML flow
|
||||
async function impersonateExternalUser() {
|
||||
setIsImpersonateLoading(true)
|
||||
|
||||
let parsedClaims = {}
|
||||
try {
|
||||
parsedClaims = additionalClaims ? JSON.parse(additionalClaims) : {}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Invalid JSON in additional claims')
|
||||
setIsImpersonateLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await state.setRole(
|
||||
{
|
||||
type: 'postgrest',
|
||||
role: 'authenticated',
|
||||
userType: 'external',
|
||||
externalAuth: {
|
||||
sub: externalUserId,
|
||||
additionalClaims: parsedClaims,
|
||||
},
|
||||
externalAuth: { sub: externalUserId, additionalClaims: parsedClaims },
|
||||
aal,
|
||||
},
|
||||
customAccessTokenHookDetails
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
|
||||
} finally {
|
||||
setIsImpersonateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSelectedSource(value: UserSource) {
|
||||
if (!isUserSelected) {
|
||||
setSelectedSource(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsImpersonateLoading(false)
|
||||
try {
|
||||
await state.setRole(undefined)
|
||||
setSelectedSource(value)
|
||||
onUserImpersonationCleared?.()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to stop impersonating user: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function stopImpersonating() {
|
||||
state.setRole(undefined)
|
||||
}
|
||||
async function changeAal(value: AuthenticatorAssuranceLevels) {
|
||||
const previousAal = aal
|
||||
setAal(value)
|
||||
|
||||
function toggleAalState() {
|
||||
setAal((prev) => (prev === 'aal2' ? 'aal1' : 'aal2'))
|
||||
}
|
||||
if (
|
||||
state.role?.type !== 'postgrest' ||
|
||||
state.role.role !== 'authenticated' ||
|
||||
!isUserSelected
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const displayName = impersonatingUser
|
||||
? getDisplayName(
|
||||
impersonatingUser,
|
||||
impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown'
|
||||
)
|
||||
: isExternalAuthImpersonating
|
||||
? isExternalAuthImpersonating.sub
|
||||
: undefined
|
||||
|
||||
// Clear all search history
|
||||
function clearSearchHistory() {
|
||||
setPreviousSearches([])
|
||||
try {
|
||||
await state.setRole({ ...state.role, aal: value }, customAccessTokenHookDetails)
|
||||
} catch (error) {
|
||||
setAal(previousAal)
|
||||
toast.error(`Failed to update MFA assurance level: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-foreground text-sm">
|
||||
{displayName ? `Impersonating ${displayName}` : 'Impersonate a user'}
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light mb-1">
|
||||
{!impersonatingUser && !isExternalAuthImpersonating
|
||||
? "Select a user to respect your database's RLS policies for that particular user."
|
||||
: "Results will respect your database's RLS policies for this user."}
|
||||
</p>
|
||||
|
||||
{impersonatingUser && (
|
||||
<UserImpersonatingRow
|
||||
user={impersonatingUser}
|
||||
onClick={stopImpersonating}
|
||||
isImpersonating={true}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
{isExternalAuthImpersonating && (
|
||||
<ExternalAuthImpersonatingRow
|
||||
sub={isExternalAuthImpersonating.sub}
|
||||
onClick={stopImpersonating}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!impersonatingUser && !isExternalAuthImpersonating && (
|
||||
<Tabs value={selectedTab} onValueChange={(value: any) => setSelectedTab(value)}>
|
||||
<TabsList className="gap-x-3">
|
||||
<TabsTrigger value="user">Project user</TabsTrigger>
|
||||
<TabsTrigger value="external" className="gap-x-1.5">
|
||||
External user
|
||||
<InfoTooltip side="bottom" className="flex flex-col gap-1 max-w-96">
|
||||
Test RLS policies with external auth providers like Clerk or Auth0 by providing a
|
||||
user ID and optional claims.
|
||||
</InfoTooltip>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="user">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
size="tiny"
|
||||
className="pr-10 border-none"
|
||||
placeholder="Search by id, email, phone, or name..."
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
{isSearching ? (
|
||||
<Loader2
|
||||
className="animate-spin text-foreground-lighter"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{searchText && (
|
||||
<InputGroupButton
|
||||
size="tiny"
|
||||
variant="text"
|
||||
onClick={() => setSearchText('')}
|
||||
>
|
||||
<span className="sr-only">Clear search</span>
|
||||
<X size={12} />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-foreground-light">Loading users...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
||||
|
||||
{isSuccess &&
|
||||
(users.length > 0 ? (
|
||||
<div>
|
||||
<ul className="divide-y max-h-[150px] overflow-y-scroll" role="list">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} role="listitem">
|
||||
<UserRow
|
||||
user={user}
|
||||
onClick={impersonateUser}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<p className="text-foreground-light text-xs" role="status">
|
||||
No users found
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<>
|
||||
{previousSearches.length > 0 && (
|
||||
<div>
|
||||
{previousSearches.length > 0 ? (
|
||||
<>
|
||||
<Collapsible className="relative">
|
||||
<CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Recents
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-1 flex flex-col gap-y-4">
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="text"
|
||||
className="absolute right-0 top-0 py-2 hover:bg-muted flex items-center text"
|
||||
onClick={clearSearchHistory}
|
||||
>
|
||||
<span className="flex items-center">Clear</span>
|
||||
</Button>
|
||||
<ScrollArea
|
||||
className={cn(previousSearches.length > 3 ? 'h-36' : 'h-auto')}
|
||||
>
|
||||
<ul className="grid gap-2 ">
|
||||
{previousSearches.map((search) => (
|
||||
<li key={search.id}>
|
||||
<UserRow user={search} onClick={impersonateUser} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No recent searches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="external">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="External User ID"
|
||||
description="The user ID from your external auth provider"
|
||||
isReactForm={false}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="e.g. user_abc123"
|
||||
value={externalUserId}
|
||||
onChange={(e) => setExternalUserId(e.target.value)}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Additional Claims (JSON)"
|
||||
description="Optional: Add custom claims like org_id or roles"
|
||||
isReactForm={false}
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
|
||||
value={additionalClaims}
|
||||
onChange={(e) => setAdditionalClaims(e.target.value)}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!externalUserId}
|
||||
onClick={impersonateExternalUser}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */}
|
||||
{!impersonatingUser && !isExternalAuthImpersonating ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="px-5 py-2 flex flex-col gap-2 relative">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group font-normal p-0 [&[data-state=open]>div>svg]:-rotate-180!">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Advanced options
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 flex flex-col gap-y-4">
|
||||
<div className="flex flex-row items-center gap-x-4 text-sm text-foreground-light">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<h3>MFA assurance level</h3>
|
||||
<InfoTooltip side="top" className="max-w-96">
|
||||
AAL1 verifies users via standard login methods, while AAL2 adds a second
|
||||
authentication factor. If you're not using MFA, you can leave this on AAL1.
|
||||
Learn more about MFA{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/auth/auth-mfa`}>here</InlineLink>.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2 text-xs font-bold">
|
||||
<p className={aal === 'aal1' ? undefined : 'text-foreground-lighter'}>AAL1</p>
|
||||
<Switch checked={aal === 'aal2'} onCheckedChange={toggleAalState} />
|
||||
<p className={aal === 'aal2' ? undefined : 'text-foreground-lighter'}>AAL2</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Base interface for shared impersonation row props to reduce
|
||||
// duplication between user and external auth impersonation displays
|
||||
interface BaseImpersonatingRowProps {
|
||||
onClick: () => void
|
||||
aal: AuthenticatorAssuranceLevels
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
isImpersonating: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const BaseImpersonatingRow = ({
|
||||
onClick,
|
||||
aal,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
isImpersonating = false,
|
||||
isLoading = false,
|
||||
}: BaseImpersonatingRowProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 text-foreground">
|
||||
<div className="flex items-center gap-4 bg-surface-200 pr-4 pl-0.5 py-0.5 border rounded-full max-w-l">
|
||||
{avatarUrl ? (
|
||||
<img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName} />
|
||||
) : (
|
||||
<div className="rounded-full w-[21px] h-[21px] bg-surface-300 border border-strong flex items-center justify-center">
|
||||
<IconUser size={12} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-sm truncate">
|
||||
{displayName}{' '}
|
||||
<span className="ml-2 text-foreground-lighter text-xs font-light">
|
||||
{aal === 'aal2' ? 'AAL2' : 'AAL1'}
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
'm-0 flex min-w-0 flex-col gap-y-2.5 border-0 p-0 transition-opacity',
|
||||
disabled && 'opacity-40'
|
||||
)}
|
||||
>
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
size="tiny"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
Users
|
||||
<InfoTooltip side="left" className="max-w-80">
|
||||
Project users come from Supabase Auth. External users let you test RLS policies with
|
||||
providers such as Clerk or Auth0.
|
||||
</InfoTooltip>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="default" onClick={onClick} disabled={isLoading} loading={isLoading}>
|
||||
{isImpersonating ? 'Stop' : 'Impersonate'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserImpersonatingRow = ({
|
||||
user,
|
||||
onClick,
|
||||
isImpersonating = false,
|
||||
isLoading = false,
|
||||
aal,
|
||||
}: UserRowProps & { aal: AuthenticatorAssuranceLevels }) => {
|
||||
const avatarUrl = getAvatarUrl(user)
|
||||
const displayName =
|
||||
getDisplayName(user, user.email ?? user.phone ?? user.id ?? 'Unknown') +
|
||||
(user.is_anonymous ? ' (anonymous)' : '')
|
||||
|
||||
return (
|
||||
<BaseImpersonatingRow
|
||||
onClick={() => onClick(user)}
|
||||
aal={aal}
|
||||
displayName={displayName}
|
||||
avatarUrl={avatarUrl}
|
||||
isImpersonating={isImpersonating}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExternalAuthImpersonatingRowProps {
|
||||
sub: string
|
||||
onClick: () => void
|
||||
aal: AuthenticatorAssuranceLevels
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const ExternalAuthImpersonatingRow = ({
|
||||
sub,
|
||||
onClick,
|
||||
aal,
|
||||
isLoading = false,
|
||||
}: ExternalAuthImpersonatingRowProps) => {
|
||||
return (
|
||||
<BaseImpersonatingRow
|
||||
onClick={onClick}
|
||||
aal={aal}
|
||||
displayName={sub}
|
||||
isImpersonating={true}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserRowProps {
|
||||
user: User
|
||||
onClick: (user: User) => void
|
||||
isImpersonating?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const UserRow = ({ user, onClick, isImpersonating = false, isLoading = false }: UserRowProps) => {
|
||||
const avatarUrl = getAvatarUrl(user)
|
||||
const emailOrPhone = user.email || user.phone
|
||||
const displayName = getDisplayName(user, '')
|
||||
const isAnonymous = user.is_anonymous
|
||||
const showDisplayName = displayName && displayName !== emailOrPhone
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 text-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
{avatarUrl ? (
|
||||
<img className="rounded-full w-5 h-5" src={avatarUrl} alt={displayName || emailOrPhone} />
|
||||
) : (
|
||||
<div className="rounded-full w-[21px] h-[21px] bg-surface-300 border flex items-center justify-center text-foreground-lighter">
|
||||
<IconUser size={12} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-sm flex items-center gap-4">
|
||||
{emailOrPhone}
|
||||
{showDisplayName && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">
|
||||
{displayName}
|
||||
{isAnonymous ? ' (anonymous)' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-foreground-light bg-surface-200 dark:bg-surface-400 rounded-md px-1 py-0.5 font-mono text-xs">
|
||||
{user?.id?.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onClick(user)}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
}
|
||||
>
|
||||
{isImpersonating ? 'Stop' : 'Impersonate'}
|
||||
</Button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedSource}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'user' || value === 'external') void changeSelectedSource(value)
|
||||
}}
|
||||
variant="default"
|
||||
size="tiny"
|
||||
aria-label="User source"
|
||||
className="w-full"
|
||||
>
|
||||
<ToggleGroupItem value="user" className="w-full">
|
||||
Project
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="external" className="w-full">
|
||||
External
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout
|
||||
id="run-as-user"
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
size="tiny"
|
||||
label="User"
|
||||
>
|
||||
<ImpersonationControl
|
||||
selectedSource={selectedSource}
|
||||
displayName={displayName}
|
||||
isImpersonateLoading={isImpersonateLoading}
|
||||
stopImpersonating={async () => {
|
||||
await state.setRole(undefined)
|
||||
onUserImpersonationCleared?.()
|
||||
}}
|
||||
isUserComboboxOpen={isUserComboboxOpen}
|
||||
setIsUserComboboxOpen={setIsUserComboboxOpen}
|
||||
searchText={searchText}
|
||||
setSearchText={setSearchText}
|
||||
isLoading={isLoading}
|
||||
isSearching={isSearching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isSuccess={isSuccess}
|
||||
users={users}
|
||||
impersonateUser={impersonateUser}
|
||||
externalUserId={externalUserId}
|
||||
setExternalUserId={setExternalUserId}
|
||||
impersonateExternalUser={impersonateExternalUser}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
{selectedSource === 'external' && !isUserSelected && (
|
||||
<FormItemLayout
|
||||
id="run-as-user-claims"
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
size="tiny"
|
||||
label="Claims"
|
||||
>
|
||||
<Input
|
||||
id="run-as-user-claims"
|
||||
size="tiny"
|
||||
placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
|
||||
value={additionalClaims}
|
||||
onChange={(event) => setAdditionalClaims(event.target.value)}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
size="tiny"
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
MFA level
|
||||
<InfoTooltip side="left" className="max-w-96">
|
||||
AAL1 verifies users via standard login methods, while AAL2 adds a second
|
||||
authentication factor. If you are not using MFA, leave this on AAL1. Learn more in the{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/auth/auth-mfa`}>MFA guide</InlineLink>.
|
||||
</InfoTooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={aal}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'aal1' || value === 'aal2') void changeAal(value)
|
||||
}}
|
||||
variant="default"
|
||||
size="tiny"
|
||||
aria-label="MFA assurance level"
|
||||
className="w-full"
|
||||
>
|
||||
<ToggleGroupItem value="aal1" className="w-full">
|
||||
AAL1
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="aal2" className="w-full">
|
||||
AAL2
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FormItemLayout>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
type ImpersonationControlProps = {
|
||||
selectedSource: UserSource
|
||||
displayName?: string
|
||||
isImpersonateLoading: boolean
|
||||
stopImpersonating: () => void
|
||||
isUserComboboxOpen: boolean
|
||||
setIsUserComboboxOpen: (open: boolean) => void
|
||||
searchText: string
|
||||
setSearchText: (value: string) => void
|
||||
isLoading: boolean
|
||||
isSearching: boolean
|
||||
isError: boolean
|
||||
error: ResponseError | null
|
||||
isSuccess: boolean
|
||||
users: User[]
|
||||
impersonateUser: (user: User) => Promise<void>
|
||||
externalUserId: string
|
||||
setExternalUserId: (value: string) => void
|
||||
impersonateExternalUser: () => Promise<void>
|
||||
}
|
||||
|
||||
const ImpersonationControl = ({
|
||||
selectedSource,
|
||||
displayName,
|
||||
isImpersonateLoading,
|
||||
stopImpersonating,
|
||||
isUserComboboxOpen,
|
||||
setIsUserComboboxOpen,
|
||||
searchText,
|
||||
setSearchText,
|
||||
isLoading,
|
||||
isSearching,
|
||||
isError,
|
||||
error,
|
||||
isSuccess,
|
||||
users,
|
||||
impersonateUser,
|
||||
externalUserId,
|
||||
setExternalUserId,
|
||||
impersonateExternalUser,
|
||||
}: ImpersonationControlProps) => {
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{displayName}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="text"
|
||||
className="px-1"
|
||||
aria-label="Stop impersonating user"
|
||||
icon={<X size={14} />}
|
||||
onClick={stopImpersonating}
|
||||
disabled={isImpersonateLoading}
|
||||
loading={isImpersonateLoading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedSource === 'external') {
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="run-as-user"
|
||||
size="tiny"
|
||||
placeholder="External user ID"
|
||||
value={externalUserId}
|
||||
onChange={(event) => setExternalUserId(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
disabled={!externalUserId}
|
||||
onClick={impersonateExternalUser}
|
||||
>
|
||||
Apply
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isUserComboboxOpen} onOpenChange={setIsUserComboboxOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="run-as-user"
|
||||
type="button"
|
||||
size="tiny"
|
||||
variant="default"
|
||||
role="combobox"
|
||||
aria-label="Find user"
|
||||
aria-expanded={isUserComboboxOpen}
|
||||
className="w-full justify-between"
|
||||
iconRight={<ChevronsUpDown className="shrink-0 text-foreground-muted" size={14} />}
|
||||
>
|
||||
Email, name, or ID
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Email, name, or ID"
|
||||
value={searchText}
|
||||
onValueChange={setSearchText}
|
||||
/>
|
||||
<CommandList className="max-h-52">
|
||||
{(isLoading || isSearching) && (
|
||||
<div className="flex items-center justify-center gap-2 px-3 py-2">
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
<span className="text-xs text-foreground-light">Loading users…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
||||
|
||||
{isSuccess && !isSearching && users.length === 0 && (
|
||||
<CommandEmpty>No users found</CommandEmpty>
|
||||
)}
|
||||
|
||||
{isSuccess && !isSearching && users.length > 0 && (
|
||||
<CommandGroup>
|
||||
{users.map((user) => {
|
||||
const emailOrPhone = user.email || user.phone
|
||||
const userDisplayName = getDisplayName(user, '')
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={user.id}
|
||||
onSelect={() => {
|
||||
void impersonateUser(user)
|
||||
setIsUserComboboxOpen(false)
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<IconUser size={14} className="shrink-0 text-foreground-lighter" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{emailOrPhone || userDisplayName || user.id}
|
||||
</span>
|
||||
{userDisplayName && userDisplayName !== emailOrPhone && (
|
||||
<span className="max-w-24 truncate text-foreground-lighter">
|
||||
{userDisplayName}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, cn } from 'ui'
|
||||
import { Check, Database, UserCheck, UserX } from 'lucide-react'
|
||||
import { Separator, ToggleGroup, ToggleGroupItem } from 'ui'
|
||||
|
||||
import { AnonIcon, AuthenticatedIcon, ServiceRoleIcon } from './Icons'
|
||||
import { RoleImpersonationRadio } from './RoleImpersonationRadio'
|
||||
import { UserImpersonationSelector } from './UserImpersonationSelector'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { PostgrestRole } from '@/lib/role-impersonation'
|
||||
import { useRoleImpersonationSelection } from './useRoleImpersonationSelection'
|
||||
import {
|
||||
useRoleImpersonationStateSnapshot,
|
||||
type RoleImpersonationController,
|
||||
@@ -17,17 +13,13 @@ export interface RoleImpersonationSelectorProps {
|
||||
serviceRoleLabel?: string
|
||||
disallowAuthenticatedOption?: boolean
|
||||
title?: string
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tightly coupled with the global role impersonation store
|
||||
* Use RoleImpersonationSelectorInterface to control the logic externally
|
||||
* Tightly coupled with the global role impersonation store.
|
||||
* Use RoleImpersonationSelectorInterface to control the logic externally.
|
||||
*/
|
||||
export const RoleImpersonationSelector = (props: RoleImpersonationSelectorProps) => {
|
||||
// valtio's Snapshot<> type is deep-readonly (incl. nested arrays), which isn't
|
||||
// structurally assignable to RoleImpersonationController's plain array fields — same
|
||||
// rationale as the cast in useGetImpersonatedRoleState.
|
||||
const state = useRoleImpersonationStateSnapshot() as unknown as RoleImpersonationController
|
||||
|
||||
return <RoleImpersonationSelectorInterface {...props} state={state} />
|
||||
@@ -37,143 +29,97 @@ type RoleImpersonationSelectorInterfaceProps = RoleImpersonationSelectorProps &
|
||||
state: RoleImpersonationController
|
||||
}
|
||||
|
||||
export const RoleImpersonationSelectorInterface = ({
|
||||
state,
|
||||
orientation,
|
||||
serviceRoleLabel = 'Postgres',
|
||||
disallowAuthenticatedOption = false,
|
||||
header = 'Impersonate a database role',
|
||||
}: RoleImpersonationSelectorInterfaceProps) => {
|
||||
const isVertical = orientation === 'vertical'
|
||||
export const RoleImpersonationSelectorInterface = (
|
||||
props: RoleImpersonationSelectorInterfaceProps
|
||||
) => {
|
||||
const { state } = props
|
||||
const serviceRoleLabel = props.serviceRoleLabel ?? 'Postgres'
|
||||
const disallowAuthenticatedOption = props.disallowAuthenticatedOption ?? false
|
||||
const { selectedOption, onSelectedChange, keepAuthenticatedSelected } =
|
||||
useRoleImpersonationSelection(state)
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<PostgrestRole>(() =>
|
||||
state.role?.type === 'postgrest' &&
|
||||
(state.role.role === 'anon' || state.role.role === 'authenticated')
|
||||
? state.role.role
|
||||
: 'service_role'
|
||||
)
|
||||
|
||||
const isAuthenticatedOptionFullySelected = Boolean(
|
||||
selectedOption === 'authenticated' &&
|
||||
state.role?.type === 'postgrest' &&
|
||||
state.role.role === 'authenticated' &&
|
||||
(('user' in state.role && state.role.user) ||
|
||||
('externalAuth' in state.role && state.role.externalAuth)) // Check for either auth type
|
||||
)
|
||||
|
||||
function onSelectedChange(value: PostgrestRole) {
|
||||
if (value === 'service_role') {
|
||||
// do not set a role for service role
|
||||
// as the default role is the "service role"
|
||||
state.setRole(undefined)
|
||||
}
|
||||
|
||||
if (value === 'anon') {
|
||||
state.setRole({
|
||||
type: 'postgrest',
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedOption(value)
|
||||
}
|
||||
const roleSummary = {
|
||||
service_role: 'Bypasses RLS and can return all rows.',
|
||||
anon: 'Returns rows available to anonymous users.',
|
||||
authenticated: 'Returns rows available to the selected user.',
|
||||
}[selectedOption]
|
||||
|
||||
return (
|
||||
<Card className="border-none">
|
||||
<CardHeader className="flex-row items-center justify-between py-3 space-y-0">
|
||||
<CardTitle>{header}</CardTitle>
|
||||
<DocsButton
|
||||
href={`${DOCS_URL}/guides/database/postgres/row-level-security#authenticated-and-unauthenticated-roles`}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
// don't allow form submission
|
||||
e.preventDefault()
|
||||
<div className="flex w-80 flex-col">
|
||||
<form
|
||||
className="p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'service_role' || value === 'anon' || value === 'authenticated') {
|
||||
void onSelectedChange(value)
|
||||
}
|
||||
}}
|
||||
variant="default"
|
||||
aria-label={props.header ?? props.title ?? 'Run query as role'}
|
||||
className="w-full flex-col items-stretch gap-0.5"
|
||||
>
|
||||
<fieldset className={cn('flex gap-3', isVertical && 'flex-col gap-2')}>
|
||||
<RoleImpersonationRadio
|
||||
value="service_role"
|
||||
isSelected={selectedOption === 'service_role'}
|
||||
onSelectedChange={onSelectedChange}
|
||||
label={serviceRoleLabel}
|
||||
description="Superuser"
|
||||
icon={<ServiceRoleIcon isSelected={selectedOption === 'service_role'} />}
|
||||
fullWidth={isVertical}
|
||||
<ToggleGroupItem value="service_role" className="w-full justify-between text-left">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<Database size={16} strokeWidth={1.5} className="shrink-0" />
|
||||
<span className="flex min-w-0 flex-col items-start">
|
||||
<span>{serviceRoleLabel}</span>
|
||||
<span className="text-xs font-normal text-foreground-lighter">Superuser</span>
|
||||
</span>
|
||||
</span>
|
||||
{selectedOption === 'service_role' && <Check size={14} />}
|
||||
</ToggleGroupItem>
|
||||
|
||||
<ToggleGroupItem value="anon" className="w-full justify-between text-left">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<UserX size={16} strokeWidth={1.5} className="shrink-0" />
|
||||
<span className="flex min-w-0 flex-col items-start">
|
||||
<span>Anonymous</span>
|
||||
<span className="text-xs font-normal text-foreground-lighter">Not logged in</span>
|
||||
</span>
|
||||
</span>
|
||||
{selectedOption === 'anon' && <Check size={14} />}
|
||||
</ToggleGroupItem>
|
||||
|
||||
{!disallowAuthenticatedOption && (
|
||||
<ToggleGroupItem value="authenticated" className="w-full justify-between text-left">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<UserCheck size={16} strokeWidth={1.5} className="shrink-0" />
|
||||
<span className="flex min-w-0 flex-col items-start">
|
||||
<span>Authenticated</span>
|
||||
<span className="text-xs font-normal text-foreground-lighter">
|
||||
Logged-in user
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{selectedOption === 'authenticated' && <Check size={14} />}
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
|
||||
{!disallowAuthenticatedOption && (
|
||||
<>
|
||||
<Separator className="my-2.5" />
|
||||
<UserImpersonationSelector
|
||||
state={state}
|
||||
disabled={selectedOption !== 'authenticated'}
|
||||
onUserImpersonationCleared={keepAuthenticatedSelected}
|
||||
/>
|
||||
|
||||
<RoleImpersonationRadio
|
||||
value="anon"
|
||||
label="Anonymous"
|
||||
isSelected={selectedOption === 'anon'}
|
||||
onSelectedChange={onSelectedChange}
|
||||
description="Not logged in"
|
||||
icon={<AnonIcon isSelected={selectedOption === 'anon'} />}
|
||||
fullWidth={isVertical}
|
||||
/>
|
||||
|
||||
{!disallowAuthenticatedOption && (
|
||||
<RoleImpersonationRadio
|
||||
value="authenticated"
|
||||
label="Authenticated"
|
||||
isSelected={
|
||||
selectedOption === 'authenticated' &&
|
||||
(isAuthenticatedOptionFullySelected || 'partially')
|
||||
}
|
||||
onSelectedChange={onSelectedChange}
|
||||
description="Specific logged in user"
|
||||
icon={<AuthenticatedIcon isSelected={selectedOption === 'authenticated'} />}
|
||||
fullWidth={isVertical}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{selectedOption === 'service_role' && (
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Full admin access
|
||||
<Badge className="ml-2">Default</Badge>
|
||||
</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
The <code className="text-code-inline">postgres</code> role, which bypasses all Row
|
||||
Level Security (RLS) policies.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedOption === 'anon' && (
|
||||
<div>
|
||||
<p className="text-sm">For unauthenticated access</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
The <code className="text-code-inline">anon</code> role, which the API (PostgREST)
|
||||
uses when a user is not logged in.
|
||||
<br />
|
||||
Row Level Security (RLS) policies apply.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOption === 'authenticated' && (
|
||||
<div>
|
||||
<p className="text-sm">For authenticated access</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
The <code className="text-code-inline">authenticated</code> role, which the API
|
||||
(PostgREST) uses when a user is logged in.
|
||||
<br />
|
||||
Row Level Security (RLS) policies apply.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{selectedOption === 'authenticated' && (
|
||||
<CardContent className="p-0">
|
||||
<UserImpersonationSelector state={state} />
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</form>
|
||||
<Separator />
|
||||
<footer className="px-3 py-2">
|
||||
<p aria-live="polite" className="text-xs text-foreground-lighter">
|
||||
{roleSummary}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getRoleSelectionUpdate, getSelectedRoleOption } from './RoleImpersonationSelector.utils'
|
||||
import type { PostgrestRole } from '@/lib/role-impersonation'
|
||||
import type { RoleImpersonationController } from '@/state/role-impersonation-state'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
export const useRoleImpersonationSelection = (state: RoleImpersonationController) => {
|
||||
const [isAuthenticatedPending, setIsAuthenticatedPending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.role?.type === 'postgrest' &&
|
||||
state.role.role === 'authenticated' &&
|
||||
isAuthenticatedPending
|
||||
) {
|
||||
setIsAuthenticatedPending(false)
|
||||
}
|
||||
}, [isAuthenticatedPending, state.role])
|
||||
|
||||
const selectedOption = isAuthenticatedPending
|
||||
? 'authenticated'
|
||||
: getSelectedRoleOption(state.role)
|
||||
|
||||
async function onSelectedChange(value: PostgrestRole) {
|
||||
const update = getRoleSelectionUpdate(value)
|
||||
if (!update.shouldSetRole) {
|
||||
setIsAuthenticatedPending(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await state.setRole(update.role)
|
||||
setIsAuthenticatedPending(false)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function keepAuthenticatedSelected() {
|
||||
setIsAuthenticatedPending(true)
|
||||
}
|
||||
|
||||
return { selectedOption, onSelectedChange, keepAuthenticatedSelected }
|
||||
}
|
||||
-1
@@ -34,7 +34,6 @@ export const RunAsSubMenu = (props: RunAsSubMenuProps) => {
|
||||
dropdown's typeahead. */}
|
||||
<DropdownMenuSubContent className="w-80 p-0" onKeyDown={(e) => e.stopPropagation()}>
|
||||
<RoleImpersonationSelectorInterface
|
||||
orientation="vertical"
|
||||
header="Run SQL query as a role"
|
||||
serviceRoleLabel={SERVICE_ROLE_LABEL}
|
||||
state={state}
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ vi.mock('@/hooks/misc/useHighAvailability', () => ({
|
||||
|
||||
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
useSelectedProjectQuery: mockUseSelectedProjectQuery,
|
||||
useIsHighAvailability: () => mockUseHighAvailability().isHighAvailability ?? false,
|
||||
}))
|
||||
|
||||
vi.mock('@/data/database/max-connections-query', () => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { ExternalLink, Info } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { SetStateAction } from 'react'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle, Button, InfoIcon } from 'ui'
|
||||
import {
|
||||
@@ -26,9 +26,9 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
useIsAwsNimbusCloudProvider,
|
||||
useIsHighAvailability,
|
||||
useSelectedProjectQuery,
|
||||
} from '@/hooks/misc/useSelectedProject'
|
||||
import { useUrlState } from '@/hooks/ui/useUrlState'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { formatBytes } from '@/lib/helpers'
|
||||
|
||||
@@ -42,14 +42,13 @@ export const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfiguratio
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
|
||||
const isAwsNimbus = useIsAwsNimbusCloudProvider()
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
const { reportsAll } = useIsFeatureEnabled(['reports:all'])
|
||||
|
||||
const [{ show_increase_disk_size_modal }, setUrlParams] = useUrlState()
|
||||
const showIncreaseDiskSizeModal = show_increase_disk_size_modal === 'true'
|
||||
const setShowIncreaseDiskSizeModal = (value: SetStateAction<boolean>) => {
|
||||
const show = typeof value === 'function' ? value(showIncreaseDiskSizeModal) : value
|
||||
setUrlParams({ show_increase_disk_size_modal: show ? 'true' : undefined })
|
||||
}
|
||||
const [showIncreaseDiskSizeModal, setShowIncreaseDiskSizeModal] = useQueryState(
|
||||
'show_increase_disk_size_modal',
|
||||
parseAsBoolean.withDefault(false)
|
||||
)
|
||||
|
||||
const { can: canUpdateDiskSizeConfig } = useAsyncCheckPermissions(
|
||||
PermissionAction.UPDATE,
|
||||
@@ -110,14 +109,16 @@ export const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfiguratio
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
className="w-min ml-auto"
|
||||
disabled={!canUpdateDiskSizeConfig || disabled}
|
||||
disabled={!canUpdateDiskSizeConfig || isHighAvailability || disabled}
|
||||
onClick={() => setShowIncreaseDiskSizeModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateDiskSizeConfig
|
||||
? 'You need additional permissions to increase the disk size'
|
||||
: undefined,
|
||||
: isHighAvailability
|
||||
? 'Disk size management is unavailable for High Availability projects'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -219,11 +220,13 @@ Read more about [disk management](${DOCS_URL}/guides/platform/database-size#disk
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<DiskSizeConfigurationModal
|
||||
visible={showIncreaseDiskSizeModal}
|
||||
loading={isUpdatingDiskSize}
|
||||
hideModal={setShowIncreaseDiskSizeModal}
|
||||
/>
|
||||
{!isHighAvailability && (
|
||||
<DiskSizeConfigurationModal
|
||||
visible={showIncreaseDiskSizeModal}
|
||||
loading={isUpdatingDiskSize}
|
||||
hideModal={setShowIncreaseDiskSizeModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+13
-9
@@ -257,7 +257,9 @@ export function mapJitMembersToUserRules(
|
||||
const memberMap = new Map((projectMembers ?? []).map((member) => [member.user_id, member]))
|
||||
const baseRoleIds = roleOptions.map((role) => role.id)
|
||||
|
||||
return (jitMembers ?? []).map((item) => {
|
||||
return (jitMembers ?? []).flatMap((item) => {
|
||||
if (!item.user_id) return []
|
||||
|
||||
const mappedMember = memberMap.get(item.user_id)
|
||||
const assignedRoles: JitRoleGrantDraft[] = (item.user_roles ?? []).map((roleObj) => {
|
||||
const roleWithBranchRestriction = roleObj as typeof roleObj & { branches_only?: boolean }
|
||||
@@ -294,14 +296,16 @@ export function mapJitMembersToUserRules(
|
||||
const email = mappedMember?.primary_email ?? item.user_id
|
||||
const name = mappedMember?.username ?? undefined
|
||||
|
||||
return {
|
||||
id: item.user_id,
|
||||
memberId: item.user_id,
|
||||
email,
|
||||
name,
|
||||
grants: cloneGrants(grants),
|
||||
status: computeStatusFromGrants(grants),
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: item.user_id,
|
||||
memberId: item.user_id,
|
||||
email,
|
||||
name,
|
||||
grants: cloneGrants(grants),
|
||||
status: computeStatusFromGrants(grants),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { metricColor } from './InstanceNode.utils'
|
||||
import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
|
||||
|
||||
/**
|
||||
* Compute metrics row at the bottom of a primary database node card, linking
|
||||
* to the database report. Metrics are project-scoped, which reports the
|
||||
* primary on both standard and High Availability projects.
|
||||
*/
|
||||
export const ComputeMetricsFooter = ({ showConnections = true }: { showConnections?: boolean }) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const {
|
||||
cpu,
|
||||
disk,
|
||||
memory,
|
||||
connections,
|
||||
isLoading: isMetricsLoading,
|
||||
isError: isMetricsError,
|
||||
} = useComputeMetrics({
|
||||
projectRef: ref,
|
||||
})
|
||||
|
||||
const observabilityUrl = `/project/${ref}/observability/database`
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={observabilityUrl}
|
||||
className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
|
||||
>
|
||||
{/* Stable live region: announces loading/failure, never the polled values */}
|
||||
<span role="status" className="sr-only">
|
||||
{isMetricsLoading && 'Loading metrics'}
|
||||
{!isMetricsLoading && isMetricsError && 'Metrics unavailable'}
|
||||
</span>
|
||||
{/* h-4 matches the text-xs line height so the card doesn't shift when metrics load */}
|
||||
{isMetricsLoading && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-4 w-44 rounded-sm bg-surface-300 motion-safe:animate-pulse"
|
||||
/>
|
||||
)}
|
||||
{!isMetricsLoading && isMetricsError && (
|
||||
<span aria-hidden="true" className="text-foreground-lighter">
|
||||
Metrics unavailable
|
||||
</span>
|
||||
)}
|
||||
{!isMetricsLoading && !isMetricsError && (
|
||||
<>
|
||||
<span>
|
||||
CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
|
||||
</span>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
|
||||
</span>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>
|
||||
RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
|
||||
</span>
|
||||
{showConnections && connections.max > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span className="text-foreground-light">
|
||||
{connections.peak}/{connections.max} conns
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Go to Database Report</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Background,
|
||||
ColorMode,
|
||||
Edge,
|
||||
EdgeTypes,
|
||||
Node,
|
||||
NodeTypes,
|
||||
ReactFlow,
|
||||
useNodesInitialized,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { getDagreGraphLayout } from './InstanceConfiguration.utils'
|
||||
import { timeout } from '@/lib/helpers'
|
||||
|
||||
interface DiagramFlowProps {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
nodeTypes: NodeTypes
|
||||
edgeTypes: EdgeTypes
|
||||
/** Prepends background group nodes (regions, shards) after the dagre layout runs */
|
||||
addGroupNodes: (nodes: Node[], edges: Edge[]) => { nodes: Node[]; edges: Edge[] }
|
||||
ranksep?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared React Flow canvas for the infrastructure diagrams, laid out with a
|
||||
* two-pass measured dagre layout: the first pass uses fallback heights while
|
||||
* the diagram is held invisible, then `useNodesInitialized` triggers a second
|
||||
* pass that re-runs dagre with the real measured node heights before fading
|
||||
* the diagram in. Must be rendered inside a `ReactFlowProvider`.
|
||||
*/
|
||||
export const DiagramFlow = ({
|
||||
nodes,
|
||||
edges,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
addGroupNodes,
|
||||
ranksep,
|
||||
}: DiagramFlowProps) => {
|
||||
const reactFlow = useReactFlow()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const [hasMeasuredLayout, setHasMeasuredLayout] = useState(false)
|
||||
|
||||
const backgroundPatternColor =
|
||||
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
|
||||
|
||||
const setReactFlow = useEffectEvent(async ({ isMeasuredPass }: { isMeasuredPass: boolean }) => {
|
||||
// Merge in React Flow's measured dimensions (if any) so dagre can use real
|
||||
// heights instead of the first-paint fallbacks.
|
||||
const measuredNodes = nodes.map((node) => {
|
||||
const existing = reactFlow.getNode(node.id)
|
||||
return existing?.measured ? { ...node, measured: existing.measured } : node
|
||||
})
|
||||
const graph = getDagreGraphLayout(measuredNodes, edges, { ranksep })
|
||||
const { nodes: groupedNodes } = addGroupNodes(graph.nodes, graph.edges)
|
||||
// Re-attach known measurements to the freshly created group nodes too —
|
||||
// handing setNodes an unmeasured node resets the store's nodesInitialized
|
||||
// flag, which would re-trigger the measured pass below on every re-measure
|
||||
// and loop forever (each iteration re-running fitView, so the diagram
|
||||
// snaps back to center and can't be panned).
|
||||
const updatedNodes = groupedNodes.map((node) => {
|
||||
if (node.measured !== undefined) return node
|
||||
const existing = reactFlow.getNode(node.id)
|
||||
return existing?.measured ? { ...node, measured: existing.measured } : node
|
||||
})
|
||||
reactFlow.setNodes(updatedNodes)
|
||||
reactFlow.setEdges(graph.edges)
|
||||
|
||||
// [Joshen] Odd fix to ensure that react flow snaps back to center when adding nodes
|
||||
await timeout(1)
|
||||
reactFlow.fitView({ maxZoom: 0.9, minZoom: 0.9 })
|
||||
if (isMeasuredPass) setHasMeasuredLayout(true)
|
||||
})
|
||||
|
||||
// First pass: lay out using fallback heights for any not-yet-measured nodes.
|
||||
// The diagram is kept invisible until the measured pass below has run, so the
|
||||
// user never sees the fallback positions.
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0) {
|
||||
setReactFlow({ isMeasuredPass: false })
|
||||
}
|
||||
}, [nodes, edges])
|
||||
|
||||
// Second pass: once React Flow has measured the nodes, re-run the layout so
|
||||
// dagre uses real heights. Only `nodesInitialized` going true should trigger
|
||||
// this — the first-pass effect above handles node changes.
|
||||
const runMeasuredLayout = useEffectEvent(() => {
|
||||
if (nodesInitialized && nodes.length > 0) {
|
||||
setReactFlow({ isMeasuredPass: true })
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
runMeasuredLayout()
|
||||
}, [nodesInitialized])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
// FIXME: https://github.com/xyflow/xyflow/issues/4876
|
||||
colorMode={'' as unknown as ColorMode}
|
||||
fitView
|
||||
fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9 }}
|
||||
// Keep the diagram invisible (but laid out, so nodes can be measured)
|
||||
// until the measured-height layout pass has run.
|
||||
className={cn(
|
||||
'instance-configuration transition-opacity duration-150',
|
||||
hasMeasuredLayout ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
zoomOnPinch={false}
|
||||
zoomOnScroll={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
zoomOnDoubleClick={false}
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
defaultNodes={[]}
|
||||
defaultEdges={[]}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color={backgroundPatternColor} />
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DiagramFlow } from './DiagramFlow'
|
||||
import { addShardNodes, generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
|
||||
import { HaPrimaryNode, HaReplicaNode, HaShardNode, MultigatewayNode } from './HaInstanceNode'
|
||||
import { buildHaTopology, selectTopologyPoolers } from './HaTopology.utils'
|
||||
import { HA_RANKSEP } from './InstanceConfiguration.constants'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
|
||||
import { haClusterGatewaysQueryOptions } from '@/data/ha-admin/ha-cluster-gateways-query'
|
||||
import { haClusterPoolersQueryOptions } from '@/data/ha-admin/ha-cluster-poolers-query'
|
||||
|
||||
const nodeTypes = {
|
||||
HA_GATEWAY: MultigatewayNode,
|
||||
HA_PRIMARY: HaPrimaryNode,
|
||||
HA_REPLICA: HaReplicaNode,
|
||||
HA_SHARD: HaShardNode,
|
||||
}
|
||||
|
||||
// The gateway and replication edges both use React Flow's built-in smoothstep.
|
||||
const edgeTypes = {}
|
||||
|
||||
const POOLER_STATUS_REFRESH_MS = 30_000
|
||||
|
||||
/**
|
||||
* Cluster topology diagram for High Availability (Multigres) projects:
|
||||
* gateway tier → shard group → primary + read replicas, driven by the
|
||||
* read-only multiadmin `/ha-admin` passthrough.
|
||||
*/
|
||||
export const HaInstanceConfiguration = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
// Gateways poll on the same interval as poolers so the gateway count and
|
||||
// gateway→primary edge track cluster changes while the page stays open.
|
||||
const {
|
||||
data: gatewaysData,
|
||||
error: gatewaysError,
|
||||
isPending: isPendingGateways,
|
||||
isError: isErrorGateways,
|
||||
} = useQuery({
|
||||
...haClusterGatewaysQueryOptions({ projectRef }),
|
||||
refetchInterval: POOLER_STATUS_REFRESH_MS,
|
||||
})
|
||||
|
||||
const {
|
||||
data: poolers,
|
||||
error: poolersError,
|
||||
isPending: isPendingPoolers,
|
||||
isError: isErrorPoolers,
|
||||
} = useQuery({
|
||||
...haClusterPoolersQueryOptions({ projectRef }),
|
||||
refetchInterval: POOLER_STATUS_REFRESH_MS,
|
||||
select: selectTopologyPoolers,
|
||||
})
|
||||
|
||||
const gateways = gatewaysData?.gateways
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
() =>
|
||||
generateHaNodesAndEdges(
|
||||
buildHaTopology({ gateways: gateways ?? [], poolers: poolers ?? [] })
|
||||
),
|
||||
[gateways, poolers]
|
||||
)
|
||||
|
||||
const isPending = isPendingGateways || isPendingPoolers
|
||||
const isError = isErrorGateways || isErrorPoolers
|
||||
const error = gatewaysError ?? poolersError
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div role="status" className="h-full w-full flex items-center justify-center">
|
||||
<span className="sr-only">Loading cluster topology...</span>
|
||||
<Loader2 aria-hidden="true" className="motion-safe:animate-spin text-foreground-light" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center px-28">
|
||||
<AlertError error={error} subject="Failed to retrieve cluster topology" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ((poolers ?? []).length === 0) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center p-6">
|
||||
<HighAvailabilityDisabledEmptyState
|
||||
title="Cluster topology unavailable"
|
||||
description="Topology data for this project is not available yet. Contact support if this persists."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nowheel h-full w-full relative">
|
||||
<DiagramFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
addGroupNodes={addShardNodes}
|
||||
ranksep={HA_RANKSEP}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
|
||||
import { buildHaTopology } from './HaTopology.utils'
|
||||
|
||||
describe('generateHaNodesAndEdges', () => {
|
||||
it('gives every pooler a unique node id even when identities are omitted', () => {
|
||||
const topology = buildHaTopology({
|
||||
gateways: [],
|
||||
poolers: [
|
||||
{
|
||||
id: { cell: 'cell-1', name: 'p-1' },
|
||||
shardKey: { shard: '0' },
|
||||
routingState: { role: 'ROUTING_ROLE_PRIMARY' },
|
||||
},
|
||||
{ shardKey: { shard: '0' }, routingState: { role: 'ROUTING_ROLE_REPLICA' } },
|
||||
{ shardKey: { shard: '0' }, routingState: { role: 'ROUTING_ROLE_REPLICA' } },
|
||||
],
|
||||
})
|
||||
|
||||
const { nodes, edges } = generateHaNodesAndEdges(topology)
|
||||
const poolerNodeIds = nodes
|
||||
.filter((node) => node.type === 'HA_PRIMARY' || node.type === 'HA_REPLICA')
|
||||
.map((node) => node.id)
|
||||
|
||||
expect(poolerNodeIds).toHaveLength(3)
|
||||
expect(new Set(poolerNodeIds).size).toBe(3)
|
||||
// Each replica still gets its own edge from the primary.
|
||||
expect(edges).toHaveLength(2)
|
||||
expect(new Set(edges.map((edge) => edge.id)).size).toBe(2)
|
||||
})
|
||||
})
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { Edge, Node } from '@xyflow/react'
|
||||
import { groupBy } from 'lodash'
|
||||
|
||||
import { getPoolerKey, hasPoolerIdentity, HaTopology } from './HaTopology.utils'
|
||||
import {
|
||||
NODE_CARD_WIDTH,
|
||||
SHARD_HEADER_HEIGHT,
|
||||
SHARD_NODE_PADDING,
|
||||
} from './InstanceConfiguration.constants'
|
||||
import { getDagreNodeHeight } from './InstanceConfiguration.utils'
|
||||
|
||||
export type MultigatewayNodeData = {
|
||||
numGateways: number
|
||||
}
|
||||
|
||||
export type HaPoolerNodeData = {
|
||||
cell?: string
|
||||
name?: string
|
||||
shardId: string
|
||||
shardName: string
|
||||
// Only set on primary nodes; hides the unused top handle when no gateway exists.
|
||||
hasGateway?: boolean
|
||||
}
|
||||
|
||||
export type HaShardNodeData = {
|
||||
name: string
|
||||
}
|
||||
|
||||
// Records without a complete identity fall back to a positional id so two of
|
||||
// them never collide on the same React Flow node id (which would drop a card).
|
||||
const getPoolerNodeId = (pooler: Parameters<typeof getPoolerKey>[0], fallbackId: string) =>
|
||||
hasPoolerIdentity(pooler) ? `pooler-${getPoolerKey(pooler)}` : `pooler-${fallbackId}`
|
||||
|
||||
export const generateHaNodesAndEdges = (topology: HaTopology): { nodes: Node[]; edges: Edge[] } => {
|
||||
const position = { x: 0, y: 0 }
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
// The alpha runs one multigateway per cell; the diagram collapses them into a
|
||||
// single gateway node with a count rather than rendering one card per cell.
|
||||
const hasGateway = topology.gateways.length > 0
|
||||
if (hasGateway) {
|
||||
nodes.push({
|
||||
position,
|
||||
id: 'multigateway',
|
||||
type: 'HA_GATEWAY',
|
||||
data: { numGateways: topology.gateways.length } satisfies MultigatewayNodeData,
|
||||
})
|
||||
}
|
||||
|
||||
topology.shards.forEach((shard) => {
|
||||
let primaryId: string | undefined
|
||||
|
||||
if (shard.primary !== undefined) {
|
||||
primaryId = getPoolerNodeId(shard.primary, `${shard.id}-primary`)
|
||||
nodes.push({
|
||||
position,
|
||||
id: primaryId,
|
||||
type: 'HA_PRIMARY',
|
||||
data: {
|
||||
cell: shard.primary.id?.cell,
|
||||
name: shard.primary.id?.name,
|
||||
shardId: shard.id,
|
||||
shardName: shard.name,
|
||||
hasGateway,
|
||||
} satisfies HaPoolerNodeData,
|
||||
})
|
||||
|
||||
if (hasGateway) {
|
||||
edges.push({
|
||||
id: `multigateway-${primaryId}`,
|
||||
source: 'multigateway',
|
||||
target: primaryId,
|
||||
type: 'smoothstep',
|
||||
className: 'cursor-default!',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shard.replicas.forEach((replica, replicaIndex) => {
|
||||
const replicaId = getPoolerNodeId(replica, `${shard.id}-replica-${replicaIndex}`)
|
||||
nodes.push({
|
||||
position,
|
||||
id: replicaId,
|
||||
type: 'HA_REPLICA',
|
||||
data: {
|
||||
cell: replica.id?.cell,
|
||||
name: replica.id?.name,
|
||||
shardId: shard.id,
|
||||
shardName: shard.name,
|
||||
} satisfies HaPoolerNodeData,
|
||||
})
|
||||
|
||||
if (primaryId !== undefined) {
|
||||
edges.push({
|
||||
id: `${primaryId}-${replicaId}`,
|
||||
source: primaryId,
|
||||
target: replicaId,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
className: 'cursor-default!',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const getNodeWidth = (node: Node) => node.measured?.width ?? NODE_CARD_WIDTH
|
||||
|
||||
/**
|
||||
* Prepends a background group node per shard wrapping its primary + replicas,
|
||||
* using the same fake-subflow trick as `addRegionNodes` (dagre has no subflow
|
||||
* support, so the box is sized from the laid-out children's bounding box).
|
||||
*/
|
||||
export const addShardNodes = (nodes: Node[], edges: Edge[]) => {
|
||||
const shardNodes: Node[] = []
|
||||
const poolerNodes = nodes.filter(
|
||||
(node) => node.type === 'HA_PRIMARY' || node.type === 'HA_REPLICA'
|
||||
) as Node<HaPoolerNodeData>[]
|
||||
|
||||
const nodesByShard = groupBy(poolerNodes, (node) => node.data.shardId)
|
||||
Object.entries(nodesByShard).forEach(([shardId, children]) => {
|
||||
const minX = Math.min(...children.map((node) => node.position.x))
|
||||
const maxX = Math.max(...children.map((node) => node.position.x + getNodeWidth(node)))
|
||||
const minY = Math.min(...children.map((node) => node.position.y))
|
||||
const maxY = Math.max(...children.map((node) => node.position.y + getDagreNodeHeight(node)))
|
||||
|
||||
shardNodes.push({
|
||||
id: `shard-${shardId}`,
|
||||
position: {
|
||||
x: minX - SHARD_NODE_PADDING,
|
||||
y: minY - SHARD_HEADER_HEIGHT - SHARD_NODE_PADDING,
|
||||
},
|
||||
// Explicit dimensions so React Flow renders the node immediately —
|
||||
// without them a node stays `visibility: hidden` until a measurement
|
||||
// pass that this post-layout background node never gets.
|
||||
width: maxX - minX + SHARD_NODE_PADDING * 2,
|
||||
height: maxY - minY + SHARD_HEADER_HEIGHT + SHARD_NODE_PADDING * 2,
|
||||
// The box covers most of the canvas; making it non-interactive lets
|
||||
// React Flow drop its pointer events so drag-to-pan passes through to
|
||||
// the pane. The header pill re-enables pointer events for its tooltip.
|
||||
selectable: false,
|
||||
focusable: false,
|
||||
type: 'HA_SHARD',
|
||||
data: {
|
||||
name: children[0].data.shardName,
|
||||
} satisfies HaShardNodeData,
|
||||
})
|
||||
})
|
||||
|
||||
return { nodes: [...shardNodes, ...nodes], edges }
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { Handle, Node, NodeProps, Position } from '@xyflow/react'
|
||||
import { Database, DatabaseBackup, HelpCircle, Layers, Loader2, Network } from 'lucide-react'
|
||||
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { ComputeMetricsFooter } from './ComputeMetricsFooter'
|
||||
import {
|
||||
HaPoolerNodeData,
|
||||
HaShardNodeData,
|
||||
MultigatewayNodeData,
|
||||
} from './HaInstanceConfiguration.utils'
|
||||
import { HA_POOLER_STATUS_LABELS, HaPoolerStatus } from './HaTopology.utils'
|
||||
import { NODE_CARD_WIDTH } from './InstanceConfiguration.constants'
|
||||
import { useHaPoolerCard } from './useHaPooler'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
const STATUS_BADGE_VARIANTS: Record<HaPoolerStatus, 'success' | 'warning' | 'default'> = {
|
||||
healthy: 'success',
|
||||
coming_up: 'default',
|
||||
going_down: 'default',
|
||||
unhealthy: 'warning',
|
||||
}
|
||||
|
||||
const PoolerStatusBadge = ({ status }: { status: HaPoolerStatus }) => (
|
||||
<Badge variant={STATUS_BADGE_VARIANTS[status]}>{HA_POOLER_STATUS_LABELS[status]}</Badge>
|
||||
)
|
||||
|
||||
const PoolerCardSubtitle = ({
|
||||
availabilityZone,
|
||||
computeSize,
|
||||
}: {
|
||||
availabilityZone?: string
|
||||
computeSize?: string
|
||||
}) => (
|
||||
<p className="flex items-center gap-x-1 text-sm text-foreground-light">
|
||||
{availabilityZone !== undefined && <span>{availabilityZone}</span>}
|
||||
{availabilityZone !== undefined && !!computeSize && (
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
)}
|
||||
{!!computeSize && <span>{computeSize}</span>}
|
||||
</p>
|
||||
)
|
||||
|
||||
export const MultigatewayNode = ({ data }: NodeProps<Node<MultigatewayNodeData>>) => {
|
||||
const { numGateways } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col rounded-sm bg-surface-100 border border-default">
|
||||
<div className="flex items-start p-3 gap-x-4" style={{ width: NODE_CARD_WIDTH }}>
|
||||
<div className="flex gap-x-3">
|
||||
<div className="min-w-8 h-8 bg-blue-600 border border-blue-800 rounded-md flex items-center justify-center">
|
||||
<Network aria-hidden="true" size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<p className="text-sm">Multigateway</p>
|
||||
<p className="flex items-center gap-x-1 text-sm text-foreground-light">
|
||||
<span>Load balancer</span>
|
||||
{numGateways > 1 && (
|
||||
<>
|
||||
<span className="text-foreground-lighter">·</span>
|
||||
<span>{numGateways} instances</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const HaPrimaryNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
|
||||
// [Joshen] Just FYI Handles cannot be conditionally rendered
|
||||
const { cell, name, hasGateway } = data
|
||||
const { status, availabilityZone, computeSize, primaryRegion } = useHaPoolerCard({ cell, name })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={!hasGateway ? 'opacity-0' : ''}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
<div className="flex flex-col rounded-sm bg-surface-100 border border-default">
|
||||
<div className="flex items-start justify-between p-3" style={{ width: NODE_CARD_WIDTH }}>
|
||||
<div className="flex gap-x-3">
|
||||
<div className="w-8 h-8 bg-brand-500 border border-brand-600 rounded-md flex items-center justify-center">
|
||||
<Database aria-hidden="true" size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm">Primary Database</p>
|
||||
{/* Stable live region so polled status changes are announced */}
|
||||
<span role="status">
|
||||
{status !== undefined && <PoolerStatusBadge status={status} />}
|
||||
</span>
|
||||
</div>
|
||||
{primaryRegion !== undefined && (
|
||||
<p className="text-sm text-foreground-light">{primaryRegion.name}</p>
|
||||
)}
|
||||
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
|
||||
</div>
|
||||
</div>
|
||||
{primaryRegion !== undefined && (
|
||||
// Decorative — the visible text next to it already names the region
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-8 rounded-xs mt-0.5"
|
||||
src={`${BASE_PATH}/img/regions/${primaryRegion.region}.svg`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Whether connection metrics are meaningful through the multigateway
|
||||
is unconfirmed, so they're left off for HA projects. */}
|
||||
<ComputeMetricsFooter showConnections={false} />
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const HaReplicaNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
|
||||
const { cell, name } = data
|
||||
const { status, availabilityZone, computeSize } = useHaPoolerCard({ cell, name })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Top} style={{ background: 'transparent' }} />
|
||||
<div
|
||||
className="flex items-start rounded-sm bg-surface-100 border border-default p-3"
|
||||
style={{ width: NODE_CARD_WIDTH }}
|
||||
>
|
||||
<div className="flex gap-x-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 border rounded-md flex items-center justify-center',
|
||||
status === 'healthy'
|
||||
? 'bg-brand-400 border-brand-500'
|
||||
: 'bg-surface-100 border-foreground/20'
|
||||
)}
|
||||
>
|
||||
{status === 'coming_up' ? (
|
||||
<Loader2 aria-hidden="true" className="motion-safe:animate-spin" size={16} />
|
||||
) : (
|
||||
<DatabaseBackup aria-hidden="true" size={16} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm">Read Replica</p>
|
||||
{/* Stable live region so polled status changes are announced */}
|
||||
<span role="status">
|
||||
{status !== undefined && <PoolerStatusBadge status={status} />}
|
||||
</span>
|
||||
</div>
|
||||
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const HaShardNode = ({ data }: NodeProps<Node<HaShardNodeData>>) => {
|
||||
const { name } = data
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full rounded-md border border-default bg-surface-100/25">
|
||||
<div className="pointer-events-auto absolute top-3 left-3 flex items-center gap-x-2 rounded-full border border-default bg-surface-100 pl-3 pr-2 py-1">
|
||||
<Layers aria-hidden="true" size={14} className="text-foreground-light" />
|
||||
<p className="text-sm">{name}</p>
|
||||
<Tooltip>
|
||||
{/* Renders a button so the explanation is keyboard reachable */}
|
||||
<TooltipTrigger className="flex items-center gap-x-1 text-sm text-foreground-light">
|
||||
Automatic failover
|
||||
<HelpCircle aria-hidden="true" size={14} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-72 text-center">
|
||||
If the primary database fails, a replica in this shard is automatically promoted to take
|
||||
its place.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user