diff --git a/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx b/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx index 3ec89693cbedc..dae1201acc671 100644 --- a/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/[...slug]/page.tsx @@ -5,7 +5,6 @@ import { } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate } from '~/features/docs/GuidesMdx.template' -// Serve 404 when accessing pages that aren't prebuilt export const dynamicParams = false type Params = { slug: string[] } diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index b01885c6b6d38..3ad2489ead11a 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -1,6 +1,6 @@ import { type Metadata, type ResolvingMetadata } from 'next' import Link from 'next/link' -import { IconBackground, TextLink } from 'ui' +import { cn, IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' @@ -29,6 +29,7 @@ const products = [ href: '/guides/database/overview', description: 'Supabase provides a full Postgres database for every project with Realtime functionality, database backups, extensions, and more.', + span: 'col-span-12 md:col-span-6', }, { title: 'Auth', @@ -37,6 +38,7 @@ const products = [ href: '/guides/auth', description: 'Add and manage email and password, passwordless, OAuth, and mobile logins to your project through a suite of identity providers and APIs.', + span: 'col-span-12 md:col-span-6', }, { title: 'Storage', @@ -46,13 +48,6 @@ const products = [ description: 'Store, organize, transform, and serve large files—fully integrated with your Postgres database with Row Level Security access policies.', }, - { - title: 'AI & Vectors', - icon: 'ai', - hasLightIcon: true, - href: '/guides/ai', - description: 'Use Supabase to store and search embedding vectors.', - }, { title: 'Realtime', icon: 'realtime', @@ -71,6 +66,27 @@ const products = [ }, ] +const postgresIntegrations = [ + { + title: 'AI & Vectors', + icon: 'ai', + href: '/guides/ai', + description: 'AI toolkit to manage embeddings', + }, + { + title: 'Cron', + icon: 'cron', + href: '/guides/cron', + description: 'Schedule and monitor recurring Jobs', + }, + // { + // title: 'Queues', + // icon: 'queue', + // href: '/guides/queue', + // description: 'Postgres-native pull queues', + // }, +] + const selfHostingOptions = [ { title: 'Auth', @@ -161,7 +177,7 @@ const HomePage = () => ( +
+
+

+ Postgres Modules +

