diff --git a/apps/docs/content/guides/ai/vector-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes.mdx index 99f1d8823b1f0..08488384a2fad 100644 --- a/apps/docs/content/guides/ai/vector-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes.mdx @@ -34,7 +34,7 @@ For pgvector versions 0.7.0 and above, it's possible to create indexes on vector You can check your current pgvector version by running: `SELECT * FROM pg_extension WHERE extname = 'vector';` or by navigating to the [Extensions](/dashboard/project/_/database/extensions) tab in your Supabase project dashboard. -If you are on an earlier version of pgvector, you should [upgrade your project here](/dashboard/project/_/settings/infrastructure). +If you are on an earlier version of pgvector, you should [upgrade your project here](/dashboard/project/_/settings/general). ## Resources diff --git a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx index 38d06d2bb64dc..fb77fe5ffd83e 100644 --- a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx @@ -45,7 +45,7 @@ For pgvector versions 0.7.0 and above, it's possible to create indexes on vector You can check your current pgvector version by running: `SELECT * FROM pg_extension WHERE extname = 'vector';` or by navigating to the [Extensions](/dashboard/project/_/database/extensions) tab in your Supabase project dashboard. -If you are on an earlier version of pgvector, you should [upgrade your project here](/dashboard/project/_/settings/infrastructure). +If you are on an earlier version of pgvector, you should [upgrade your project here](/dashboard/project/_/settings/general). ## Example with high-dimensional vectors diff --git a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx index a4a8897deba9e..4e0d3274518dd 100644 --- a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx +++ b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx @@ -195,7 +195,7 @@ limit 100; -PostgREST error codes are only captured in the logs for projects running V14+. You can check your PostgREST version and upgrade your project in the [Infrastructure Settings](/dashboard/project/_/settings/infrastructure) +PostgREST error codes are only captured in the logs for projects running V14+. You can check your PostgREST version and upgrade your project in the [General Settings](/dashboard/project/_/settings/general) diff --git a/apps/docs/content/guides/api/securing-your-api.mdx b/apps/docs/content/guides/api/securing-your-api.mdx index cf844f70811f6..5df2faf3cf8c8 100644 --- a/apps/docs/content/guides/api/securing-your-api.mdx +++ b/apps/docs/content/guides/api/securing-your-api.mdx @@ -130,7 +130,7 @@ X-Powered-By: Nerd Rage Use JSON functions and operators to build dynamic responses from exceptions. Include the `status_text` key in the `detail` clause when you use a custom HTTP status code such as 419. See [JSON Functions and Operators](https://www.postgresql.org/docs/current/functions-json.html) in the Postgres documentation. -For PostgREST 11 or earlier, use the legacy syntax for raising errors. [Check your PostgREST version](/dashboard/project/_/settings/infrastructure) in the Dashboard. See [Raise errors with HTTP status codes](https://postgrest.org/en/stable/references/errors.html#raise-errors-with-http-status-codes) in the PostgREST documentation. +For PostgREST 11 or earlier, use the legacy syntax for raising errors. [Check your PostgREST version](/dashboard/project/_/settings/general) in the Dashboard. See [Raise errors with HTTP status codes](https://postgrest.org/en/stable/references/errors.html#raise-errors-with-http-status-codes) in the PostgREST documentation. ## Configure Data API security diff --git a/apps/docs/content/guides/database/extensions.mdx b/apps/docs/content/guides/database/extensions.mdx index a9ed68e173921..f83a7af2be22f 100644 --- a/apps/docs/content/guides/database/extensions.mdx +++ b/apps/docs/content/guides/database/extensions.mdx @@ -56,7 +56,7 @@ In addition to the pre-configured extensions, you can also install your own SQL ## Upgrade extensions -If a new version of an extension becomes available on Supabase, you need to initiate a software upgrade in the [Infrastructure Settings](/dashboard/project/_/settings/infrastructure) to access it. Software upgrades can also be initiated by restarting your server in the [General Settings](/dashboard/project/_/settings/general). +If a new version of an extension becomes available on Supabase, you need to initiate a software upgrade in the [General Settings](/dashboard/project/_/settings/general) to access it. Software upgrades can also be initiated by restarting your server in the same [General Settings](/dashboard/project/_/settings/general) page. ## Full list of extensions diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index bb505ceae2063..7d0a3a399c9d1 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -311,7 +311,7 @@ order by "created" desc; -Supabase supports reconfiguring pg*net starting from v0.12.0+. For the latest release, initiate a Postgres upgrade in the [Infrastructure Settings](/dashboard/project/*/settings/infrastructure). +Supabase supports reconfiguring `pg_net` starting from v0.12.0+. For the latest release, initiate a Postgres upgrade in the [General Settings](/dashboard/project/_/settings/general). diff --git a/apps/docs/content/guides/getting-started/features.mdx b/apps/docs/content/guides/getting-started/features.mdx index 2fd7bbbfe5e51..b573c41d5d161 100644 --- a/apps/docs/content/guides/getting-started/features.mdx +++ b/apps/docs/content/guides/getting-started/features.mdx @@ -68,7 +68,7 @@ Deploy read-only databases across multiple regions, for lower latency and better ### Log drains -Export Supabase logs to third-party providers and external tooling. [Docs](/docs/guides/telemetry/log-drains). +Export Supabase logs to third-party providers and external tooling. [Docs](/docs/guides/monitoring-and-debugging/log-drains). ## Studio diff --git a/apps/docs/content/guides/monitoring-and-debugging/metrics/grafana-self-hosted.mdx b/apps/docs/content/guides/monitoring-and-debugging/metrics/grafana-self-hosted.mdx index dcb7d38db302e..ad4ec6fc6b671 100644 --- a/apps/docs/content/guides/monitoring-and-debugging/metrics/grafana-self-hosted.mdx +++ b/apps/docs/content/guides/monitoring-and-debugging/metrics/grafana-self-hosted.mdx @@ -10,7 +10,7 @@ Self-hosting [Prometheus](https://prometheus.io/docs/prometheus/latest/installat Use this guide only if you need full manual control (custom scrape topology, self-hosted Prometheus or non-standard auth). -Otherwise, use the [Grafana Cloud integration](/docs/guides/telemetry/metrics/grafana-cloud#installation) available in the Supabase Dashboard. +Otherwise, use the [Grafana Cloud integration](/docs/guides/monitoring-and-debugging/metrics/grafana-cloud#installation) available in the Supabase Dashboard. diff --git a/apps/docs/content/guides/platform/billing-faq.mdx b/apps/docs/content/guides/platform/billing-faq.mdx index 4ea5fdb68c7de..30c52f06d4e23 100644 --- a/apps/docs/content/guides/platform/billing-faq.mdx +++ b/apps/docs/content/guides/platform/billing-faq.mdx @@ -12,7 +12,7 @@ subtitle: 'This documentation covers frequently asked questions around subscript ### What are organizations and projects? The Supabase Platform has "organizations" and "projects". An organization may contain multiple projects. Each project is a dedicated Supabase instance with all of its sub-services including Storage, Auth, Functions and Realtime. -Each organization only has a single subscription with a single plan (Free, Pro, Team or Enterprise). Project add-ons such as [Compute](/docs/guides/platform/compute-and-disk), [IPv4](/docs/guides/platform/ipv4-address), [Log Drains](/docs/guides/telemetry/log-drains), [Advanced MFA](/docs/guides/auth/auth-mfa/phone), [Custom Domains](/docs/guides/platform/custom-domains) and [PITR](/docs/guides/platform/backups#point-in-time-recovery) are configured per project and are added to your organization subscription. +Each organization only has a single subscription with a single plan (Free, Pro, Team or Enterprise). Project add-ons such as [Compute](/docs/guides/platform/compute-and-disk), [IPv4](/docs/guides/platform/ipv4-address), [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), [Advanced MFA](/docs/guides/auth/auth-mfa/phone), [Custom Domains](/docs/guides/platform/custom-domains) and [PITR](/docs/guides/platform/backups#point-in-time-recovery) are configured per project and are added to your organization subscription. Read more on [About billing on Supabase](/docs/guides/platform/billing-on-supabase#organization-based-billing). diff --git a/apps/docs/content/guides/platform/network-restrictions.mdx b/apps/docs/content/guides/platform/network-restrictions.mdx index b471ba27eab43..1427441a21560 100644 --- a/apps/docs/content/guides/platform/network-restrictions.mdx +++ b/apps/docs/content/guides/platform/network-restrictions.mdx @@ -8,7 +8,7 @@ This topic explains how to configure network restrictions for your Supabase proj -If you can't find the Network Restrictions section in your [Database Settings](/dashboard/project/_/database/settings), update your Postgres version in [Infrastructure Settings](/dashboard/project/_/settings/infrastructure). +If you can't find the Network Restrictions section in your [Database Settings](/dashboard/project/_/database/settings), update your Postgres version in [General Settings](/dashboard/project/_/settings/general). diff --git a/apps/docs/content/guides/platform/project-transfer.mdx b/apps/docs/content/guides/platform/project-transfer.mdx index e463bf2aa77a5..cc4718c63406a 100644 --- a/apps/docs/content/guides/platform/project-transfer.mdx +++ b/apps/docs/content/guides/platform/project-transfer.mdx @@ -43,7 +43,7 @@ Target organization - the organization you want to move the project to ## Usage-billing and project add-ons -For usage metrics such as disk size, egress or image transformations and project add-ons such as [Compute Add-On](/docs/guides/platform/compute-and-disk), [Point-In-Time-Recovery](/docs/guides/platform/backups#point-in-time-recovery), [IPv4](/docs/guides/platform/ipv4-address), [Log Drains](/docs/guides/telemetry/log-drains), [Advanced MFA](/docs/guides/auth/auth-mfa/phone) or a [Custom Domain](/docs/guides/platform/custom-domains), the source organization will still be charged for the usage up until the transfer. The charges will be added to the invoice when the billing cycle resets. +For usage metrics such as disk size, egress or image transformations and project add-ons such as [Compute Add-On](/docs/guides/platform/compute-and-disk), [Point-In-Time-Recovery](/docs/guides/platform/backups#point-in-time-recovery), [IPv4](/docs/guides/platform/ipv4-address), [Log Drains](/docs/guides/monitoring-and-debugging/log-drains), [Advanced MFA](/docs/guides/auth/auth-mfa/phone) or a [Custom Domain](/docs/guides/platform/custom-domains), the source organization will still be charged for the usage up until the transfer. The charges will be added to the invoice when the billing cycle resets. The target organization will be charged at the end of the billing cycle for usage after the project transfer. diff --git a/apps/docs/content/guides/platform/temporary-access.mdx b/apps/docs/content/guides/platform/temporary-access.mdx index e9cadf3a61f05..2862e3bdcba6d 100644 --- a/apps/docs/content/guides/platform/temporary-access.mdx +++ b/apps/docs/content/guides/platform/temporary-access.mdx @@ -9,7 +9,7 @@ Enabling temporary access only applies to connections to Postgres and Supavisor -Projects need to be at least on Postgres 17.6.1.081 (or higher) to enable temporary access. You can find the Postgres version of your project on the [infrastructure settings](/dashboard/project/_/settings/infrastructure) page. If your project is on an older version, you will need to [upgrade](/docs/guides/platform/upgrading) to use this feature. +Projects need to be at least on Postgres 17.6.1.081 (or higher) to enable temporary access. You can find the Postgres version of your project on the [General Settings](/dashboard/project/_/settings/general) page. If your project is on an older version, you will need to [upgrade](/docs/guides/platform/upgrading) to use this feature. diff --git a/apps/docs/content/guides/platform/upgrading.mdx b/apps/docs/content/guides/platform/upgrading.mdx index beec25a21692a..1400a906e59ee 100644 --- a/apps/docs/content/guides/platform/upgrading.mdx +++ b/apps/docs/content/guides/platform/upgrading.mdx @@ -18,7 +18,7 @@ Free projects will move to the latest minor version when their paused project is The upgrade process is as follows: -1. Use the "Upgrade project" button on the [Infrastructure](/dashboard/project/_/settings/infrastructure) section of your dashboard. +1. Use the "Upgrade project" button on the [General settings](/dashboard/project/_/settings/general) page of your dashboard. 2. An estimate of the time to upgrade is shown and anything that needs to be addressed before you are eligible to upgrade is shown as a warning. Ensure you have reviewed the [caveats](#caveats) section of this document before executing the upgrade. 3. Your project is taken offline and the Dashboard shows the upgrade status. 4. Behind the scenes, a new instance is created running the latest version of Supabase. @@ -39,7 +39,7 @@ When upgrading, a notification will inform you about what is blocking the upgrad 3. Logical replication slots must be dropped. 4. Deprecated/unsupported extensions must be dropped. Extensions can have dependencies, make sure you backup that data to restore it after the upgrade, with the updated extension version. -Newer versions of services can break functionality or change the performance characteristics you rely on. If your project is eligible for an upgrade, you will be able to find your current service versions from within [the Supabase dashboard](/dashboard/project/_/settings/infrastructure). +Newer versions of services can break functionality or change the performance characteristics you rely on. If your project is eligible for an upgrade, you will be able to find your current service versions from within [the Supabase dashboard](/dashboard/project/_/settings/general). Breaking changes are generally only present in major version upgrades of Postgres and PostgREST. You can find their respective release notes at: diff --git a/apps/docs/content/troubleshooting/forbidden-resource-error-from-the-cli-L6rm6l.mdx b/apps/docs/content/troubleshooting/forbidden-resource-error-from-the-cli-L6rm6l.mdx index 2c8fba0f00f68..5fef06d558635 100644 --- a/apps/docs/content/troubleshooting/forbidden-resource-error-from-the-cli-L6rm6l.mdx +++ b/apps/docs/content/troubleshooting/forbidden-resource-error-from-the-cli-L6rm6l.mdx @@ -18,7 +18,7 @@ This error typically occurs as a protective measure to prevent unauthorized acce To address this issue, we recommend following these troubleshooting steps: -- Verify Project ID: Ensure the $PROJECT*REF variable in your commands contains the correct Project ID. You can find your Reference ID under [Project -> Settings -> General](/dashboard/project/*/settings/general) in your Supabase Dashboard. A Reference ID looks something like `xvljpkujuwroxcuvossw`. +- Verify Project ID: Ensure the `$PROJECT_REF` variable in your commands contains the correct Project ID. You can find your Reference ID under [Project -> Settings -> General](/dashboard/project/_/settings/general) in your Supabase Dashboard. A Reference ID looks something like `xvljpkujuwroxcuvossw`. - Authorization Check: Confirm that you’ve been properly authorized. You can also generate a new Access Token in your dashboard and use it for login. Generate a new token [here](/dashboard/account/tokens) and use it to [log in](/docs/reference/cli/supabase-login). - Re-link Project: Try [re-linking](/docs/reference/cli/supabase-link) your project with the newly generated token. - Owner/Admin Permissions: Make sure you have [Owner/Admin](/docs/guides/platform/access-control) permissions for the project. diff --git a/apps/docs/content/troubleshooting/grafana-not-displaying-data-sXJrMj.mdx b/apps/docs/content/troubleshooting/grafana-not-displaying-data-sXJrMj.mdx index b9caf4f6c0fcd..c13e0e40dd27d 100644 --- a/apps/docs/content/troubleshooting/grafana-not-displaying-data-sXJrMj.mdx +++ b/apps/docs/content/troubleshooting/grafana-not-displaying-data-sXJrMj.mdx @@ -7,7 +7,7 @@ keywords = [ "grafana", "docker", "metrics", "configuration" ] database_id = "76a4099e-450f-4b5b-a539-224760348c18" --- -This guide is for identifying configuration mistakes in [self-hosted Supabase Grafana installations](/docs/guides/telemetry/metrics/grafana-self-hosted) +This guide is for identifying configuration mistakes in [self-hosted Supabase Grafana installations](/docs/guides/monitoring-and-debugging/metrics/grafana-self-hosted) ## Step 1: Ping your Grafana endpoint diff --git a/apps/docs/content/troubleshooting/increase-vector-lookup-speeds-by-applying-an-hsnw-index-ohLHUM.mdx b/apps/docs/content/troubleshooting/increase-vector-lookup-speeds-by-applying-an-hsnw-index-ohLHUM.mdx index 9312406008910..53fd669bd82fa 100644 --- a/apps/docs/content/troubleshooting/increase-vector-lookup-speeds-by-applying-an-hsnw-index-ohLHUM.mdx +++ b/apps/docs/content/troubleshooting/increase-vector-lookup-speeds-by-applying-an-hsnw-index-ohLHUM.mdx @@ -32,7 +32,7 @@ Applying an index can be slow and computationally expensive, so there are a few **1. Make sure your pgvector is the latest available version on Supabase.** -Versions 0.6 and later have accelerated HNSW build speeds. You can observe your current version in the [Dashboard's Extensions Page](/dashboard/project/_/database/extensions). You can perform a software upgrade in the [Infrastructure Settings ](/dashboard/project/_/settings/infrastructure)if necessary. +Versions 0.6 and later have accelerated HNSW build speeds. You can observe your current version in the [Dashboard's Extensions Page](/dashboard/project/_/database/extensions). You can perform a software upgrade in the [General Settings](/dashboard/project/_/settings/general) if necessary. **2. Setting up an external connection** diff --git a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-cpu-charts-9JSlkC.mdx b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-cpu-charts-9JSlkC.mdx index a5828b5c87372..5e86d52777e46 100644 --- a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-cpu-charts-9JSlkC.mdx +++ b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-cpu-charts-9JSlkC.mdx @@ -7,7 +7,7 @@ keywords = [ "cpu", "grafana", "metrics" ] database_id = "ef05da0a-f8bc-44a4-9719-5ae811dba104" --- -> [Guide](/docs/guides/telemetry/metrics/grafana-self-hosted) for setting up Supabase Grafana +> [Guide](/docs/guides/monitoring-and-debugging/metrics/grafana-self-hosted) for setting up Supabase Grafana ## CPU diff --git a/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx b/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx index 9223be77fd3d9..7602ce6a06433 100644 --- a/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx +++ b/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx @@ -25,7 +25,7 @@ If you are trying to make changes, use the cron functions. If the cron functions ## Cron Jobs are not running -> You should consider initiating a software upgrade in the [Infrastructure Settings](/dashboard/project/_/settings/infrastructure) if your Postgres version is below v15.6.1.122. Upgrading will give you access to pg_cron v1.6.4+, which has many bug fixes and auto-revive capabilities. +> You should consider initiating a software upgrade in the [General Settings](/dashboard/project/_/settings/general) if your Postgres version is below v15.6.1.122. Upgrading will give you access to pg_cron v1.6.4+, which has many bug fixes and auto-revive capabilities. ### Debugging steps: @@ -114,7 +114,7 @@ You can view your concurrent peak connection usage throughout the day at the bot Unfortunately, excessive resource strain can slow down or disrupt jobs. -Go to the [reports page](/dashboard/project/_/observability/database) (or [Supabase Grafana](/docs/guides/telemetry/metrics/grafana-self-hosted) if you have it setup), and check for signs of resource exhaustion. If it's clear your database is under pressure, consider upgrading your compute add-on or following the advice from one of the optimization guides: +Go to the [reports page](/dashboard/project/_/observability/database) (or [Supabase Grafana](/docs/guides/monitoring-and-debugging/metrics/grafana-self-hosted) if you have it setup), and check for signs of resource exhaustion. If it's clear your database is under pressure, consider upgrading your compute add-on or following the advice from one of the optimization guides: - [Connections](https://github.com/orgs/supabase/discussions/27141) - [Disk/IO](https://github.com/orgs/supabase/discussions/27003) @@ -127,7 +127,7 @@ It is important to make sure you are running the latest release of pg_cron (1.6. #### Check the log explorer for more information -Although pg*cron records errors in the `cron.job_run_details` table, in rare cases, more information can be found in the general Postgres logs. You can check the [Log Explorer](/dashboard/project/*/logs/explorer) for failure events with the following query +Although `pg_cron` records errors in the `cron.job_run_details` table, in rare cases, more information can be found in the general Postgres logs. You can check the [Log Explorer](/dashboard/project/_/logs/explorer) for failure events with the following query ```sql select @@ -152,7 +152,7 @@ order by timestamp desc limit 100; ``` -If you're interested in modifying the query, there is an advanced [guide](https://github.com/orgs/supabase/discussions/26224) for navigating the Postgres logs and a general purpose [one](/docs/guides/telemetry/advanced-log-filtering) for applying filters. +If you're interested in modifying the query, there is an advanced [guide](https://github.com/orgs/supabase/discussions/26224) for navigating the Postgres logs and a general-purpose [one](/docs/guides/monitoring-and-debugging/advanced-log-filtering) for applying filters.
@@ -190,7 +190,7 @@ You can then search for your custom messages in the [Logs Interface](/dashboard/ #### Upgrading pg_cron version -The current version of pg*cron on Supabase is 1.6.4. It comes with a [few bug fixes](https://github.com/citusdata/pg_cron/releases/tag/v1.6.4). You should consider upgrading to Postgres v15.6.1.122+ in the[ Infrastructure Settings](/dashboard/project/*/settings/infrastructure) to get the latest extension. +The current version of `pg_cron` on Supabase is 1.6.4. It comes with a [few bug fixes](https://github.com/citusdata/pg_cron/releases/tag/v1.6.4). You should consider upgrading to Postgres v15.6.1.122+ in the [General Settings](/dashboard/project/_/settings/general) to get the latest extension.
diff --git a/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx b/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx index e132244871a81..aac2981217f21 100644 --- a/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx +++ b/apps/docs/content/troubleshooting/postgrest-error-400-column-example_tableexample_column-does-not-exist-when-using-or-operators-46ff23.mdx @@ -17,14 +17,14 @@ The bug is triggered when an `or()` filter is included in a mutation request. Po ## How to confirm 1. **Reproduce the asymmetry** — run the same filter as a `GET` request. If it succeeds but the `PATCH`/`POST`/`DELETE` fails with the same column reference, the bug is the likely cause. -2. **Check your PostgREST version** — go to [Project Settings > Infrastructure](/dashboard/project/_/settings/infrastructure) and note the Postgres version. PostgREST 14.1 and earlier are affected; 14.4+ includes the fix. +2. **Check your PostgREST version** — go to [Project Settings > General](/dashboard/project/_/settings/general) and note the Postgres version. PostgREST 14.1 and earlier are affected; 14.4+ includes the fix. 3. **Check Postgres logs** — if you have logging enabled, you should see `column "example_column" does not exist` errors correlating with the timestamps of the failed mutation requests. ## Resolution ### Option 1: Upgrade (permanent fix) -Navigate to [Project Settings > Infrastructure](/dashboard/project/_/settings/infrastructure) and upgrade to the latest Postgres version. This automatically upgrades PostgREST to 14.5+, where the bug is resolved. +Navigate to [Project Settings > General](/dashboard/project/_/settings/general) and upgrade to the latest Postgres version. This automatically upgrades PostgREST to 14.5+, where the bug is resolved. ### Option 2: Query workaround (immediate) diff --git a/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx b/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx index 00af4c77436b9..7717cc606e8ea 100644 --- a/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx +++ b/apps/docs/content/troubleshooting/rls-performance-and-best-practices-Z5Jjwv.mdx @@ -123,7 +123,7 @@ Show RLS and before after for above examples. | ---- | --------------------------------- | -------------------------------------------------- | ------- | ------ | | 1 | auth.uid()=user_id | user_id indexed | 171ms | <.1 | | 2a | auth.uid()=user_id | (select auth.uid()) = user_id | 179 | 9 | -| 2b | is*admin() \_table join* | (select is*admin()) \_table join* | 11,000 | 7 | +| 2b | `is_admin()` _table join_ | (select `is_admin()`) _table join_ | 11,000 | 7 | | 2c | is_admin() OR auth.uid()=user_id | (select is_admin()) OR (select auth.uid()=user_id) | 11,000 | 10 | | 2d | has_role()=role | (select has_role())=role | 178,000 | 12 | | 2e | team_id=any(user_teams()) | team_id=any(array(select user_teams())) | 173000 | 16 | diff --git a/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx b/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx index bf32e5ee9b0a9..c871926220cf2 100644 --- a/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx +++ b/apps/docs/content/troubleshooting/webhook-debugging-guide-M8sk47.mdx @@ -7,7 +7,7 @@ keywords = [ "webhook", "timeout", "pg_net", "postgres", "trigger" ] database_id = "a5524182-9dc4-4470-86fe-45ace109a53a" --- -> NOTE: version 0.10.0 of pg*net is out. You should consider updating your database in the[ Infrastructure Settings](/dashboard/project/*/settings/infrastructure) if you are on a prior version. If you do not know your version, you can check the [Extensions Dashboard](/dashboard/project/_/database/extensions). +> NOTE: version 0.10.0 of `pg_net` is out. You should consider updating your database in the [General Settings](/dashboard/project/_/settings/general) if you are on a prior version. If you do not know your version, you can check the [Extensions Dashboard](/dashboard/project/_/database/extensions). **Debugging Steps** @@ -34,7 +34,7 @@ The `net` tables are special and if triggers on them fail or call a pg_net funct **3. Check for timeout errors** -> **NOTE**: The timeout issue has been patched in pg*net v0.11. It is available in Postgres 15.6.1.135 and above. You can upgrade your version of Postgres in the [Infrastructure Settings.](/dashboard/project/*/settings/infrastructure) +> **NOTE**: The timeout issue has been patched in `pg_net` v0.11. It is available in Postgres 15.6.1.135 and above. You can upgrade your version of Postgres in the [General Settings](/dashboard/project/_/settings/general). Go to your [Table Editor](/dashboard/project/_/editor/) and navigate to the net schema. It will contain two tables: diff --git a/apps/docs/lib/octokit.auth.test.ts b/apps/docs/lib/octokit.auth.test.ts new file mode 100644 index 0000000000000..e34d0e43875c7 --- /dev/null +++ b/apps/docs/lib/octokit.auth.test.ts @@ -0,0 +1,88 @@ +import crypto from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { githubAuthOptions } from './octokit.auth.js' + +const APP_ID = '123456' +const INSTALLATION_ID = '7890' +// PKCS1 on purpose: the App rung has to convert it, and universal-github-app-jwt +// only accepts PKCS8. +const PKCS1_PRIVATE_KEY = crypto + .generateKeyPairSync('rsa', { modulusLength: 2048 }) + .privateKey.export({ type: 'pkcs1', format: 'pem' }) + .toString() + +function stubEnv(env: Record) { + // Empty string rather than `undefined`: some Vitest versions stringify the + // latter to 'undefined', which is truthy and would silently defeat these tests. + for (const name of [ + 'DOCS_GITHUB_APP_ID', + 'DOCS_GITHUB_APP_INSTALLATION_ID', + 'DOCS_GITHUB_APP_PRIVATE_KEY', + 'GH_TOKEN', + 'GITHUB_TOKEN', + ]) { + vi.stubEnv(name, env[name] ?? '') + } +} + +const APP_ENV = { + DOCS_GITHUB_APP_ID: APP_ID, + DOCS_GITHUB_APP_INSTALLATION_ID: INSTALLATION_ID, + DOCS_GITHUB_APP_PRIVATE_KEY: PKCS1_PRIVATE_KEY, +} + +describe('githubAuthOptions', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('authenticates as the App and converts the key to PKCS8', () => { + stubEnv(APP_ENV) + const options = githubAuthOptions() + if (!('authStrategy' in options)) throw new Error('expected App auth') + expect(options.auth).toMatchObject({ appId: APP_ID, installationId: INSTALLATION_ID }) + expect(options.auth.privateKey).toMatch(/^-----BEGIN PRIVATE KEY-----/) + }) + + it('prefers the App when a token is also present', () => { + stubEnv({ ...APP_ENV, GITHUB_TOKEN: 'ghp_example' }) + expect(githubAuthOptions()).toHaveProperty('authStrategy') + }) + + it.each(['GH_TOKEN', 'GITHUB_TOKEN'])( + 'authenticates with a token from %s when the App is not configured', + (name) => { + stubEnv({ [name]: 'ghp_example' }) + expect(githubAuthOptions()).toEqual({ auth: 'ghp_example' }) + } + ) + + it('gives GH_TOKEN precedence over GITHUB_TOKEN, as the gh CLI documents', () => { + stubEnv({ GH_TOKEN: 'ghp_from_gh', GITHUB_TOKEN: 'ghp_from_actions' }) + expect(githubAuthOptions()).toEqual({ auth: 'ghp_from_gh' }) + }) + + it('refuses a partially configured App instead of masking it with a token', () => { + stubEnv({ DOCS_GITHUB_APP_ID: APP_ID, GITHUB_TOKEN: 'ghp_example' }) + expect(githubAuthOptions).toThrow(/Incomplete GitHub App configuration/) + // Names what is missing, and not what is already set. + expect(githubAuthOptions).toThrow(/DOCS_GITHUB_APP_INSTALLATION_ID/) + expect(githubAuthOptions).toThrow(/DOCS_GITHUB_APP_PRIVATE_KEY/) + expect(githubAuthOptions).not.toThrow(/DOCS_GITHUB_APP_ID\b/) + }) + + it('reports only the missing App var when one is absent', () => { + stubEnv({ + DOCS_GITHUB_APP_ID: APP_ID, + DOCS_GITHUB_APP_INSTALLATION_ID: INSTALLATION_ID, + GITHUB_TOKEN: 'ghp_example', + }) + expect(githubAuthOptions).toThrow(/DOCS_GITHUB_APP_PRIVATE_KEY not set\. Set all three/) + }) + + it('names every credential option when none is set', () => { + stubEnv({}) + expect(githubAuthOptions).toThrow(/DOCS_GITHUB_APP_ID/) + expect(githubAuthOptions).toThrow(/GH_TOKEN/) + expect(githubAuthOptions).toThrow(/GITHUB_TOKEN/) + }) +}) diff --git a/apps/docs/lib/octokit.auth.ts b/apps/docs/lib/octokit.auth.ts new file mode 100644 index 0000000000000..0ce042ce336e7 --- /dev/null +++ b/apps/docs/lib/octokit.auth.ts @@ -0,0 +1,70 @@ +import { createAppAuth } from '@octokit/auth-app' +import crypto from 'node:crypto' + +type AppAuth = { appId: string; installationId: string; privateKey: string } + +/** + * Octokit auth options for reading public content from GitHub. + * + * Prefers the docs GitHub App (CI and production). Falls back to a personal + * access token so contributors can run the search index build locally without + * the App's private key: `GH_TOKEN` then `GITHUB_TOKEN`, matching the + * precedence the GitHub CLI documents (`gh help environment`), so an + * already-exported token just works. + * + * Both rungs authenticate on purpose: unauthenticated calls are limited to + * 60 req/hr per IP, which is what caused the flaky CI failures fixed in #43015, + * and callers here fetch one file per request. Env is read on each call rather + * than at module scope so the choice reflects the environment at call time. + * + * A partially configured App is an error rather than a token fall-back: a + * rotated-out or misnamed secret would otherwise be masked by whatever token + * happens to be in the environment, quietly reading as the wrong identity. + * + * Deliberately free of `server-only` imports: the search index scripts use this + * too, and they run outside Next. + */ +export function githubAuthOptions(): + | { authStrategy: typeof createAppAuth; auth: AppAuth } + | { auth: string } { + const appId = process.env.DOCS_GITHUB_APP_ID + const installationId = process.env.DOCS_GITHUB_APP_INSTALLATION_ID + const privateKey = process.env.DOCS_GITHUB_APP_PRIVATE_KEY + + if (appId && installationId && privateKey) { + return { + authStrategy: createAppAuth, + auth: { + appId, + installationId, + // https://github.com/gr2m/universal-github-app-jwt?tab=readme-ov-file#converting-pkcs1-to-pkcs8 + privateKey: crypto + .createPrivateKey(privateKey) + .export({ type: 'pkcs8', format: 'pem' }) + .toString(), + }, + } + } + + const appVars: Array<[string, string | undefined]> = [ + ['DOCS_GITHUB_APP_ID', appId], + ['DOCS_GITHUB_APP_INSTALLATION_ID', installationId], + ['DOCS_GITHUB_APP_PRIVATE_KEY', privateKey], + ] + const missing = appVars.filter(([, value]) => !value).map(([name]) => name) + const partiallyConfigured = missing.length < appVars.length + if (partiallyConfigured) { + throw new Error( + `Incomplete GitHub App configuration: ${missing.join(', ')} not set. Set all three, or unset the others to authenticate with GH_TOKEN / GITHUB_TOKEN instead.` + ) + } + + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (token) { + return { auth: token } + } + + throw new Error( + 'Missing GitHub credentials. Set DOCS_GITHUB_APP_ID, DOCS_GITHUB_APP_INSTALLATION_ID, and DOCS_GITHUB_APP_PRIVATE_KEY, or set GH_TOKEN / GITHUB_TOKEN for a local run (export GH_TOKEN=$(gh auth token)).' + ) +} diff --git a/apps/docs/lib/octokit.ts b/apps/docs/lib/octokit.ts index 0c488b0f1c663..5ddf874a37971 100644 --- a/apps/docs/lib/octokit.ts +++ b/apps/docs/lib/octokit.ts @@ -1,11 +1,10 @@ import 'server-only' -import { createAppAuth } from '@octokit/auth-app' import { Octokit } from '@octokit/core' import { retry } from '@octokit/plugin-retry' -import crypto from 'node:crypto' import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { githubAuthOptions } from './octokit.auth' import { OCTOKIT_RETRY_OPTIONS } from './octokit.constants' export { OCTOKIT_RETRY_OPTIONS } @@ -16,25 +15,7 @@ let octokitInstance: InstanceType export function octokit() { if (!octokitInstance) { - const privateKey = process.env.DOCS_GITHUB_APP_PRIVATE_KEY - if (!privateKey) { - throw new Error('DOCS_GITHUB_APP_PRIVATE_KEY environment variable is required') - } - - // https://github.com/gr2m/universal-github-app-jwt?tab=readme-ov-file#converting-pkcs1-to-pkcs8 - const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({ - type: 'pkcs8', - format: 'pem', - }) - - octokitInstance = new RetryOctokit({ - authStrategy: createAppAuth, - auth: { - appId: process.env.DOCS_GITHUB_APP_ID, - installationId: process.env.DOCS_GITHUB_APP_INSTALLATION_ID, - privateKey: privateKeyPkcs8, - }, - }) + octokitInstance = new RetryOctokit(githubAuthOptions()) } return octokitInstance diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4a42be68f318c..99aaeebe6e1f4 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -267,6 +267,7 @@ Samir Ketema Sana Cordeaux Sasi Kanumuri Sara Read +Satya Rohith Gannamanedi Sean Oliver Sean Romberg Sean Thompson diff --git a/apps/docs/scripts/search/sources/lint-warnings-guide.ts b/apps/docs/scripts/search/sources/lint-warnings-guide.ts index d28a57a27fde8..43cee19ab7f7d 100644 --- a/apps/docs/scripts/search/sources/lint-warnings-guide.ts +++ b/apps/docs/scripts/search/sources/lint-warnings-guide.ts @@ -1,16 +1,12 @@ -import { createAppAuth } from '@octokit/auth-app' import { Octokit } from '@octokit/core' import { retry } from '@octokit/plugin-retry' -import crypto, { createHash } from 'node:crypto' +import { createHash } from 'node:crypto' +import { githubAuthOptions } from '../../../lib/octokit.auth.js' import { OCTOKIT_RETRY_OPTIONS } from '../../../lib/octokit.constants.js' import { BaseLoader, BaseSource } from './base.js' const RetryOctokit = Octokit.plugin(retry) -const appId = process.env.DOCS_GITHUB_APP_ID -const installationId = process.env.DOCS_GITHUB_APP_INSTALLATION_ID -const privateKey = process.env.DOCS_GITHUB_APP_PRIVATE_KEY - const getBasename = (path: string) => path.split('/').at(-1)!.replace(/\.md$/, '') export class LintWarningsGuideLoader extends BaseLoader { @@ -28,18 +24,7 @@ export class LintWarningsGuideLoader extends BaseLoader { } async load() { - if (!appId || !installationId || !privateKey) { - throw new Error('Missing DOCS_GITHUB_APP_* environment variables') - } - - const octokit = new RetryOctokit({ - authStrategy: createAppAuth, - auth: { - appId, - installationId, - privateKey: crypto.createPrivateKey(privateKey).export({ type: 'pkcs8', format: 'pem' }), - }, - }) + const octokit = new RetryOctokit(githubAuthOptions()) const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: this.org, diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index b9400112caaa0..9d663a7ff71bc 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -64,7 +64,9 @@ "DOCS_REVALIDATION_KEYS", "DOCS_REVALIDATION_OVERRIDE_KEYS", "ENABLED_FEATURES_OVERRIDE_DISABLE_ALL", + "GH_TOKEN", "GITHUB_ACTIONS", + "GITHUB_TOKEN", "FORCE_ASSET_CDN", "LOGFLARE_INGESTION_API_KEY", "LOGFLARE_SOURCE_TOKEN", diff --git a/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx b/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx new file mode 100644 index 0000000000000..f63f2b83a5de7 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/AddCellDropdown.tsx @@ -0,0 +1,47 @@ +import { FileText, Plus, SquareCode } from 'lucide-react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' + +import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' + +interface AddCellDropdownProps { + cellId: string +} + +export const AddCellDropdown = ({ cellId }: AddCellDropdownProps) => { + const snap = useNotebooksStateSnapshot() + const currentNotebook = useCurrentNotebook() + + const onSelectAddCell = (type: 'markdown' | 'query') => { + const notebookId = currentNotebook?.notebook.id + if (!notebookId) return + + const cell = type === 'markdown' ? createMarkdownCellSkeleton() : createQueryCellSkeleton() + + snap.insertCellAfter({ id: notebookId, cellId, cell }) + } + + return ( + + + } + tooltip={{ content: { side: 'bottom', text: 'Add cell' } }} + /> + + + onSelectAddCell('query')}> + + Add query cell + + onSelectAddCell('markdown')}> + + Add markdown cell + + + + ) +} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx new file mode 100644 index 0000000000000..89102a59e4b51 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -0,0 +1,153 @@ +import { useFlag, useParams } from 'common' +import dayjs from 'dayjs' +import { Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu' +import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' +import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' +import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' +import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' +import { + createDefaultCellSource, + QUERY_SOURCE_LABELS, + QUERY_SOURCES, + type CellSource, +} from '@/data/query-sources/query-source-registry' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' + +export type ExplorerQuerySourceMenuProps = { + source: CellSource + onSourceChange: (source: CellSource) => void +} + +/** + * Source binding and parameter controls shared by standalone Explorer queries + * and notebook query-cell toolbars. The consumer owns the binding; this menu + * only emits complete, validated-by-construction `CellSource` values. + */ +export const ExplorerQuerySourceMenu = ({ + source, + onSourceChange, +}: ExplorerQuerySourceMenuProps) => { + const { ref } = useParams() + const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') + const entitledToLogDays = getEntitlementNumericValue() + + const availableSources = QUERY_SOURCES.filter( + (candidate) => + candidate.type !== 'logs' || + (isLogsSourceEnabled && isOtelLogsEnabled) || + source.type === 'logs' + ) + + const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { + const fromIso = dayjs(from).startOf('day').toISOString() + if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { + setShowUpgradePrompt(true) + return + } + + onSourceChange({ + id: 'logs', + type: 'logs', + parameters: { + time_range: { + type: 'absolute', + from: fromIso, + to: dayjs(to).endOf('day').toISOString(), + }, + }, + }) + } + + return ( + <> + + + + + + {availableSources.map((candidate) => ( + { + event.preventDefault() + if (candidate.id !== source.id) { + onSourceChange(createDefaultCellSource(candidate.id)) + } + }} + > + + + {QUERY_SOURCE_LABELS[candidate.id]} + + {source.id === candidate.id && } + + ))} + + + + {source.type === 'database' ? ( + + onSourceChange({ + id: 'database', + type: 'database', + parameters: { identifier }, + }) + } + /> + ) : ( + + onSourceChange({ + id: 'logs', + type: 'logs', + parameters: { time_range: timeRange }, + }) + } + onOpenCustomRange={() => setIsCustomRangeOpen(true)} + onShowUpgrade={() => setShowUpgradePrompt(true)} + /> + )} + + + + {source.type === 'logs' && ( + <> + + + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerToolbar.tsx b/apps/studio/components/interfaces/Explorer/ExplorerToolbar.tsx index 14e434db48823..0c99f82e52d8c 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerToolbar.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerToolbar.tsx @@ -100,9 +100,9 @@ const ExplorerToolbarTitle = ({ ) : onSaveTitle ? ( diff --git a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx index b08550a4408c6..3cbf89238be8d 100644 --- a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx +++ b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx @@ -3,6 +3,8 @@ import { useState } from 'react' import { Button, cn } from 'ui' import { Markdown } from '../Markdown' +import { AddCellDropdown } from './AddCellDropdown' +import { MoveCellDropdownContent } from './MoveCellDropdownContent' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { SortableSection } from '@/components/ui/SortableSection' @@ -14,6 +16,8 @@ interface MarkdownCellProps { cell: MarkdownCellSchema } +// [Joshen] handleUpdateMarkdown could be shifted into notebook-state as a updateCell action + export const MarkdownCell = ({ cell }: MarkdownCellProps) => { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() @@ -45,10 +49,15 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => { const handleUpdateMarkdownRef = useLatest(handleUpdateMarkdown) return ( - + } + gripDropdownContent={} + gripClassName="mt-1.5 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" + > {isEditing ? (
{ onMouseDown={(e) => e.preventDefault()} onClick={() => handleUpdateMarkdown(cell.id, value)} > - Save + Done
@@ -102,7 +111,7 @@ export const MarkdownCell = ({ cell }: MarkdownCellProps) => {
{ variant="text" className={cn( 'absolute right-1 top-1 px-1', - 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' + 'opacity-0 group-hover/mdcell:opacity-100 focus-visible:opacity-100' )} icon={} onClick={handleStartEditing} tooltip={{ content: { side: 'bottom', text: 'Edit' } }} /> - h1]:mb-2 [&>h2]:mb-2', - '[&_ol>li]:pl-3', - '[--tw-prose-body:var(--foreground-muted)]', - '[--tw-prose-headings:var(--foreground-default)]', - '[--tw-prose-links:var(--foreground-muted)]', - '[--tw-prose-bold:var(--foreground-muted)]', - '[--tw-prose-quotes:var(--foreground-muted)]' - )} - > - {cell.text} - + {cell.text ? ( + h1]:mb-2 [&>h2]:mb-2', + '[&_ol>li]:pl-3', + '[--tw-prose-body:var(--foreground-muted)]', + '[--tw-prose-headings:var(--foreground-default)]', + '[--tw-prose-links:var(--foreground-muted)]', + '[--tw-prose-bold:var(--foreground-muted)]', + '[--tw-prose-quotes:var(--foreground-muted)]' + )} + > + {cell.text} + + ) : ( +

This cell has no content

+ )}
)}
diff --git a/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx b/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx new file mode 100644 index 0000000000000..5fb0e392ba77c --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/MoveCellDropdownContent.tsx @@ -0,0 +1,57 @@ +import { ArrowDown, ArrowUp, Trash } from 'lucide-react' +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from 'ui' + +import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' + +interface MoveCellDropdownContentProps { + cellId: string +} + +export const MoveCellDropdownContent = ({ cellId }: MoveCellDropdownContentProps) => { + const snap = useNotebooksStateSnapshot() + const currentNotebook = useCurrentNotebook() + + const cells = currentNotebook?.notebook.content?.cells ?? [] + const currentIndex = cells.findIndex((c) => c.id === cellId) + const isFirstCell = currentIndex <= 0 + const isLastCell = currentIndex === -1 || currentIndex === cells.length - 1 + + const onSelectMoveCell = (direction: 'up' | 'down') => { + const notebookId = currentNotebook?.notebook.id + if (!notebookId) return + + snap.moveCell({ id: notebookId, cellId, direction }) + } + + const onSelectRemoveCell = () => { + const notebookId = currentNotebook?.notebook.id + if (!notebookId) return + snap.removeCell({ id: notebookId, cellId }) + } + + return ( + + onSelectMoveCell('up')} + > + +

Move up

+
+ onSelectMoveCell('down')} + > + +

