From 135a545deac3544ea20e43e1dced9550a0fe9064 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:43:07 -0500 Subject: [PATCH 01/15] docs: update troubleshooting for invalid claim (#33884) invalid cliam: missing sub seems to (also?) be returned as a 403, not a 401. Discovered this when I was testing something else, so updating the troubleshooting guide for it. --- .../auth-error-401-invalid-claim-missing-sub--AFwMR.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx b/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx index 871d852df5c..e7277102b49 100644 --- a/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx +++ b/apps/docs/content/troubleshooting/auth-error-401-invalid-claim-missing-sub--AFwMR.mdx @@ -12,6 +12,10 @@ sdk = [ "auth-getuser" ] [[errors]] http_status_code = 401 message = "invalid claim: missing sub" + +[[errors]] +http_status_code = 403 +message = "invalid claim: missing sub" --- The missing sub claim error is returned when `supabase.auth.getUser()` is called with an invalid JWT in the session or when the user attempts to register/sign in but hasn't completed the sign in when the `getUser` call is made. From 2fa4ccb723a9d9f4d682e4bf70b0e8761324079d Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:54:26 -0500 Subject: [PATCH 02/15] chore(docs): add spelling exception (#33818) Presigned/presigning should be allowed --- supa-mdx-lint/Rule003Spelling.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 2846ee73b81..671d1f4461b 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -75,6 +75,7 @@ allow_list = [ "[Pp]laintext", "[Pp]olyfill(s|ed)?", "[Pp]oolers?", + "[Pp]resign(ed|ing)?", "[Pp]sycopg", "[Qq]uickstarts?", "[Rr]ealtime", From d30478cc22b5373ef049185f1e0af3b3ac22c9b4 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Fri, 28 Feb 2025 03:30:58 +0530 Subject: [PATCH 03/15] doc: postgres/row-level-security: wrong link to auth (#33900) --- .../content/guides/database/postgres/row-level-security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 00f2e392378..0e095879d21 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -24,7 +24,7 @@ enable row level security; -RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be [combined with Supabase Auth](/docs/guides/database/postgres/row-level-security) for end-to-end user security from the browser to the database. +RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database. RLS is a Postgres primitive and can provide "[defense in depth]()" to protect your data from malicious actors even when accessed through third-party tooling. From d0e1d7b06afe24a618b99fc14c3ccc8a785050f9 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:15:37 -0500 Subject: [PATCH 04/15] chore: add preferred usage item for storage docs (#33597) chore(docs lint): add preferred usage for storage connections --- supa-mdx-lint.config.toml | 2 +- .../{preferred_spelling.toml => preferred_usage.toml} | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename supa-mdx-lint/Rule004ExcludeWords/{preferred_spelling.toml => preferred_usage.toml} (79%) diff --git a/supa-mdx-lint.config.toml b/supa-mdx-lint.config.toml index ff4f033d9d5..2a0ebca0b44 100644 --- a/supa-mdx-lint.config.toml +++ b/supa-mdx-lint.config.toml @@ -16,5 +16,5 @@ rules.filler = "include('supa-mdx-lint/Rule004ExcludeWords/filler.toml')" rules.first_person = "include('supa-mdx-lint/Rule004ExcludeWords/first_person.toml')" rules.gender_neutral = "include('supa-mdx-lint/Rule004ExcludeWords/gender_neutral.toml')" rules.inclusive_wording = "include('supa-mdx-lint/Rule004ExcludeWords/inclusive_wording.toml')" -rules.preferred_spelling = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml')" +rules.preferred_usage = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml')" rules.slang = "include('supa-mdx-lint/Rule004ExcludeWords/slang.toml')" diff --git a/supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml b/supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml similarity index 79% rename from supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml rename to supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml index 47832c55754..41fc225568b 100644 --- a/supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml +++ b/supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml @@ -21,4 +21,8 @@ words = [ "PostgreSQL", "Postgres", ], + [ + "concurrent clients", + "concurrent connections", + ], ] From 66d1f40fbaa61c9a3fbf88ab5c590088fa16ed9e Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 27 Feb 2025 17:22:13 -0500 Subject: [PATCH 05/15] fix: troubleshooting sync (#33908) Relative links don't make sense in GitHub discussions so they need to be converted to absolute links for syncing. --- .../docs/Troubleshooting.utils.common.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/docs/features/docs/Troubleshooting.utils.common.mjs b/apps/docs/features/docs/Troubleshooting.utils.common.mjs index 12c275a08e0..2463eb7709e 100644 --- a/apps/docs/features/docs/Troubleshooting.utils.common.mjs +++ b/apps/docs/features/docs/Troubleshooting.utils.common.mjs @@ -158,7 +158,12 @@ export async function getAllTroubleshootingEntriesInternal() { ].includes(child.type) ) } + + if (node.type === 'link' || node.type === 'image') { + canonicalizeUrl(node) + } }) + const contentWithoutJsx = toMarkdown(mdxTree, { extensions: [gfmToMarkdown()], }) @@ -174,6 +179,16 @@ export async function getAllTroubleshootingEntriesInternal() { return (await Promise.all(troubleshootingFiles)).filter((x) => x != null) } +/** + * + * @param {import('mdast').Image | import('mdast').Link} node + */ +function canonicalizeUrl(node) { + if (node.url.startsWith('/')) { + node.url === 'https://supabase.com' + node.url + } +} + /** * @param {TroubleshootingEntry} entry */ From 1c32d55fabfed975925d345f94b94195c0b75000 Mon Sep 17 00:00:00 2001 From: Laurence Isla Date: Thu, 27 Feb 2025 18:21:12 -0500 Subject: [PATCH 06/15] docs: add more filtering examples to the API docs (#33899) - Add logical operators "not" and "or" - All Javascript examples are now present for Bash Co-authored-by: Terry Sutton --- .../components/interfaces/Docs/Snippets.ts | 31 +++++++++++++++++-- .../ProjectAPIDocs.constants.ts | 29 +++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Docs/Snippets.ts b/apps/studio/components/interfaces/Docs/Snippets.ts index d72a54af4ec..c75d889c60c 100644 --- a/apps/studio/components/interfaces/Docs/Snippets.ts +++ b/apps/studio/components/interfaces/Docs/Snippets.ts @@ -342,10 +342,31 @@ let { data: ${resourceId}, error } = await supabase bash: { language: 'bash', code: ` -curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\ +curl --get '${endpoint}/rest/v1/${resourceId}' \\ -H "apikey: ${apiKey}" \\ -H "Authorization: Bearer ${apiKey}" \\ --H "Range: 0-9" +-H "Range: 0-9" \\ +-d "select=*" \\ +\\ +\`# Filters\` \\ +-d "column=eq.Equal+to" \\ +-d "column=gt.Greater+than" \\ +-d "column=lt.Less+than" \\ +-d "column=gte.Greater+than+or+equal+to" \\ +-d "column=lte.Less+than+or+equal+to" \\ +-d "column=like.*CaseSensitive*" \\ +-d "column=ilike.*CaseInsensitive*" \\ +-d "column=is.null" \\ +-d "column=in.(Array,Values)" \\ +-d "column=neq.Not+equal+to" \\ +\\ +\`# Arrays\` \\ +-d "array_column=cs.{array,contains}" \\ +-d "array_column=cd.{contained,by}" \\ +\\ +\`# Logical operators\` \\ +-d "column=not.like.Negate+filter" \\ +-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)" `, }, js: { @@ -354,6 +375,7 @@ curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\ let { data: ${resourceId}, error } = await supabase .from('${resourceId}') .select("*") + // Filters .eq('column', 'Equal to') .gt('column', 'Greater than') @@ -365,9 +387,14 @@ let { data: ${resourceId}, error } = await supabase .is('column', null) .in('column', ['Array', 'Values']) .neq('column', 'Not equal to') + // Arrays .contains('array_column', ['array', 'contains']) .containedBy('array_column', ['contained', 'by']) + + // Logical operators + .not('column', 'like', 'Negate filter') + .or('some_column.eq.Some value, other_column.eq.Other value') `, }, }), diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts index 67b9adefa81..a0e9929114f 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts @@ -722,10 +722,31 @@ let { data: ${resourceId}, error } = await supabase key: 'with-filtering', title: 'With filtering', bash: ` -curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\ +curl --get '${endpoint}/rest/v1/${resourceId}' \\ -H "apikey: ${apikey}" \\ -H "Authorization: Bearer ${apikey}" \\ --H "Range: 0-9" +-H "Range: 0-9" \\ +-d "select=*" \\ +\\ +\`# Filters\` \\ +-d "column=eq.Equal+to" \\ +-d "column=gt.Greater+than" \\ +-d "column=lt.Less+than" \\ +-d "column=gte.Greater+than+or+equal+to" \\ +-d "column=lte.Less+than+or+equal+to" \\ +-d "column=like.*CaseSensitive*" \\ +-d "column=ilike.*CaseInsensitive*" \\ +-d "column=is.null" \\ +-d "column=in.(Array,Values)" \\ +-d "column=neq.Not+equal+to" \\ +\\ +\`# Arrays\` \\ +-d "array_column=cs.{array,contains}" \\ +-d "array_column=cd.{contained,by}" \\ +\\ +\`# Logical operators\` \\ +-d "column=not.like.Negate+filter" \\ +-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)" `, js: ` let { data: ${resourceId}, error } = await supabase @@ -747,6 +768,10 @@ let { data: ${resourceId}, error } = await supabase // Arrays .contains('array_column', ['array', 'contains']) .containedBy('array_column', ['contained', 'by']) + + // Logical operators + .not('column', 'like', 'Negate filter') + .or('some_column.eq.Some value, other_column.eq.Other value') `, }, ] From 1675a1f06f9fda087896281e6509d9fe430eaf73 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 28 Feb 2025 11:11:20 +0800 Subject: [PATCH 07/15] chore: clarify bursting and recommend compute (#33823) --- .../guides/platform/compute-and-disk.mdx | 6 ++++-- .../troubleshooting/exhaust-disk-io.mdx | 18 ++++++++--------- .../Infrastructure.constants.ts | 6 +++--- .../Infrastructure/InfrastructureActivity.tsx | 20 +++++++++---------- ...sourceExhaustionWarningBanner.constants.ts | 8 ++++---- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index f90c4b24143..1534494cdd0 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -98,7 +98,9 @@ The compute size of your project sets the upper limit for disk throughput and IO | 12XL | 14,250 MiB/s | 50,000 IOPS | | 16XL | 19,000 MiB/s | 80,000 IOPS | -Smaller compute instances like Nano, Micro, and Small have baseline performance levels that can occasionally be exceeded. Larger compute-instances (4XL and above) are designed for sustained, high performance with specific IOPS and throughput limits. If you hit your IOPS or throughput limit, throttling will occur. +Smaller compute instances like Nano, Micro, Small, and Medium have baseline performance levels that can occasionally be exceeded for short periods of time. If it does exceed the baseline, you should consider upgrading your instance size for a more reliable performance. + +Larger compute instances (4XL and above) are designed for sustained, high performance with specific IOPS and throughput limits which you can [configure](/docs/guides/platform/manage-your-usage/disk-throughput). If you hit your IOPS or throughput limit, throttling will occur. ### Choosing the right compute instance for consistent disk performance @@ -106,7 +108,7 @@ If you need consistent disk performance, choose the 4XL or larger compute instan ### Provisioned disk throughput and IOPS -The default disk type is gp3, which comes with a baseline throughput of 125 MiB/s and a default IOPS of 3,000. You can provision additional IOPS and throughput from the [Database Settings](/dashboard/project/_/settings/database) page, but keep in mind that the effective IOPS and throughput will be limited by the compute instance size. +The default disk type is gp3, which comes with a baseline throughput of 125 MiB/s and a default IOPS of 3,000. You can provision additional IOPS and throughput from the [Database Settings](https://supabase.com/dashboard/project/_/settings/compute-and-disk) page, but keep in mind that the effective IOPS and throughput will be limited by the compute instance size. This requires Large compute size or above. diff --git a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx index 828814d24d0..9303e6ec283 100644 --- a/apps/docs/content/troubleshooting/exhaust-disk-io.mdx +++ b/apps/docs/content/troubleshooting/exhaust-disk-io.mdx @@ -7,9 +7,9 @@ database_id = "4844905d-1456-44a1-858e-7a4995e5054c" ## Understanding disk IO and disk IO budget -Disk IO refers to two metrics: throughput in Megabits per Second and IOPS which are Input/Output Operations per Second. Depending on the compute add-on of your instance you will have [different baseline performances](https://supabase.com/docs/guides/platform/compute-add-ons#disk-io). +Disk IO refers to two metrics: throughput in Megabits per Second and IOPS which are Input/Output Operations per Second. Depending on the compute add-on of your instance you will have [different baseline performances](/docs/guides/platform/compute-add-ons#compute-size). -Smaller compute instances can burst and exceed their baseline performance for a short quota of time every day. This quota is represented as your Disk IO Budget and once your Disk IO Budget is consumed, your instance reverts back to its baseline performance. You can read more about this under [Bursting and Disk IO Budget](https://supabase.com/docs/guides/platform/compute-add-ons#bursting-and-disk-io-budget). +Smaller compute instances can burst and exceed their baseline performance for a short quota of time every day. This is represented as your Disk IO Budget and once your Disk IO Budget is consumed, your instance reverts back to its baseline performance. Learn more about [choosing the right compute instance for consistent disk performance](/docs/guides/platform/compute-add-ons#choosing-the-right-compute-instance-for-consistent-disk-performance). ## Depleting your disk IO budget @@ -17,26 +17,26 @@ Running out of Disk IO Budget means that your instance is using more disk than i - Response times on requests can increase noticeably - CPU usage rises noticeably due to IO wait -- Disruption of [daily backup](https://supabase.com/docs/guides/platform/backups#daily-backups) routines -- Disruption of internal Postgres processes such as [autovacuuming](https://supabase.com/docs/guides/platform/database-size#vacuum-operations) +- Disruption of [daily backup](/docs/guides/platform/backups#daily-backups) routines +- Disruption of internal Postgres processes such as [autovacuuming](/docs/guides/platform/database-size#vacuum-operations) - Your instance may become unresponsive ## Monitor your disk IO budget To check your Disk IO Budget on the Supabase Platform, head over to [Database Health in the Reports section](https://supabase.com/dashboard/project/_/reports/database). -It is also possible to monitor your resources and set up alerts using Prometheus/Grafana. With Grafana you will be able to see how much of your RAM is used for caching and you can track other metrics such as your Swap usage. Read the [Metrics Guide](https://supabase.com/docs/guides/platform/metrics) to learn more. +It is also possible to monitor your resources and set up alerts using Prometheus/Grafana. With Grafana you will be able to pinpoint potential causes and see more fine-grained metrics like how much of your RAM is used for caching and your Swap usage. Read the [Metrics Guide](/docs/guides/platform/metrics) to learn more. ## Common reasons for high disk IO usage Most operations on your Supabase project require disk IO in some form. Hence, there can be many reasons for high disk IO usage. Here are some common ones: - **High Memory Usage:** Every Supabase project has 1GB of disk allocated for swapping. When your memory usage is high, the operating system might frequently move parts of the memory back and forth of the swap space on the disk. -- **Low Cache Usage:** If your cache hit rate is low, many of your database requests might go straight to the disk. Go to the [Cache Hit Rate Guide](https://supabase.com/docs/guides/platform/performance#hit-rate) to learn more. -- **Query performance:** Queries that take a long time to complete (>1 second) could be using your disk inefficiently. Check our guide on [examining query performance](https://supabase.com/docs/guides/platform/performance#examining-query-performance). +- **Low Cache Usage:** If your cache hit rate is low, many of your database requests might go straight to the disk. Go to the [Cache Hit Rate Guide](/docs/guides/platform/performance#hit-rate) to learn more. +- **Query performance:** Queries that take a long time to complete (>1 second) could be using your disk inefficiently. Check our guide on [examining query performance](/docs/guides/platform/performance#examining-query-performance). - **High popularity:** Congrats! Your side project turned out to be a real success and is getting more requests than it can handle. ## How to fix -1. **Upgrade your compute:** You can get a Compute Add-on for your project. See your [upgrade options](https://supabase.com/dashboard/project/_/settings/compute-and-disk) by selecting your project. Do reference the [different baseline performances](https://supabase.com/docs/guides/platform/compute-add-ons#disk-io) that come with larger Compute Add-ons. -2. **Optimize performance:** Get more out of your instance's resources by optimizing your usage. Have a look at our [performance tuning guide](https://supabase.com/docs/guides/platform/performance#examining-query-performance) and our [production readiness guide](https://supabase.com/docs/guides/platform/going-into-prod#performance). +1. **Upgrade your compute:** You can get a Compute Add-on for your project. Larger compute options (4XL and above) have more consistent disk performance. See your [upgrade options](https://supabase.com/dashboard/project/_/settings/compute-and-disk) by selecting your project. Do reference the [different baseline performances](/docs/guides/platform/compute-add-ons#compute-size) that come with larger Compute Add-ons. +2. **Optimize performance:** Get more out of your instance's resources by optimizing your usage. Have a look at our [performance tuning guide](/docs/guides/platform/performance#examining-query-performance) and our [production readiness guide](/docs/guides/platform/going-into-prod#performance). diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts index 73dff33764b..92b696360ac 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts @@ -90,8 +90,8 @@ export const INFRA_ACTIVITY_METRICS: CategoryMeta[] = [ url: 'https://supabase.com/docs/guides/platform/compute-add-ons#disk-throughput-and-iops', }, { - name: 'Interpreting Disk IO budget', - url: 'https://supabase.com/docs/guides/platform/compute-add-ons#bursting-and-disk-io-budget', + name: 'High Disk I/O', + url: 'https://supabase.com/docs/guides/troubleshooting/exhaust-disk-io', }, { name: 'Metrics', @@ -99,7 +99,7 @@ export const INFRA_ACTIVITY_METRICS: CategoryMeta[] = [ }, ], description: - 'The disk performance of your workload is determined by the Disk IO bandwidth.\nSmaller compute instances (below 4XL) can burst up to their largest throughput and IOPS for 30 minutes in a day. Beyond that, the performance reverts to the baseline. Your disk IO budget gets replenished throughout the day.', + 'The disk performance of your workload is determined by the Disk IO bandwidth.\nSmaller compute instances (below 4XL) can burst above their baseline disk throughput and IOPS for short periods of time. Beyond that, the performance reverts to the baseline.', chartDescription: '', }, ], diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index b0540dc13bb..d88f781dc0d 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -288,11 +288,9 @@ const InfrastructureActivity = () => {

) : (

- Your current compute can burst up to{' '} - {currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps for - 30 minutes a day and reverts to the baseline performance of{' '} + Your current compute can burst above the baseline disk throughput of{' '} {currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()}{' '} - Mbps. + Mbps for short periods of time.

)} @@ -306,6 +304,13 @@ const InfrastructureActivity = () => { 'Micro'}