+
+
+ {postgresIntegrations.map((integration) => ( + + + + ))} +
+
+
diff --git a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx index 5f957a2b7a710..906e4a5b0feb1 100644 --- a/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/MenuIconPicker.tsx @@ -1,4 +1,4 @@ -import { Heart, Server } from 'lucide-react' +import { Clock, Heart, Server, SquareStack } from 'lucide-react' import { IconBranching, @@ -93,6 +93,10 @@ function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, c return case 'deployment': return + case 'cron': + return + case 'queue': + return default: return } diff --git a/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx b/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx index c9dfa87898017..353214ce46e93 100644 --- a/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/MenuIcons.tsx @@ -1,4 +1,4 @@ -import { products } from 'shared-data' +import { PRODUCT_MODULES, products } from 'shared-data' type HomeMenuIcon = { width?: number @@ -450,7 +450,7 @@ export function IconMenuAI({ width = 16, height = 16, className }: HomeMenuIcon) xmlns="http://www.w3.org/2000/svg" > { return MenuId.Api case pathname.startsWith('auth'): return MenuId.Auth - case pathname.startsWith('local-development'): - return MenuId.LocalDevelopment + case pathname.startsWith('cron'): + return MenuId.Cron case pathname.startsWith('database'): return MenuId.Database case pathname.startsWith('deployment'): @@ -123,6 +123,8 @@ export const getMenuId = (pathname: string | null) => { return MenuId.Graphql case pathname.startsWith('integrations'): return MenuId.Integrations + case pathname.startsWith('local-development'): + return MenuId.LocalDevelopment case pathname.startsWith('monitoring-troubleshooting'): return MenuId.MonitoringTroubleshooting case pathname.startsWith('platform'): diff --git a/apps/docs/content/guides/cron.mdx b/apps/docs/content/guides/cron.mdx new file mode 100644 index 0000000000000..84182b0dd1460 --- /dev/null +++ b/apps/docs/content/guides/cron.mdx @@ -0,0 +1,34 @@ +--- +title: 'Cron' +subtitle: 'Schedule Recurring Jobs with Cron Syntax in Postgres' +--- + +Supabase Cron is a Postgres Module that simplifies scheduling recurring Jobs with cron syntax and monitoring Job runs inside Postgres. + +Cron Jobs can be created via SQL or the Cron interface inside of Supabase Dashboard and can run anywhere from every second to once a year depending on your use case. + +Every Job can run SQL snippets or database functions with zero network latency or make an HTTP request, such as invoking a Supabase Edge Function, with ease. + + + +For best performance, we recommend no more than 8 Jobs run concurrently. Each Job should run no more than 10 minutes. + + + +## How does Cron work? + +Under the hood, Supabase Cron uses the [`pg_cron`](https://github.com/citusdata/pg_cron) Postgres database extension which is the scheduling and execution engine for your Jobs. + + + +`pg_cron` is not fully supported on Fly Postgres. Learn more about [Fly Postgres limitations](/docs/guides/platform/fly-postgres#limitations). + + + +The extension creates a `cron` schema in your database and all Jobs are stored on the `cron.job` table. Every Job's run and its status is recorded on the `cron.job_run_details` table. + +The Supabase Dashboard provides and interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL. + +## Resources + +- [`pg_cron` GitHub Repository](https://github.com/citusdata/pg_cron) diff --git a/apps/docs/content/guides/cron/install.mdx b/apps/docs/content/guides/cron/install.mdx new file mode 100644 index 0000000000000..736b8870c287e --- /dev/null +++ b/apps/docs/content/guides/cron/install.mdx @@ -0,0 +1,44 @@ +--- +title: 'Install' +--- + +Install the Supabase Cron Postgres Module to begin scheduling recurring Jobs. + + + + +1. Go to the [Cron Postgres Module](/dashboard/project/_/integrations/cron/overview) under Integrations in the Dashboard. +2. Enable the `pg_cron` extension. + + + + +```sql +create extension pg_cron with schema pg_catalog; + +grant usage on schema cron to postgres; +grant all privileges on all tables in schema cron to postgres; +``` + + + + +## Uninstall + +Uninstall Supabase Cron by disabling the `pg_cron` extension: + +```sql +drop extension if exists pg_cron; +``` + + + +Disabling the `pg_cron` extension will permanently delete all Jobs. + + diff --git a/apps/docs/content/guides/cron/quickstart.mdx b/apps/docs/content/guides/cron/quickstart.mdx new file mode 100644 index 0000000000000..874644001a493 --- /dev/null +++ b/apps/docs/content/guides/cron/quickstart.mdx @@ -0,0 +1,337 @@ +--- +title: 'Quickstart' +--- + + + +Job names are case sensitive and cannot be edited once created. + +Attempting to create a second Job with the same name (and case) will overwrite the first Job. + + + +## Schedule a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section to schedule your first Job. +2. Click on `Create job` button or navigate to the new Cron Job form [here](/dashboard/project/_/integrations/cron/jobs?dialog-shown=true). +3. Name your Cron Job. +4. Choose a schedule for your Job by inputting cron syntax (refer to the syntax chart in the form) or natural language. +5. Input SQL snippet or select a Database function, HTTP request, or Supabase Edge Function. + +Cron Create + + + + +```sql +-- Cron Job name cannot be edited +select cron.schedule('permanent-cron-job-name', '30 seconds', 'CALL do_something()'); +``` + + + + + +
+ + + ``` + ┌───────────── min (0 - 59) + │ ┌────────────── hour (0 - 23) + │ │ ┌─────────────── day of month (1 - 31) + │ │ │ ┌──────────────── month (1 - 12) + │ │ │ │ ┌───────────────── day of week (0 - 6) (0 to 6 are Sunday to + │ │ │ │ │ Saturday, or use names; 7 is also Sunday) + │ │ │ │ │ + │ │ │ │ │ + * * * * * + ``` + + You can use [1-59] seconds (e.g. `30 seconds`) as the cron syntax to schedule sub-minute Jobs. + + + +
+
+ + + +You can input seconds for your Job schedule interval as long as you're on Postgres version 15.1.1.61 or later. + + + +## Edit a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to edit. +2. Click on the three vertical dots menu on the right side of the Job and click `Edit cron job`. +3. Make your changes and then click `Save cron job`. + +Cron Edit + + + + +```sql +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + schedule := '*/5 * * * *' +); +``` + +Full options for the `cron.alter_job()` function are: + +```sql +cron.alter_job( + job_id bigint, + schedule text default null, + command text default null, + database text default null, + username text default null, + active boolean default null +) +``` + +It is also possible to modify a job by using the `cron.schedule()` function by inputting the same job name. This will replace the existing job via upsert. + + + + +## Activate/Deactivate a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to unschedule. +2. Toggle the `Active`/`Inactive` switch next to Job name. + +Cron Toggle + + + + +```sql +-- Activate Job +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + active := true +); + +-- Deactivate Job +select cron.alter_job( + job_id := (select jobid from cron.job where jobname = 'permanent-cron-job-name'), + active := false +); +``` + + + + +## Unschedule a Job + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you'd like to delete. +2. Click on the three vertical dots menu on the right side of the Job and click `Delete cron job`. +3. Confirm deletion by entering the Job name. + +Cron Unschedule + + + + +```sql +select cron.unschedule('permanent-cron-job-name'); +``` + + + +Unscheduling a Job will permanently delete the Job from `cron.job` table but its run history remain in `cron.job_run_details` table. + + + + + + +## Inspecting Job Runs + + + + +1. Go to the [Jobs](/dashboard/project/_/integrations/cron/jobs) section and find the Job you want to see the runs of. +2. Click on the `History` button next to the Job name. + +Cron Job Runs + + + + +```sql +select + * +from cron.job_run_details +where jobid = (select jobid from cron.job where jobname = 'permanent-cron-job-name') +order by start_time desc +limit 10; +``` + + + +The records in the `cron.job_run_details` table are not cleaned up automatically. They are also not removed when jobs are unscheduled, which will take up disk space in your database. + + + + + + +## Examples + +### Delete data every week + +{/* */} + +Delete old data every Saturday at 3:30AM (GMT): + +{/* */} + +```sql +select cron.schedule ( + 'saturday-cleanup', -- name of the cron job + '30 3 * * 6', -- Saturday at 3:30AM (GMT) + $$ delete from events where event_time < now() - interval '1 week' $$ +); +``` + +### Run a vacuum every day + +{/* */} + +Vacuum every day at 3:00AM (GMT): + +{/* */} + +```sql +select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM'); +``` + +### Call a database function every 5 minutes + +Create a [`hello_world()`](/docs/guides/database/functions?language=sql#simple-functions) database function and then call it every 5 minutes: + +```sql +select cron.schedule('call-db-function', '*/5 * * * *', 'SELECT hello_world()'); +``` + +### Call a database stored procedure + +To use a stored procedure, you can call it like this: + +```sql +select cron.schedule('call-db-procedure', '*/5 * * * *', 'CALL my_procedure()'); +``` + +### Invoke Supabase Edge Function every 30 seconds + +Make a POST request to a Supabase Edge Function every 30 seconds: + +```sql +select + cron.schedule( + 'invoke-function-every-half-minute', + '30 seconds', + $$ + select + net.http_post( + url:='https://project-ref.supabase.co/functions/v1/function-name', + headers:=jsonb_build_object('Content-Type','application/json', 'Authorization', 'Bearer ' || 'YOUR_ANON_KEY'), + body:=jsonb_build_object('time', now() ), + timeout_milliseconds:=5000 + ) as request_id; + $$ + ); +``` + + + +This requires the [`pg_net` extension](/docs/guides/database/extensions/pg_net) to be enabled. + + + +## Caution: Scheduling System Maintenance + +Be extremely careful when setting up Jobs for system maintenance tasks as they can have unintended consequences. + +For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting, such as `idle_session_timeout`, that can perform these common maintenance tasks without the risk. + +Reach out to [Supabase Support](https://supabase.com/support) if you're unsure if that applies to your use case. diff --git a/apps/docs/content/guides/database/extensions/pg_cron.mdx b/apps/docs/content/guides/database/extensions/pg_cron.mdx index 6b934fbbe97a2..680a7680351da 100644 --- a/apps/docs/content/guides/database/extensions/pg_cron.mdx +++ b/apps/docs/content/guides/database/extensions/pg_cron.mdx @@ -1,194 +1,7 @@ --- id: 'pg_cron' -title: 'pg_cron: Job Scheduling' -description: 'pgnet: a simple cron-based job scheduler for PostgreSQL that runs inside the database.' +title: 'pg_cron: Schedule Recurring Jobs with Cron Syntax in Postgres' +description: 'pg_cron: schedule recurring Jobs with cron syntax in Postgres.' --- -The `pg_cron` extension is a simple cron-based job scheduler for PostgreSQL that runs inside the database. - - - -pg_cron is not fully supported on Fly Postgres. Read more about this [Fly Postgres limitation here](/docs/guides/platform/fly-postgres#limitations). - - - -## Usage - -### Enable the extension - - - - -1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard. -2. Click on **Extensions** in the sidebar. -3. Search for "pg_cron" and enable the extension. - - - - -```sql --- Example: enable the "pg_cron" extension -create extension pg_cron with schema pg_catalog; - -grant usage on schema cron to postgres; -grant all privileges on all tables in schema cron to postgres; - --- Example: disable the "pg_cron" extension -drop extension if exists pg_cron; -``` - - - - -### Syntax - -The schedule uses the standard cron syntax, in which \* means "run every time period", and a specific number means "but only at this time": - -```bash - ┌───────────── min (0 - 59) - │ ┌────────────── hour (0 - 23) - │ │ ┌─────────────── day of month (1 - 31) - │ │ │ ┌──────────────── month (1 - 12) - │ │ │ │ ┌───────────────── day of week (0 - 6) (0 to 6 are Sunday to - │ │ │ │ │ Saturday, or use names; 7 is also Sunday) - │ │ │ │ │ - │ │ │ │ │ - * * * * * -``` - -You can use `[1-59] seconds` as the cron syntax to schedule sub-minute jobs. This is available on `pg_cron` v1.5.0+; upgrade your existing Supabase project to use this syntax. - -Head over to [crontab.guru](https://crontab.guru/) to validate your cron schedules. - -### Scheduling system maintenance - -Be extremely careful when setting up `pg_cron` jobs for system maintenance tasks as they can have unintended consequences. For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting e.g. `idle_session_timeout` that can perform these common maintenance tasks without the risk. - -Reach out to [Supabase Support](https://supabase.com/support) if you're unsure if that applies to your use case. - -## Examples - -### Delete data every week - -Delete old data every Saturday at 3:30am (GMT): - -```sql -select cron.schedule ( - 'saturday-cleanup', -- name of the cron job - '30 3 * * 6', -- Saturday at 3:30am (GMT) - $$ delete from events where event_time < now() - interval '1 week' $$ -); -``` - -### Run a vacuum every day - -Vacuum every day at 3:00am (GMT): - -```sql -select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM'); -``` - -### Call a database function every 5 minutes - -Create a [`hello_world()`](https://supabase.com/docs/guides/database/functions?language=sql#simple-functions) database function and then call it every 5 minutes: - -```sql -select cron.schedule('call-db-function', '*/5 * * * *', 'SELECT hello_world()'); -``` - -### Call a database stored procedure - -To use a stored procedure, you can call it like this: - -```sql -select cron.schedule('call-db-procedure', '*/5 * * * *', 'CALL my_procedure()'); -``` - -### Invoke Supabase Edge Function every 30 seconds - - - -This requires `pg_cron` v1.5.0+ and the [`pg_net` extension](/docs/guides/database/extensions/pgnet) to be enabled. - - - -Make a POST request to a Supabase Edge Function every 30 seconds: - -```sql -select - cron.schedule( - 'invoke-function-every-half-minute', - '30 seconds', - $$ - select - net.http_post( - url:='https://project-ref.supabase.co/functions/v1/function-name', - headers:=jsonb_build_object('Content-Type','application/json', 'Authorization', 'Bearer ' || 'YOUR_ANON_KEY'), - body:=jsonb_build_object('time', now() ), - timeout_milliseconds:=5000 - ) as request_id; - $$ - ); -``` - -### Edit a job - -Changes the frequency of a job called `'vacuum'` to once every 5 minutes. - -```sql -select cron.alter_job( - job_id := (select jobid from cron.job where jobname = 'vacuum'), - schedule := '*/5 * * * *' -); -``` - -Full options for the `cron.alter_job()` function are: - -```sql -cron.alter_job( - job_id bigint, - schedule text default null, - command text default null, - database text default null, - username text default null, - active boolean default null -) -``` - -It is also possible to modify a job by using the `cron.schedule()` function, with the same job name. This will replace the existing job, in the manner of an upsert. - -### Unschedule a job - -Unschedules a job called `'nightly-vacuum'` - -```sql -select cron.unschedule('nightly-vacuum'); -``` - -### Viewing previously ran jobs - -View the last ten jobs that have ran - -```sql -select - * -from cron.job_run_details -order by start_time desc -limit 10; -``` - - - -The records in cron.job_run_details are not cleaned automatically which will take up disk space in your database. - - - -## Resources - -- [`pg_cron` GitHub Repository](https://github.com/citusdata/pg_cron) +See the [Supabase Cron docs](/docs/guides/cron). diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx index aaeaf2cc54380..3402d053de0b0 100644 --- a/apps/docs/content/guides/deployment/branching.mdx +++ b/apps/docs/content/guides/deployment/branching.mdx @@ -582,7 +582,7 @@ With the Supabase branching integration, you can sync the Git branch used by the - The Vercel Integration for Supabase branching is under development. To express your interest, [join the discussion on GitHub discussions](https://github.com/orgs/supabase/discussions/18938). + The Vercel Integration for Supabase branching is working only with Supabase managed projects. There is currently no support for Vercel Marketplace managed resources, however the support is planned in the future. diff --git a/apps/docs/content/guides/functions/schedule-functions.mdx b/apps/docs/content/guides/functions/schedule-functions.mdx index 1dd5fb88ed274..f0d64f319c633 100644 --- a/apps/docs/content/guides/functions/schedule-functions.mdx +++ b/apps/docs/content/guides/functions/schedule-functions.mdx @@ -13,7 +13,7 @@ description: 'Schedule Edge Functions with pg_cron.' >
-The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pgcron), a simple cron-based job scheduler for PostgreSQL that runs inside the database. +The hosted Supabase Platform supports the [`pg_cron` extension](/docs/guides/database/extensions/pgcron), a recurring job scheduler in Postgres. In combination with the [`pg_net` extension](/docs/guides/database/extensions/pgnet), this allows us to invoke Edge Functions periodically on a set schedule. diff --git a/apps/docs/content/guides/integrations/vercel-marketplace.mdx b/apps/docs/content/guides/integrations/vercel-marketplace.mdx index be0ef42121c49..ec92cffa11c6c 100644 --- a/apps/docs/content/guides/integrations/vercel-marketplace.mdx +++ b/apps/docs/content/guides/integrations/vercel-marketplace.mdx @@ -10,7 +10,7 @@ The Vercel Marketplace is a feature that allows you to manage third-party resour When you create an organization and projects through Vercel Marketplace, they function just like those created directly within Supabase. However, the billing is handled through your Vercel account, and you can manage your resources directly from the Vercel dashboard or CLI. Additionally, environment variables are automatically synchronized, making them immediately available for your connected projects. -For more information, see [Introducing the Vercel Marketplace](https://vercel.com/blog/share/introducing-the-vercel-marketplace) blog post. +For more information, see [Introducing the Vercel Marketplace](https://vercel.com/blog/introducing-the-vercel-marketplace) blog post. diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index d21e7fe6e9eb3..aaef7455e70e8 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -1,7 +1,6 @@ import matter from 'gray-matter' import { fromMarkdown } from 'mdast-util-from-markdown' -import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm' -import { toMarkdown } from 'mdast-util-to-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import { type Metadata, type ResolvingMetadata } from 'next' import { notFound } from 'next/navigation' @@ -26,6 +25,7 @@ const PUBLISHED_SECTIONS = [ 'ai', 'api', 'auth', + 'cron', 'database', 'deployment', 'functions', diff --git a/apps/docs/layouts/MainSkeleton.tsx b/apps/docs/layouts/MainSkeleton.tsx index 9e116e883c9a7..dbee0c2a53808 100644 --- a/apps/docs/layouts/MainSkeleton.tsx +++ b/apps/docs/layouts/MainSkeleton.tsx @@ -30,6 +30,10 @@ const levelsData = { icon: 'database', name: 'Database', }, + cron: { + icon: 'cron', + name: 'Cron', + }, api: { icon: 'rest', name: 'REST API', diff --git a/apps/docs/public/img/guides/database/cron/cron-create.png b/apps/docs/public/img/guides/database/cron/cron-create.png new file mode 100644 index 0000000000000..0751b5c6186ac Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-create.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-edit.png b/apps/docs/public/img/guides/database/cron/cron-edit.png new file mode 100644 index 0000000000000..632d4692a0e27 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-edit.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-history.png b/apps/docs/public/img/guides/database/cron/cron-history.png new file mode 100644 index 0000000000000..d65ef1f9a78a5 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-history.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-toggle.png b/apps/docs/public/img/guides/database/cron/cron-toggle.png new file mode 100644 index 0000000000000..54a7c4a4936bc Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-toggle.png differ diff --git a/apps/docs/public/img/guides/database/cron/cron-unschedule.png b/apps/docs/public/img/guides/database/cron/cron-unschedule.png new file mode 100644 index 0000000000000..10c6bb7275bf6 Binary files /dev/null and b/apps/docs/public/img/guides/database/cron/cron-unschedule.png differ diff --git a/apps/docs/spec/cli_v1_config.yaml b/apps/docs/spec/cli_v1_config.yaml index d6c2cbb2d1a64..2fbdd858fab61 100644 --- a/apps/docs/spec/cli_v1_config.yaml +++ b/apps/docs/spec/cli_v1_config.yaml @@ -1142,6 +1142,39 @@ parameters: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' + - id: 'auth.hook..enabled' + title: 'auth.hook..enabled' + tags: ['auth'] + required: false + default: 'false' + description: | + Enable Auth Hook. Possible values for `hook_name` are: `custom_access_token`, `send_sms`, `send_email`, `mfa_verification_attempt`, and `password_verification_attempt`. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks' + + - id: 'auth.hook..uri' + title: 'auth.hook..uri' + tags: ['auth'] + required: false + default: '' + description: | + URI of hook to invoke. Should be a http or https function or Postgres function taking the form: `pg-functions:////`. For example, `pg-functions://postgres/auth/custom-access-token-hook`. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks' + + - id: 'auth.hook..secrets' + title: 'auth.hook..secrets' + tags: ['auth'] + required: false + default: '' + description: | + Configure when using a HTTP Hooks. Takes a list of base64 comma separated values to allow for secret rotation. Currently, Supabase Auth uses only the first value in the list. + links: + - name: 'Auth Hooks' + link: 'https://supabase.com/docs/guides/auth/auth-hooks?queryGroups=language&language=http' + - id: 'auth.mfa.totp.enroll_enabled' title: 'auth.mfa.totp.enroll_enabled' tags: ['auth'] @@ -1254,6 +1287,28 @@ parameters: - name: 'Auth Multi-Factor Authentication' link: 'https://supabase.com/docs/guides/auth/auth-mfa' + - id: 'auth.sessions.timebox' + title: 'auth.sessions.timebox' + tags: ['auth'] + required: false + default: '' + description: | + Force log out after the specified duration. Sample values include: '50m', '20h'. + links: + - name: 'Auth Sessions' + link: 'https://supabase.com/docs/guides/auth/sessions' + + - id: 'auth.sessions.inactivity_timeout' + title: 'auth.sessions.inactivity_timeout' + tags: ['auth'] + required: false + default: '' + description: | + Force log out if the user has been inactive longer than the specified duration. Sample values include: '50m', '20h'. + links: + - name: 'Auth Sessions' + link: 'https://supabase.com/docs/guides/auth/sessions' + - id: 'auth.third_party.aws_cognito.enabled' title: 'auth.third_party.aws_cognito.enabled' tags: ['auth'] diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index a81f971a346ef..eded06a85da43 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -6,7 +6,6 @@ import { useIsLoggedIn, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' -import { useFlag } from 'hooks/ui/useFlag' import { DEFAULT_HOME, IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' @@ -14,7 +13,6 @@ import { useAppStateSnapshot } from 'state/app-state' const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const router = useRouter() const { ref, slug, id } = useParams() - const navLayoutV2 = useFlag('navigationLayoutV2') const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() @@ -56,7 +54,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (!isValidOrg) { toast.error('This organization does not exist') - router.push(navLayoutV2 ? `/org/${organizations[0].slug}` : DEFAULT_HOME) + router.push(DEFAULT_HOME) return } } @@ -81,7 +79,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (!isValidProject && !isValidBranch) { toast.error('This project does not exist') - router.push(navLayoutV2 ? `/org/${organizations?.[0].slug}` : DEFAULT_HOME) + router.push(DEFAULT_HOME) return } } diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx deleted file mode 100644 index 29545a60c9242..0000000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyChat.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { compact, last } from 'lodash' -import { ChevronsUpDown, Lightbulb } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' - -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { SchemaComboBox } from 'components/ui/SchemaComboBox' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { IS_PLATFORM } from 'lib/constants' -import { useProfile } from 'lib/profile' -import { - AiIconAnimation, - Button, - Tooltip_Shadcn_, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, -} from 'ui' -import { AssistantChatForm } from 'ui-patterns/AssistantChat' -import { MessageWithDebug } from './AIPolicyEditorPanel.utils' -import Message from './Message' - -interface AIPolicyChatProps { - selectedTable: string - messages: MessageWithDebug[] - selectedMessage?: string - loading: boolean - onSubmit: (s: string) => void - onDiff: (message: { id: string; content: string }) => void - clearHistory?: () => void -} - -export const AIPolicyChat = ({ - selectedTable, - messages, - selectedMessage, - loading, - onSubmit, - onDiff, - clearHistory, -}: AIPolicyChatProps) => { - const { profile } = useProfile() - const project = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const bottomRef = useRef(null) - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - const [value, setValue] = useState('') - const [hasSuggested, setHasSuggested] = useState(false) - const inputRef = useRef(null) - - const isOptedInToAI = useOrgOptedIntoAi() - const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM - - const name = compact([profile?.first_name, profile?.last_name]).join(' ') - const pendingReply = loading && last(messages)?.role === 'user' - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!loading) { - setValue('') - if (inputRef.current) inputRef.current.focus() - } - - // Try to scroll on each rerender to the bottom - setTimeout( - () => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, - loading ? 100 : 500 - ) - }, [loading]) - - return ( -
-
-
- {messages.length > 0 && ( - - )} -
- - {includeSchemaMetadata ? ( -
- - Include these schemas in your prompts: - - 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - /> -
- ) : ( - } - tooltip={{ - content: { - side: 'bottom', - className: 'w-72', - text: ( - <> - Opt in to sending anonymous data to OpenAI in your{' '} - - organization settings - {' '} - to share schemas with the Assistant for more accurate responses. - - ), - }, - }} - > - No schemas selected - - )} -
- {messages.map((m) => ( - onDiff({ id: m.id, content })} - /> - ))} - {pendingReply && } -
-
- {!hasSuggested && ( -
- - - - - - Suggest policies for this table - - -
- )} -
- - } - placeholder="Ask for help with your RLS policies" - value={value} - onValueChange={(e) => setValue(e.target.value)} - onSubmit={(event) => { - event.preventDefault() - onSubmit(value) - sendEvent({ - category: 'rls_editor', - action: 'ai_suggestion_asked', - label: 'rls-ai-assistant', - }) - }} - /> -
-
- ) -} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx deleted file mode 100644 index b6c88f2d44f2d..0000000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import * as Tooltip from '@radix-ui/react-tooltip' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { Check, Copy, FileDiff } from 'lucide-react' -import { useEffect, useState } from 'react' -import { format } from 'sql-formatter' -import { Button, CodeBlock, cn } from 'ui' - -interface AAIPolicyPreProps { - onDiff: (s: string) => void - children: string[] - className?: string -} - -export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps) => { - const [copied, setCopied] = useState(false) - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!copied) return - const timer = setTimeout(() => setCopied(false), 2000) - return () => clearTimeout(timer) - }, [copied]) - - let formatted = (children || [''])[0] - try { - formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) - } catch {} - - if (formatted.length === 0) { - return null - } - - function handleCopy(formatted: string) { - navigator.clipboard.writeText(formatted).then() - setCopied(true) - } - - return ( -
-      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
-        )}
-        hideCopy
-        hideLineNumbers
-      />
-      
- - - - - - - -
- Apply changes -
-
-
-
- - - - - - - -
- Copy code -
-
-
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx deleted file mode 100644 index de1f663d79686..0000000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/Message.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import dayjs from 'dayjs' -import { noop } from 'lodash' -import Image from 'next/image' -import { PropsWithChildren, memo, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui' - -import { useProfile } from 'lib/profile' -import { AIPolicyPre } from './AIPolicyPre' - -interface MessageProps { - name?: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string - createdAt?: number - isDebug?: boolean - isSelected?: boolean - onDiff?: (s: string) => void -} - -const Message = memo(function Message({ - name, - role, - content, - createdAt, - isDebug, - isSelected, - onDiff = noop, - children, -}: PropsWithChildren) { - const { profile } = useProfile() - - const icon = useMemo(() => { - return role === 'assistant' ? ( -
- div>div]:border-background')} - /> -
- ) : ( -
- {/* // TODO: this only works for GitHub profiles */} - avatar -
- ) - }, [content, profile?.username, role]) - - if (!content) return null - - return ( -
-
- {icon} - - {role === 'assistant' ? 'Assistant' : name ? name : 'You'} - - {createdAt && ( - {dayjs(createdAt).fromNow()} - )} - {isDebug && Debug request} -
- { - return ( - div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - )} - > - {props.children[0].props.children} - - ) - }, - }} - > - {content} - - {children} -
- ) -}) - -export default Message diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx deleted file mode 100644 index 62dfc833efb73..0000000000000 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ /dev/null @@ -1,943 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import { Monaco } from '@monaco-editor/react' -import type { PostgresPolicy } from '@supabase/postgres-meta' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import { isEqual, uniqBy } from 'lodash' -import { FileDiff } from 'lucide-react' -import dynamic from 'next/dynamic' -import { useQueryState } from 'nuqs' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - -import { useChat } from 'ai/react' -import { IS_PLATFORM, useParams } from 'common' -import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' -import { - IStandaloneCodeEditor, - IStandaloneDiffEditor, -} from 'components/interfaces/SQLEditor/SQLEditor.types' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' -import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' -import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' -import { databasePoliciesKeys } from 'data/database-policies/keys' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH } from 'lib/constants' -import { uuidv4 } from 'lib/helpers' -import { - Button, - Checkbox_Shadcn_, - Form_Shadcn_, - Label_Shadcn_, - ScrollArea, - Sheet, - SheetContent, - SheetFooter, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { AIPolicyChat } from './AIPolicyChat' -import { - MessageWithDebug, - checkIfPolicyHasChanged, - generateCreatePolicyQuery, - generatePlaceholder, - generatePolicyDefinition, - generateThreadMessage, -} from './AIPolicyEditorPanel.utils' -import { AIPolicyHeader } from './AIPolicyHeader' -import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' -import { PolicyDetailsV2 } from './PolicyDetailsV2' -import { PolicyTemplates } from './PolicyTemplates' -import QueryError from './QueryError' -import RLSCodeEditor from './RLSCodeEditor' - -const DiffEditor = dynamic( - () => import('@monaco-editor/react').then(({ DiffEditor }) => DiffEditor), - { ssr: false } -) - -interface AIPolicyEditorPanelProps { - visible: boolean - schema: string - searchString?: string - selectedTable?: string - selectedPolicy?: PostgresPolicy - onSelectCancel: () => void - authContext: 'database' | 'realtime' -} - -/** - * Using memo for this component because everything rerenders on window focus because of outside fetches - */ -export const AIPolicyEditorPanel = memo(function ({ - visible, - schema, - searchString, - selectedTable, - selectedPolicy, - onSelectCancel, - authContext, -}: AIPolicyEditorPanelProps) { - const { ref } = useParams() - const queryClient = useQueryClient() - const selectedProject = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - - const [editView, setEditView] = useQueryState('view', { defaultValue: 'templates' as const }) - - // [Joshen] Hyrid form fields, just spit balling to get a decent POC out - const [using, setUsing] = useState('') - const [check, setCheck] = useState('') - const [fieldError, setFieldError] = useState() - const [showCheckBlock, setShowCheckBlock] = useState(true) - - const monacoOneRef = useRef(null) - const editorOneRef = useRef(null) - const [expOneLineCount, setExpOneLineCount] = useState(1) - const [expOneContentHeight, setExpOneContentHeight] = useState(0) - - const monacoTwoRef = useRef(null) - const editorTwoRef = useRef(null) - const [expTwoLineCount, setExpTwoLineCount] = useState(1) - const [expTwoContentHeight, setExpTwoContentHeight] = useState(0) - - // Use chat id because useChat doesn't have a reset function to clear all messages - const [chatId, setChatId] = useState(uuidv4()) - const [tabId, setTabId] = useState<'templates' | 'conversation'>( - editView as 'templates' | 'conversation' - ) - - const diffEditorRef = useRef(null) - const placeholder = generatePlaceholder(selectedPolicy) - const isOptedInToAI = useOrgOptedIntoAi() - - const [error, setError] = useState() - const [errorPanelOpen, setErrorPanelOpen] = useState(true) - const [showDetails, setShowDetails] = useState(false) - const [selectedDiff, setSelectedDiff] = useState() - // [Joshen] Separate state here as there's a delay between submitting and the API updating the loading status - const [debugThread, setDebugThread] = useState([]) - const [assistantVisible, setAssistantPanel] = useState(false) - const [incomingChange, setIncomingChange] = useState() - // Used for confirmation when closing the panel with unsaved changes - const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) - - const formId = 'rls-editor' - const FormSchema = z.object({ - name: z.string().min(1, 'Please provide a name'), - table: z.string(), - behavior: z.string(), - command: z.string(), - roles: z.string(), - }) - const defaultValues = { - name: '', - table: '', - behavior: 'permissive', - command: 'select', - roles: '', - } - const form = useForm>({ - mode: 'onBlur', - reValidateMode: 'onBlur', - resolver: zodResolver(FormSchema), - defaultValues, - }) - - // Customers on HIPAA plans should not have access to Supabase AI - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - - const [selectedSchemas] = useSchemasForAi(selectedProject?.ref!) - - // get entitiy (tables/views/materialized views/etc) definitions to pass to AI Assistant - const { data: entities } = useEntityDefinitionsQuery( - { - schemas: selectedSchemas, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - }, - { enabled: true, refetchOnWindowFocus: false } - ) - const entityDefinitions = entities?.map((def) => def.sql.trim()) - - const { name, table, behavior, command, roles } = form.watch() - const supportWithCheck = ['update', 'all'].includes(command) - const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name - - const { project } = useProjectContext() - const { data } = useDatabasePoliciesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const existingPolicies = (data ?? []) - .filter((policy) => policy.schema === schema && policy.table === selectedTable) - .sort((a, b) => a.name.localeCompare(b.name)) - - const existingPolicyDefinition = existingPolicies - .map((policy) => { - const definition = generatePolicyDefinition(policy) - return ( - definition - .trim() - .replace(/\s*;\s*$/, '') - .replace(/\n\s*$/, '') + ';' - ) - }) - .join('\n\n') - - const { - messages: chatMessages, - append, - isLoading, - } = useChat({ - id: chatId, - api: `${BASE_PATH}/api/ai/sql/suggest`, - body: { - entityDefinitions: isOptedInToAI || !IS_PLATFORM ? entityDefinitions : undefined, - existingPolicies, - policyDefinition: - selectedPolicy !== undefined ? generatePolicyDefinition(selectedPolicy) : undefined, - }, - }) - - const messages = useMemo(() => { - const merged = [...debugThread, ...chatMessages.map((m) => ({ ...m, isDebug: false }))] - - return merged.sort( - (a, b) => - (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || - a.role.localeCompare(b.role) - ) - }, [chatMessages, debugThread]) - - const { mutate: sendEvent } = useSendEventMutation() - - const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ - onSuccess: async () => { - // refresh all policies - await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) - toast.success('Successfully created new policy') - onSelectCancel() - }, - onError: (error) => setError(error), - }) - - const { mutate: updatePolicy, isLoading: isUpdating } = useDatabasePolicyUpdateMutation({ - onSuccess: () => { - toast.success('Successfully updated policy') - onSelectCancel() - }, - }) - - const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation() - - const acceptChange = useCallback(async () => { - if (!incomingChange || !editorOneRef.current || !diffEditorRef.current) return - - const editorModel = editorOneRef.current.getModel() - const diffModel = diffEditorRef.current.getModel() - if (!editorModel || !diffModel) return - - const sql = diffModel.modified.getValue() - - // apply the incoming change in the editor directly so that Undo/Redo work properly - editorOneRef.current.executeEdits('apply-ai-edit', [ - { text: sql, range: editorModel.getFullModelRange() }, - ]) - - // remove the incoming change to revert to the original editor - setIncomingChange(undefined) - }, [incomingChange]) - - const onClosingPanel = () => { - const editorOneValue = editorOneRef.current?.getValue().trim() ?? null - const editorOneFormattedValue = !editorOneValue ? null : editorOneValue - const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null - const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue - - const policyCreateUnsaved = - selectedPolicy === undefined && - (name.length > 0 || roles.length > 0 || editorOneFormattedValue || editorTwoFormattedValue) - const policyUpdateUnsaved = - selectedPolicy !== undefined - ? checkIfPolicyHasChanged(selectedPolicy, { - name, - roles: roles.length === 0 ? ['public'] : roles.split(', '), - definition: editorOneFormattedValue, - check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, - }) - : false - - if (policyCreateUnsaved || policyUpdateUnsaved || messages.length > 0) { - setIsClosingPolicyEditorPanel(true) - } else { - onSelectCancel() - setEditView(null) - } - } - - const onSelectDebug = async () => { - const policy = editorOneRef.current?.getValue().replaceAll('\n', ' ').replaceAll(' ', ' ') - if (error === undefined || policy === undefined) return - - setAssistantPanel(true) - const messageId = uuidv4() - - const assistantMessageBefore = generateThreadMessage({ - id: messageId, - content: 'Thinking...', - isDebug: true, - }) - setDebugThread([...debugThread, assistantMessageBefore]) - - const { solution, sql } = await debugSql({ - sql: policy.trim(), - errorMessage: error.message, - entityDefinitions, - }) - - const assistantMessageAfter = generateThreadMessage({ - id: messageId, - content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, - isDebug: true, - }) - const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) - - setDebugThread(cleanedMessages) - } - - const updateEditorWithCheckForDiff = (value: { id: string; content: string }) => { - const editorModel = editorOneRef.current?.getModel() - if (!editorModel) return - - const existingValue = editorOneRef.current?.getValue() ?? '' - if (existingValue.length === 0) { - editorOneRef.current?.executeEdits('apply-template', [ - { - text: value.content, - range: editorModel.getFullModelRange(), - }, - ]) - } else { - setSelectedDiff(value.id) - setIncomingChange(value.content) - } - } - - const onSubmit = (data: z.infer) => { - const { name, table, behavior, command, roles } = data - let using = editorOneRef.current?.getValue().trim() ?? undefined - let check = editorTwoRef.current?.getValue().trim() - - // [Terry] b/c editorOneRef will be the check statement in this scenario - if (command === 'insert') { - check = using - } - - if (command === 'insert' && (check === undefined || check.length === 0)) { - return setFieldError('Please provide a SQL expression for the WITH CHECK statement') - } else if (command !== 'insert' && (using === undefined || using.length === 0)) { - return setFieldError('Please provide a SQL expression for the USING statement') - } else { - setFieldError(undefined) - } - - if (selectedPolicy === undefined) { - const sql = generateCreatePolicyQuery({ - name: name, - schema, - table, - behavior, - command, - roles: roles.length === 0 ? 'public' : roles, - using: using ?? '', - check: command === 'insert' ? using ?? '' : check ?? '', - }) - - setError(undefined) - executeMutation({ - sql, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - handleError: (error) => { - throw error - }, - }) - } else if (selectedProject !== undefined) { - const payload: { - name?: string - definition?: string - check?: string - roles?: string[] - } = {} - const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') - - if (name !== selectedPolicy.name) payload.name = name - if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles - if (selectedPolicy.definition !== null && selectedPolicy.definition !== using) - payload.definition = using - - if (selectedPolicy.command === 'INSERT') { - // [Joshen] Cause editorOneRef will be the check statement in this scenario - if (selectedPolicy.check !== null && selectedPolicy.check !== using) payload.check = using - } else { - if (selectedPolicy.check !== null && selectedPolicy.check !== check) payload.check = check - } - - if (Object.keys(payload).length === 0) return onSelectCancel() - - updatePolicy({ - id: selectedPolicy.id, - projectRef: selectedProject.ref, - connectionString: selectedProject?.connectionString, - payload, - }) - } - } - - const clearHistory = () => { - setChatId(uuidv4()) - } - - // when the panel is closed, reset all values - useEffect(() => { - if (!visible) { - editorOneRef.current?.setValue('') - editorTwoRef.current?.setValue('') - setIncomingChange(undefined) - setAssistantPanel(false) - setIsClosingPolicyEditorPanel(false) - setError(undefined) - setDebugThread([]) - setChatId(uuidv4()) - setShowDetails(false) - setSelectedDiff(undefined) - - setUsing('') - setCheck('') - setShowCheckBlock(false) - setFieldError(undefined) - - form.reset(defaultValues) - } else { - if (canUpdatePolicies) setAssistantPanel(true) - if (selectedPolicy !== undefined) { - const { name, action, table, command, roles } = selectedPolicy - form.reset({ - name, - table, - behavior: action.toLowerCase(), - command: command.toLowerCase(), - roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '), - }) - if (selectedPolicy.definition) setUsing(` ${selectedPolicy.definition}`) - if (selectedPolicy.check) setCheck(` ${selectedPolicy.check}`) - if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') { - setShowCheckBlock(true) - } - } else if (selectedTable !== undefined) { - form.reset({ ...defaultValues, table: selectedTable }) - } - } - }, [visible]) - - // whenever the deps (current policy details, new error or error panel opens) change, recalculate - // the height of the editor - useEffect(() => { - editorOneRef.current?.layout({ width: 0, height: 0 }) - window.requestAnimationFrame(() => { - editorOneRef.current?.layout() - }) - }, [showDetails, error, errorPanelOpen]) - - // update tabId when the editView changes - useEffect(() => { - editView === 'conversation' ? setTabId('conversation') : setTabId('templates') - }, [editView]) - - return ( - <> - -
- onClosingPanel()}> - -
- - -
- {incomingChange ? ( -
-
- - Accept changes from assistant -
-
- - -
-
- ) : null} - - {incomingChange ? ( - (diffEditorRef.current = editor)} - options={{ - wordWrap: 'on', - renderSideBySide: false, - scrollBeyondLastLine: false, - renderOverviewRuler: false, - renderLineHighlight: 'none', - minimap: { enabled: false }, - occurrencesHighlight: false, - folding: false, - selectionHighlight: false, - lineHeight: 20, - padding: { top: 10, bottom: 10 }, - }} - /> - ) : null} - - {/* which left side editor to show in the sheet */} - {editView === 'conversation' ? ( -
- -
- ) : ( - <> - { - setFieldError(undefined) - if (!['update', 'all'].includes(command)) { - setShowCheckBlock(false) - } else { - setShowCheckBlock(true) - } - }} - authContext={authContext} - /> -
- - -
- { - setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) - setExpOneLineCount( - editorOneRef.current?.getModel()?.getLineCount() ?? 1 - ) - }} - onMount={() => { - setTimeout(() => { - setExpOneContentHeight( - editorOneRef.current?.getContentHeight() ?? 0 - ) - setExpOneLineCount( - editorOneRef.current?.getModel()?.getLineCount() ?? 1 - ) - }, 200) - }} - /> -
- -
-
-
-