Move down

+
+ + onSelectRemoveCell()}> + +

Remove cell

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx index 6cc223ce9e270..3b95637b04965 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx @@ -7,13 +7,12 @@ import { useSensors, } from '@dnd-kit/core' import { - arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { useParams } from 'common' -import { Notebook, NotebookText, Play, Save } from 'lucide-react' +import { FileText, Notebook, NotebookText, Play, Save, SquareCode } from 'lucide-react' import { AiIconAnimation, Button } from 'ui' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' @@ -26,6 +25,8 @@ import { } from './ExplorerToolbar' import { MarkdownCell } from './MarkdownCell' import { QueryCell } from './QueryCell' +import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -55,11 +56,17 @@ export const NotebookEditor = () => { const { active, over } = event if (!id || !over || active.id === over.id) return - const oldIndex = cells.findIndex((cell) => cell.id === active.id) - const newIndex = cells.findIndex((cell) => cell.id === over.id) - if (oldIndex === -1 || newIndex === -1) return + snap.reorderCells({ id, activeCellId: active.id, overCellId: over.id }) + } + + const onSelectAddCell = (type: 'markdown' | 'query') => { + const notebookId = currentNotebook?.notebook.id + if (!notebookId) return + + const cell = type === 'markdown' ? createMarkdownCellSkeleton() : createQueryCellSkeleton() + const lastCellId = cells[cells.length - 1]?.id - snap.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) }) + snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell }) } return ( @@ -88,34 +95,53 @@ export const NotebookEditor = () => { contentClassName="[&>h3]:text-sm [&>p]:text-xs" >
- - + +
)} {cells.length > 0 && ( - - cell.id)} - strategy={verticalListSortingStrategy} - > -
- {cells.map((cell) => { - switch (cell._tag) { - case 'markdown_cell': - return + <> + + cell.id)} + strategy={verticalListSortingStrategy} + > +
+ {cells.map((cell) => { + switch (cell._tag) { + case 'markdown_cell': + return + case 'database_cell': + case 'log_cell': + return + } + })} +
+
+
- case 'database_cell': - return - - case 'log_cell': - // [Joshen] Will eventually hook it up - return null - } - })} -
-
-
+
+ } + className="w-7" + onClick={() => onSelectAddCell('query')} + tooltip={{ content: { side: 'bottom', text: 'Add query cell' } }} + /> + } + className="w-7" + onClick={() => onSelectAddCell('markdown')} + tooltip={{ content: { side: 'bottom', text: 'Add markdown cell' } }} + /> +
+ )} diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx index 70c07a3bae55b..d826537d3723e 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx @@ -32,6 +32,8 @@ interface DisplaySettingsButtonProps { } // [Joshen] TODO support multiple y axis charts +// [Joshen] TODO onUpdateChartConfig can likely be shifted into the notebook-state +// so this component doesn't need to know about other cells export const DisplaySettingsButton = ({ display, diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 9553fa6426270..57c22e307aa37 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -2,74 +2,148 @@ import { untrustedSql } from '@supabase/pg-meta' import { useState } from 'react' import { type Snapshot } from 'valtio' +import { AddCellDropdown } from '../AddCellDropdown' +import { MoveCellDropdownContent } from '../MoveCellDropdownContent' import { QueryEditor } from '../QueryEditor' import { type QueryDisplay, type QueryResult } from '../types' import { SortableSection } from '@/components/ui/SortableSection' -import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' +import { + type DatabaseCell as DatabaseCellSchema, + type LogCell as LogCellSchema, +} from '@/data/content/notebooks/notebook-schema' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + createDefaultCellSource, + type CellSource, +} from '@/data/query-sources/query-source-registry' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' interface QueryCellProps { - cell: Snapshot + cell: Snapshot } +/** + * [Joshen] Aiming to keep PRs small so the following are deliberating missing for now: + * - Auto limit logic + * - Database selection logic + * + * QueryCell atm minimally supports running queries and rendering results + */ + type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDisplay } /** Notebook adapter around the shared QueryEditor. */ export const QueryCell = ({ cell }: QueryCellProps) => { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() - const cells = currentNotebook?.notebook.content?.cells ?? [] - const [sql, setSql] = useState(cell.unchecked_sql) + const { id, title: cellTitle, view, chart, unchecked_sql } = cell + const rowLimit = 'row_limit' in cell ? cell.row_limit : undefined + const source = + cell._tag === 'database_cell' + ? createDefaultCellSource('database') + : createDefaultCellSource('logs') + + const [sql, setSql] = useState(unchecked_sql) const [result, setResult] = useState() - const title = cell.title ?? 'Untitled snippet' + const title = cellTitle ?? 'Untitled snippet' const display: QueryDisplay = { - view: cell.view ?? 'table', - chart: cell.chart ? { ...cell.chart, y_columns: [...cell.chart.y_columns] } : undefined, + view: view ?? 'table', + chart: chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined, + } + + const handleSourceChange = (source: CellSource) => { + const notebookId = currentNotebook?.notebook.id + if (!notebookId) return + + snap.updateCell({ + id: notebookId, + cellId: id, + updater: (candidate) => { + if (source.type === 'database' && candidate._tag === 'log_cell') { + const { _tag, time_range, unchecked_sql, ...rest } = candidate + return { + ...rest, + _tag: 'database_cell' as const, + row_limit: 100, + unchecked_sql: untrustedSql(unchecked_sql), + } + } + + if (source.type === 'logs' && candidate._tag === 'database_cell') { + const { _tag, row_limit, unchecked_sql, ...rest } = candidate + return { + ...rest, + _tag: 'log_cell' as const, + time_range: { + _tag: 'relative_time_range' as const, + unit: 'hour' as const, + amount: 1, + }, + unchecked_sql: untrustedLogSql(unchecked_sql), + } + } + + return candidate + }, + }) } const handleUpdateCell = (payload: QueryCellUpdate) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return - const nextCells = cells.map((candidate) => { - if (candidate.id !== cell.id || candidate._tag !== 'database_cell') return candidate + snap.updateCell({ + id: notebookId, + cellId: id, + updater: (candidate) => { + if (candidate._tag !== 'database_cell' && candidate._tag !== 'log_cell') return candidate - if ('sql' in payload) { - return { ...candidate, unchecked_sql: untrustedSql(payload.sql) } - } + if ('sql' in payload) { + return candidate._tag === 'database_cell' + ? { ...candidate, unchecked_sql: untrustedSql(payload.sql) } + : { ...candidate, unchecked_sql: untrustedLogSql(payload.sql) } + } - if ('title' in payload) { - const nextTitle = payload.title.trim() - return nextTitle ? { ...candidate, title: nextTitle } : candidate - } + if ('title' in payload) { + const nextTitle = payload.title.trim() + return nextTitle ? { ...candidate, title: nextTitle } : candidate + } - return { - ...candidate, - view: payload.display.view, - chart: payload.display.chart, - } + return { + ...candidate, + view: payload.display.view, + chart: payload.display.chart, + } + }, }) - - snap.updateCells({ id: notebookId, cells: nextCells }) } return ( - + } + gripDropdownContent={} + gripClassName="mt-2 opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" + > handleUpdateCell({ title })} onSqlChange={setSql} onSqlCommit={(sql) => handleUpdateCell({ sql })} + onSourceChange={handleSourceChange} onResultChange={setResult} - onDisplayChange={(display) => handleUpdateCell({ display })} + onDisplayChange={ + cell._tag === 'database_cell' ? (display) => handleUpdateCell({ display }) : undefined + } /> ) diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index d1ecf65fd9f8a..8c79ef0077794 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -1,8 +1,10 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' +import { useFlag } from 'common' import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' import { useState, type ReactNode } from 'react' import { cn } from 'ui' +import { resolveLogTimeRange } from '../QuerySources/LogTimeRange.utils' import { ExplorerQuery, ExplorerQueryEditor, @@ -10,6 +12,7 @@ import { ExplorerQueryResults, ExplorerQueryViewport, } from './ExplorerQuery' +import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu' import { ExplorerToolbar, ExplorerToolbarAction, @@ -23,6 +26,15 @@ import { QueryResultTable } from './QueryResultTable' import { type QueryDisplay, type QueryResult } from './types' import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { isValidConnString } from '@/data/fetchers' +import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' +import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { + createDefaultCellSource, + QUERY_SOURCE_REGISTRY, + type CellSource, +} from '@/data/query-sources/query-source-registry' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -32,13 +44,15 @@ export type QueryEditorProps = { variant: 'embedded' | 'viewport' title: string sql: string + source?: CellSource result?: QueryResult - rowLimit: number + rowLimit?: number display?: QueryDisplay toolbarActions?: ReactNode onTitleChange: (title: string) => void onSqlChange: (sql: string) => void onSqlCommit?: (sql: string) => void + onSourceChange?: (source: CellSource) => void onResultChange: (result: QueryResult) => void onDisplayChange?: (display: QueryDisplay) => void } @@ -53,6 +67,7 @@ export const QueryEditor = ({ variant, title, sql, + source, result, rowLimit, display, @@ -60,35 +75,88 @@ export const QueryEditor = ({ onTitleChange, onSqlChange, onSqlCommit, + onSourceChange, onResultChange, onDisplayChange, }: QueryEditorProps) => { const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) + const isOtelLogsEnabled = useFlag('otelLegacyLogs') const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery() const view = display?.view ?? 'table' const columns = Object.keys(result?.rows?.[0] ?? {}) + const sourceBinding = source ?? createDefaultCellSource('database') const [showQuery, setShowQuery] = useState(true) - const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({ + const databaseIdentifier = + sourceBinding.type === 'database' ? sourceBinding.parameters.identifier : undefined + + const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery( + { projectRef: project?.ref }, + { + enabled: + databaseIdentifier !== undefined && + project?.ref !== undefined && + databaseIdentifier !== project.ref, + } + ) + + const { mutate: executeSql, isPending: isExecutingSql } = useExecuteSqlMutation({ onSuccess: (data) => onResultChange({ rows: data.result }), onError: (error) => onResultChange({ error }), }) + const { mutate: executeLogsSql, isPending: isExecutingLogs } = useExecuteLogsSqlMutation({ + onSuccess: (data) => onResultChange({ rows: data.rows as readonly Record[] }), + onError: (error) => onResultChange({ error }), + }) + + const isResolvingDatabase = + databaseIdentifier !== undefined && databaseIdentifier !== project?.ref && isLoadingDatabases + const isExecuting = isExecutingSql || isExecutingLogs + const isBusy = isLoadingProject || isResolvingDatabase || isExecuting + const handleRunQuery = (sqlToRun: string = sql) => { - if (!project || isLoadingProject || isExecuting || sqlToRun.trim().length === 0) return + if (!project || isBusy || sqlToRun.trim().length === 0) return onSqlCommit?.(sql) + if (sourceBinding.type === 'logs') { + if (!isOtelLogsEnabled) { + onResultChange({ + error: { message: "Querying logs isn't available for this project yet." }, + }) + return + } + + executeLogsSql({ + projectRef: project.ref, + sql: acceptUntrustedLogsSql(untrustedLogSql(sqlToRun)), + range: resolveLogTimeRange(sourceBinding.parameters.time_range), + endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, + }) + return + } + const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun)) const limitedSql = applyAutoLimit(safeSql, rowLimit) + const connectionString = + databaseIdentifier === undefined || databaseIdentifier === project.ref + ? project.connectionString + : databases?.find((database) => database.identifier === databaseIdentifier) + ?.connectionString + + if (!isValidConnString(connectionString)) { + onResultChange({ error: { message: 'Unable to run query: Connection string is missing' } }) + return + } executeSql({ projectRef: project.ref, - connectionString: project.connectionString, + connectionString, sql: limitedSql.sql, autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined, contextualInvalidation: true, @@ -107,6 +175,9 @@ export const QueryEditor = ({ {title} {toolbarActions} + {source && onSourceChange && ( + + )} {display && onDisplayChange && (