+
+

Baseline IO Bandwidth

+

+ {currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()}{' '} + Mbps +

+

Maximum IO Bandwidth (burst limit) @@ -314,13 +319,6 @@ const InfrastructureActivity = () => { {currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps

-
-

Baseline IO Bandwidth

-

- {currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()}{' '} - Mbps -

-
{currentComputeInstanceSpecs.max_disk_io_mbs !== currentComputeInstanceSpecs?.baseline_disk_io_mbs && (
diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts index 07915757f5b..6333c7fae09 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts @@ -42,7 +42,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = }, }, docsUrl: 'https://supabase.com/docs/guides/platform/database-size#disabling-read-only-mode', - buttonText: 'View Compute and Disk', + buttonText: 'Learn more', metric: 'read_only', }, disk_io_exhaustion: { @@ -70,7 +70,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = }, }, docsUrl: 'https://supabase.com/docs/guides/troubleshooting/exhaust-disk-io', - buttonText: 'Check usage', + buttonText: 'Learn more', metric: 'disk_io', }, disk_space_exhaustion: { @@ -125,7 +125,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = }, }, docsUrl: 'https://supabase.com/docs/guides/troubleshooting/high-cpu-usage', - buttonText: 'Check usage', + buttonText: 'Learn more', metric: 'cpu', }, memory_and_swap_exhaustion: { @@ -153,7 +153,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = }, }, docsUrl: 'https://supabase.com/docs/guides/troubleshooting/exhaust-ram', - buttonText: 'Check usage', + buttonText: 'Learn more', metric: 'ram', }, auth_rate_limit_exhaustion: { From a458977e2da817cf7c0d6fbfbeda36a8a424624f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Feb 2025 11:14:42 +0800 Subject: [PATCH 08/15] Support for Dedicated Pooler in Connection Pooling (#33817) * Init * Initial set up for hooking up supavisor and pgbouncer * Hook up pgbouncer status check after swapping pooler type * Add check for nano compute for switching to pg bouncer * Add check for ipv4 addon * Remove expect error tag * Add badge to select options for pooler types * Remove statement mode * Resolve undefined problem with react hook form * Fix * Update UI texts from PgBouncer to Dedicated Pooler * Feex * FEEX * Fix * Small update to UI * Smol update --- .../fields/ComputeSizeField.tsx | 5 +- .../ui/DiskManagement.constants.tsx | 5 - .../ConnectionPooling.constants.ts | 2 + .../ConnectionPooling/ConnectionPooling.tsx | 954 ++++++++++++------ .../components/ui/Forms/Form.constants.ts | 21 +- apps/studio/components/ui/Panel.tsx | 15 +- .../data/database/pgbouncer-config-query.ts | 20 +- .../pgbouncer-config-update-mutation.ts | 73 ++ .../data/database/pgbouncer-status-query.ts | 20 +- .../database/pooling-configuration-query.ts | 22 + .../pooling-configuration-update-mutation.ts | 37 +- .../ui/src/components/radio-group-card.tsx | 2 +- 12 files changed, 823 insertions(+), 353 deletions(-) create mode 100644 apps/studio/data/database/pgbouncer-config-update-mutation.ts diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 1d93f5fb677..1d211ddc1e7 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -5,10 +5,12 @@ import { UseFormReturn } from 'react-hook-form' import { components } from 'api-types' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { DocsButton } from 'components/ui/DocsButton' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' +import { InstanceSpecs } from 'lib/constants' import { cn, FormField_Shadcn_, RadioGroupCard, RadioGroupCardItem, Skeleton } from 'ui' import { ComputeBadge } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -22,8 +24,6 @@ import { import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' import { NoticeBar } from '../ui/NoticeBar' -import { InstanceSpecs } from 'lib/constants' -import { DocsButton } from 'components/ui/DocsButton' /** * to do: this could be a type from api-types @@ -196,7 +196,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { lockedOption && 'opacity-50' )} disabled={disabled || lockedOption} - // @ts-expect-error label={ <> {showUpgradeBadge && compute.identifier === 'ci_micro' && ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx index 1b7d7f091c2..734dc4f28cc 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskManagement.constants.tsx @@ -57,11 +57,6 @@ export const DISK_LIMITS = { }, } -export const DISK_TYPE_LABELS = { - [DiskType.GP3]: 'General Purpose SSD (gp3)', - [DiskType.IO2]: 'Provisioned IOPS SSD (io2)', -} - interface PlanDetails { includedDiskGB: { gp3: number; io2: number } } diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts index 6b6e15d41bb..c9407d2e881 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants.ts @@ -1,5 +1,7 @@ // https://supabase.com/docs/guides/platform/performance#optimizing-the-number-of-connections // https://github.com/supabase/infrastructure/blob/develop/worker/src/lib/constants.ts#L544-L596 +// https://github.com/supabase/supabase-admin-api/blob/master/optimizations/pgbouncer.go +// [Joshen] This matches for both Supavisor and PgBouncer export const POOLING_OPTIMIZATIONS = { ci_nano: { diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 122ff895fbf..2645568895c 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -1,25 +1,30 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' import { capitalize } from 'lodash' -import { Fragment, useEffect } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' import z from 'zod' -import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' -import { StringToPositiveNumber } from 'components/ui/Forms/Form.constants' +import { setValueAsNullableNumber } from 'components/ui/Forms/Form.constants' import { FormActions } from 'components/ui/Forms/FormActions' +import { InlineLink } from 'components/ui/InlineLink' import Panel from 'components/ui/Panel' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' -import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' -import { usePoolingConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' +import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' +import { usePgbouncerConfigurationUpdateMutation } from 'data/database/pgbouncer-config-update-mutation' +import { usePgbouncerStatusQuery } from 'data/database/pgbouncer-status-query' +import { useSupavisorConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useSupavisorConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' +import { toast } from 'sonner' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' import { AlertDescription_Shadcn_, @@ -27,35 +32,178 @@ import { Alert_Shadcn_, Badge, FormControl_Shadcn_, - FormDescription_Shadcn_, FormField_Shadcn_, - FormItem_Shadcn_, - FormLabel_Shadcn_, - FormMessage_Shadcn_, Form_Shadcn_, Input_Shadcn_, Listbox, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, Separator, + Tooltip, + TooltipContent, + TooltipTrigger, + cn, } from 'ui' import { Admonition } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' -const formId = 'connection-pooling-form' +const formId = 'pooling-configuration-form' -const FormSchema = z.object({ - default_pool_size: StringToPositiveNumber, - pool_mode: z.union([z.literal('transaction'), z.literal('session')]), - max_client_conn: StringToPositiveNumber, +const PoolingConfigurationFormSchema = z.object({ + type: z.union([z.literal('Supavisor'), z.literal('PgBouncer')]), + default_pool_size: z.number().nullable(), + pool_mode: z.union([z.literal('transaction'), z.literal('session'), z.literal('statement')]), + max_client_conn: z.number().nullable(), }) +/** + * [Joshen] Some outstanding questions that need clarification for support both type of poolers + * I've left comments in the code itself below, but just leaving a summary here for easier reference + * - How to check for when Supavisor is ready to receive connections? We have pgbouncer/status for PgBouncer + * - Are we currently ensuring the 2 hour window on the BE? I noticed pgbouncer/status flips active to false in a second after setting pgbouncer_enabled to false + * - Existing projects currently have pgbouncer_enabled and supavisor_enabled as true, are we going to backfill? + * - We're using pgbouncer_enabled to determine the pooler type, which means that all projects are going to show on the UI that pgbouncer is being used + * + * Apart from the above, some pointers to note: + * - max_client_conn should be editable for pgbouncer + * - (Nice to have) Show a countdown of 2 hours when the pooler is swapped as a UI indication for users + * - [TODO] Connect UI needs to be updated to show the correct pooler connection string depending on which type is being used + * - [TODO] Project addons IPv4 needs an update on the CTA "You do not need...", needs to now be dependent on the Pooler type + * + * Added a feature flag just in case + * - Toggles visibility of Pooler Type input field + * - Whether to use pgbouncer_enabled to determine pooler type + */ + export const ConnectionPooling = () => { const { ref: projectRef } = useParams() const { project } = useProjectContext() + const org = useSelectedOrganization() const snap = useDatabaseSettingsStateSnapshot() + const allowPgBouncerSelection = useFlag('dualPoolerSupport') + const toastIdRef = useRef() + const [showConfirmation, setShowConfirmation] = useState(false) + const [refetchPgBouncerStatus, setRefetchPgBouncerStatus] = useState(false) + + const canUpdateConnectionPoolingConfiguration = useCheckPermissions( + PermissionAction.UPDATE, + 'projects', + { resource: { project_id: project?.id } } + ) + + const { + data: supavisorPoolingInfo, + error: supavisorConfigError, + isLoading: isLoadingSupavisorConfig, + isError: isErrorSupavisorConfig, + isSuccess: isSuccessSupavisorConfig, + } = useSupavisorConfigurationQuery({ projectRef }) + + const { + data: pgbouncerConfig, + error: pgbouncerConfigError, + isLoading: isLoadingPgbouncerConfig, + isError: isErrorPgbouncerConfig, + isSuccess: isSuccessPgbouncerConfig, + } = usePgbouncerConfigQuery({ + projectRef, + }) + + const { data: maxConnData } = useMaxConnectionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) + + usePgbouncerStatusQuery( + { projectRef }, + { + refetchInterval: (data) => { + // [Joshen] Need to clarify the following: + // - How to check for when Supavisor is ready to receive connections when swapping over to Supavisor + // - I notice status goes to false when i swap over to Supavisor in 2 seconds, does this mean that PgBouncer is already offline? + // - Cause we need to consider the 2 hour window that we're providing for users to swap over the pooler connection strings + if (refetchPgBouncerStatus) { + if ( + (!!pgbouncerConfig?.pgbouncer_enabled && !data?.active) || + (!pgbouncerConfig?.pgbouncer_enabled && !!data?.active) + ) { + return 2000 + } else { + toast.success( + `${data?.active ? 'Dedicated Pooler' : 'Supavisor'} is now ready to receive connections!`, + { id: toastIdRef.current } + ) + toastIdRef.current = undefined + setRefetchPgBouncerStatus(false) + return false + } + } else { + return false + } + }, + } + ) + + const { mutate: updateSupavisorConfig, isLoading: isUpdatingSupavisor } = + useSupavisorConfigurationUpdateMutation() + const { + mutate: updatePgbouncerConfig, + mutateAsync: updatePgBouncerConfigAsync, + isLoading: isUpdatingPgBouncer, + } = usePgbouncerConfigurationUpdateMutation() + + const form = useForm>({ + resolver: zodResolver(PoolingConfigurationFormSchema), + defaultValues: { + type: undefined, + pool_mode: undefined, + default_pool_size: undefined, + max_client_conn: null, + }, + }) + const { type, default_pool_size, max_client_conn } = form.watch() + const error = useMemo( + () => (type === 'PgBouncer' ? pgbouncerConfigError : supavisorConfigError), + [type] + ) + const isLoading = useMemo( + () => (type === 'PgBouncer' ? isLoadingPgbouncerConfig : isLoadingSupavisorConfig), + [type] + ) + const isError = useMemo( + () => (type === 'PgBouncer' ? isErrorPgbouncerConfig : isErrorSupavisorConfig), + [type] + ) + const isSuccess = useMemo( + () => (type === 'PgBouncer' ? isSuccessPgbouncerConfig : isSuccessSupavisorConfig), + [type] + ) + const isSaving = isUpdatingSupavisor || isUpdatingPgBouncer + + const currentPooler = allowPgBouncerSelection + ? pgbouncerConfig?.pgbouncer_enabled + ? 'PgBouncer' + : 'Supavisor' + : 'Supavisor' + // [Joshen] These are labels just for user-facing texts + const formattedCurrentPooler = + currentPooler === 'PgBouncer' ? 'the Dedicated Pooler' : currentPooler + const formattedTargetPooler = type === 'PgBouncer' ? 'the Dedicated Pooler' : type + + const hasIpv4Addon = !!addons?.selected_addons.find((addon) => addon.type === 'ipv4') const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance') + const computeSize = + computeInstance?.variant.name ?? capitalize(project?.infra_compute_size) ?? 'Nano' const poolingOptimizations = POOLING_OPTIMIZATIONS[ (computeInstance?.variant.identifier as keyof typeof POOLING_OPTIMIZATIONS) ?? @@ -63,80 +211,115 @@ export const ConnectionPooling = () => { ] const defaultPoolSize = poolingOptimizations.poolSize ?? 15 const defaultMaxClientConn = poolingOptimizations.maxClientConn ?? 200 - const computeSize = - computeInstance?.variant.name ?? capitalize(project?.infra_compute_size) ?? 'Micro' - const { - data: poolingInfo, - error, - isLoading, - isError, - isSuccess, - } = usePoolingConfigurationQuery({ projectRef }) + const isFreePlan = subscription?.plan.id === 'free' + const supavisorConfig = supavisorPoolingInfo?.find((x) => x.database_type === 'PRIMARY') + const connectionPoolingUnavailable = + type === 'PgBouncer' ? pgbouncerConfig?.pool_mode === null : supavisorConfig?.pool_mode === null + const disablePoolModeSelection = + type === 'Supavisor' && supavisorConfig?.pool_mode === 'transaction' + const disablePgBouncerSelection = computeSize === 'Nano' + const showPoolModeWarning = type === 'Supavisor' && supavisorConfig?.pool_mode === 'session' + const isChangingPoolerType = + (currentPooler === 'PgBouncer' && type === 'Supavisor') || + (currentPooler === 'Supavisor' && type === 'PgBouncer') - const { data: authConfig } = useAuthConfigQuery({ projectRef }) + const poolerSwitchWarningTitle = + 'Your current pooler will be active for 2 hours before fully deactivated' + const poolerSwitchWarningDescription = `Migrate your applications from ${formattedCurrentPooler} to ${formattedTargetPooler} during this time by switching to ${formattedTargetPooler} connection strings in your applications.` - const { data: maxConnData } = useMaxConnectionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) + const onSubmit: SubmitHandler> = async (data) => { + const { type, pool_mode, default_pool_size, max_client_conn } = data - const poolingConfiguration = poolingInfo?.find((x) => x.database_type === 'PRIMARY') - const connectionPoolingUnavailable = poolingConfiguration?.pool_mode === null - - const canUpdateConnectionPoolingConfiguration = useCheckPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { - project_id: project?.id, - }, - } - ) - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', - default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, - max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, - }, - }) - - const { mutate: updateConfiguration, isLoading: isUpdating } = - usePoolingConfigurationUpdateMutation({ - onSuccess: (data) => { - if (data) { - form.reset({ - pool_mode: data.pool_mode, - default_pool_size: data.default_pool_size, - max_client_conn: poolingConfiguration?.max_client_conn, - }) - } - toast.success('Successfully saved settings') - }, - }) - - const onSubmit: SubmitHandler> = async (data) => { if (!projectRef) return console.error('Project ref is required') - if (!poolingInfo) return console.error('Pooling info required') + if (isChangingPoolerType && !showConfirmation) return setShowConfirmation(true) - updateConfiguration({ - ref: projectRef, - default_pool_size: data.default_pool_size as number | undefined, - pool_mode: data.pool_mode, - }) + if (type === 'PgBouncer') { + if (!pgbouncerConfig) return console.error('Pgbouncer configuration is required') + updatePgbouncerConfig( + { + ref: projectRef, + pgbouncer_enabled: true, + ignore_startup_parameters: pgbouncerConfig.ignore_startup_parameters ?? '', + pool_mode: pool_mode as 'transaction' | 'session' | 'statement', + max_client_conn, + default_pool_size: default_pool_size as number | undefined, + }, + { + onSuccess: (data) => { + if (isChangingPoolerType) { + const toastId = toast.loading('Swapping pooler to the Dedicated Pooler') + toastIdRef.current = toastId + setRefetchPgBouncerStatus(true) + } else { + toast.success(`Successfully updated Dedicated Pooler configuration`) + } + + setShowConfirmation(false) + form.reset({ type: 'PgBouncer', ...data }) + }, + } + ) + } else if (type === 'Supavisor') { + if (isChangingPoolerType && pgbouncerConfig) { + await updatePgBouncerConfigAsync({ + ref: projectRef, + pgbouncer_enabled: false, + ignore_startup_parameters: pgbouncerConfig.ignore_startup_parameters ?? '', + pool_mode: pgbouncerConfig.pool_mode as 'transaction' | 'session' | 'statement', + }) + } + updateSupavisorConfig( + { + ref: projectRef, + default_pool_size, + pool_mode: pool_mode as 'transaction' | 'session', + }, + { + onSuccess: (data) => { + if (isChangingPoolerType) { + const toastId = toast.loading('Swapping pooler to Supavisor') + toastIdRef.current = toastId + setRefetchPgBouncerStatus(true) + } else { + toast.success(`Successfully updated Supavisor configuration`) + } + setShowConfirmation(false) + form.reset({ type: 'Supavisor', ...data }) + }, + } + ) + } + } + + const resetForm = () => { + if (currentPooler === 'PgBouncer') { + if (pgbouncerConfig) { + form.reset({ + type: 'PgBouncer', + pool_mode: pgbouncerConfig.pool_mode, + default_pool_size: pgbouncerConfig.default_pool_size, + max_client_conn: pgbouncerConfig.max_client_conn, + }) + } + } else { + if (supavisorConfig) { + form.reset({ + type: 'Supavisor', + pool_mode: supavisorConfig.pool_mode, + default_pool_size: supavisorConfig.default_pool_size, + max_client_conn: supavisorConfig.max_client_conn, + }) + } + } } useEffect(() => { - if (isSuccess) { - form.reset({ - pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', - default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, - max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, - }) + // [Joshen] We're using pgbouncer_enabled from pgbouncer's config to determine the current type + if (isSuccessPgbouncerConfig && isSuccessSupavisorConfig) { + resetForm() } - }, [isSuccess, authConfig]) + }, [isSuccessPgbouncerConfig, isSuccessSupavisorConfig]) return (
@@ -145,12 +328,8 @@ export const ConnectionPooling = () => { title={
-

- {connectionPoolingUnavailable - ? 'Connection Pooling is not available for this project' - : 'Connection pooling configuration'} -

- Supavisor +

Connection pooling configuration

+ {!allowPgBouncerSelection && Supavisor}
@@ -158,9 +337,9 @@ export const ConnectionPooling = () => { footer={ form.reset()} + handleReset={() => resetForm()} helper={ !canUpdateConnectionPoolingConfiguration ? 'You need additional permissions to update connection pooling settings' @@ -169,158 +348,310 @@ export const ConnectionPooling = () => { /> } > - {isLoading && ( - - {Array.from({ length: 4 }).map((_, i) => ( - -
- - -
- -
- ))} + + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+ + +
+ +
+ ))} - - - )} - - {isError && ( -
- -
- )} - - {isSuccess && ( - <> - {connectionPoolingUnavailable && ( -

Please start a new project to enable this feature.

- )} - -
- ( - - - Pool Mode - - - field.onChange(value)} - > - -

Transaction mode

-

- {TRANSACTION_MODE_DESCRIPTION} -

-
- -

Session mode

-

- {SESSION_MODE_DESCRIPTION} -

-
-
-
- - {poolingConfiguration?.pool_mode === 'transaction' && ( - - - - Pool mode is permanently set to Transaction on port 6543 - - - You can use Session mode by connecting to the pooler on port 5432 - instead - - - - )} - - {poolingConfiguration?.pool_mode === 'session' && ( - <> - {field.value === 'transaction' ? ( - - - - ) : ( - - {/* [Joshen] Can probably remove this after Feb 28 */} - - - - )} - - )} - - -

- Specify when a connection can be returned to the pool.{' '} - snap.setShowPoolingModeHelper(true)} - className="cursor-pointer underline underline-offset-2" - > - Learn more about pool modes - - . -

-
- - -
- )} + +
+ )} + {isError && ( + + )} + {isSuccess && ( + <> + {connectionPoolingUnavailable && ( + - ( - - - Pool Size - - - - - {maxConnData !== undefined && - Number(form.getValues('default_pool_size') ?? 15) > - maxConnData.maxConnections * 0.8 && ( -
- + )} + + + {allowPgBouncerSelection && ( + ( + + {isChangingPoolerType && ( + + )} + {type === 'PgBouncer' && !hasIpv4Addon && ( + + If you were using Supavisor for IPv6, we recommend purchasing + a dedicated IPv4 address from the{' '} + + add-ons page + + {isChangingPoolerType && ' before changing your pooler type'}. + + } + /> + )} + + } + > + { + field.onChange(e) + if (e === 'Supavisor' && supavisorConfig) { + form.setValue('type', 'Supavisor') + form.setValue('pool_mode', supavisorConfig.pool_mode) + form.setValue( + 'default_pool_size', + supavisorConfig.default_pool_size + ) + form.setValue('max_client_conn', supavisorConfig.max_client_conn) + } else if (e === 'PgBouncer' && pgbouncerConfig) { + form.setValue('type', 'PgBouncer') + form.setValue('pool_mode', pgbouncerConfig.pool_mode as any) + form.setValue( + 'default_pool_size', + pgbouncerConfig.default_pool_size as any + ) + form.setValue( + 'max_client_conn', + pgbouncerConfig.max_client_conn || null + ) + } + }} + > + + + + + + + +
+

Supavisor

+ IPv4 +
+
+ + + +
+

Dedicated Pooler

+
+ {hasIpv4Addon && Dedicated IPv4} + IPv6 +
+
+
+
+ {disablePgBouncerSelection && ( + + Dedicated Pooler can only be used while on a Micro Compute and + above.{' '} + {isFreePlan ? ( + <> + + Upgrade your plan + {' '} + to adjust your project's compute size. + + ) : ( + <> + Upgrade your compute size through your{' '} + + project's settings + + . + + )} + + )} +
+
+
+
+ )} + /> + )} + + ( + + {disablePoolModeSelection && ( + + + Pool mode is permanently set to Transaction on port 6543 + + + You can use Session mode by connecting to the pooler on port 5432 + instead + + + )} + {showPoolModeWarning && ( + <> + {field.value === 'transaction' ? ( + + ) : ( + <> + + + + )} + + )} +

+ Specify when a connection can be returned to the pool.{' '} + snap.setShowPoolingModeHelper(true)} + className="transition cursor-pointer underline underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground" + > + Learn more about pool modes + + . +

+ + } + > + + field.onChange(value)} + > + +