- {7 + expOneLineCount} -

-
-

- {showCheckBlock ? ( - <> - with check{' '} - ( - - ) : ( - <> - ); - - )} -

-
-
- - {showCheckBlock && ( - <> -
- { - setExpTwoContentHeight( - editorTwoRef.current?.getContentHeight() ?? 0 - ) - setExpTwoLineCount( - editorTwoRef.current?.getModel()?.getLineCount() ?? 1 - ) - }} - onMount={() => { - setTimeout(() => { - setExpTwoContentHeight( - editorTwoRef.current?.getContentHeight() ?? 0 - ) - setExpTwoLineCount( - editorTwoRef.current?.getModel()?.getLineCount() ?? 1 - ) - }, 200) - }} - /> -
-
-
-
-

- {8 + expOneLineCount + expTwoLineCount} -

-
-

- ); -

-
-
- - )} - - {isRenamingPolicy && ( - - )} - - {fieldError !== undefined && ( -

{fieldError}

- )} - - {supportWithCheck && ( -
- { - setFieldError(undefined) - setShowCheckBlock(!showCheckBlock) - }} - /> - - Use check expression - -
- )} -
- - )} - -
- {error !== undefined && ( - { - setTabId('conversation') - onSelectDebug() - }} - open={errorPanelOpen} - setOpen={setErrorPanelOpen} - /> - )} - - - - { - if (editView === 'conversation') { - const sql = editorOneRef.current?.getValue().trim() - if (!sql) return onSelectCancel() - executeMutation({ - sql: sql, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - handleError: (error) => { - throw error - }, - }) - } - }} - tooltip={{ - content: { - side: 'top', - text: !canUpdatePolicies - ? 'You need additional permissions to update policies' - : undefined, - }, - }} - > - Save policy - - -
-
-
- {assistantVisible && ( -
- - - {editView === 'templates' && ( - setTabId('templates')} - className="px-0 data-[state=active]:bg-transparent" - > - Templates - - )} - - {editView === 'conversation' && !hasHipaaAddon && ( - setTabId('conversation')} - className="px-0 data-[state=active]:bg-transparent" - > - Assistant - - )} - - - {editView === 'templates' && ( - - - { - form.setValue('name', value.name) - form.setValue('behavior', 'permissive') - form.setValue('command', value.command.toLowerCase()) - form.setValue('roles', value.roles.join(', ') ?? '') - - setUsing(` ${value.definition}`) - setCheck(` ${value.check}`) - setExpOneLineCount(1) - setExpTwoLineCount(1) - setFieldError(undefined) - - if (!['update', 'all'].includes(value.command.toLowerCase())) { - setShowCheckBlock(false) - } else if (value.check.length > 0) { - setShowCheckBlock(true) - } else { - setShowCheckBlock(false) - } - }} - /> - - - )} - - - - append({ - content: message, - role: 'user', - createdAt: new Date(), - }) - } - onDiff={updateEditorWithCheckForDiff} - loading={isLoading || isDebugSqlLoading} - clearHistory={clearHistory} - /> - - -
- )} -
-
-
-
- - { - setIsClosingPolicyEditorPanel(false) - }} - onConfirm={() => { - onSelectCancel() - setIsClosingPolicyEditorPanel(false) - setEditView(null) - }} - > -