{(result?.rows ?? []).length.toLocaleString()} rows

-

·

-

Limit {rowLimit} rows

+ {rowLimit && ( + <> +

·

+

Limit {rowLimit} rows

+ + )} ) diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx index 432aba351d50c..e6e4774cddfc3 100644 --- a/apps/studio/components/interfaces/Explorer/QueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -75,6 +75,7 @@ export const QueryTab = () => { variant="viewport" title={draft.name} sql={draft.uncheckedSql} + source={draft.source} result={result} rowLimit={QUERY_ROW_LIMIT} onTitleChange={(value) => { @@ -83,6 +84,7 @@ export const QueryTab = () => { tabs.updateTab(createTabId('query', { id }), { label: name }) }} onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })} + onSourceChange={(source) => explorerQueryState.updateDraft({ id, source })} onResultChange={handleResultChange} /> ) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index 99e21109f2a6b..506236d2cf26b 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -1,6 +1,6 @@ -import { untrustedSql } from '@supabase/pg-meta' import { useRouter } from 'next/router' +import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { generateUuid } from '@/lib/api/snippets.browser' import { useProfile } from '@/lib/profile' @@ -23,35 +23,28 @@ export const useCreateNotebook = () => { if (!profile) return console.error('Profile is required') if (!project) return console.error('Project is required') - // [Joshen] Just adding sample data to play around with, keep for now - clean up at the end - const DEFAULT_CELLS = [ - { - _tag: 'markdown_cell', - id: generateUuid(), - text: ` + const sampleMdCell1 = createMarkdownCellSkeleton({ + content: ` # Title A brief description on what this notebook is about - `.trim(), - }, - { - _tag: 'markdown_cell', - id: generateUuid(), - text: ` +`.trim(), + }) + const sampleMdCell2 = createMarkdownCellSkeleton({ + content: ` ## Section This is a sample paragraph to demonstrate the Markdown cells 1. List item 1 2. List item 2 3. List item 3 - `, - }, - { - _tag: 'database_cell', - id: generateUuid(), - view: 'table', - chart: undefined, - unchecked_sql: untrustedSql('select * from colors;'), - row_limit: 100, - }, +`.trim(), + }) + const sampleQueryCell = createQueryCellSkeleton({ sql: 'select * from colors;' }) + + // [Joshen] Just adding sample data to play around with, keep for now - clean up at the end + const DEFAULT_CELLS = [ + sampleMdCell1, + sampleMdCell2, + sampleQueryCell, ] as Notebooks.Content['cells'] const id = idOverride ?? generateUuid() diff --git a/apps/studio/components/interfaces/Explorer/utils.ts b/apps/studio/components/interfaces/Explorer/utils.ts new file mode 100644 index 0000000000000..059c84784d444 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/utils.ts @@ -0,0 +1,31 @@ +import { untrustedSql } from '@supabase/pg-meta' + +import { generateUuid } from '@/lib/api/snippets.browser' + +export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { + return { + _tag: 'database_cell' as const, + id: generateUuid(), + view: 'table' as const, + chart: undefined, + unchecked_sql: untrustedSql(sql ?? ''), + row_limit: 100, + } +} + +const DEFAULT_MARKDOWN_CONTENT = ` + # New section + Add notes about your queries and results +`.trim() + +export const createMarkdownCellSkeleton = ({ + content = DEFAULT_MARKDOWN_CONTENT, +}: { + content?: string +} = {}) => { + return { + _tag: 'markdown_cell' as const, + id: generateUuid(), + text: content, + } +} diff --git a/apps/studio/components/ui/SortableSection.tsx b/apps/studio/components/ui/SortableSection.tsx index 3484e61e49eb3..53e4425679511 100644 --- a/apps/studio/components/ui/SortableSection.tsx +++ b/apps/studio/components/ui/SortableSection.tsx @@ -1,17 +1,44 @@ +import { useDndMonitor } from '@dnd-kit/core' import { useSortable } from '@dnd-kit/sortable' import { GripVertical } from 'lucide-react' -import type { CSSProperties, PropsWithChildren } from 'react' -import { Button, cn } from 'ui' +import type { CSSProperties, PropsWithChildren, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Button, cn, DropdownMenu, DropdownMenuTrigger } from 'ui' export const SortableSection = ({ id, children, + actions, gripClassName, -}: PropsWithChildren<{ id: string; gripClassName?: string }>) => { + gripDropdownContent, +}: PropsWithChildren<{ + id: string + gripClassName?: string + actions?: ReactNode + gripDropdownContent?: ReactNode +}>) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }) + const [menuOpen, setMenuOpen] = useState(false) + const isDraggingRef = useRef(false) + const openTimeoutRef = useRef>(undefined) + + useDndMonitor({ + onDragStart: (event) => { + if (event.active.id === id) isDraggingRef.current = true + }, + onDragEnd: (event) => { + if (event.active.id === id) isDraggingRef.current = false + }, + onDragCancel: (event) => { + if (event.active.id === id) isDraggingRef.current = false + }, + }) + + useEffect(() => () => clearTimeout(openTimeoutRef.current), []) + const style: CSSProperties = { transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` @@ -23,22 +50,43 @@ export const SortableSection = ({
-
{children}
) diff --git a/apps/studio/data/content/content-remap.test.ts b/apps/studio/data/content/content-remap.test.ts index 1c288ea781098..36f4648157168 100644 --- a/apps/studio/data/content/content-remap.test.ts +++ b/apps/studio/data/content/content-remap.test.ts @@ -118,6 +118,7 @@ describe('remapSqlContentField', () => { _tag: 'database_cell', id: DATABASE_CELL.id, row_limit: 100, + view: 'table', unchecked_sql: untrustedSql(DATABASE_CELL.sql), }) expect(databaseCell).not.toHaveProperty('sql') @@ -125,6 +126,7 @@ describe('remapSqlContentField', () => { _tag: 'log_cell', id: LOG_CELL.id, time_range: LOG_CELL.time_range, + view: 'table', unchecked_sql: untrustedLogSql(LOG_CELL.sql), }) expect(logCell).not.toHaveProperty('sql') diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts index f50bd20c9a69e..c6fc30abbaa22 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.test.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -225,6 +225,7 @@ describe('notebookDomainSchema', () => { _tag: 'database_cell', id: FULL_NOTEBOOK.cells[1].id, row_limit: 100, + view: 'table', unchecked_sql: untrustedSql('select * from auth.users limit 100'), }) expect(databaseCell).not.toHaveProperty('sql') diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts index 545267b75e9b9..c326011f3b7c4 100644 --- a/apps/studio/data/content/notebooks/notebook-schema.ts +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -51,7 +51,7 @@ const databaseCellSchema = z.object({ title: z.string().optional(), sql: z.string(), row_limit: z.number(), - view: z.enum(['table', 'chart']).default('table').optional(), + view: z.enum(['table', 'chart']).optional(), chart: chartConfigSchema.optional(), }) @@ -61,6 +61,7 @@ const logCellSchema = z.object({ title: z.string().optional(), sql: z.string(), time_range: timeRangeSchema, + view: z.enum(['table', 'chart']).optional(), chart: chartConfigSchema.optional(), }) @@ -126,19 +127,19 @@ export const agentNotebookSchema = z.object({ export type AgentNotebook = z.infer export type AgentCell = z.infer -// The domain shape: parses the same wire cell (`cellSchema`) and transforms `sql` into a -// branded `unchecked_sql`. +// The domain shape: parses the same wire cell (`cellSchema`), transforms `sql` into a +// branded `unchecked_sql`, and defaults `view` to 'table' const cellDomainSchema = cellSchema.transform((cell) => { switch (cell._tag) { case 'markdown_cell': return cell case 'database_cell': { - const { sql, ...rest } = cell - return { ...rest, unchecked_sql: untrustedSql(sql) } + const { sql, view, ...rest } = cell + return { ...rest, view: view ?? 'table', unchecked_sql: untrustedSql(sql) } } case 'log_cell': { - const { sql, ...rest } = cell - return { ...rest, unchecked_sql: untrustedLogSql(sql) } + const { sql, view, ...rest } = cell + return { ...rest, view: view ?? 'table', unchecked_sql: untrustedLogSql(sql) } } } }) diff --git a/apps/studio/evals/dataset.ts b/apps/studio/evals/dataset.ts index ab3871c2d9063..9101b66f98ae2 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -463,4 +463,47 @@ export const dataset: AssistantEvalCase[] = [ 'Guards against inventing a notebook instead of calling the tool and reporting the real, negative result', }, }, + { + input: { prompt: 'What queries does my Auth health check notebook run?' }, + expected: { + requiredTools: ['list_notebooks', 'get_notebook'], + correctAnswer: + "Reports exactly two queries: a database query for signups per day from auth.users, and a logs query for auth errors against the auth_logs source scoped to the last hour. May (but does not have to) note that the remaining cell is markdown and runs no query. Accurate statements about a cell's configuration — the signups cell's 30-row limit or its line chart, the auth errors cell's relative time range — are acceptable. Does not attribute any query, table, or log source the notebook does not contain, and does not misstate the auth errors cell's time range.", + }, + metadata: { + category: ['general_help'], + description: + 'Resolves a notebook name to an id via list_notebooks, then reads it — guards against paraphrasing cells into queries the notebook does not contain', + }, + }, + { + input: { prompt: "Summarize what's in my Edge function error triage notebook" }, + expected: { + requiredTools: ['list_notebooks', 'get_notebook'], + correctAnswer: + 'Summarizes the notebook as a markdown intro plus one log cell ("hello-world failures") that queries function_edge_logs for TypeError failures over the last day. Log cells hold SQL against a logs source, so describing that cell\'s SQL, columns, or time range is correct and acceptable. Does not attribute a third cell, or any Postgres database cell, to the notebook.', + }, + metadata: { + category: ['general_help'], + description: 'Baseline read of a smaller, single-query notebook', + }, + }, + { + input: { prompt: 'Get the notebook with id 00000000-0000-0000-0000-000000000000' }, + expected: { + requiredTools: [ + { + name: 'get_notebook', + input: { id: { equals: '00000000-0000-0000-0000-000000000000' } }, + }, + ], + correctAnswer: + 'States that no notebook with that id was found, and does not describe any notebook contents.', + }, + metadata: { + category: ['general_help'], + description: + 'Exercises the not-found error path of get_notebook and guards against hallucinating contents for a notebook that does not exist', + }, + }, ] diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts index cdd1cd936030a..b0c637c4d1f80 100644 --- a/apps/studio/lib/ai/prompts.ts +++ b/apps/studio/lib/ai/prompts.ts @@ -767,6 +767,7 @@ export const NOTEBOOKS_PROMPT = ` - Use \`execute_sql\` for a single ad-hoc question with no need to persist it. - When the request clearly calls for a notebook, call \`create_notebook\` or \`update_notebook\` directly; both tools handle user approval. - \`update_notebook\` re-fetches the notebook right before applying edits, so the latest save always wins — it cannot detect edits made by someone else in between. +- When describing an existing notebook, report each query cell's configuration that changes what it returns — a log cell's time range, a database cell's row limit — and don't count markdown cells as queries. ` export const OUTPUT_ONLY_PROMPT = ` diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index 1231cbd509e65..c6889a74a0677 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -208,12 +208,14 @@ describe('ai/tools/notebook-tools', () => { _tag: 'database_cell', id: 'cell-2', row_limit: 100, + view: 'table', sql: 'select * from auth.users limit 100', }, { _tag: 'log_cell', id: 'cell-3', time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 }, + view: 'table', sql: 'select timestamp, event_message from edge_logs limit 10', }, ], diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts index b1c653430ba60..e63364b756c58 100644 --- a/apps/studio/state/explorer-query.test.ts +++ b/apps/studio/state/explorer-query.test.ts @@ -26,12 +26,57 @@ describe('explorer query drafts', () => { expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) expect(secondState.drafts['query-1']).toMatchObject({ name: 'Active users', + source: { id: 'database', type: 'database', parameters: {} }, uncheckedSql: 'select * from users', projectRef: 'project-a', }) expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false) }) + it('persists source parameters and clears stale results when they change', () => { + const storage = createMemoryStorage() + const state = createExplorerQueryState(storage) + + state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' }) + state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } }) + state.updateDraft({ + id: 'query-1', + source: { + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + }, + }) + + expect(state.results['query-1']).toBeUndefined() + + const restored = createExplorerQueryState(storage) + expect(restored.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(restored.drafts['query-1'].source).toEqual({ + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + }) + }) + + it('restores pre-source drafts as database queries', () => { + const storage = createMemoryStorage() + storage.setItem( + LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'), + JSON.stringify({ + 'query-1': { name: 'Legacy query', sql: 'select 1', updatedAt: 1 }, + }) + ) + + const state = createExplorerQueryState(storage) + expect(state.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true) + expect(state.drafts['query-1'].source).toEqual({ + id: 'database', + type: 'database', + parameters: {}, + }) + }) + it('removes the persisted draft and its session result when its tab closes', () => { const storage = createMemoryStorage() const state = createExplorerQueryState(storage) diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts index 28df97f7b59a3..7a10a41f5df88 100644 --- a/apps/studio/state/explorer-query.ts +++ b/apps/studio/state/explorer-query.ts @@ -3,11 +3,17 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { proxy, ref, snapshot, useSnapshot } from 'valtio' import { type QueryResult } from '@/components/interfaces/Explorer/types' +import { + cellSourceSchema, + createDefaultCellSource, + type CellSource, +} from '@/data/query-sources/query-source-registry' export type ExplorerQueryDraft = { id: string projectRef: string name: string + source: CellSource uncheckedSql: UntrustedSqlFragment updatedAt: number } @@ -18,6 +24,7 @@ export type ExplorerQueryResult = QueryResult & { type PersistedExplorerQueryDraft = { name: string + source: CellSource sql: string updatedAt: number } @@ -35,18 +42,27 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} return Object.fromEntries( - Object.entries(parsed).filter((entry): entry is [string, PersistedExplorerQueryDraft] => { - const draft = entry[1] - return ( - draft !== null && - typeof draft === 'object' && - 'name' in draft && - typeof draft.name === 'string' && - 'sql' in draft && - typeof draft.sql === 'string' && - 'updatedAt' in draft && - typeof draft.updatedAt === 'number' - ) + Object.entries(parsed).flatMap(([id, value]) => { + if ( + value === null || + typeof value !== 'object' || + !('name' in value) || + typeof value.name !== 'string' || + !('sql' in value) || + typeof value.sql !== 'string' || + !('updatedAt' in value) || + typeof value.updatedAt !== 'number' + ) { + return [] + } + + const parsedSource = + 'source' in value ? cellSourceSchema.safeParse(value.source) : { success: false as const } + const source = parsedSource.success + ? parsedSource.data + : createDefaultCellSource('database') + + return [[id, { name: value.name, source, sql: value.sql, updatedAt: value.updatedAt }]] }) ) } catch { @@ -74,23 +90,26 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage projectRef, name = 'Untitled query', sql = '', + source = createDefaultCellSource('database'), }: { id: string projectRef: string name?: string sql?: string + source?: CellSource }) => { const draft: ExplorerQueryDraft = { id, projectRef, name, + source: cellSourceSchema.parse(source), uncheckedSql: untrustedSql(sql), updatedAt: Date.now(), } state.drafts[id] = draft const persisted = readPersistedDrafts(storage, projectRef) - persisted[id] = { name, sql, updatedAt: draft.updatedAt } + persisted[id] = { name, source: draft.source, sql, updatedAt: draft.updatedAt } writePersistedDrafts(storage, projectRef, persisted) return id @@ -106,23 +125,39 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage id, projectRef, name: persisted.name, + source: persisted.source, uncheckedSql: untrustedSql(persisted.sql), updatedAt: persisted.updatedAt, } return true }, - updateDraft: ({ id, name, sql }: { id: string; name?: string; sql?: string }) => { + updateDraft: ({ + id, + name, + source, + sql, + }: { + id: string + name?: string + source?: CellSource + sql?: string + }) => { const draft = state.drafts[id] if (!draft) return if (name !== undefined) draft.name = name + if (source !== undefined) { + draft.source = cellSourceSchema.parse(source) + delete state.results[id] + } if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql) draft.updatedAt = Date.now() const persisted = readPersistedDrafts(storage, draft.projectRef) persisted[id] = { name: draft.name, + source: draft.source, sql: draft.uncheckedSql, updatedAt: draft.updatedAt, } diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts index f9f3054d93498..14449cf92796a 100644 --- a/apps/studio/state/notebooks/notebooks-state.ts +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -1,3 +1,5 @@ +import { type UniqueIdentifier } from '@dnd-kit/core' +import { arrayMove } from '@dnd-kit/sortable' import { useParams } from 'common' import { useMemo } from 'react' import { proxy, snapshot, useSnapshot, type Snapshot } from 'valtio' @@ -92,6 +94,118 @@ export const notebooksState = proxy({ if (!skipSave) notebooksState.needsSaving.set(id, false) }, + /** + * Insert a cell right after `cellId` in a notebook's cell array — or at the + * end, if `cellId` is omitted or isn't found (e.g. an empty notebook has no + * cell to insert after). The caller builds the cell to insert (e.g. via + * `createMarkdownCellSkeleton`/`createQueryCellSkeleton`) since deciding + * what kind of cell to create is a UI concern, not a state one. + */ + insertCellAfter: ({ + id, + cellId, + cell, + }: { + id: string + cellId?: string + cell: Notebooks.Cell + }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + + const cells = stateNotebook.notebook.content.cells + const insertAt = cellId ? cells.findIndex((c) => c.id === cellId) : -1 + const nextCells = [...cells] + nextCells.splice(insertAt === -1 ? cells.length : insertAt + 1, 0, cell) + + notebooksState.updateCells({ id, cells: nextCells }) + }, + + /** + * Update a single cell in a notebook's cell array via an updater callback. + * The caller decides how the cell's content should change (e.g. field + * defaults, tag conversion) since that's a UI concern, not a state one — + * this only finds the cell by id and re-saves the array. + */ + updateCell: ({ + id, + cellId, + updater, + }: { + id: string + cellId: string + updater: (cell: Notebooks.Cell) => Notebooks.Cell + }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + + const nextCells = stateNotebook.notebook.content.cells.map((cell) => + cell.id === cellId ? updater(cell) : cell + ) + notebooksState.updateCells({ id, cells: nextCells }) + }, + + /** + * Remove a single cell from a notebook's cell array. + */ + removeCell: ({ id, cellId }: { id: string; cellId: string }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + + const nextCells = stateNotebook.notebook.content.cells.filter((c) => c.id !== cellId) + notebooksState.updateCells({ id, cells: nextCells }) + }, + + /** + * Shift a cell one position up or down in a notebook's cell array. No-ops if + * the cell is already at that boundary (or isn't found). + */ + moveCell: ({ + id, + cellId, + direction, + }: { + id: string + cellId: string + direction: 'up' | 'down' + }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + + const cells = stateNotebook.notebook.content.cells + const currentIndex = cells.findIndex((c) => c.id === cellId) + if (currentIndex === -1) return + + const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1 + if (nextIndex < 0 || nextIndex >= cells.length) return + + notebooksState.updateCells({ id, cells: arrayMove([...cells], currentIndex, nextIndex) }) + }, + + /** + * Reorder a notebook's cell array by moving the cell at `activeCellId` to + * where `overCellId` currently sits (dnd-kit's drag-end positions). + */ + reorderCells: ({ + id, + activeCellId, + overCellId, + }: { + id: string + activeCellId: UniqueIdentifier + overCellId: UniqueIdentifier + }) => { + const stateNotebook = notebooksState.notebooks[id] + if (!stateNotebook?.notebook.content) return + + const cells = stateNotebook.notebook.content.cells + const oldIndex = cells.findIndex((c) => c.id === activeCellId) + const newIndex = cells.findIndex((c) => c.id === overCellId) + if (oldIndex === -1 || newIndex === -1) return + + notebooksState.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) }) + }, + addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true), })