Transaction mode

+

+ {TRANSACTION_MODE_DESCRIPTION} +

+
+ +

Session mode

+

+ {SESSION_MODE_DESCRIPTION} +

+
+
+
+
+ )} + /> + + ( + +

+ The maximum number of connections made to the underlying Postgres + cluster, per user+db combination. Pool size has a default of{' '} + {defaultPoolSize} based on your compute size of {computeSize}. +

+ {type === 'Supavisor' && ( +

+ Please refer to our{' '} + + documentation + {' '} + to find out more. +

+ )} + + } + > + + + + {!!maxConnData && + (default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && ( + Pool size is greater than 80% of the max connections ( {maxConnData.maxConnections}) on your database @@ -330,72 +661,87 @@ export const ConnectionPooling = () => { connections. -
- )} - - The maximum number of connections made to the underlying Postgres cluster, - per user+db combination. Pool size has a default of {defaultPoolSize} based - on your compute size of {computeSize}. - - - Please refer to our{' '} - - documentation - {' '} - to find out more. - - -
- )} - /> - ( - - - Max Client Connections - - - - - - The maximum number of concurrent client connections allowed. This value is - fixed at {defaultMaxClientConn} based on your compute size of {computeSize}{' '} - and cannot be changed. - - - Please refer to our{' '} - - documentation - {' '} - to find out more. - - - - )} - /> - - - - )} + )} + + )} + /> + + ( + +

+ The maximum number of concurrent client connections allowed.{' '} + {type === 'Supavisor' ? ( + <> + This value is fixed at {defaultMaxClientConn} based on your + compute size of {computeSize} and cannot be changed. + + ) : ( + <> + This has a default of {defaultMaxClientConn} based on your compute + size of {computeSize}. + + )} +

+ {type === 'Supavisor' && ( +

+ Please refer to our{' '} + + documentation + {' '} + to find out more. +

+ )} + + } + > + + + +
+ )} + /> + + + + )} +
+ setShowConfirmation(false)} + onConfirm={() => onSubmit(form.getValues())} + alert={{ + base: { variant: 'warning' }, + title: poolerSwitchWarningTitle, + description: poolerSwitchWarningDescription, + }} + > +