- Are you sure you want to close the editor? Any unsaved changes on your policy and - conversations with the Assistant will be lost. -

-
- - ) -}) - -AIPolicyEditorPanel.displayName = 'AIPolicyEditorPanel' diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/LockedQuerySection.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyDetailsV2.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts similarity index 63% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts index 937eaaa4f1db9..ba3c1e73b1062 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts @@ -1,30 +1,7 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { isEqual } from 'lodash' -import type { Message } from 'ai/react' -import { uuidv4 } from 'lib/helpers' - -export type MessageWithDebug = Message & { isDebug: boolean } - -export const generateThreadMessage = ({ - id, - content, - isDebug, -}: { - id?: string - content: string - isDebug: boolean -}) => { - const message: MessageWithDebug = { - id: id ?? uuidv4(), - role: 'assistant', - content, - createdAt: new Date(), - isDebug: isDebug, - } - return message -} - +// [Joshen] Not used but keeping this for now in case we do an inline editor export const generatePlaceholder = (policy?: PostgresPolicy) => { if (policy === undefined) { return ` @@ -69,17 +46,6 @@ COMMIT; } } -export const generatePolicyDefinition = (policy: PostgresPolicy) => { - return ` -CREATE POLICY "${policy.name}" on "${policy.schema}"."${policy.table}" -AS ${policy.action} FOR ${policy.command} -TO ${policy.roles.join(', ')} -${policy.definition ? `USING (${policy.definition})` : ''} -${policy.check ? `WITH CHECK (${policy.check})` : ''} -; -`.trim() -} - export const generateCreatePolicyQuery = ({ name, schema, @@ -107,34 +73,6 @@ export const generateCreatePolicyQuery = ({ return query } -export const generateAlterPolicyQuery = ({ - name, - newName, - schema, - table, - command, - roles, - using, - check, -}: { - name: string - newName: string - schema: string - table: string - command: string - roles: string - using: string - check: string -}) => { - const querySkeleton = `alter policy "${name}" on "${schema}"."${table}" to ${roles}` - const query = - command === 'insert' - ? `${querySkeleton} with check (${check});` - : `${querySkeleton} using (${using})${(check ?? '').length > 0 ? `with check (${check});` : ';'}` - if (newName === name) return query - else return `${query}\n${querySkeleton} rename to "${newName}"` -} - export const checkIfPolicyHasChanged = ( selectedPolicy: PostgresPolicy, policyForm: { diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx similarity index 92% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx index 810569287951b..a35fc09846f63 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx @@ -15,14 +15,14 @@ import { cn, } from 'ui' -export const AIPolicyHeader = ({ +export const PolicyEditorPanelHeader = ({ selectedPolicy, - assistantVisible, - setAssistantVisible, + showTools, + setShowTools, }: { selectedPolicy?: PostgresPolicy - assistantVisible: boolean - setAssistantVisible: (v: boolean) => void + showTools: boolean + setShowTools: (v: boolean) => void }) => { const [showDetails, setShowDetails] = useState(false) @@ -106,15 +106,15 @@ export const AIPolicyHeader = ({ - {assistantVisible ? 'Hide' : 'Show'} tools + {showTools ? 'Hide' : 'Show'} tools diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx similarity index 100% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx similarity index 68% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx index 0ac3fa00896d8..ba1a1e5128d51 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/QueryError.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/QueryError.tsx @@ -2,11 +2,7 @@ import { initial, last } from 'lodash' import { Dispatch, SetStateAction } from 'react' import styles from '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css' -import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { QueryResponseError } from 'data/sql/execute-sql-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertTitle_Shadcn_, Alert_Shadcn_, @@ -17,24 +13,15 @@ import { cn, } from 'ui' -const QueryError = ({ +export const QueryError = ({ error, open, setOpen, - onSelectDebug, }: { error: QueryResponseError open: boolean setOpen: Dispatch> - onSelectDebug: () => void }) => { - // Customers on HIPAA plans should not have access to Supabase AI - const organization = useSelectedOrganization() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - - const { mutate: sendEvent } = useSendEventMutation() - const formattedError = (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] @@ -72,27 +59,6 @@ const QueryError = ({ {open ? 'Hide error details' : 'Show error details'} - {/* [Joshen] Temp hidden as new assistant doesnt support this. Current assistant's UX is not great too tbh so okay to hide this */} - {/* {!hasHipaaAddon && ( - - )} */}
{formattedError.length > 0 ? ( @@ -137,5 +103,3 @@ const QueryError = ({
) } - -export default QueryError diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx similarity index 97% rename from apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx rename to apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx index e27f8699a4d98..fdaec522c6bc4 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx @@ -29,10 +29,9 @@ interface RLSCodeEditorProps { editorRef: MutableRefObject monacoRef?: MutableRefObject - editView: 'templates' | 'conversation' // someone help } -const RLSCodeEditor = ({ +export const RLSCodeEditor = ({ id, defaultValue, wrapperClassName, @@ -48,7 +47,6 @@ const RLSCodeEditor = ({ editorRef, monacoRef, - editView, }: RLSCodeEditorProps) => { const hasValue = useRef() const monaco = useMonaco() @@ -199,5 +197,3 @@ const RLSCodeEditor = ({ ) } - -export default RLSCodeEditor diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx new file mode 100644 index 0000000000000..4ee9c0d21d673 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx @@ -0,0 +1,585 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Monaco } from '@monaco-editor/react' +import type { PostgresPolicy } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { isEqual } from 'lodash' +import { memo, useEffect, useRef, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { + Button, + Checkbox_Shadcn_, + Form_Shadcn_, + Label_Shadcn_, + ScrollArea, + Sheet, + SheetContent, + SheetFooter, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tabs_Shadcn_, + cn, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils' +import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection' +import { PolicyDetailsV2 } from './PolicyDetailsV2' +import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader' +import { PolicyTemplates } from './PolicyTemplates' +import { QueryError } from './QueryError' +import { RLSCodeEditor } from './RLSCodeEditor' + +interface PolicyEditorPanelProps { + visible: boolean + schema: string + searchString?: string + selectedTable?: string + selectedPolicy?: PostgresPolicy + onSelectCancel: () => void + authContext: 'database' | 'realtime' +} + +/** + * Using memo for this component because everything rerenders on window focus because of outside fetches + */ +export const PolicyEditorPanel = memo(function ({ + visible, + schema, + searchString, + selectedTable, + selectedPolicy, + onSelectCancel, + authContext, +}: PolicyEditorPanelProps) { + const { ref } = useParams() + const queryClient = useQueryClient() + const selectedProject = useSelectedProject() + + const canUpdatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + + // [Joshen] Hyrid form fields, just spit balling to get a decent POC out + const [using, setUsing] = useState('') + const [check, setCheck] = useState('') + const [fieldError, setFieldError] = useState() + const [showCheckBlock, setShowCheckBlock] = useState(true) + + const monacoOneRef = useRef(null) + const editorOneRef = useRef(null) + const [expOneLineCount, setExpOneLineCount] = useState(1) + const [expOneContentHeight, setExpOneContentHeight] = useState(0) + + const monacoTwoRef = useRef(null) + const editorTwoRef = useRef(null) + const [expTwoLineCount, setExpTwoLineCount] = useState(1) + const [expTwoContentHeight, setExpTwoContentHeight] = useState(0) + + const [error, setError] = useState() + const [errorPanelOpen, setErrorPanelOpen] = useState(true) + const [showDetails, setShowDetails] = useState(false) + const [selectedDiff, setSelectedDiff] = useState() + + const [showTools, setShowTools] = useState(false) + const [isClosingPolicyEditorPanel, setIsClosingPolicyEditorPanel] = useState(false) + + const formId = 'rls-editor' + const FormSchema = z.object({ + name: z.string().min(1, 'Please provide a name'), + table: z.string(), + behavior: z.string(), + command: z.string(), + roles: z.string(), + }) + const defaultValues = { + name: '', + table: '', + behavior: 'permissive', + command: 'select', + roles: '', + } + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(FormSchema), + defaultValues, + }) + + const { name, table, behavior, command, roles } = form.watch() + const supportWithCheck = ['update', 'all'].includes(command) + const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name + + const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async () => { + // refresh all policies + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + toast.success('Successfully created new policy') + onSelectCancel() + }, + onError: (error) => setError(error), + }) + + const { mutate: updatePolicy, isLoading: isUpdating } = useDatabasePolicyUpdateMutation({ + onSuccess: () => { + toast.success('Successfully updated policy') + onSelectCancel() + }, + }) + + const onClosingPanel = () => { + const editorOneValue = editorOneRef.current?.getValue().trim() ?? null + const editorOneFormattedValue = !editorOneValue ? null : editorOneValue + const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null + const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue + + const policyCreateUnsaved = + selectedPolicy === undefined && + (name.length > 0 || roles.length > 0 || editorOneFormattedValue || editorTwoFormattedValue) + const policyUpdateUnsaved = + selectedPolicy !== undefined + ? checkIfPolicyHasChanged(selectedPolicy, { + name, + roles: roles.length === 0 ? ['public'] : roles.split(', '), + definition: editorOneFormattedValue, + check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue, + }) + : false + + if (policyCreateUnsaved || policyUpdateUnsaved) { + setIsClosingPolicyEditorPanel(true) + } else { + onSelectCancel() + } + } + + const onSubmit = (data: z.infer) => { + const { name, table, behavior, command, roles } = data + let using = editorOneRef.current?.getValue().trim() ?? undefined + let check = editorTwoRef.current?.getValue().trim() + + // [Terry] b/c editorOneRef will be the check statement in this scenario + if (command === 'insert') { + check = using + } + + if (command === 'insert' && (check === undefined || check.length === 0)) { + return setFieldError('Please provide a SQL expression for the WITH CHECK statement') + } else if (command !== 'insert' && (using === undefined || using.length === 0)) { + return setFieldError('Please provide a SQL expression for the USING statement') + } else { + setFieldError(undefined) + } + + if (selectedPolicy === undefined) { + const sql = generateCreatePolicyQuery({ + name: name, + schema, + table, + behavior, + command, + roles: roles.length === 0 ? 'public' : roles, + using: using ?? '', + check: command === 'insert' ? using ?? '' : check ?? '', + }) + + setError(undefined) + executeMutation({ + sql, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + handleError: (error) => { + throw error + }, + }) + } else if (selectedProject !== undefined) { + const payload: { + name?: string + definition?: string + check?: string + roles?: string[] + } = {} + const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ') + + if (name !== selectedPolicy.name) payload.name = name + if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles + if (selectedPolicy.definition !== null && selectedPolicy.definition !== using) + payload.definition = using + + if (selectedPolicy.command === 'INSERT') { + // [Joshen] Cause editorOneRef will be the check statement in this scenario + if (selectedPolicy.check !== null && selectedPolicy.check !== using) payload.check = using + } else { + if (selectedPolicy.check !== null && selectedPolicy.check !== check) payload.check = check + } + + if (Object.keys(payload).length === 0) return onSelectCancel() + + updatePolicy({ + id: selectedPolicy.id, + projectRef: selectedProject.ref, + connectionString: selectedProject?.connectionString, + payload, + }) + } + } + + // when the panel is closed, reset all values + useEffect(() => { + if (!visible) { + editorOneRef.current?.setValue('') + editorTwoRef.current?.setValue('') + setShowTools(false) + setIsClosingPolicyEditorPanel(false) + setError(undefined) + setShowDetails(false) + setSelectedDiff(undefined) + + setUsing('') + setCheck('') + setShowCheckBlock(false) + setFieldError(undefined) + + form.reset(defaultValues) + } else { + if (canUpdatePolicies) setShowTools(true) + if (selectedPolicy !== undefined) { + const { name, action, table, command, roles } = selectedPolicy + form.reset({ + name, + table, + behavior: action.toLowerCase(), + command: command.toLowerCase(), + roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '), + }) + if (selectedPolicy.definition) setUsing(` ${selectedPolicy.definition}`) + if (selectedPolicy.check) setCheck(` ${selectedPolicy.check}`) + if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') { + setShowCheckBlock(true) + } + } else if (selectedTable !== undefined) { + form.reset({ ...defaultValues, table: selectedTable }) + } + } + }, [visible]) + + // whenever the deps (current policy details, new error or error panel opens) change, recalculate + // the height of the editor + useEffect(() => { + editorOneRef.current?.layout({ width: 0, height: 0 }) + window.requestAnimationFrame(() => { + editorOneRef.current?.layout() + }) + }, [showDetails, error, errorPanelOpen]) + + return ( + <> + +
+ onClosingPanel()}> + +
+ + +
+ { + setFieldError(undefined) + if (!['update', 'all'].includes(command)) { + setShowCheckBlock(false) + } else { + setShowCheckBlock(true) + } + }} + authContext={authContext} + /> +
+ + +
+ { + setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) + setExpOneLineCount(editorOneRef.current?.getModel()?.getLineCount() ?? 1) + }} + onMount={() => { + setTimeout(() => { + setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0) + setExpOneLineCount( + editorOneRef.current?.getModel()?.getLineCount() ?? 1 + ) + }, 200) + }} + /> +
+ +
+
+
+

+ {7 + expOneLineCount} +

+
+

+ {showCheckBlock ? ( + <> + with check{' '} + ( + + ) : ( + <> + ); + + )} +

+
+
+ + {showCheckBlock && ( + <> +
+ { + setExpTwoContentHeight(editorTwoRef.current?.getContentHeight() ?? 0) + setExpTwoLineCount( + editorTwoRef.current?.getModel()?.getLineCount() ?? 1 + ) + }} + onMount={() => { + setTimeout(() => { + setExpTwoContentHeight( + editorTwoRef.current?.getContentHeight() ?? 0 + ) + setExpTwoLineCount( + editorTwoRef.current?.getModel()?.getLineCount() ?? 1 + ) + }, 200) + }} + /> +
+
+
+
+

+ {8 + expOneLineCount + expTwoLineCount} +

+
+

+ ); +

+
+
+ + )} + + {isRenamingPolicy && ( + + )} + + {fieldError !== undefined && ( +

{fieldError}

+ )} + + {supportWithCheck && ( +
+ { + setFieldError(undefined) + setShowCheckBlock(!showCheckBlock) + }} + /> + + Use check expression + +
+ )} +
+ +
+ {error !== undefined && ( + + )} + + + + + Save policy + + +
+
+
+ {showTools && ( +
+ + + + Templates + + + + + + { + form.setValue('name', value.name) + form.setValue('behavior', 'permissive') + form.setValue('command', value.command.toLowerCase()) + form.setValue('roles', value.roles.join(', ') ?? '') + + setUsing(` ${value.definition}`) + setCheck(` ${value.check}`) + setExpOneLineCount(1) + setExpTwoLineCount(1) + setFieldError(undefined) + + if (!['update', 'all'].includes(value.command.toLowerCase())) { + setShowCheckBlock(false) + } else if (value.check.length > 0) { + setShowCheckBlock(true) + } else { + setShowCheckBlock(false) + } + }} + /> + + + +
+ )} +
+
+
+
+ + setIsClosingPolicyEditorPanel(false)} + onConfirm={() => { + onSelectCancel() + setIsClosingPolicyEditorPanel(false) + }} + > +