+ Are you sure you wish to switch your pooler type to {formattedTargetPooler} and apply the + provided configurations? +

+
) } diff --git a/apps/studio/components/ui/Forms/Form.constants.ts b/apps/studio/components/ui/Forms/Form.constants.ts index ad65518ec6c..e3910fabb06 100644 --- a/apps/studio/components/ui/Forms/Form.constants.ts +++ b/apps/studio/components/ui/Forms/Form.constants.ts @@ -1,19 +1,5 @@ import * as z from 'zod' -// This validator validates a string to be a positive integer or if it's an empty string, transforms it to a null -export const StringToPositiveNumber = z.union([ - // parse the value if it's a number - z.number().positive().int(), - // parse the value if it's a non-empty string - z.string().min(1).pipe(z.coerce.number().positive().int()), - // transform a non-empty string into a null value - z - .string() - .max(0, 'The field accepts only a number') - .transform((v) => null), - z.null(), -]) - export const StringNumberOrNull = z .string() .transform((v) => (v === '' ? null : v)) @@ -22,3 +8,10 @@ export const StringNumberOrNull = z message: 'Invalid number', }) .transform((value) => (value === null ? null : Number(value))) + +/** + * [Joshen] After wrangling with RHF I think this is the easiest way to handle nullable number fields + * - Declare the field normally as you would in the zod form schema (e.g field: z.number().nullable()) + * - In the InputField, add a form.register call `{...form.register('field_name', { setValueAs: setValueAsNullableNumber })}` + */ +export const setValueAsNullableNumber = (v: any) => (v === '' || v === null ? null : parseInt(v)) diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index dbc51b7dfa0..12b2d88f0bc 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -36,11 +36,7 @@ function Panel(props: PropsWithChildren) {
)} {props.children} - {props.footer && ( -
-
{props.footer}
-
- )} + {props.footer &&
{props.footer}
} ) @@ -55,6 +51,14 @@ function Content({ children, className }: { children: ReactNode; className?: str return
{children}
} +function Footer({ children }: { children: ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ) +} + const PanelNotice = forwardRef< HTMLDivElement, { @@ -139,5 +143,6 @@ const PanelNotice = forwardRef< PanelNotice.displayName = 'PanelNotice' Panel.Content = Content +Panel.Footer = Footer Panel.Notice = PanelNotice export default Panel diff --git a/apps/studio/data/database/pgbouncer-config-query.ts b/apps/studio/data/database/pgbouncer-config-query.ts index d3deadb9e33..93809e5ba4c 100644 --- a/apps/studio/data/database/pgbouncer-config-query.ts +++ b/apps/studio/data/database/pgbouncer-config-query.ts @@ -4,12 +4,12 @@ import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { databaseKeys } from './keys' -export type ProjectPgbouncerConfigVariables = { +export type PgbouncerConfigVariables = { projectRef?: string } -export async function getProjectPgbouncerConfig( - { projectRef }: ProjectPgbouncerConfigVariables, +export async function getPgbouncerConfig( + { projectRef }: PgbouncerConfigVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -22,19 +22,19 @@ export async function getProjectPgbouncerConfig( return data } -export type ProjectPgbouncerConfigData = Awaited> -export type ProjectPgbouncerConfigError = ResponseError +export type PgbouncerConfigData = Awaited> +export type PgbouncerConfigError = ResponseError -export const useProjectPgbouncerConfigQuery = ( - { projectRef }: ProjectPgbouncerConfigVariables, +export const usePgbouncerConfigQuery = ( + { projectRef }: PgbouncerConfigVariables, { enabled = true, ...options - }: UseQueryOptions = {} + }: UseQueryOptions = {} ) => - useQuery( + useQuery( databaseKeys.pgbouncerConfig(projectRef), - ({ signal }) => getProjectPgbouncerConfig({ projectRef }, signal), + ({ signal }) => getPgbouncerConfig({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/database/pgbouncer-config-update-mutation.ts b/apps/studio/data/database/pgbouncer-config-update-mutation.ts new file mode 100644 index 00000000000..3159da1b0b1 --- /dev/null +++ b/apps/studio/data/database/pgbouncer-config-update-mutation.ts @@ -0,0 +1,73 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { components } from 'data/api' +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { databaseKeys } from './keys' + +export type PgbouncerConfigurationUpdateVariables = { + ref: string +} & components['schemas']['UpdatePgbouncerConfigBody'] + +export async function updatePgbouncerConfiguration({ + ref, + pool_mode, + default_pool_size, + pgbouncer_enabled, + ignore_startup_parameters, + max_client_conn, +}: PgbouncerConfigurationUpdateVariables) { + if (!ref) return console.error('Project ref is required') + + const { data, error } = await patch('/platform/projects/{ref}/config/pgbouncer', { + params: { path: { ref } }, + body: { + default_pool_size, + pool_mode, + pgbouncer_enabled, + ignore_startup_parameters, + max_client_conn, + }, + }) + + if (error) handleError(error) + return data +} + +type PgbouncerConfigurationUpdateData = Awaited> + +export const usePgbouncerConfigurationUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PgbouncerConfigurationUpdateData, + ResponseError, + PgbouncerConfigurationUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PgbouncerConfigurationUpdateData, + ResponseError, + PgbouncerConfigurationUpdateVariables + >((vars) => updatePgbouncerConfiguration(vars), { + async onSuccess(data, variables, context) { + const { ref } = variables + await queryClient.invalidateQueries(databaseKeys.pgbouncerConfig(ref)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update PgBouncer configuration: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/database/pgbouncer-status-query.ts b/apps/studio/data/database/pgbouncer-status-query.ts index 623808b292c..fd178fd8eb2 100644 --- a/apps/studio/data/database/pgbouncer-status-query.ts +++ b/apps/studio/data/database/pgbouncer-status-query.ts @@ -4,12 +4,12 @@ import { get, handleError } from 'data/fetchers' import { ResponseError } from 'types' import { databaseKeys } from './keys' -export type ProjectPgbouncerStatusVariables = { +export type PgbouncerStatusVariables = { projectRef?: string } -export async function getProjectPgbouncerStatus( - { projectRef }: ProjectPgbouncerStatusVariables, +export async function getPgbouncerStatus( + { projectRef }: PgbouncerStatusVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -22,19 +22,19 @@ export async function getProjectPgbouncerStatus( return data } -export type ProjectPgbouncerStatusData = Awaited> -export type ProjectPgbouncerStatusError = ResponseError +export type PgbouncerStatusData = Awaited> +export type PgbouncerStatusError = ResponseError -export const useProjectPgbouncerStatusQuery = ( - { projectRef }: ProjectPgbouncerStatusVariables, +export const usePgbouncerStatusQuery = ( + { projectRef }: PgbouncerStatusVariables, { enabled = true, ...options - }: UseQueryOptions = {} + }: UseQueryOptions = {} ) => - useQuery( + useQuery( databaseKeys.pgbouncerStatus(projectRef), - ({ signal }) => getProjectPgbouncerStatus({ projectRef }, signal), + ({ signal }) => getPgbouncerStatus({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', ...options, diff --git a/apps/studio/data/database/pooling-configuration-query.ts b/apps/studio/data/database/pooling-configuration-query.ts index 9f58ebad7c5..8059e26dcb0 100644 --- a/apps/studio/data/database/pooling-configuration-query.ts +++ b/apps/studio/data/database/pooling-configuration-query.ts @@ -31,6 +31,9 @@ export async function getPoolingConfiguration( export type PoolingConfigurationData = Awaited> export type PoolingConfigurationError = ResponseError +/** + * @deprecated use useSupavisorConfigurationQuery isntead + */ export const usePoolingConfigurationQuery = ( { projectRef }: PoolingConfigurationVariables, { @@ -46,3 +49,22 @@ export const usePoolingConfigurationQuery = ( ...options, } ) + +/** + * Just a duplicate of usePoolingConfigurationQuery until we move everything over + */ +export const useSupavisorConfigurationQuery = ( + { projectRef }: PoolingConfigurationVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + databaseKeys.poolingConfiguration(projectRef), + ({ signal }) => getPoolingConfiguration({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/database/pooling-configuration-update-mutation.ts b/apps/studio/data/database/pooling-configuration-update-mutation.ts index f02db60d97b..8252daecf98 100644 --- a/apps/studio/data/database/pooling-configuration-update-mutation.ts +++ b/apps/studio/data/database/pooling-configuration-update-mutation.ts @@ -57,7 +57,42 @@ export const usePoolingConfigurationUpdateMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to update pooling configuration: ${data.message}`) + toast.error(`Failed to update PgBouncer configuration: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} + +export const useSupavisorConfigurationUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PoolingConfigurationUpdateData, + ResponseError, + PoolingConfigurationUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PoolingConfigurationUpdateData, + ResponseError, + PoolingConfigurationUpdateVariables + >((vars) => updatePoolingConfiguration(vars), { + async onSuccess(data, variables, context) { + const { ref } = variables + await queryClient.invalidateQueries(databaseKeys.poolingConfiguration(ref)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update Supavisor configuration: ${data.message}`) } else { onError(data, variables, context) } diff --git a/packages/ui/src/components/radio-group-card.tsx b/packages/ui/src/components/radio-group-card.tsx index 234817b79cd..1f0b8c6adfb 100644 --- a/packages/ui/src/components/radio-group-card.tsx +++ b/packages/ui/src/components/radio-group-card.tsx @@ -16,7 +16,7 @@ RadioGroupCard.displayName = RadioGroupPrimitive.Root.displayName interface RadioGroupCardItemProps { image?: React.ReactNode - label: string + label: string | React.ReactNode showIndicator?: boolean } From c28fe37fe18187492d37818ca2fa1841d209a6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Fri, 28 Feb 2025 12:00:07 +0800 Subject: [PATCH 09/15] chore: increase stale time for project addons / org subscription (#33911) Occasionally results in a 429 in API - there are plans to fix this on the API level, but it's not a quick change, so hoping to reduce the likeliness of 429 with a higher stale time. Subscriptions and project addons rarely change and if they are changed, it's invalidated on the client anyway. --- apps/studio/data/subscriptions/org-subscription-query.ts | 1 + apps/studio/data/subscriptions/project-addons-query.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/studio/data/subscriptions/org-subscription-query.ts b/apps/studio/data/subscriptions/org-subscription-query.ts index 7f70b0f22f8..ee6657fd33a 100644 --- a/apps/studio/data/subscriptions/org-subscription-query.ts +++ b/apps/studio/data/subscriptions/org-subscription-query.ts @@ -50,6 +50,7 @@ export const useOrgSubscriptionQuery = ( ({ signal }) => getOrgSubscription({ orgSlug }, signal), { enabled: enabled && canReadSubscriptions && typeof orgSlug !== 'undefined', + staleTime: 60 * 60 * 1000, // 60 minutes ...options, } ) diff --git a/apps/studio/data/subscriptions/project-addons-query.ts b/apps/studio/data/subscriptions/project-addons-query.ts index 18542e75fed..288b024ac90 100644 --- a/apps/studio/data/subscriptions/project-addons-query.ts +++ b/apps/studio/data/subscriptions/project-addons-query.ts @@ -36,6 +36,7 @@ export const useProjectAddonsQuery = ( ({ signal }) => getProjectAddons({ projectRef }, signal), { enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined', + staleTime: 60 * 60 * 1000, // 60 minutes ...options, } ) From cb32aa9579f2eca20b732cd8c102ee511f135fca Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Feb 2025 15:55:30 +0800 Subject: [PATCH 10/15] Use TimestampInfo for scheduled backups (#33892) * Use TimestampInfo for scheduled backups * Fix * Update TimestampInfo * Fix * Fix --- .../Database/Backups/BackupItem.tsx | 23 ++++++++-------- apps/studio/data/database/backups-query.ts | 2 +- packages/ui-patterns/TimestampInfo/index.tsx | 26 +++++++++---------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index 96d2c57b55f..67e66b60f19 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -1,13 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' import { Download } from 'lucide-react' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import type { DatabaseBackup } from 'data/database/backups-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge } from 'ui' -import { useParams } from 'common' +import { TimestampInfo } from 'ui-patterns' interface BackupItemProps { index: number @@ -36,8 +36,9 @@ const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProp }, }) - const generateSideButtons = (backup: any) => { - if (backup.status === 'COMPLETED') + const generateSideButtons = (backup: DatabaseBackup) => { + // [Joshen] API typing is incorrect here, status is getting typed as Record + if ((backup as any).status === 'COMPLETED') return (
Backup In Progress... } - const generateBackupName = (backup: any) => { - if (backup.status == 'COMPLETED') { - return `${dayjs(backup.inserted_at).format('DD MMM YYYY HH:mm:ss')} UTC` - } - return dayjs(backup.inserted_at).format('DD MMM YYYY') - } - return (
-

{generateBackupName(backup)}

+
{generateSideButtons(backup)}
) diff --git a/apps/studio/data/database/backups-query.ts b/apps/studio/data/database/backups-query.ts index 5c267947088..63824aff058 100644 --- a/apps/studio/data/database/backups-query.ts +++ b/apps/studio/data/database/backups-query.ts @@ -2,9 +2,9 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' +import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' import type { ResponseError } from 'types' import { databaseKeys } from './keys' -import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' export type BackupsVariables = { projectRef?: string diff --git a/packages/ui-patterns/TimestampInfo/index.tsx b/packages/ui-patterns/TimestampInfo/index.tsx index 9c6d3c5af2b..ef318ccb7b0 100644 --- a/packages/ui-patterns/TimestampInfo/index.tsx +++ b/packages/ui-patterns/TimestampInfo/index.tsx @@ -6,7 +6,6 @@ import { cn } from 'ui/src/lib/utils' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import utc from 'dayjs/plugin/utc' -import { Clipboard } from 'lucide-react' dayjs.extend(relativeTime) dayjs.extend(utc) @@ -94,7 +93,7 @@ export const TimestampInfo = ({ const [copied, setCopied] = useState(false) return ( - { e.stopPropagation() }} @@ -112,23 +111,24 @@ export const TimestampInfo = ({ }, 1000) }} className={cn( - 'relative cursor-default grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1 group', + 'relative cursor-pointer flex gap-y-2 gap-x-0.5 hover:bg-surface-100 px-2 py-1 group', { 'bg-surface-100': copied } )} > - {label}: -
+
+

{label}

+
+
+
+
{copied && ( - + Copied! )} - - {value} - - + {value}
- +
) } @@ -145,9 +145,9 @@ export const TimestampInfo = ({ : timestampUtcFormatter({ utcTimestamp, format: labelFormat })} - + - + From 5508d2cd9da3e32566bc259eb4dde8c09b7f57e2 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 28 Feb 2025 18:07:30 +1000 Subject: [PATCH 11/15] Test edge functions (#33728) * page components * page component changes * settings but broken saving * rvert * use sheet for provider * styling * remove things * Some refactoring and fixing, added JSDocs to layouts * Smol refactor * Fix * Update JSDocs * updated scaffolding * update edge functions layout * remove params * single function layout * invocation cleanup * remove vars * Clean up * Spelling * Clean up FormFieldWrappers * One last clean up * test edge function * sheet flag * remove prop * fix merge errors * fix merge errors * update sheet * rmeove import * fix ts * Some clean ups * Fix * Make test response area resizeable * Final clean up --------- Co-authored-by: Joshen Lim --- .../EdgeFunctionDetails.constants.ts | 2 + .../EdgeFunctionDetails.tsx | 2 +- .../EdgeFunctionDetails.types.ts | 11 + .../EdgeFunctionTesterSheet.tsx | 443 ++++++++++++++++++ .../EdgeFunctionDetailsLayout.tsx | 23 +- .../layouts/PageLayout/PageHeader.tsx | 6 +- apps/studio/middleware.ts | 1 + apps/studio/pages/api/edge-functions/test.ts | 112 +++++ .../project/[ref]/advisors/performance.tsx | 4 +- 9 files changed, 594 insertions(+), 10 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx create mode 100644 apps/studio/pages/api/edge-functions/test.ts diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts index edf13395e10..5303cc8bc86 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts @@ -66,3 +66,5 @@ final data = res.data;`, )`, }, ] + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index fdbab04ad27..8803b8cd48b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -148,7 +148,7 @@ export const EdgeFunctionDetails = () => { diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts new file mode 100644 index 00000000000..a831766cb30 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types.ts @@ -0,0 +1,11 @@ +export type ResponseData = { + status: number + headers: Record + body: string +} + +export type ErrorWithStatus = Error & { + cause?: { + status: number + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx new file mode 100644 index 00000000000..e49599c4639 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -0,0 +1,443 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2, Plus, Send, X } from 'lucide-react' +import { useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import * as z from 'zod' + +import { useParams } from 'common' +import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' +import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { constructHeaders } from 'data/fetchers' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { prettifyJSON } from 'lib/helpers' +import { getRoleImpersonationJWT } from 'lib/role-impersonation' +import { useGetImpersonatedRole } from 'state/role-impersonation-state' +import { + Badge, + Button, + CodeBlock, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as Input, + Label_Shadcn_ as Label, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + Select_Shadcn_ as Select, + SelectContent_Shadcn_ as SelectContent, + SelectItem_Shadcn_ as SelectItem, + SelectTrigger_Shadcn_ as SelectTrigger, + SelectValue_Shadcn_ as SelectValue, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, + TextArea_Shadcn_ as Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { HTTP_METHODS } from './EdgeFunctionDetails.constants' +import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' + +interface EdgeFunctionTesterSheetProps { + visible: boolean + onClose: () => void +} + +const FormSchema = z.object({ + method: z.enum(HTTP_METHODS), + body: z + .string() + .optional() + .transform((str) => str || '{}'), + headers: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), + queryParams: z.array( + z.object({ + key: z.string(), + value: z.string(), + }) + ), +}) + +type FormValues = z.infer + +export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { + const { ref: projectRef, functionSlug } = useParams() + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + const getImpersonatedRole = useGetImpersonatedRole() + const { serviceKey } = getAPIKeys(settings) + + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.endpoint ?? '' + const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}` + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + method: 'POST', + body: '{ "name": "Functions" }', + headers: [{ key: '', value: '' }], + queryParams: [{ key: '', value: '' }], + }, + }) + const { method } = form.watch() + + const { + fields: headerFields, + append: appendHeader, + remove: removeHeader, + } = useFieldArray({ + control: form.control, + name: 'headers', + }) + + const { + fields: queryParamFields, + append: appendQueryParam, + remove: removeQueryParam, + } = useFieldArray({ + control: form.control, + name: 'queryParams', + }) + + const addKeyValuePair = (type: 'headers' | 'queryParams') => { + if (type === 'headers') { + appendHeader({ key: '', value: '' }) + } else { + appendQueryParam({ key: '', value: '' }) + } + } + + const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => { + if (type === 'headers') { + removeHeader(index) + } else { + removeQueryParam(index) + } + } + + const onSubmit = async (values: FormValues) => { + try { + setIsLoading(true) + setError(null) + setResponse(null) + + // Validate that the body is valid JSON + try { + JSON.parse(values.body) + } catch (e) { + form.setError('body', { message: 'Must be a valid JSON string' }) + return + } + + let testAuthorization: string | undefined + const role = getImpersonatedRole() + + if ( + projectRef !== undefined && + config?.jwt_secret !== undefined && + role !== undefined && + role.type === 'postgrest' + ) { + try { + const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role) + testAuthorization = 'Bearer ' + token + } catch (err: any) { + console.error('Failed to generate JWT:', { + error: err.message, + roleDetails: role, + }) + } + } + + // Construct custom headers + const customHeaders: Record = {} + headerFields.forEach(({ key, value }) => { + if (key && value) { + customHeaders[key] = value + } + }) + + // Construct query parameters + const queryString = queryParamFields + .filter(({ key, value }) => key && value) + .map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&') + + const finalUrl = queryString ? `${url}?${queryString}` : url + + const defaultHeaders = await constructHeaders() + const res = await fetch(`${BASE_PATH}/api/edge-functions/test`, { + method: 'POST', + headers: { + ...defaultHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: finalUrl, + method: values.method, + body: values.body, + headers: { + ...(accessToken && { + Authorization: `Bearer ${accessToken}`, + }), + 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`, + 'Content-Type': 'application/json', + ...customHeaders, + }, + }), + }) + + const data = await res.json() + if (!res.ok) { + throw new Error(data.error?.message || 'Failed to test edge function', { + cause: { status: data.status }, + }) + } + + setResponse(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred') + if (err instanceof Error) { + const errorWithStatus = err as ErrorWithStatus + setResponse({ + status: errorWithStatus.cause?.status || 500, + headers: {}, + body: '', + }) + } + } finally { + setIsLoading(false) + } + } + + const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( +
+
+ + +
+
+ {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( +
+
+ ))} +
+
+ ) + + return ( + + + + Test {functionSlug} + + + +
+ + +
+ ( + + + + + + )} + /> + {method !== 'GET' && ( + ( + + +