+ Are you sure you want to close the editor? Any unsaved changes on your policy and + conversations with the Assistant will be lost. +

+
+ + ) +}) + +PolicyEditorPanel.displayName = 'PolicyEditorPanel' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index 8f19dc64f8fc6..1088e079d951d 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -7,12 +7,15 @@ import { toast } from 'sonner' import z from 'zod' import { urlRegex } from 'components/interfaces/Auth/Auth.constants' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseCronJobCreateMutation } from 'data/database-cron-jobs/database-cron-jobs-create-mutation' -import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query' +import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry' import { Button, Form_Shadcn_, @@ -28,13 +31,9 @@ import { SheetTitle, WarningIcon, } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' - -import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry' import { CRONJOB_DEFINITIONS } from './CronJobs.constants' import { buildCronQuery, @@ -172,6 +171,12 @@ export const CreateCronJobSheet = ({ const isEditing = !!selectedCronJob?.jobname const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) + + const { data: cronJobs } = useCronJobsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const { mutate: sendEvent } = useSendEventMutation() const { mutate: upsertCronJob, isLoading } = useDatabaseCronJobCreateMutation() @@ -208,6 +213,18 @@ export const CreateCronJobSheet = ({ } const onSubmit: SubmitHandler = async ({ name, schedule, values }) => { + // job names should be unique + const nameExists = cronJobs?.some( + (job) => job.jobname === name && job.jobname !== selectedCronJob?.jobname + ) + if (nameExists) { + form.setError('name', { + type: 'manual', + message: 'A cron job with this name already exists', + }) + return + } + let command = '' if (values.type === 'edge_function') { command = buildHttpRequestCommand( diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index 040045578a50a..a5245ab1299f5 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -26,7 +26,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { CreateCronJobForm } from './CreateCronJobSheet' -import { getScheduleMessage, secondsPattern } from './CronJobs.utils' +import { formatScheduleString, getScheduleMessage, secondsPattern } from './CronJobs.utils' import CronSyntaxChart from './CronSyntaxChart' interface CronJobScheduleSectionProps { @@ -36,14 +36,10 @@ interface CronJobScheduleSectionProps { export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => { const { project } = useProjectContext() - const initialValue = form.getValues('schedule') - const schedule = form.watch('schedule') - const [presetValue, setPresetValue] = useState(initialValue) - const [inputValue, setInputValue] = useState(initialValue) + const [inputValue, setInputValue] = useState('') const [debouncedValue] = useDebounce(inputValue, 750) const [useNaturalLanguage, setUseNaturalLanguage] = useState(false) - const [scheduleString, setScheduleString] = useState('') const PRESETS = [ ...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []), @@ -54,15 +50,21 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul { name: 'Every Monday at 2 AM', expression: '0 2 * * 1' }, ] as const - const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({ + const { + complete: generateCronSyntax, + isLoading: isGeneratingCron, + stop, + } = useCompletion({ api: `${BASE_PATH}/api/ai/sql/cron`, onResponse: async (response) => { if (response.ok) { // remove quotes from the cron expression const expression = (await response.text()).trim().replace(/^"|"$/g, '') - form.setValue('schedule', expression) - setPresetValue(expression) - setScheduleString(CronToString(expression)) + form.setValue('schedule', expression, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) } }, onError: (error) => { @@ -76,47 +78,15 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul }) useEffect(() => { - if (!useNaturalLanguage || !debouncedValue) return - generateCronSyntax(debouncedValue) + if (useNaturalLanguage && debouncedValue) { + generateCronSyntax(debouncedValue) + return () => stop() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue, useNaturalLanguage]) - useEffect(() => { - if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message - - // update the cronstrue string when the input value changes - try { - setScheduleString(CronToString(inputValue)) - form.setValue('schedule', inputValue) - } catch (error) { - console.error('Error converting cron expression to string:', error) - } - }, [form, inputValue]) - - useEffect(() => { - if (useNaturalLanguage) return - - setPresetValue(schedule) - - if (!schedule) { - setScheduleString('') - return - } - - try { - // Don't allow seconds-based schedules if seconds aren't supported - if (!supportsSeconds && secondsPattern.test(schedule)) { - setScheduleString('Invalid cron expression') - return - } - - setScheduleString(CronToString(schedule)) - } catch (error) { - setScheduleString('Invalid cron expression') - console.error('Error converting cron expression to string:', error) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule]) + const schedule = form.watch('schedule') + const scheduleString = formatScheduleString(schedule) return ( @@ -136,108 +106,99 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
-
- {useNaturalLanguage ? ( - { - if (e.key === 'Enter') { - e.preventDefault() - } - }} - onChange={(e) => setInputValue(e.target.value)} - /> - ) : ( - { - if (e.key === 'Enter') { - e.preventDefault() - } - }} - /> - )} - - -
- { - setUseNaturalLanguage(!useNaturalLanguage) - setInputValue('') - setPresetValue('') - setScheduleString('') - form.setValue('schedule', '') - }} - /> -

Use natural language

-
- -
-
    - {PRESETS.map((preset) => ( -
  • - -
  • - ))} -
-
- - - - View syntax chart - - - - - - -
+ {useNaturalLanguage ? ( + { + if (e.key === 'Enter') { + e.preventDefault() + } + }} + onChange={(e) => setInputValue(e.target.value)} + /> + ) : ( + { + if (e.key === 'Enter') { + e.preventDefault() + } + }} + /> + )}
+ +
+
+ { + setUseNaturalLanguage(!useNaturalLanguage) + setInputValue('') + }} + /> +

Use natural language

+
+ + + + + View syntax chart + + + + + + +

Schedule {timezone ? `(${timezone})` : ''}

- {scheduleString ? ( - - {isGeneratingCron ? : presetValue || '* * * * *'} - - ) : ( - - {isGeneratingCron ? : presetValue || '* * * * *'} - - )} + + {isGeneratingCron ? : schedule || '* * * * * *'} + + {!inputValue && !isGeneratingCron && !scheduleString ? ( Describe your schedule above ) : ( - {isGeneratingCron ? ( - - ) : ( - getScheduleMessage(scheduleString, schedule) - )} + {isGeneratingCron ? : getScheduleMessage(scheduleString)} )}
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts index b62ce3febdbd1..07210781d20d0 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts @@ -1,3 +1,5 @@ +import { toString as CronToString } from 'cronstrue' + import { CronJobType } from './CreateCronJobSheet' import { HTTPHeader } from './CronJobs.constants' @@ -75,6 +77,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => { edgeFunctionName: url, httpHeaders: headersObjs, httpBody: body, + // @ts-ignore timeoutMs: +matches[5] ?? 1000, } } @@ -85,6 +88,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => { endpoint: url, httpHeaders: headersObjs, httpBody: body, + // @ts-ignore timeoutMs: +matches[5] ?? 1000, } } @@ -153,13 +157,14 @@ export function isSecondsFormat(schedule: string): boolean { return secondsPattern.test(schedule.trim()) } -export function getScheduleMessage(scheduleString: string, schedule: string) { +export function getScheduleMessage(scheduleString: string) { if (!scheduleString) { return 'Enter a valid cron expression above' } - if (secondsPattern.test(schedule)) { - return `The cron will be run every ${schedule}` + // if the schedule is in seconds format, scheduleString is same as the schedule + if (secondsPattern.test(scheduleString)) { + return `The cron will run every ${scheduleString}` } if (scheduleString.includes('Invalid cron expression')) { @@ -171,5 +176,17 @@ export function getScheduleMessage(scheduleString: string, schedule: string) { .map((s, i) => (i === 0 ? s.toLowerCase() : s)) .join(' ') - return `The cron will be run ${readableSchedule}.` + return `The cron will run ${readableSchedule}.` +} + +export const formatScheduleString = (value: string) => { + try { + if (secondsPattern.test(value)) { + return value + } else { + return CronToString(value) + } + } catch (error) { + return '' + } } diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index 185d58209a00d..752ee4eea1a7c 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -1,10 +1,10 @@ import { Clock5, Layers, Timer, Vault, Webhook } from 'lucide-react' +import dynamic from 'next/dynamic' import Image from 'next/image' import { ComponentType, ReactNode } from 'react' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { BASE_PATH } from 'lib/constants' -import dynamic from 'next/dynamic' import { cn } from 'ui' import { WRAPPERS } from '../Wrappers/Wrappers.constants' import { WrapperMeta } from '../Wrappers/Wrappers.types' @@ -117,10 +117,10 @@ const supabaseIntegrations: IntegrationDefinition[] = [ icon: ({ className, ...props } = {}) => ( ), - description: 'Schedule and automate tasks to run maintenance routines at specified intervals.', + description: 'Schedule recurring Jobs in Postgres.', docsUrl: 'https://github.com/citusdata/pg_cron', author: { - name: 'pg_cron', + name: 'Citus Data', websiteUrl: 'https://github.com/citusdata/pg_cron', }, navigation: [ diff --git a/apps/studio/components/interfaces/Realtime/Policies.tsx b/apps/studio/components/interfaces/Realtime/Policies.tsx index daa7c887744c7..56a17b589f891 100644 --- a/apps/studio/components/interfaces/Realtime/Policies.tsx +++ b/apps/studio/components/interfaces/Realtime/Policies.tsx @@ -1,7 +1,7 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { useState } from 'react' -import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel' +import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel' import Policies from 'components/interfaces/Auth/Policies/Policies' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' @@ -60,7 +60,7 @@ export const RealtimePolicies = () => { )} - void -} - -const AISchemaSuggestionPopover = ({ - children, - delay = 300, - onClickSettings, -}: PropsWithChildren) => { - const selectedOrganization = useSelectedOrganization() - const isOptedInToAI = useOrgOptedIntoAi() - const [hasEnabledAISchema] = useLocalStorageQuery('supabase_sql-editor-ai-schema', true) - const [isDelayComplete, setIsDelayComplete] = useState(false) - const [aiQueryCount] = useLocalStorageQuery('supabase_sql-editor-ai-query-count', 0) - - const includeSchemaMetadata = (isOptedInToAI || !IS_PLATFORM) && hasEnabledAISchema - - const [isSchemaSuggestionDismissed, setIsSchemaSuggestionDismissed] = useLocalStorageQuery( - 'supabase_sql-editor-ai-schema-suggestion-dismissed', - false - ) - - useEffect(() => { - const timeout = window.setTimeout(() => { - setIsDelayComplete(true) - }, delay) - - return () => window.clearTimeout(timeout) - }) - - return ( - = 3 && - !isSchemaSuggestionDismissed - } - > - {children} - - - - - -
-
- -

- Generate more relevant queries by including database metadata in your requests. -

-
-
- - -
-
-
-
-
-
- ) -} - -export default AISchemaSuggestionPopover diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx deleted file mode 100644 index 86d7cabdca88e..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { InsertCode, ReplaceCode } from 'icons' -import { Check, Copy } from 'lucide-react' -import { useEffect, useState } from 'react' -import { format } from 'sql-formatter' -import { - Button, - CodeBlock, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, - Tooltip_Shadcn_, - cn, -} from 'ui' - -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { DiffType } from '../SQLEditor.types' - -interface AiMessagePreProps { - onDiff: (type: DiffType, s: string) => void - children: string[] - className?: string -} - -// [DEPRECATED] To delete -export const AiMessagePre = ({ onDiff, children, className }: AiMessagePreProps) => { - const [copied, setCopied] = useState(false) - - const { mutate: sendEvent } = useSendEventMutation() - - useEffect(() => { - if (!copied) return - const timer = setTimeout(() => setCopied(false), 2000) - return () => clearTimeout(timer) - }, [copied]) - - let formatted = (children || [''])[0] - try { - formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) - } catch {} - - if (formatted.length === 0) { - return null - } - - function handleCopy(formatted: string) { - navigator.clipboard.writeText(formatted).then() - setCopied(true) - } - - return ( -
-      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block'
-        )}
-        hideCopy
-        hideLineNumbers
-      />
-      
- - - - - - Insert code - - - - - - - - - Replace code - - - - - - - - - Copy code - - -
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx deleted file mode 100644 index 03c2156d57c0c..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import dayjs from 'dayjs' -import { noop } from 'lodash' -import Image from 'next/image' -import { PropsWithChildren, memo, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { AiIconAnimation, Badge, cn, markdownComponents } from 'ui' - -import { useProfile } from 'lib/profile' -import { DiffType } from '../SQLEditor.types' -import { AiMessagePre } from './AiMessagePre' - -interface MessageProps { - name?: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string - createdAt?: number - isDebug?: boolean - isSelected?: boolean - onDiff?: (type: DiffType, s: string) => void - action?: React.ReactNode -} - -// [DEPRECATED] To delete -const Message = memo(function Message({ - name, - role, - content, - createdAt, - isDebug, - isSelected = false, - onDiff = noop, - children, - action = null, -}: PropsWithChildren) { - const { profile } = useProfile() - - const icon = useMemo(() => { - return role === 'assistant' ? ( - - ) : ( -
- avatar -
- ) - }, [content, profile?.username, role]) - - if (!content) return null - - return ( -
-
-
- {icon} - - - {role === 'assistant' ? 'Assistant' : name ? name : 'You'} - - {createdAt && ( - {dayjs(createdAt).fromNow()} - )} - {isDebug && Debug request} -
{' '} - {action} -
- { - return ( - div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' - )} - > - {props.children[0].props.children} - - ) - }, - }} - > - {content} - - {children} -
- ) -}) - -export default Message diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx deleted file mode 100644 index 0424b2d477b84..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/index.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { compact, last } from 'lodash' -import { Info } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - -import { Alert, AlertDescription, AlertTitle } from '@ui/components/shadcn/ui/alert' -import { Message as MessageType } from 'ai' -import { useChat } from 'ai/react' -import { useParams } from 'common' -import { IS_PLATFORM } from 'common/constants/environment' -import { Markdown } from 'components/interfaces/Markdown' -import { SchemaComboBox } from 'components/ui/SchemaComboBox' -import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' -import { useProfile } from 'lib/profile' -import uuidv4 from 'lib/uuid' -import { - AiIconAnimation, - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Button, - cn, - Tooltip_Shadcn_, - TooltipContent_Shadcn_, - TooltipTrigger_Shadcn_, - WarningIcon, -} from 'ui' -import { AssistantChatForm } from 'ui-patterns/AssistantChat' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import OptInToOpenAIToggle from '../../Organization/GeneralSettings/OptInToOpenAIToggle' -import { DiffType } from '../SQLEditor.types' -import Message from './Message' - -export type MessageWithDebug = MessageType & { isDebug: boolean } - -interface AiAssistantPanelProps { - selectedMessage?: string - existingSql: string - includeSchemaMetadata: boolean - onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void - onClose: () => void -} - -// [DEPRECATED] To delete -export const AiAssistantPanel = ({ - selectedMessage, - existingSql, - onDiff, - onClose, - includeSchemaMetadata, -}: AiAssistantPanelProps) => { - const { ref } = useParams() - const { profile } = useProfile() - const project = useSelectedProject() - const selectedOrganization = useSelectedOrganization() - - const bottomRef = useRef(null) - const inputRef = useRef(null) - - const [chatId, setChatId] = useState(uuidv4()) - const [value, setValue] = useState('') - const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) - - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const [showAiNotOptimizedWarningSetting, setShowAiNotOptimizedWarningSetting] = - useLocalStorageQuery(LOCAL_STORAGE_KEYS.SHOW_AI_NOT_OPTIMIZED_WARNING(ref as string), true) - const shouldShowNotOptimizedAlert = - selectedOrganization && !includeSchemaMetadata && showAiNotOptimizedWarningSetting - - const { data: check } = useCheckOpenAIKeyQuery() - const isApiKeySet = IS_PLATFORM || !!check?.hasKey - - const { data } = useEntityDefinitionsQuery( - { - schemas: selectedSchemas, - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: includeSchemaMetadata } - ) - - const entityDefinitions = includeSchemaMetadata ? data?.map((def) => def.sql.trim()) : undefined - - // Use chat id because useChat doesn't have a reset function to clear all messages - const { - messages: chatMessages, - isLoading, - append, - } = useChat({ - id: chatId, - api: `${BASE_PATH}/api/ai/sql/generate-v2`, - body: { - existingSql, - entityDefinitions: entityDefinitions, - }, - }) - const messages = useMemo(() => { - const merged = [...chatMessages.map((m) => ({ ...m, isDebug: false }))] - - return merged.sort( - (a, b) => - (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || - a.role.localeCompare(b.role) - ) - }, [chatMessages]) - - const name = compact([profile?.first_name, profile?.last_name]).join(' ') - const pendingReply = isLoading && last(messages)?.role === 'user' - - const { mutate: sendEvent } = useSendEventMutation() - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - - const confirmOptInToShareSchemaData = async () => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!selectedOrganization?.slug) return console.error('Organization slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - - const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags - : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] - - updateOrganization( - { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - toast.success('Successfully opted-in') - setIsConfirmOptInModalOpen(false) - }, - } - ) - } - - useEffect(() => { - if (!isLoading) { - setValue('') - if (inputRef.current) inputRef.current.focus() - } - - // Try to scroll on each rerender to the bottom - setTimeout( - () => { - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, - isLoading ? 100 : 500 - ) - }, [isLoading]) - - return ( -
-
- - {messages.length > 0 && ( - - )} - -
- } - > - {includeSchemaMetadata ? ( -
- - Include these schemas in your prompts: - -
-
- 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - /> - - - - - - Select specific schemas to send with your queries. Supabase AI can use this - data to improve the responses it shows you. - - -
-
-
- ) : ( - <> - {shouldShowNotOptimizedAlert ? ( - - - AI Assistant is not optimized - - You need to agree to share anonymous schema data with OpenAI for the best - experience. - -
- - - -
-
- ) : ( - <> - )} - - )} - - {messages.map((m, index) => { - const isFirstUserMessage = - m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') - - return ( - onDiff({ id: m.id, diffType, sql })} - > - {isFirstUserMessage && !includeSchemaMetadata && !shouldShowNotOptimizedAlert && ( - - - - Quick reminder that you're not sending project metadata with your queries. By - opting into sending anonymous data, Supabase AI can improve the answers it - shows you. - - - - - )} - {isFirstUserMessage && includeSchemaMetadata && selectedSchemas.length === 0 && ( - - - - We recommend including the schemas for better answers by Supabase AI. - - - - )} - - ) - })} - {pendingReply && } -
-
- -
- {!isApiKeySet && ( - - OpenAI API key not set - - - - - )} - - } - placeholder="Ask a question about your SQL query" - value={value} - onValueChange={(e) => setValue(e.target.value)} - onSubmit={(event) => { - event.preventDefault() - append({ - content: value, - role: 'user', - createdAt: new Date(), - }) - sendEvent({ - category: 'sql_editor_ai_assistant', - action: 'ai_suggestion_asked', - label: 'sql-editor-ai-assistant', - }) - }} - /> -
- setIsConfirmOptInModalOpen(false)} - onConfirm={confirmOptInToShareSchemaData} - loading={isUpdating} - > -

- By opting into sending anonymous data, Supabase AI can improve the answers it shows you. - This is an organization-wide setting, and affects all projects in your organization. -

- - -
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 620c265b844d8..fd6408d24b0cf 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -50,7 +50,6 @@ import { } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import AISchemaSuggestionPopover from './AISchemaSuggestionPopover' import { DiffActionBar } from './DiffActionBar' import { ROWS_PER_PAGE_OPTIONS, @@ -82,7 +81,7 @@ const DiffEditor = dynamic( { ssr: false } ) -const SQLEditor = () => { +export const SQLEditor = () => { const router = useRouter() const { ref, id: urlId } = useParams() @@ -101,13 +100,10 @@ const SQLEditor = () => { const databaseSelectorState = useDatabaseSelectorStateSnapshot() const queryClient = useQueryClient() - const { open } = appSnap.aiAssistantPanel - const { mutate: formatQuery } = useFormatQueryMutation() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const { mutateAsync: debugSql, isLoading: isDebugSqlLoading } = useSqlDebugMutation() - const [debugSolution, setDebugSolution] = useState() const [sourceSqlDiff, setSourceSqlDiff] = useState() const [pendingTitle, setPendingTitle] = useState() const [hasSelection, setHasSelection] = useState(false) @@ -457,11 +453,10 @@ const SQLEditor = () => { sendEvent({ category: 'sql_editor', action: 'ai_suggestion_accepted', - label: debugSolution ? 'debug_snippet' : 'edit_snippet', + label: 'edit_snippet', }) setSelectedDiffType(DiffType.Modification) - setDebugSolution(undefined) setSourceSqlDiff(undefined) setPendingTitle(undefined) } finally { @@ -472,7 +467,6 @@ const SQLEditor = () => { selectedDiffType, handleNewQuery, generateSqlTitle, - debugSolution, router, id, pendingTitle, @@ -483,13 +477,12 @@ const SQLEditor = () => { sendEvent({ category: 'sql_editor', action: 'ai_suggestion_rejected', - label: debugSolution ? 'debug_snippet' : 'edit_snippet', + label: 'edit_snippet', }) - setDebugSolution(undefined) setSourceSqlDiff(undefined) setPendingTitle(undefined) - }, [debugSolution, router]) + }, [router]) useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -659,39 +652,26 @@ const SQLEditor = () => { direction="vertical" autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE} > - {(open || isDiffOpen) && !hasHipaaAddon && ( - { - appSnap.setShowAiSettingsModal(true) - }} - > - {isDiffOpen && ( - - {debugSolution && ( -
- {debugSolution} -
- )} - setSelectedDiffType(diffType)} - onAccept={acceptAiHandler} - onCancel={discardAiHandler} - /> -
+ {!hasHipaaAddon && isDiffOpen && ( + + > + setSelectedDiffType(diffType)} + onAccept={acceptAiHandler} + onCancel={discardAiHandler} + /> + )}
@@ -830,5 +810,3 @@ const SQLEditor = () => { ) } - -export default SQLEditor diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index a01bbb7697c92..3988a93a7cd58 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -22,8 +22,6 @@ export type SQLEditorContextValues = { setAiInput: Dispatch> sqlDiff?: ContentDiff setSqlDiff: Dispatch> - debugSolution?: string - setDebugSolution: Dispatch> setSelectedDiffType: Dispatch> } diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 131895eae2d7d..100917b1c33bc 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -31,9 +31,11 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { pluralize } from 'lib/helpers' import { getIntegrationConfigurationUrl } from 'lib/integration-utils' import { useSidePanelsStateSnapshot } from 'state/side-panels' -import { Button, cn } from 'ui' +import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, cn } from 'ui' import { IntegrationImageHandler } from '../IntegrationsSettings' import VercelIntegrationConnectionForm from './VercelIntegrationConnectionForm' +import PartnerManagedResource from 'components/ui/PartnerManagedResource' +import PartnerIcon from 'components/ui/PartnerIcon' const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => { const project = useSelectedProject() @@ -164,6 +166,18 @@ You can change the scope of the access for Supabase by configuring {!canReadVercelConnection ? ( + ) : org?.managed_by === 'vercel-marketplace' ? ( + + + + + Vercel Integration is not available for Vercel Marketplace managed projects. + + ) : ( <> diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 360dacc27c1d7..368b614b8e180 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' import { useSupabaseClient } from '@supabase/auth-helpers-react' -import { ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react' +import { ChevronDown, ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' @@ -23,6 +23,9 @@ import { Button, Checkbox_Shadcn_, cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -342,7 +345,9 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {organizations?.map((org) => ( - {org.name} + + {org.name} + ))} {isSuccessOrganizations && (organizations ?? []).length === 0 && ( @@ -378,7 +383,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {projects?.map((project) => ( - + {project.name} ))} @@ -425,7 +430,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {CATEGORY_OPTIONS.map((option) => ( - + {option.label} {option.description} @@ -458,7 +463,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {SEVERITY_OPTIONS.map((option) => ( - + {option.label} {option.description} @@ -554,7 +559,7 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo {CLIENT_LIBRARIES.map((option) => ( - + {option.language} ))} @@ -629,7 +634,37 @@ export const SupportFormV2 = ({ setSentCategory, setSelectedProject }: SupportFo layout="flex" className={cn(CONTAINER_CLASSES)} label="Allow Supabase Support to access your project temporarily" - description="In some cases, we may require temporary access to your project to complete troubleshooting, or to answer questions related specifically to your project" + description={ + <> + + + More information about temporary access + + + + By checking this box, you grant permission for our support team to access + your project temporarily and, if applicable, to use AI tools to assist in + diagnosing and resolving issues. This access may involve analyzing database + configurations, query performance, and other relevant data to expedite + troubleshooting and enhance support accuracy. We are committed to + maintaining strict data privacy and security standards in all support + activities.{' '} + + Privacy Policy + + + + + } > {children} - } - return ( <> @@ -157,12 +150,7 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren
- + {children}
diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 7fc7609a392de..b217aeae79c0c 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -1,9 +1,8 @@ import { isUndefined } from 'lodash' +import { ArrowUpRight, LogOut } from 'lucide-react' import Link from 'next/link' import { ReactNode } from 'react' -import { useFlag } from 'hooks/ui/useFlag' -import { ArrowUpRight, LogOut } from 'lucide-react' import { Badge, cn, Menu } from 'ui' import { LayoutHeader } from '../ProjectLayout/LayoutHeader' import type { SidebarLink, SidebarSection } from './AccountLayout.types' @@ -32,7 +31,6 @@ const WithSidebar = ({ customSidebarContent, }: WithSidebarProps) => { const noContent = !sections && !customSidebarContent - const navLayoutV2 = useFlag('navigationLayoutV2') return (
@@ -80,7 +78,7 @@ const WithSidebar = ({
)}
- {!navLayoutV2 && } +
{children}
diff --git a/apps/studio/components/layouts/AppLayout/AppHeader.tsx b/apps/studio/components/layouts/AppLayout/AppHeader.tsx deleted file mode 100644 index d338be2f2e247..0000000000000 --- a/apps/studio/components/layouts/AppLayout/AppHeader.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useParams } from 'common' -import Link from 'next/link' -import { useRouter } from 'next/router' - -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { FeedbackDropdown } from '../ProjectLayout/LayoutHeader/FeedbackDropdown' -import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover' -import NotificationsPopoverV2 from '../ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover' -import BranchDropdown from './BranchDropdown' -import EnableBranchingButton from './EnableBranchingButton/EnableBranchingButton' -import OrganizationDropdown from './OrganizationDropdown' -import ProjectDropdown from './ProjectDropdown' -import SettingsButton from './SettingsButton' -import UserSettingsDropdown from './UserSettingsDropdown' -import AssistantButton from './AssistantButton' - -// [Joshen] Just FYI this is only for Nav V2 which is still going through design iteration -// Component is not currently in use - -const AppHeader = () => { - const router = useRouter() - const { ref } = useParams() - const project = useSelectedProject() - const organization = useSelectedOrganization() - - const isBranchingEnabled = - project?.is_branch_enabled === true || project?.parent_project_ref !== undefined - - return ( -
-
- - Supabase - - - {ref !== undefined && } - {ref !== undefined && ( - <> - {isBranchingEnabled ? : } - - )} -
- -
-
- - - - - -
-
- -
-
-
- ) -} - -export default AppHeader diff --git a/apps/studio/components/layouts/AppLayout/AppLayout.tsx b/apps/studio/components/layouts/AppLayout/AppLayout.tsx index 0616fa87bc38f..24748c86ced14 100644 --- a/apps/studio/components/layouts/AppLayout/AppLayout.tsx +++ b/apps/studio/components/layouts/AppLayout/AppLayout.tsx @@ -1,17 +1,7 @@ import { PropsWithChildren } from 'react' -import { useFlag } from 'hooks/ui/useFlag' -import AppHeader from './AppHeader' - const AppLayout = ({ children }: PropsWithChildren<{}>) => { - const navLayoutV2 = useFlag('navigationLayoutV2') - - return ( -
- {navLayoutV2 && } - {children} -
- ) + return
{children}
} export default AppLayout diff --git a/apps/studio/components/layouts/OrganizationLayout.tsx b/apps/studio/components/layouts/OrganizationLayout.tsx index 24c7b8f3f2ba4..55d32e15e561b 100644 --- a/apps/studio/components/layouts/OrganizationLayout.tsx +++ b/apps/studio/components/layouts/OrganizationLayout.tsx @@ -7,13 +7,11 @@ import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query' import { useCurrentPath } from 'hooks/misc/useCurrentPath' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { Alert_Shadcn_, AlertTitle_Shadcn_, Button, NavMenu, NavMenuItem } from 'ui' import AccountLayout from './AccountLayout/AccountLayout' import { ScaffoldContainer, ScaffoldDivider, ScaffoldHeader, ScaffoldTitle } from './Scaffold' -import SettingsLayout from './SettingsLayout/SettingsLayout' const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const selectedOrganization = useSelectedOrganization() @@ -23,16 +21,10 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const invoicesEnabledOnProfileLevel = useIsFeatureEnabled('billing:invoices') const invoicesEnabled = invoicesEnabledOnProfileLevel - const navLayoutV2 = useFlag('navigationLayoutV2') - const { data, isSuccess } = useVercelRedirectQuery({ installationId: selectedOrganization?.partner_id, }) - if (navLayoutV2) { - return {children} - } - const navMenuItems = [ { label: 'General', diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 06089b1163eec..8d45873c7a9a5 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -68,7 +68,6 @@ const NavigationBar = () => { const signOut = useSignOut() - const navLayoutV2 = useFlag('navigationLayoutV2') const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const [userDropdownOpen, setUserDropdownOpenState] = useState(false) @@ -212,19 +211,17 @@ const NavigationBar = () => { }} >
    - {(!navLayoutV2 || !IS_PLATFORM) && ( - - Supabase - - )} + + Supabase + - {!navLayoutV2 && !hideHeader && IS_PLATFORM && } + {!hideHeader && IS_PLATFORM && } - + )} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx index a37fa7765fab4..0d657513cf45a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -5,7 +5,7 @@ import { useAppStateSnapshot } from 'state/app-state' import { cn } from 'ui' import { AIAssistant } from './AIAssistant' -export const AiAssistantPanel = () => { +export const AIAssistantPanel = () => { const { aiAssistantPanel, resetAiAssistantPanel } = useAppStateSnapshot() const [initialMessages, setInitialMessages] = useState( aiAssistantPanel.messages?.length > 0 ? (aiAssistantPanel.messages as any) : undefined diff --git a/apps/studio/components/ui/ErrorBoundaryState.tsx b/apps/studio/components/ui/ErrorBoundaryState.tsx index dfbaf1f3485da..cf29098fe4263 100644 --- a/apps/studio/components/ui/ErrorBoundaryState.tsx +++ b/apps/studio/components/ui/ErrorBoundaryState.tsx @@ -1,15 +1,31 @@ +import { isError } from 'lodash' import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import type { FallbackProps } from 'react-error-boundary' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import { WarningIcon } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + WarningIcon, +} from 'ui' + +// More correct version of FallbackProps from react-error-boundary +export type FallbackProps = { + error: unknown + resetErrorBoundary: (...args: any[]) => void +} export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) => { const router = useRouter() - const message = `Path name: ${router.pathname}\n\n${error.stack}` - const isRemoveChildError = error.message.includes("Failed to execute 'removeChild' on 'Node'") + const checkIsError = isError(error) + + const errorMessage = checkIsError ? error.message : '' + const urlMessage = checkIsError ? `Path name: ${router.pathname}\n\n${error?.stack}` : '' + const isRemoveChildError = checkIsError + ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") + : false return (
    @@ -18,7 +34,7 @@ export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) Application error: a client-side exception has occurred (see browser console for more information)

    -

    Error: {error.message}

    +

    Error: {errorMessage}

    {isRemoveChildError && ( @@ -48,7 +64,7 @@ export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps)