diff --git a/.gitignore b/.gitignore index 4105694af0a..25662fb0017 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,10 @@ next-env.d.ts .idea .vercel +# AI assistant local files +.serena/ +CLAUDE.md + #include template .env file for docker-compose !docker/.env !docker/supabase/.env diff --git a/apps/design-system/content/docs/components/table.mdx b/apps/design-system/content/docs/components/table.mdx index ff05bebe235..ab2cc6bf5dc 100644 --- a/apps/design-system/content/docs/components/table.mdx +++ b/apps/design-system/content/docs/components/table.mdx @@ -1,44 +1,16 @@ --- title: Table -description: A responsive table component. +description: A responsive table component for basic data. component: true -source: - shadcn: true --- +Table presents basic tabular data presentation within a [Card](/docs/components/card). + -## Installation +Columns will naturally take the width of their content. Width specification is therefore not required unless you require columns to span a specific width. An example use-case may be when placing multiple Table instances on the one page, where columns on each table should optically align. - - - - CLI - Manual - - - -```bash -npx shadcn-ui@latest add table -``` - - - - - - - -Copy and paste the following code into your project. - - - -Update the import paths to match your project setup. - - - - - - +`TableHead` includes a `whitespace-nowrap` utility class to prevent its text from wrapping. `TableCell` must have any custom width specified, as there are legitimate use cases for both wrapping (or truncated) text. ## Usage @@ -59,7 +31,7 @@ import { A list of your recent invoices. - Invoice + Invoice Status Method Amount diff --git a/apps/design-system/registry/default/example/table-demo.tsx b/apps/design-system/registry/default/example/table-demo.tsx index d3893e18649..6ee6dad665a 100644 --- a/apps/design-system/registry/default/example/table-demo.tsx +++ b/apps/design-system/registry/default/example/table-demo.tsx @@ -1,4 +1,5 @@ import { + Card, Table, TableBody, TableCaption, @@ -14,74 +15,87 @@ const invoices = [ invoice: 'INV001', paymentStatus: 'Paid', totalAmount: '$250.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Website design services', }, { invoice: 'INV002', paymentStatus: 'Pending', totalAmount: '$150.00', paymentMethod: 'PayPal', + description: 'Monthly subscription fee', }, { invoice: 'INV003', paymentStatus: 'Unpaid', totalAmount: '$350.00', - paymentMethod: 'Bank Transfer', + paymentMethod: 'Bank transfer', + description: 'Consulting hours', }, { invoice: 'INV004', paymentStatus: 'Paid', totalAmount: '$450.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Software license renewal', }, { invoice: 'INV005', paymentStatus: 'Paid', totalAmount: '$550.00', paymentMethod: 'PayPal', + description: 'Custom development work', }, { invoice: 'INV006', paymentStatus: 'Pending', totalAmount: '$200.00', - paymentMethod: 'Bank Transfer', + paymentMethod: 'Bank transfer', + description: 'Hosting and maintenance', }, { invoice: 'INV007', paymentStatus: 'Unpaid', totalAmount: '$300.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Training session package', }, ] export default function TableDemo() { return ( - - A list of your recent invoices. - - - Invoice - Status - Method - Amount - - - - {invoices.map((invoice) => ( - - {invoice.invoice} - {invoice.paymentStatus} - {invoice.paymentMethod} - {invoice.totalAmount} + +
+ A list of your recent invoices + + + Invoice + Status + Method + Description + Amount - ))} - - - - Total - $2,500.00 - - -
+
+ + {invoices.map((invoice) => ( + + {invoice.invoice} + {invoice.paymentStatus} + {invoice.paymentMethod} + + {invoice.description} + + {invoice.totalAmount} + + ))} + + + + Total + $2,250.00 + + + + ) } diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index e40c690b7e9..a053d8a96a2 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1045,6 +1045,10 @@ export const database: NavMenuConstant = { name: 'Managing connections', url: '/guides/database/connection-management' as `/${string}`, }, + { + name: 'Managing event triggers', + url: '/guides/database/postgres/event-triggers' as `/${string}`, + }, ], }, { diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx index 7d7999c63c8..bb656aa3b0d 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx @@ -20,18 +20,25 @@ import { themes, } from 'ui' import MenuIconPicker from './MenuIconPicker' +import { isFeatureEnabled } from 'common' const menu = [ [ - { - label: 'Supabase.com', - icon: 'home', - href: 'https://supabase.com', - otherProps: { - target: '_blank', - rel: 'noreferrer noopener', - }, - }, + isFeatureEnabled('docs:navigation_dropdown_links_home') + ? { + label: 'Supabase.com', + icon: 'home', + href: 'https://supabase.com', + otherProps: { + target: '_blank', + rel: 'noreferrer noopener', + }, + } + : { + label: 'Dashboard', + icon: 'home', + href: '../dashboard', + }, { label: 'GitHub', icon: 'github', diff --git a/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx index ab0fb45827e..ececa37033a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx @@ -1,8 +1,8 @@ 'use client' import type { User } from '@supabase/supabase-js' -import { LogOut, Globe, LifeBuoy, Settings, UserIcon, Database } from 'lucide-react' -import { logOut } from 'common' +import { isFeatureEnabled, logOut } from 'common' +import { Database, Globe, Home, LifeBuoy, LogOut, Settings, UserIcon } from 'lucide-react' import type { menuItem } from 'ui-patterns/AuthenticatedDropdownMenu' import { IconGitHub } from './MenuIcons' @@ -27,15 +27,21 @@ const useDropdownMenu = (user: User | null) => { }, ], [ - { - label: 'Supabase.com', - icon: Globe, - href: 'https://supabase.com', - otherProps: { - target: '_blank', - rel: 'noreferrer noopener', - }, - }, + isFeatureEnabled('docs:navigation_dropdown_links_home') + ? { + label: 'Supabase.com', + icon: Globe, + href: 'https://supabase.com', + otherProps: { + target: '_blank', + rel: 'noreferrer noopener', + }, + } + : { + label: 'Dashboard', + icon: Home, + href: '../dashboard', + }, { label: 'GitHub', icon: IconGitHub as any, diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index fea520c5c2d..332f0cebf2a 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -39,12 +39,12 @@ It's strongly recommended you set up a custom domain and optionally verify your -Google's consent screen is shown to users when they sign in. Optionally configure the following to improve the appearance of the screen, increasing the perception of trust by your users: +Google's consent screen is shown to users when they sign in. Optionally configure one of the following to improve the appearance of the screen, increasing the perception of trust by your users: +1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days. 1. Set up a [custom domain for your project](/docs/guides/platform/custom-domains) to present the user with a clear relationship to the website they clicked Sign in with Google on. - A good approach is to use `auth.example.com` or `api.example.com`, if your application is hosted on `example.com`. - If you don't set this up, users will see `.supabase.co` which does not inspire trust and can make your application more susceptible to successful phishing attempts. -1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days. ## Project setup diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index 7b1bec9fa1c..45fe681be5a 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -109,36 +109,38 @@ If a setting you need is not yet configurable, [share your use case with us](/da The following parameters are available for overrides: -1. [effective_cache_size](https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE) -1. [logical_decoding_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM) (CLI only) -1. [maintenance_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM) -1. [max_connections](https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS) (CLI only) -1. [max_locks_per_transaction](https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION) (CLI only) -1. [max_parallel_maintenance_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS) -1. [max_parallel_workers_per_gather](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER) -1. [max_parallel_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS) -1. [max_replication_slots](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS) (CLI only) -1. [max_slot_wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE) (CLI only) -1. [max_standby_archive_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY) (CLI only) -1. [max_standby_streaming_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY) (CLI only) -1. [max_wal_size](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE) (CLI only) -1. [max_wal_senders](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS) (CLI only) -1. [max_worker_processes](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES) (CLI only) -1. [session_replication_role](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE) -1. [shared_buffers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS) (CLI only) -1. [statement_timeout](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT) -1. [track_activity_query_size](https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-ACTIVITY-QUERY-SIZE) -1. [track_commit_timestamp](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP) -1. [wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE) (CLI only) -1. [wal_sender_timeout](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT) (CLI only) -1. [work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM) +1. [checkpoint_timeout](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-CHECKPOINT-TIMEOUT) +2. [effective_cache_size](https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE) +3. [hot_standby_feedback](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-HOT-STANDBY-FEEDBACK) +4. [logical_decoding_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM) (CLI only) +5. [maintenance_work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM) +6. [max_connections](https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS) (CLI only) +7. [max_locks_per_transaction](https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION) (CLI only) +8. [max_parallel_maintenance_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS) +9. [max_parallel_workers_per_gather](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER) +10. [max_parallel_workers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS) +11. [max_replication_slots](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS) (CLI only) +12. [max_slot_wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE) (CLI only) +13. [max_standby_archive_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY) (CLI only) +14. [max_standby_streaming_delay](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY) (CLI only) +15. [max_wal_size](https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE) (CLI only) +16. [max_wal_senders](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS) (CLI only) +17. [max_worker_processes](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES) (CLI only) +18. [session_replication_role](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE) +19. [shared_buffers](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS) (CLI only) +20. [statement_timeout](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT) +21. [track_activity_query_size](https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-ACTIVITY-QUERY-SIZE) +22. [track_commit_timestamp](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP) +23. [wal_keep_size](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE) (CLI only) +24. [wal_sender_timeout](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT) (CLI only) +25. [work_mem](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM) #### Managing Postgres configuration with the CLI To start: 1. [Install](/docs/guides/resources/supabase-cli) Supabase CLI 1.69.0+. -1. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI. +2. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI. To update Postgres configurations, use the [`postgres config`](/docs/reference/cli/supabase-postgres-config) command: @@ -227,5 +229,5 @@ alter role "" set "" to default; ### Considerations 1. Changes through the CLI might restart the database causing momentary disruption to existing database connections; in most cases this should not take more than a few seconds. However, you can use the --no-restart flag to bypass the restart and keep the connections intact. Keep in mind that this depends on the specific configuration changes you're making. if the change requires a restart, using the --no-restart flag will prevent the restart but you won't see those changes take effect until a restart is manually triggered. Additionally, some parameters are required to be the same on Primary and Read Replicas; not restarting in these cases can result in read replica failure if the Primary/Read Replicas restart in isolation. -1. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute. -1. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io). +2. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute. +3. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io). diff --git a/apps/docs/content/guides/database/postgres/event-triggers.mdx b/apps/docs/content/guides/database/postgres/event-triggers.mdx new file mode 100644 index 00000000000..0254f74757e --- /dev/null +++ b/apps/docs/content/guides/database/postgres/event-triggers.mdx @@ -0,0 +1,135 @@ +--- +id: 'postgres-event-triggers' +title: 'Event Triggers' +description: 'Automatically execute SQL on database events.' +subtitle: 'Automatically execute SQL on database events.' +--- + +In Postgres, an [event trigger](https://www.postgresql.org/docs/current/event-triggers.html) is similar to a [trigger](/docs/guides/database/postgres/triggers), except that it is triggered by database level events (and is usually reserved for [superusers](/docs/guides/database/postgres/roles-superuser)) + +With our `Supautils` extension (installed automatically for all Supabase projects), the `postgres` user has the ability to create and manage event triggers. + +Some use cases for event triggers are: + +- Capturing Data Definition Language (DDL) changes - these are changes to your database schema (though the [pgAudit](/docs/guides/database/extensions/pgaudit) extension provides a more complete solution) +- Enforcing/monitoring/preventing actions - such as preventing tables from being dropped in Production or enforcing RLS on all new tables + +The guide covers two example event triggers: + +1. Preventing accidental dropping of a table +2. Automatically enabling Row Level Security on new tables in the `public` schema + +## Creating an event trigger + +Only the `postgres` user can create event triggers, so make sure you are authenticated as them. As with triggers, event triggers consist of 2 parts + +1. A [Function](/docs/guides/database/functions) which will be executed when the triggering event occurs +2. The actual Event Trigger object, with parameters around when the trigger should be run + +### Example trigger function - prevent dropping tables + +This example protects any table from being dropped. You can override it by temporarily disabling the event trigger: `ALTER EVENT TRIGGER dont_drop_trigger DISABLE;` + +```sql +-- Function +CREATE OR REPLACE FUNCTION dont_drop_function() + RETURNS event_trigger LANGUAGE plpgsql AS $$ +DECLARE + obj record; + tbl_name text; +BEGIN + FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() + LOOP + IF obj.object_type = 'table' THEN + RAISE EXCEPTION 'ERROR: All tables in this schema are protected and cannot be dropped'; + END IF; + END LOOP; +END; +$$; + +-- Event trigger +CREATE EVENT TRIGGER dont_drop_trigger +ON sql_drop +EXECUTE FUNCTION dont_drop_function(); +``` + +### Example trigger function - auto enable Row Level Security + +```sql +CREATE OR REPLACE FUNCTION rls_auto_enable() +RETURNS EVENT_TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +DECLARE + cmd record; +BEGIN + FOR cmd IN + SELECT * + FROM pg_event_trigger_ddl_commands() + WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') + AND object_type IN ('table','partitioned table') + LOOP + IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN + BEGIN + EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity); + RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity; + EXCEPTION + WHEN OTHERS THEN + RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity; + END; + ELSE + RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name; + END IF; + END LOOP; +END; +$$; + +DROP EVENT TRIGGER IF EXISTS ensure_rls; +CREATE EVENT TRIGGER ensure_rls +ON ddl_command_end +WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') +EXECUTE FUNCTION rls_auto_enable(); +``` + +### Event trigger Functions and firing events + +Event triggers can be triggered on: + +- `ddl_command_start` - occurs just before a DDL command for almost all objects within a schema +- `ddl_command_end` - occurs just after a DDL command for almost all objects within a schema +- `sql_drop` - occurs just before `ddl_command_end` for any DDL commands that `DROP` a database object (note that altering a table can cause it to be dropped) +- `table_rewrite` - occurs just before a table is rewritten using the `ALTER TABLE` command + + + +Event triggers run for each DDL command specified above and can consume resources which may cause performance issues if not used carefully. + + + +Within each event trigger, helper functions exist to view the objects being modified or the command being run. For example, our example calls `pg_event_trigger_dropped_objects()` to view the object(s) being dropped. For a more comprehensive overview of these functions, read the [official event trigger definition documentation](https://www.postgresql.org/docs/current/event-trigger-definition.html) + +To view the matrix commands that cause an event trigger to fire, read the [official event trigger matrix documentation](https://www.postgresql.org/docs/current/event-trigger-matrix.html) + +## Disabling an event trigger + +You can disable an event trigger using the `alter event trigger` command: + +```sql +ALTER EVENT TRIGGER dont_drop_trigger DISABLE; +``` + +## Dropping an event trigger + +You can delete a trigger using the `drop event trigger` command: + +```sql +DROP EVENT TRIGGER dont_drop_trigger; +``` + +## Resources + +- Official Postgres Docs: [Event Trigger Behaviours](https://www.postgresql.org/docs/current/event-trigger-definition.html) +- Official Postgres Docs: [Event Trigger Firing Matrix](https://www.postgresql.org/docs/current/event-trigger-matrix.html) +- Supabase blog: [Postgres Event Triggers without superuser access](/blog/event-triggers-wo-superuser) diff --git a/apps/docs/content/guides/database/postgres/roles-superuser.mdx b/apps/docs/content/guides/database/postgres/roles-superuser.mdx index 552ce4b3ba0..0f34e691799 100644 --- a/apps/docs/content/guides/database/postgres/roles-superuser.mdx +++ b/apps/docs/content/guides/database/postgres/roles-superuser.mdx @@ -12,7 +12,5 @@ However, this does mean that some operations, that typically require `superuser` ## Unsupported operations -- `CREATE SUBSCRIPTION` -- `CREATE EVENT TRIGGER` - `COPY ... FROM PROGRAM` - `ALTER USER ... WITH SUPERUSER` diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index f907179f866..45fe7c653f9 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -80,6 +80,7 @@ Jeff Smick Jenny Kibiri Jess Shears Jim Chanco Jr +Joakim Ahrlin John Pena John Schaeffer Jon M diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 1e15bc95b8d..f9de73a58a2 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -7375,12 +7375,7 @@ functions: ```js supabase .channel('room1') - .send({ - type: 'broadcast', - event: 'cursor-pos', - payload: { x: Math.random(), y: Math.random() - }, - }) + .httpSend('cursor-pos', { x: Math.random(), y: Math.random() }) ``` - id: get-channels title: getChannels() diff --git a/apps/studio/components/interfaces/Database/ETL/Destinations.tsx b/apps/studio/components/interfaces/Database/ETL/Destinations.tsx index 7432e6cd173..dca4d6fdd74 100644 --- a/apps/studio/components/interfaces/Database/ETL/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/ETL/Destinations.tsx @@ -117,8 +117,6 @@ export const Destinations = () => {
- {(isSourcesLoading || isDestinationsLoading) && } - {(isSourcesError || isDestinationsError) && ( { /> )} - {replicationNotEnabled ? ( + {isSourcesLoading || isDestinationsLoading ? ( + + ) : replicationNotEnabled ? (

Stream data to external destinations in real-time

diff --git a/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx index a83ce63f42d..72d8d7e7194 100644 --- a/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx +++ b/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner' import { z } from 'zod' import { useParams } from 'common' -import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation' +import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation' import { useReplicationTablesQuery } from 'data/etl/tables-query' import { Button, @@ -139,7 +139,7 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati - {tables?.tables.map((table) => ( + {tables?.map((table) => ( { {isSuccess && publications.map((x) => ( - - {x.name} - {/* [Joshen] Making this tooltip very specific for these 2 publications */} - {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( - x.name - ) && ( - - - - - - {x.name === 'supabase_realtime' - ? 'This publication is managed by Supabase and handles Postgres changes' - : x.name === 'supabase_realtime_messages_publication' - ? 'This publication is managed by Supabase and handles broadcasts from the database' - : undefined} - - - )} + +
+ {x.name} + {/* [Joshen] Making this tooltip very specific for these 2 publications */} + {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( + x.name + ) && ( + + + + + + {x.name === 'supabase_realtime' + ? 'Managed by Supabase and handles Postgres changes' + : x.name === 'supabase_realtime_messages_publication' + ? 'Managed by Supabase and handles broadcasts from the database' + : undefined} + + + )} +
{x.id} {publicationEvents.map((event) => ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index c5aec787a99..62b689f9843 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -78,8 +78,8 @@ export const PublicationsTableItem = ({ return ( {table.name} - {table.schema} - + {table.schema} + {table.comment} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index d3e77df1c63..f543e5469dc 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -102,7 +102,7 @@ export const PublicationsTables = () => { Name Schema - Description + Description {/* We've disabled All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233. diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index f88f13ebe16..cfaa15e552c 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -20,6 +20,7 @@ import { useRouter } from 'next/router' import { useState } from 'react' import { useParams } from 'common' +import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' @@ -63,7 +64,6 @@ import { } from 'ui' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' -import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' interface TableListProps { onAddTable: () => void @@ -325,7 +325,7 @@ export const TableList = ({ Size (Estimated) - + Realtime Enabled @@ -457,11 +457,11 @@ export const TableList = ({ {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? ( -
+
) : ( -
+
)} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx index 237f6b381a0..8365f779f0f 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx @@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
- Add new secrets + Add or replace secrets {fields.map((fieldItem, index) => ( @@ -178,7 +178,7 @@ const AddNewSecretForm = () => { name={`secrets.${index}.name`} render={({ field }) => ( - Key + Name { Add another - + +

+ Insert or update multiple secrets at once by pasting key-value pairs +

+
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 9e9aaf20ecc..576f19937ff 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -1,18 +1,28 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Trash } from 'lucide-react' +import { Edit2, MoreVertical, Trash } from 'lucide-react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { ProjectSecret } from 'data/secrets/secrets-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { TableCell, TableRow } from 'ui' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' import { TimestampInfo } from 'ui-patterns' interface EdgeFunctionSecretProps { secret: ProjectSecret onSelectDelete: () => void + onSelectEdit: () => void } -const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) => { +const EdgeFunctionSecret = ({ secret, onSelectEdit, onSelectDelete }: EdgeFunctionSecretProps) => { const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*') // [Joshen] Following API's validation: // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106 @@ -37,7 +47,7 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) displayAs="utc" utcTimestamp={secret.updated_at} labelFormat="DD MMM YYYY HH:mm:ss (ZZ)" - className="!text-sm text-foreground-light" + className="!text-sm text-foreground-light whitespace-nowrap" /> ) : ( '-' @@ -45,23 +55,63 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
- } - className="px-1" - disabled={!canUpdateSecrets || isReservedSecret} - onClick={() => onSelectDelete()} - tooltip={{ - content: { - side: 'bottom', - text: isReservedSecret - ? 'This is a reserved secret and cannot be deleted' - : !canUpdateSecrets - ? 'You need additional permissions to delete edge function secrets' - : undefined, - }, - }} - /> + + +
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 01938c432b6..85bf4695c55 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -15,11 +15,17 @@ import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import AddNewSecretForm from './AddNewSecretForm' import EdgeFunctionSecret from './EdgeFunctionSecret' +import { EditSecretSheet } from './EditSecretSheet' + +type SelectedProjectSecret = { + secret: ProjectSecret + op: 'delete' | 'edit' +} const EdgeFunctionSecrets = () => { const { ref: projectRef } = useParams() const [searchString, setSearchString] = useState('') - const [selectedSecret, setSelectedSecret] = useState() + const [selectedSecret, setSelectedSecret] = useState() const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.SECRETS_READ, @@ -33,7 +39,7 @@ const EdgeFunctionSecrets = () => { const { mutate: deleteSecret, isLoading: isDeleting } = useSecretsDeleteMutation({ onSuccess: () => { - toast.success(`Successfully deleted ${selectedSecret?.name}`) + toast.success(`Successfully deleted ${selectedSecret?.secret.name}`) setSelectedSecret(undefined) }, }) @@ -99,7 +105,8 @@ const EdgeFunctionSecrets = () => { setSelectedSecret(secret)} + onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })} + onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })} /> )) ) : secrets.length === 0 && searchString.length > 0 ? ( @@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => { )} + setSelectedSecret(undefined)} + /> + setSelectedSecret(undefined)} onConfirm={() => { if (selectedSecret !== undefined) { - deleteSecret({ projectRef, secrets: [selectedSecret.name] }) + deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] }) } }} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx new file mode 100644 index 00000000000..aec6bc30393 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useParams } from 'common' +import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation' +import { ProjectSecret } from 'data/secrets/secrets-query' +import { Eye, EyeOff, X } from 'lucide-react' +import { useLatest } from 'react-use' +import { + Button, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input, + Input_Shadcn_, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const FORM_ID = 'edit-secret-sidepanel' + +const FormSchema = z.object({ + name: z.string().min(1, 'Please provide a name for your secret'), + value: z.string().min(1, 'Please provide a value for your secret'), +}) + +type FormSchemaType = z.infer + +interface EditSecretSheetProps { + secret?: ProjectSecret + visible: boolean + onClose: () => void +} + +export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) { + const secretName = useLatest(secret?.name) + const form = useForm({ + resolver: zodResolver(FormSchema), + }) + useEffect(() => { + if (visible) { + form.reset({ + name: secretName.current ?? '', + value: '', + }) + } + }, [form, secretName, visible]) + const isValid = form.formState.isValid + + const { ref: projectRef } = useParams() + const { mutate: updateSecret, isLoading: isUpdating } = useSecretsCreateMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully updated secret "${variables.secrets[0].name}"`) + onClose() + }, + }) + const onSubmit: SubmitHandler = async ({ name, value }) => { + updateSecret({ + projectRef, + secrets: [{ name, value }], + }) + } + + const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose, + }) + + return ( + + +
+ + + + + + + + {closeConfirmationModal} + + ) +} + +const Header = (): ReactNode => { + return ( + + + + Close + + Edit secret + + ) +} + +type FormBodyProps = { + form: UseFormReturn + onSubmit: SubmitHandler +} + +const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => { + return ( + + + + + + + + + + + + + ) +} + +type NameFieldProps = { + form: UseFormReturn +} + +const NameField = ({ form }: NameFieldProps): ReactNode => { + return ( + ( + + + + + + )} + /> + ) +} + +type SecretFieldProps = { + form: UseFormReturn +} + +const SecretField = ({ form }: SecretFieldProps): ReactNode => { + const [showSecretValue, setShowSecretValue] = useState(false) + + return ( + ( + + + +
+ } + /> + + + )} + /> + ) +} + +type UseConfirmOnCloseParams = { + checkIsDirty: () => boolean + onClose: () => void +} + +type ConfirmOnCloseReturn = { + confirmOnClose: () => void + modal: ReactNode +} + +const useConfirmOnClose = ({ + checkIsDirty, + onClose, +}: UseConfirmOnCloseParams): ConfirmOnCloseReturn => { + const [visible, setVisible] = useState(false) + + const confirmOnClose = useCallback(() => { + if (checkIsDirty()) { + setVisible(true) + } else { + onClose() + } + }, [checkIsDirty, onClose]) + + const onConfirm = () => { + setVisible(false) + onClose() + } + + return { + confirmOnClose, + modal: ( + setVisible(false)} + /> + ), + } +} + +type CloseConfirmationModalProps = { + visible: boolean + onClose: () => void + onCancel: () => void +} + +const CloseConfirmationModal = ({ + visible, + onClose, + onCancel, +}: CloseConfirmationModalProps): ReactNode => { + return ( + +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index 9056d0f55cc..358c8fc2ad6 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -37,9 +37,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP className="cursor-pointer" > -
-

{item.name}

-
+

{item.name}

@@ -80,7 +78,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP - +

{dayjs(item.updated_at).fromNow()}

diff --git a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx index b6212222f1a..931c4265c90 100644 --- a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx +++ b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx @@ -49,7 +49,7 @@ export const AdvisorWidget = () => { ) const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const securityLints = useMemo( () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')), @@ -80,10 +80,10 @@ export const AdvisorWidget = () => { const handleLintClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) }, - [setSelectedItemId, openSidebar] + [setSelectedItem, openSidebar] ) const totalIssues = diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx index 3bccc7f130b..ffcc9d52752 100644 --- a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -30,7 +30,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const { mutate: sendEvent } = useSendEventMutation() const { data: organization } = useSelectedOrganizationQuery() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const errorLints: Lint[] = useMemo(() => { return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] @@ -67,7 +67,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const handleCardClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) if (projectRef && organization?.slug) { sendEvent({ @@ -84,7 +84,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo }) } }, - [sendEvent, setSelectedItemId, openSidebar, projectRef, organization, totalErrors] + [sendEvent, setSelectedItem, openSidebar, projectRef, organization, totalErrors] ) if (showEmptyState) { diff --git a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexImprovementText.tsx b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexImprovementText.tsx index 23472e38ab2..88af921bc73 100644 --- a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexImprovementText.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/IndexImprovementText.tsx @@ -19,7 +19,7 @@ export const IndexImprovementText = ({ const improvement = calculateImprovement(totalCostBefore, totalCostAfter) return ( -

+

Query's performance can be improved by{' '} {improvement.toFixed(2)}% by creating this{' '} {indexStatements.length > 1 ? 'indexes' : 'index'}: diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx index 3c68de3b599..036b2bfb867 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx @@ -124,9 +124,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { return ( - -

-

Indexes in use

+ +
+

Indexes in use

This query is using the following index{(usedIndexes ?? []).length > 1 ? 's' : ''}:

@@ -171,12 +171,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
)}
- -
- - -
-

New index recommendations

+ +
+

New index recommendations

{isLoadingExtensions ? ( ) : !isIndexAdvisorEnabled ? ( @@ -206,7 +203,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { {isLinterWarning ? ( @@ -238,10 +235,10 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap' )} /> -

+

This recommendation serves to prevent your queries from slowing down as your application grows, and hence the index may not be used immediately after - it's created. (e.g If your table is still small at this time) + it's created (e.g If your table is still small at this time).

)} @@ -250,11 +247,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { )}
- {isIndexAdvisorEnabled && hasIndexRecommendation && ( - <> -
-

Query costs

-
+ + {isIndexAdvisorEnabled && hasIndexRecommendation && ( + <> + +
+

Query costs

+
{
+
+
-

FAQ

+

FAQ

@@ -311,13 +312,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
- - )} -
+ + + )} {isIndexAdvisorEnabled && hasIndexRecommendation && (
-
+
Apply index to database This will run the SQL that is shown above diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx index 77bda75ecc7..15fd4fb0173 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx @@ -44,7 +44,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps)

This chart is available from{' '} - {!!report.availableIn?.length ? report.availableIn[0] : 'Pro'} + {Array.isArray(report.availableIn) && report.availableIn.length > 0 + ? report.availableIn[0] + : 'Pro'} {' '} plan and above

@@ -58,7 +60,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps) Upgrade to{' '} - {!!report.availableIn?.length ? report.availableIn[0] : 'Pro'} + {Array.isArray(report.availableIn) && report.availableIn.length > 0 + ? report.availableIn[0] + : 'Pro'} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index 769ee3b4bac..d1ab6e80ac1 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -68,8 +68,7 @@ export const ReportChartV2 = ({ const { plan: orgPlan } = useCurrentOrgPlan() const orgPlanId = orgPlan?.id - const isAvailable = - report.availableIn === undefined || (orgPlanId && report.availableIn.includes(orgPlanId)) + const isAvailable = !report?.availableIn || (orgPlanId && report.availableIn?.includes(orgPlanId)) const canFetch = orgPlanId !== undefined && isAvailable diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx index 95ef50311d9..1ec27e28f3e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx @@ -1,29 +1,31 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { AnimatePresence, motion } from 'framer-motion' import { snakeCase } from 'lodash' -import { Plus } from 'lucide-react' -import { useState } from 'react' +import { Loader2, Plus } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCreateDestinationPipelineMutation } from 'data/etl/create-destination-pipeline-mutation' -import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation' +import { useCreateTenantSourceMutation } from 'data/etl/create-tenant-source-mutation' +import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation' +import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' import { useReplicationSourcesQuery } from 'data/etl/sources-query' import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' -import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation' -import { useTablesQuery } from 'data/tables/tables-query' +import { useReplicationTablesQuery } from 'data/etl/tables-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogSection, @@ -33,10 +35,13 @@ import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, + Progress, } from 'ui' +import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector } from 'ui-patterns/multi-select' import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils' +import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' /** @@ -52,24 +57,100 @@ import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperIn * - Error handling due to multiple async processes */ -const FormSchema = z.object({ - tables: z.array(z.string()).min(1, 'At least one table is required'), -}) - const formId = 'connect-tables-form' -const isEnabled = false // Kill switch if we wanna hold off supporting connecting tables +const FormSchema = z.object({ + tables: z.array(z.string()).min(1, 'Select at least one table'), +}) type ConnectTablesForm = z.infer -export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { +enum PROGRESS_STAGE { + CREATE_PUBLICATION = 'CREATE_PUBLICATION', + CREATE_PIPELINE = 'CREATE_PIPELINE', + START_PIPELINE = 'START_PIPELINE', + UPDATE_PUBLICATION = 'CREATE_REPLICATION', +} + +const PROGRESS_INDICATORS = { + CREATE: [ + { + step: PROGRESS_STAGE.CREATE_PUBLICATION, + getDescription: (numTables: number) => + `Creating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`, + }, + { step: PROGRESS_STAGE.CREATE_PIPELINE, description: `Creating replication pipeline` }, + { step: PROGRESS_STAGE.START_PIPELINE, description: `Starting replication pipeline` }, + ], + UPDATE: [ + { + step: PROGRESS_STAGE.UPDATE_PUBLICATION, + getDescription: (numTables: number) => + `Updating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`, + }, + { step: PROGRESS_STAGE.START_PIPELINE, description: 'Restarting replication pipeline...' }, + ], +} + +interface ConnectTablesDialogProps { + onSuccessConnectTables: (tables: { schema: string; name: string }[]) => void +} + +export const ConnectTablesDialog = ({ onSuccessConnectTables }: ConnectTablesDialogProps) => { + const { ref: projectRef, bucketId } = useParams() + const [visible, setVisible] = useState(false) + + const isEnabled = useFlag('storageAnalyticsVector') // Kill switch if we wanna hold off supporting connecting tables + + const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId, + }) + const isEditingExistingPublication = !!publication && !!pipeline + + return ( + + + } + onClick={() => setVisible(true)} + tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }} + > + {isEditingExistingPublication ? 'Add tables' : 'Connect tables'} + + + + {!sourceId ? ( + setVisible(false)} /> + ) : ( + setVisible(false)} + onSuccessConnectTables={onSuccessConnectTables} + /> + )} + + ) +} + +export const ConnectTablesDialogContent = ({ + visible, + onClose, + onSuccessConnectTables, +}: ConnectTablesDialogProps & { visible: boolean; onClose: () => void }) => { + const { ref: projectRef, bucketId } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [isConnecting, setIsConnecting] = useState(false) + const [connectingStep, setConnectingStep] = useState() + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { tables: [] }, }) - - const [visible, setVisible] = useState(false) - const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() + const { tables: selectedTables } = form.watch() const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) @@ -78,52 +159,54 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true }) const { serviceKey } = getKeys(apiKeys) - const { data: tables } = useTablesQuery({ + const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({ projectRef, - connectionString: project?.connectionString, - includeColumns: false, + bucketId, }) + const isEditingExistingPublication = !!publication && !!pipeline - const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) - const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id - - const { mutateAsync: createNamespace, isLoading: isCreatingNamespace } = - useIcebergNamespaceCreateMutation() - - const { mutateAsync: createPublication, isLoading: isCreatingPublication } = - useCreatePublicationMutation() - - const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } = - useCreateDestinationPipelineMutation({ - onSuccess: () => {}, - }) + const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + const { mutateAsync: createPublication } = useCreatePublicationMutation() + const { mutateAsync: updatePublication } = useUpdatePublicationMutation() + const { mutateAsync: createDestinationPipeline } = useCreateDestinationPipelineMutation() const { mutateAsync: startPipeline } = useStartPipelineMutation() - const isConnecting = isCreatingNamespace || creatingDestinationPipeline || isCreatingPublication - - const onSubmit: SubmitHandler = async (values) => { - // [Joshen] Currently creates the destination for the analytics bucket here - // Which also involves creating a namespace + publication - // Publication name is automatically generated as {bucketId}_publication - // Destination name is automatically generated as {bucketId}_destination + const progressIndicator = useMemo( + () => (isEditingExistingPublication ? PROGRESS_INDICATORS.UPDATE : PROGRESS_INDICATORS.CREATE), + // [Joshen] This is to prevent the progressIndicator from flipping to UPDATE in the middle of CREATE + // since the publication and pipelines are getting created in the middle of CREATE + // eslint-disable-next-line react-hooks/exhaustive-deps + [isConnecting] + ) + const totalProgressSteps = progressIndicator.length + const currentStep = progressIndicator.findIndex((x) => x.step === connectingStep) + 1 + const progressDescription = progressIndicator.find((x) => x.step === connectingStep) + const onSubmitNewPublication: SubmitHandler = async (values) => { if (!projectRef) return console.error('Project ref is required') - if (!sourceId) return toast.error('Source ID is required') if (!bucketId) return toast.error('Bucket ID is required') + if (!sourceId) return toast.error('Replication has not been enabled for your project') try { + setIsConnecting(true) + + // Step 1: Create publication + setConnectingStep(PROGRESS_STAGE.CREATE_PUBLICATION) const publicationName = getAnalyticsBucketPublicationName(bucketId) + const publicationTables = values.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }) await createPublication({ projectRef, sourceId, name: publicationName, - tables: values.tables.map((table) => { - const [schema, name] = table.split('.') - return { schema, name } - }), + tables: publicationTables, }) + // Step 2: Create destination pipeline + setConnectingStep(PROGRESS_STAGE.CREATE_PIPELINE) const keysToDecrypt = Object.entries(wrapperValues) .filter(([name]) => ['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name) @@ -141,29 +224,15 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']] const s3Region = projectSettings?.region ?? '' - const protocol = projectSettings?.app_config?.protocol ?? 'https' - const endpoint = - projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint - const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint) - const namespace = `${bucketId}_namespace` - await createNamespace({ - catalogUri, - warehouse: warehouseName, - token: catalogToken, - namespace, - }) - const icebergConfiguration = { projectRef, warehouseName, - namespace, catalogToken, s3AccessKeyId, s3SecretAccessKey, s3Region, } const destinationName = `${snakeCase(bucketId)}_destination` - const { pipeline_id: pipelineId } = await createDestinationPipeline({ projectRef, destinationName, @@ -172,104 +241,241 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { pipelineConfig: { publicationName }, }) - // Pipeline can start behind the scenes, don't need to await - startPipeline({ projectRef, pipelineId }) + // Step 3: Start the destination pipeline + setConnectingStep(PROGRESS_STAGE.START_PIPELINE) + await startPipeline({ projectRef, pipelineId }) + + onSuccessConnectTables?.(publicationTables) toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`) form.reset() - setVisible(false) + onClose() } catch (error: any) { // [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly // e.g publication gets created, but namespace creation fails -> should the old publication get deleted? // Another question is probably whether all of these step by step logic should be at the API level instead of client level // Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA toast.error(`Failed to connect tables: ${error.message}`) + } finally { + setIsConnecting(false) + setConnectingStep(undefined) } } - const handleClose = () => { - form.reset() - setVisible(false) + const onSubmitUpdatePublication: SubmitHandler = async (values) => { + if (!projectRef) return console.error('Project ref is required') + if (!sourceId) return toast.error('Replication has not been enabled on this project') + if (!bucketId) return toast.error('Bucket ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + try { + setIsConnecting(true) + + const tablesToBeAdded = values.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }) + setConnectingStep(PROGRESS_STAGE.UPDATE_PUBLICATION) + const publicationTables = publication.tables.concat(tablesToBeAdded) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: publicationTables, + }) + + setConnectingStep(PROGRESS_STAGE.START_PIPELINE) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + + onSuccessConnectTables?.(tablesToBeAdded) + toast.success('Successfully updated connected tables! Pipeline is being restarted') + onClose() + } catch (error: any) { + toast.error(`Failed to update tables: ${error.message}`) + } finally { + setIsConnecting(false) + setConnectingStep(undefined) + } } + const onSubmit: SubmitHandler = async (values) => { + if (isEditingExistingPublication) { + onSubmitUpdatePublication(values) + } else { + onSubmitNewPublication(values) + } + } + + useEffect(() => { + form.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]) + return ( - { - if (!open) handleClose() - }} - > - - } - onClick={() => setVisible(true)} - tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }} - > - Connect tables - - + + + + {isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'} + + - - - Connect tables - + - - - -
- -

- Select the database tables to send data from. A destination analytics table will be - created for each, and data will replicate automatically. -

-
- - - ( - - - - - - - {tables?.map((table) => ( + + + +

+ Select the database tables to send data from. A destination analytics table will be + created for each, and data will replicate automatically. +

+
+ + + ( + + + + + + + {tables?.map((table) => { + const alreadyConnected = (publication?.tables ?? []).some( + (x) => x.schema === table.schema && x.name === table.name + ) + return ( - {`${table.schema}.${table.name}`} + {`${table.schema}.${table.name}`} + {alreadyConnected && Connected to analytics bucket} - ))} - - - - - - )} - /> - - -
+ ) + })} +
+
+
+
+
+ )} + /> +
+ +
- - + + +
+ ) +} + +const EnableReplicationDialogContent = ({ onClose }: { onClose: () => void }) => { + const { ref: projectRef } = useParams() + const enablePgReplicate = useFlag('enablePgReplicate') + const { error } = useReplicationSourcesQuery({ projectRef }) + const noAccessToReplication = + !enablePgReplicate || error?.message.includes('feature flag is required') + + const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } = + useCreateTenantSourceMutation() + + const onEnableReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + await createTenantSource({ projectRef }) + } + + return ( + + + Database replication needs to be enabled + + Replication is used to sync data from your Postgres tables + + + + + + {noAccessToReplication ? ( +

+ Access to database replication is currently not available yet for public use. If + you're interested, do reach out to us via support! +

+ ) : ( + <> +

+ This feature is in active development and may change as we gather feedback. + Availability and behavior can evolve while in Alpha. +

+

+ Pricing has not been finalized yet. You can enable replication now; we’ll announce + pricing later and notify you before any charges apply. +

+ + )} +
+
+ + + {!noAccessToReplication && ( + - - -
-
+ )} + + ) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx deleted file mode 100644 index a64264be2b1..00000000000 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react' -import { useMemo, useState } from 'react' - -import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' -import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' -import { FDW } from 'data/fdw/fdws-query' -import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query' -import { BASE_PATH } from 'lib/constants' -import { Button, cn, TableCell, TableRow } from 'ui' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' - -type NamespaceRowProps = { - bucketName: string - namespace: string - schema: string - tables: (FormattedWrapperTable & { id: number })[] - token: string - wrapperInstance: FDW - wrapperValues: Record - wrapperMeta: WrapperMeta -} - -export const NamespaceRow = ({ - bucketName, - namespace, - schema, - tables, - token, - wrapperInstance, - wrapperValues, - wrapperMeta, -}: NamespaceRowProps) => { - const { data: project } = useSelectedProjectQuery() - const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false) - - const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery( - { - catalogUri: wrapperValues.catalog_uri, - warehouse: wrapperValues.warehouse, - token: token, - namespace: namespace, - }, - { enabled: !!token } - ) - - const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } = - useFDWImportForeignSchemaMutation() - - const rescanNamespace = async () => { - await importForeignSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - serverName: wrapperInstance.server_name, - sourceSchema: namespace, - targetSchema: schema, - }) - } - - const missingTables = useMemo(() => { - return (tablesData || []).filter( - (t) => !tables.find((table) => table.table.split('.')[1] === t) - ) - }, [tablesData, tables]) - - let scanTooltip = useMemo(() => { - if (isImportingForeignSchema) return 'Scanning for new tables...' - if (isLoadingNamespaceTables) return 'Loading tables...' - if (missingTables.length > 0) return `Found ${missingTables.length} new tables` - if (tables.length === 0) return 'No tables found' - return 'All tables are up to date' - }, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length]) - - return ( - - {namespace} - - {schema ?? 'No schema'} - - - {tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``} - - -
- 0 ? 'primary' : 'default'} - icon={} - loading={isImportingForeignSchema || isLoadingNamespaceTables} - onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))} - disabled={missingTables.length === 0} - tooltip={{ content: { text: scanTooltip } }} - > - Sync - - {schema ? ( - - ) : ( - } - disabled - tooltip={{ - content: { text: 'There are no tables connected.' }, - }} - > - Open in Table Editor - - )} -
-
- setImportForeignSchemaShown(false)} - /> -
- ) -} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx new file mode 100644 index 00000000000..6a1f43de7af --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -0,0 +1,240 @@ +import { snakeCase } from 'lodash' +import { MoreVertical, Pause, Play } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' +import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' +import { useReplicationTablesQuery } from 'data/etl/tables-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { SqlEditor } from 'icons' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities' + +export const TableRowComponent = ({ + index, + table, + schema, + isLoading, +}: { + index: number + table: { id: number; name: string; isConnected: boolean } + schema: string + isLoading?: boolean +}) => { + const { ref: projectRef, bucketId } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [showStopReplicationModal, setShowStopReplicationModal] = useState(false) + const [showStartReplicationModal, setShowStartReplicationModal] = useState(false) + const [isUpdatingReplication, setIsUpdatingReplication] = useState(false) + + const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId, + }) + + const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + + const { mutateAsync: updatePublication } = useUpdatePublicationMutation() + const { mutateAsync: startPipeline } = useStartPipelineMutation() + + const isReplicating = publication?.tables.find( + (x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`) + ) + + const onConfirmStopReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('Bucket ID is required') + if (!sourceId) return toast.error('Source ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + try { + setIsUpdatingReplication(true) + // [Joshen ALPHA] Assumption here is that all the namespace tables have _changelog as suffix + // May need to update if that assumption falls short (e.g for those dealing with iceberg APIs directly) + const updatedTables = publication.tables.filter( + (x) => table.name !== snakeCase(`${x.schema}.${x.name}_changelog`) + ) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: updatedTables, + }) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setShowStopReplicationModal(false) + toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + } catch (error: any) { + toast.error(`Failed to stop replication for table: ${error.message}`) + } finally { + setIsUpdatingReplication(false) + } + } + + const onConfirmStartReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('Bucket ID is required') + if (!sourceId) return toast.error('Source ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + // [Joshen ALPHA] This has potential to be flaky - we should see how we can get the table name and schema better + const pgTable = tables?.find((t) => snakeCase(`${t.schema}.${t.name}_changelog`) === table.name) + if (!pgTable) return toast.error('Unable to find corresponding Postgres table') + + try { + setIsUpdatingReplication(true) + const updatedTables = publication.tables.concat([ + { schema: pgTable.schema, name: pgTable.name }, + ]) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: updatedTables, + }) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setShowStartReplicationModal(false) + toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + } catch (error: any) { + toast.error(`Failed to stop replication for table: ${error.message}`) + } finally { + setIsUpdatingReplication(false) + } + } + + return ( + <> + + {table.name} + {!!publication && ( + +
+
+ + +
+ + {isLoading && !isReplicating + ? '-' + : isReplicating + ? 'Replicating' + : 'Not replicating'} + +
+
+ )} + + {table.isConnected && ( + + <> + + + + - - - )}
+ + {tablesToPoll.length > 0 && } + Table name - Status + {!!publication && ( + + Replication Status + + )} @@ -311,11 +234,11 @@ export const NamespaceWithTables = ({ ) : ( - allTables.map(({ name, isConnected }, index) => ( + allTables.map((table, index) => ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx index d01c01c5581..a9efb065f22 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx @@ -1,8 +1,8 @@ import { uniq } from 'lodash' -import { SquarePlus } from 'lucide-react' +import { Loader2, SquarePlus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' @@ -38,6 +38,7 @@ import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal' import { ConnectTablesDialog } from './ConnectTablesDialog' import { NamespaceWithTables } from './NamespaceWithTables' import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' +import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' import { useIcebergWrapperExtension } from './useIcebergWrapper' @@ -55,14 +56,38 @@ export const AnalyticBucketDetails = () => { const bucket = _bucket as undefined | AnalyticsBucket const [modal, setModal] = useState<'delete' | null>(null) + // [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after + // updating connected tables until namespaces are updated + // Namespace would just be the schema (Which is currently limited to public) + // Wrapper table would be {schema}_{table}_changelog + const [tablesToPoll, setTablesToPoll] = useState<{ schema: string; name: string }[]>([]) + const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) + const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */ const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({ bucketId: bucket?.id, }) + const { pipeline } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId: bucket?.id, + }) + const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta + const state = isLoading + ? 'loading' + : extensionState === 'installed' + ? wrapperInstance + ? 'added' + : 'missing' + : extensionState + + const wrapperTables = useMemo(() => { + if (!wrapperInstance) return [] + return formatWrapperTables(wrapperInstance, wrapperMeta!) + }, [wrapperInstance, wrapperMeta]) const { data: extensionsData } = useDatabaseExtensionsQuery({ projectRef: project?.ref, @@ -85,15 +110,24 @@ export const AnalyticBucketDetails = () => { warehouse: wrapperValues.warehouse, token: token!, }, - { enabled: isSuccessToken } + { + enabled: isSuccessToken, + refetchInterval: (data) => { + if (pollIntervalNamespaces === 0) return false + if (tablesToPoll.length > 0) { + const schemas = [...new Set(tablesToPoll.map((x) => x.schema))] + const hasSchemaMissingFromNamespace = !schemas.some((x) => (data ?? []).includes(x)) + + if (!hasSchemaMissingFromNamespace) { + setPollIntervalNamespaces(0) + return false + } + } + return pollIntervalNamespaces + }, + } ) - const wrapperTables = useMemo(() => { - if (!wrapperInstance) return [] - - return formatWrapperTables(wrapperInstance, wrapperMeta!) - }, [wrapperInstance, wrapperMeta]) - const namespaces = useMemo(() => { const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[] const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])]) @@ -110,13 +144,11 @@ export const AnalyticBucketDetails = () => { }) }, [wrapperTables, namespacesData]) - const state = isLoading - ? 'loading' - : extensionState === 'installed' - ? wrapperInstance - ? 'added' - : 'missing' - : extensionState + useEffect(() => { + if (pollIntervalNamespaces === 0 && pollIntervalNamespaceTables === 0) { + setTablesToPoll([]) + } + }, [pollIntervalNamespaces, pollIntervalNamespaceTables]) return ( <> @@ -160,22 +192,68 @@ export const AnalyticBucketDetails = () => { Analytics tables stored in this bucket - {namespaces.length > 0 && } +
+ {!!pipeline && ( + + )} + {namespaces.length > 0 && ( + { + setTablesToPoll(tables) + setPollIntervalNamespaces(4000) + setPollIntervalNamespaceTables(4000) + }} + /> + )} +
{isLoadingNamespaces || isLoading ? ( ) : namespaces.length === 0 ? ( - + <> + {tablesToPoll.length > 0 ? ( + + ) : ( + + )} + ) : (
{namespaces.map(({ namespace, schema, tables }) => ( @@ -190,6 +268,9 @@ export const AnalyticBucketDetails = () => { wrapperInstance={wrapperInstance} wrapperValues={wrapperValues} wrapperMeta={wrapperMeta} + tablesToPoll={tablesToPoll} + pollIntervalNamespaceTables={pollIntervalNamespaceTables} + setPollIntervalNamespaceTables={setPollIntervalNamespaceTables} /> ))}
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx index c869502e2b0..41a3c6aa334 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx @@ -1,13 +1,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' -import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' -import { FDW } from 'data/fdw/fdws-query' +import { useReplicationPipelinesQuery } from 'data/etl/pipelines-query' import { ReplicationPublication, useReplicationPublicationsQuery, } from 'data/etl/publications-query' import { useReplicationSourcesQuery } from 'data/etl/sources-query' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' +import { FDW } from 'data/fdw/fdws-query' import { useS3AccessKeyDeleteMutation } from 'data/storage/s3-access-key-delete-mutation' import { S3AccessKey, useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -57,7 +58,10 @@ export const useAnalyticsBucketAssociatedEntities = ( (p) => p.name === getAnalyticsBucketPublicationName(bucketId ?? '') ) - return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } + const { data: pipelines } = useReplicationPipelinesQuery({ projectRef }) + const pipeline = pipelines?.pipelines.find((x) => x.config.publication_name === publication?.name) + + return { icebergWrapper, icebergWrapperMeta, s3AccessKey, sourceId, publication, pipeline } } export const useAnalyticsBucketDeleteCleanUp = () => { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx index b685e212e87..39e3f234c9c 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx @@ -6,12 +6,12 @@ import { getWrapperMetaForWrapper, wrapperMetaComparator, } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { useFDWsQuery } from 'data/fdw/fdws-query' +import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' export const useAnalyticsBucketWrapperInstance = ( { bucketId }: { bucketId?: string }, - options?: { enabled?: boolean } + options?: { enabled?: boolean; refetchInterval?: (data: FDW[] | undefined) => number | false } ) => { const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() @@ -21,7 +21,11 @@ export const useAnalyticsBucketWrapperInstance = ( projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: defaultEnabled && !!bucketId } + { + enabled: defaultEnabled && !!bucketId, + refetchInterval: (data) => + !!options?.refetchInterval ? options.refetchInterval(data) : false, + } ) const icebergWrapper = useMemo(() => { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index 2e2afe50d13..54210e35902 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -164,8 +164,8 @@ export const CreateAnalyticsBucketModal = ({ groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - toast.success(`Created bucket “${values.name}”`) form.reset() + toast.success(`Created bucket “${values.name}”`) setVisible(false) router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`) } catch (error: any) { diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index 6efe28e4835..17f536eef04 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -36,11 +36,14 @@ export const ImportForeignSchemaDialog = ({ onClose, circumstance = 'fresh', }: ImportForeignSchemaDialogProps) => { - const { data: project } = useSelectedProjectQuery() const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() const [loading, setLoading] = useState(false) const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false) + const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! }) + + const { mutateAsync: createSchema } = useSchemaCreateMutation() const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({}) const { mutateAsync: updateFDW } = useFDWUpdateMutation({ onSuccess: () => { @@ -49,8 +52,6 @@ export const ImportForeignSchemaDialog = ({ }, }) - const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! }) - const FormSchema = z.object({ bucketName: z.string().trim(), sourceNamespace: z.string().trim(), @@ -73,12 +74,10 @@ export const ImportForeignSchemaDialog = ({ defaultValues: { bucketName, sourceNamespace: namespace, - targetSchema: '', + targetSchema: `fdw_analytics_${namespace}`, }, }) - const { mutateAsync: createSchema } = useSchemaCreateMutation() - const onSubmit: SubmitHandler> = async (values) => { const serverName = `${snakeCase(values.bucketName)}_fdw_server` @@ -150,7 +149,7 @@ export const ImportForeignSchemaDialog = ({ form.reset({ bucketName, sourceNamespace: namespace, - targetSchema: '', + targetSchema: `fdw_analytics_${namespace}`, }) } }, [visible, form, bucketName, namespace]) diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx index a80d9d47623..95ebf325cd8 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx @@ -1,8 +1,8 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { noop } from 'lodash' -import { Bucket } from 'data/storage/buckets-query' import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow' +import { Bucket } from 'data/storage/buckets-query' import { Bucket as BucketIcon } from 'icons' import { @@ -40,11 +40,13 @@ export const StoragePoliciesBucketRow = ({ }: StoragePoliciesBucketRowProps) => { return ( - -
+ +
- {label} - {bucket?.public && Public} +
+ {label} + {bucket?.public && Public} +
- - Description - - - Access key ID - - - Created at - + Name + Key ID + Created at diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx index fb24091c0c5..36a0eff15eb 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx @@ -53,7 +53,9 @@ export const StorageCredItem = ({ - {daysSince(created_at)} + + {daysSince(created_at)} + {canRemoveAccessKey && ( diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 6b3c3c3fccc..7dd855ae65d 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -1,14 +1,11 @@ import { Lightbulb } from 'lucide-react' - -import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectLintsQuery } from 'data/lint/lint-query' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { cn } from 'ui' -export const AdvisorButton = () => { - const { ref: projectRef } = useParams() +export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() const { data: lints } = useProjectLintsQuery({ projectRef }) diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index c2ed5bcdeae..47229e4acba 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -8,10 +8,11 @@ import { Sidebar } from 'components/interfaces/Sidebar' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy' import { useAppStateSnapshot } from 'state/app-state' -import { SidebarProvider } from 'ui' +import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui' import { LayoutHeader } from './ProjectLayout/LayoutHeader/LayoutHeader' import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar' import { ProjectContextProvider } from './ProjectLayout/ProjectContext' +import { LayoutSidebar } from './ProjectLayout/LayoutSidebar' import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' export interface DefaultLayoutProps { @@ -55,8 +56,8 @@ const DefaultLayout = ({ return ( - - + +
{/* Top Banner */} @@ -75,13 +76,22 @@ const DefaultLayout = ({
{/* Sidebar - Only show for project pages, not account pages */} {!router.pathname.startsWith('/account') && } - {/* Main Content */} -
{children}
+ {/* Main Content with Layout Sidebar */} + + +
{children}
+
+ +
-
-
+ +
) } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 8c7765ca2aa..b8e99afb3e4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -27,8 +27,6 @@ import { HelpPopover } from './HelpPopover' import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import MergeRequestButton from './MergeRequestButton' -import { NotificationsPopoverV2 } from './NotificationsPopoverV2/NotificationsPopover' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -212,11 +210,10 @@ export const LayoutHeader = ({
- + {!!projectRef && ( <> - @@ -229,10 +226,10 @@ export const LayoutHeader = ({ <>
+ {!!projectRef && ( <> - diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx deleted file mode 100644 index 574b9aa4721..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import dayjs from 'dayjs' -import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useEffect } from 'react' -import { useInView } from 'react-intersection-observer' -import { Button, cn } from 'ui' - -import { Markdown } from 'components/interfaces/Markdown' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useVirtualizerContext } from 'components/ui/InfiniteList' -import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query' -import { useProjectDetailQuery } from 'data/projects/project-detail-query' -import type { Organization } from 'types' -import { CriticalIcon, WarningIcon } from 'ui' - -interface NotificationRowProps { - index: number - item: NotificationsData[number] - getOrganizationById: (id: number) => Organization - getOrganizationBySlug: (slug: string) => Organization - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void - queueMarkRead: (id: string) => void -} - -const NotificationRow = ({ - index, - item: notification, - getOrganizationById, - getOrganizationBySlug, - onUpdateNotificationStatus, - queueMarkRead, -}: NotificationRowProps) => { - const { virtualizer } = useVirtualizerContext() - const { ref: viewRef, inView } = useInView() - - const { status, priority } = notification - const data = notification.data as NotificationData - - const { data: project } = useProjectDetailQuery({ ref: data.project_ref }) - - const organization = - data.org_slug !== undefined - ? getOrganizationBySlug(data.org_slug) - : project !== undefined - ? getOrganizationById(project.organization_id) - : undefined - - const daysFromNow = dayjs().diff(dayjs(notification.inserted_at), 'day') - const formattedTimeFromNow = dayjs(notification.inserted_at).fromNow() - const formattedInsertedAt = dayjs(notification.inserted_at).format('MMM DD, YYYY') - - const onButtonAction = (type?: string) => { - // [Joshen] Implement accordingly - BE team will need to give us a heads up on this - console.log('Action', type) - } - - useEffect(() => { - if (inView && notification.status === 'new') { - queueMarkRead(notification.id) - } - }, [inView]) - - return ( -
-
- {(project !== undefined || organization !== undefined) && ( -
- {organization !== undefined && ( - - {organization.name} - - )} - {organization !== undefined && project !== undefined && ( - - - - - - )} - {project !== undefined && ( - - {project.name} - - )} -
- )} -
-

- {data.title}{' '} - - {daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow} - -

-
- {data.message !== undefined && ( - - )} - {(data.actions ?? []).length > 0 && ( -
- {data.actions.map((action, idx) => { - const key = `${notification.id}-action-${idx}` - if (action.url !== undefined) { - const url = action.url.includes('[ref]') - ? action.url.replace('[ref]', project?.ref ?? '_') - : action.url.includes('[slug]') - ? action.url.replace('[slug]', organization?.slug ?? '_') - : action.url - return ( - - ) - } else if (action.action_type !== undefined) { - return ( - - ) - } else { - return null - } - })} -
- )} -
-
- {priority === 'Warning' && } - {priority === 'Critical' && } - {notification.status === 'archived' ? ( - - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'seen')} - tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }} - /> - ) : ( - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'archived')} - tooltip={{ content: { text: 'Archive', side: 'bottom' } }} - /> - )} -
-
- ) -} - -export default NotificationRow diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx deleted file mode 100644 index 1cea7a375c8..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { RotateCcw, Settings2Icon, X } from 'lucide-react' -import { useMemo, useState } from 'react' -import { - Button, - Checkbox_Shadcn_, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, - Command_Shadcn_, - DropdownMenuLabel, - Label_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, - cn, -} from 'ui' - -import { CommandGroup } from '@ui/components/shadcn/ui/command' -import { useDebounce } from '@uidotdev/usehooks' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { CriticalIcon, WarningIcon } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' - -// [Joshen] Opting to not use infinite loading for projects in this UI specifically -// since the UX feels quite awkward having infinite loading for just a specific section in this Popover - -export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => { - const [open, setOpen] = useState(false) - const snap = useNotificationsStateSnapshot() - - const [search, setSearch] = useState('') - const debouncedSearch = useDebounce(search, 500) - - const { data: organizations } = useOrganizationsQuery() - const { data } = useProjectsInfiniteQuery( - { search: search.length === 0 ? search : debouncedSearch }, - { keepPreviousData: true, enabled: open } - ) - const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] - const projectCount = data?.pages[0].pagination.count ?? 0 - const pageLimit = data?.pages[0].pagination.limit ?? 0 - - return ( - - - - - - - - - No filters found that match your search - - - - - Status - { - snap.setFilters('unread', 'status') - }} - > - - - Unread - - - - - - - - Priority - { - snap.setFilters('Warning', 'priority') - }} - className="flex items-center gap-x-2" - > - - - - Warning - - - { - snap.setFilters('Critical', 'priority') - }} - > - - - - Critical - - - - - - - - Organizations - {(organizations ?? []).map((org) => ( - { - snap.setFilters(org.slug, 'organizations') - }} - > - - - {org.name} - - - ))} - - - - - - Projects - {/* - [Joshen] Adding a separate search input field here for projects as the - top level CommandInput doesn't work well with a mix of sync and async data - */} -
- setSearch(e.target.value)} - actions={ - search.length > 0 ? ( - setSearch('')} - /> - ) : null - } - /> -
- {(projects ?? []).map((project) => ( - { - snap.setFilters(project.ref, 'projects') - }} - > - - - {project.name} - - - ))} - {projectCount > pageLimit && ( -

- Not all projects are shown here. Try searching to find a specific project. -

- )} -
-
- - - - - snap.resetFilters()} - className="flex gap-x-2 items-center" - > - - Reset filters - - -
-
-
-
- ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx deleted file mode 100644 index 81db3d3d2c1..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { ArchiveIcon, InboxIcon } from 'lucide-react' -import { useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - -import AlertError from 'components/ui/AlertError' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InfiniteListDefault } from 'components/ui/InfiniteList' -import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useNotificationsArchiveAllMutation } from 'data/notifications/notifications-v2-archive-all-mutation' -import { useNotificationsV2Query } from 'data/notifications/notifications-v2-query' -import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query' -import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { - Button, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import NotificationRow from './NotificationRow' -import { NotificationsFilter } from './NotificationsFilter' - -export const NotificationsPopoverV2 = () => { - const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox') - - const snap = useNotificationsStateSnapshot() - - // Storing in ref as no re-rendering required - const markedRead = useRef([]) - - const { data: organizations } = useOrganizationsQuery({ enabled: open }) - const { - data, - error, - isLoading, - isError, - isSuccess, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useNotificationsV2Query( - { - status: - activeTab === 'archived' - ? 'archived' - : snap.filterStatuses.includes('unread') - ? 'new' - : undefined, - filters: { - priority: snap.filterPriorities, - organizations: snap.filterOrganizations, - projects: snap.filterProjects, - }, - }, - { enabled: open } - ) - const { data: summary } = useNotificationsSummaryQuery() - const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() - const { mutate: archiveAllNotifications, isLoading: isArchiving } = - useNotificationsArchiveAllMutation({ - onSuccess: () => toast.success('Successfully archived all notifications'), - }) - - const notifications = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data?.pages]) - const hasNewNotifications = summary?.unread_count ?? 0 > 0 - const hasWarning = summary?.has_warning - const hasCritical = summary?.has_critical - - const markNotificationsRead = () => { - if (markedRead.current.length > 0) { - updateNotifications({ ids: markedRead.current, status: 'seen' }) - } - } - - return ( - { - setOpen(open) - if (!open) markNotificationsRead() - }} - > - - - - {hasCritical && ( -
-
-
- )} - {hasWarning && !hasCritical && ( -
-
-
- )} - {!!hasNewNotifications && !hasCritical && !hasWarning && ( -
-
-
- )} -
- } - /> - - -
-

Notifications

-
- { - setActiveTab(tab as 'inbox' | 'archived') - if (tab === 'archived' && snap.filterStatuses.includes('unread')) { - snap.setFilters('unread', 'status') - } - }} - value={activeTab} - > -
- - - Inbox -
9 ? 'px-0.5 w-auto' : 'w-4' - )} - > - {summary?.unread_count} -
-
- - Archived - -
- -
-
-
-
-
- {isLoading && ( -
- -
- )} - {isError && ( -
- -
- )} - {isSuccess && ( -
- {notifications.length > 0 && - !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( - ( -
- -
- )} - itemProps={{ - getOrganizationById: (id: number) => - organizations?.find((org) => org.id === id)!, - getOrganizationBySlug: (slug: string) => - organizations?.find((org) => org.slug === slug)!, - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => { - updateNotifications({ ids: [id], status }) - }, - queueMarkRead: (id: string) => { - if (markedRead.current && !markedRead.current.includes(id)) { - markedRead.current.push(id) - } - }, - }} - getItemSize={() => 56} - hasNextPage={hasNextPage} - isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} - /> - ) : ( -
- -
-

- {activeTab === 'archived' - ? `No archived notifications${ - snap.numFiltersApplied > 0 - ? ` based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : '' - }` - : snap.numFiltersApplied > 0 - ? `No notifications based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : 'All caught up'} -

-

- {activeTab === 'archived' - ? 'Notifications that you have previously archived will be shown here' - : 'You will be notified here for any notices on your organizations and projects'} -

-
-
- )} -
- )} -
- {notifications.length > 0 && activeTab === 'inbox' && ( -
- -
- )} -
- - ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index 85b8d86d5ea..ca5af49f6b3 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -1,13 +1,12 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' - -import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' +import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' export const SIDEBAR_KEYS = { AI_ASSISTANT: 'ai-assistant', @@ -15,16 +14,14 @@ export const SIDEBAR_KEYS = { ADVISOR_PANEL: 'advisor-panel', } as const -// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout -// to prevent unnecessary registering / unregistering of sidebars with every route change export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() - useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i') - useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e') - useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => ) + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) + useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) const router = useRouter() const { openSidebar, activeSidebar } = useSidebarManagerSnapshot() diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index 1c5cef6bb60..3a0bd639a17 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({ AIAssistant: () =>
AI Assistant
, })) +vi.mock('components/ui/EditorPanel/EditorPanel', () => ({ + EditorPanel: () =>
Editor Panel
, +})) + +vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({ + AdvisorPanel: () =>
Advisor Panel
, +})) + +const mockProject = { + id: 1, + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY' as const, + organization_id: 1, + cloud_provider: 'AWS', + region: 'us-east-1', + inserted_at: new Date().toISOString(), + subscription_id: 'subscription-1', + db_host: 'db.supabase.co', + is_branch_enabled: false, + is_physical_backups_enabled: false, + restUrl: 'https://project-1.supabase.co', +} + +let mockProjectData: typeof mockProject | undefined = mockProject + +vi.mock('hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => { + // Access the variable at runtime when the function is called + return { + data: mockProjectData, + } + }, +})) + +vi.mock('hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ + data: { + id: 1, + name: 'Organization 1', + slug: 'test-org', + plan: { id: 'free', name: 'Free' }, + managed_by: 'supabase', + is_owner: true, + billing_email: 'billing@example.com', + billing_partner: null, + usage_billing_enabled: false, + stripe_customer_id: 'stripe-1', + subscription_id: 'subscription-1', + organization_requires_mfa: false, + opt_in_tags: [], + restriction_status: null, + restriction_data: null, + organization_missing_address: false, + }, + }), +})) + +vi.mock('data/telemetry/send-event-mutation', () => ({ + useSendEventMutation: () => ({ + mutate: vi.fn(), + }), +})) + const resetSidebarManagerState = () => { Object.keys(sidebarManagerState.sidebars).forEach((id) => { sidebarManagerState.unregisterSidebar(id) @@ -59,7 +123,8 @@ describe('LayoutSidebar', () => { sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) }) - expect(await screen.findByTestId('ai-assistant-sidebar')).toBeInTheDocument() + const sidebar = await screen.findByTestId('ai-assistant-sidebar') + expect(sidebar).toBeTruthy() }) it('auto-opens when sidebar query param matches a registered sidebar', async () => { @@ -69,4 +134,54 @@ describe('LayoutSidebar', () => { await screen.findByTestId('ai-assistant-sidebar') }) + + describe('at organization level', () => { + beforeEach(() => { + routerMock.setCurrentUrl('/org/default') + // Set project to undefined to simulate org-level (no project) + mockProjectData = undefined + }) + + afterEach(() => { + // Reset to project data for other tests + mockProjectData = mockProject + }) + + it('does not register project-related sidebars when no project is available', async () => { + renderSidebar() + + // Wait a bit to ensure sidebars have been registered + await waitFor(() => { + // Project-related sidebars should not be registered + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.AI_ASSISTANT]).toBeUndefined() + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.EDITOR_PANEL]).toBeUndefined() + // Advisor panel should still be available (doesn't require project) + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + }) + + it('does not render project-related sidebars even when toggled', async () => { + renderSidebar() + + await waitFor(() => { + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + + // Try to toggle AI_ASSISTANT - should not work since it's not registered + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + }) + + // Should not render since it's not registered + expect(screen.queryByTestId('ai-assistant-sidebar')).toBeNull() + expect(screen.queryByTestId('editor-panel-sidebar')).toBeNull() + + // Advisor panel should work + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + }) + + expect(await screen.findByTestId('advisor-panel-sidebar')).toBeTruthy() + }) + }) }) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 451cb975512..45514fe65d8 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -19,7 +19,6 @@ import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' -import { LayoutSidebar } from './LayoutSidebar' import { LoadingState } from './LoadingState' import { ProjectPausedState } from './PausedState/ProjectPausedState' import { PauseFailedState } from './PauseFailedState' @@ -182,39 +181,26 @@ export const ProjectLayout = forwardRef - - -
- {showPausedState ? ( -
-
- -
-
- ) : ( - - - {children} - - )} -
-
- -
+ {showPausedState ? ( +
+
+ +
+
+ ) : ( + + + {children} + + )} +
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx new file mode 100644 index 00000000000..cc124a5c69c --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx @@ -0,0 +1,41 @@ +import LintDetail from 'components/interfaces/Linter/LintDetail' +import { Lint } from 'data/lint/lint-query' +import { Notification } from 'data/notifications/notifications-v2-query' +import { noop } from 'lodash' +import { AdvisorItem } from './AdvisorPanelHeader' +import { NotificationDetail } from './NotificationDetail' + +interface AdvisorDetailProps { + item: AdvisorItem + projectRef: string + onUpdateNotificationStatus?: (id: string, status: 'archived' | 'seen') => void +} + +export const AdvisorDetail = ({ + item, + projectRef, + onUpdateNotificationStatus = noop, +}: AdvisorDetailProps) => { + if (item.source === 'lint') { + const lint = item.original as Lint + return ( +
+ +
+ ) + } + + if (item.source === 'notification') { + const notification = item.original as Notification + return ( +
+ +
+ ) + } + + return null +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx new file mode 100644 index 00000000000..6fc70c1930c --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx @@ -0,0 +1,100 @@ +import { X } from 'lucide-react' + +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FilterPopover } from 'components/ui/FilterPopover' +import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state' +import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui' + +const severityOptions = [ + { label: 'Critical', value: 'critical' }, + { label: 'Warning', value: 'warning' }, + { label: 'Info', value: 'info' }, +] + +const statusOptions = [ + { label: 'Unread', value: 'unread' }, + { label: 'Archived', value: 'archived' }, +] + +interface AdvisorFiltersProps { + activeTab: AdvisorTab + onTabChange: (tab: string) => void + severityFilters: AdvisorSeverity[] + onSeverityFiltersChange: (filters: AdvisorSeverity[]) => void + statusFilters: string[] + onStatusFiltersChange: (filters: string[]) => void + hasProjectRef?: boolean + onClose: () => void + isPlatform?: boolean +} + +export const AdvisorFilters = ({ + activeTab, + onTabChange, + severityFilters, + onSeverityFiltersChange, + statusFilters, + onStatusFiltersChange, + hasProjectRef = true, + onClose, + isPlatform = false, +}: AdvisorFiltersProps) => { + return ( +
+
+ + + + All + + + Security + + + Performance + + {isPlatform && ( + + Messages + + )} + + +
+ {isPlatform && ( + + )} + { + onSeverityFiltersChange(values as AdvisorSeverity[]) + }} + /> + } + onClick={onClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index 1a5cdfe2724..23d798e7dea 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -1,33 +1,23 @@ -import { AlertTriangle, ChevronLeft, ChevronRight, Gauge, Inbox, Shield, X } from 'lucide-react' -import { useMemo } from 'react' +import dayjs from 'dayjs' +import { useMemo, useRef } from 'react' -import LintDetail from 'components/interfaces/Linter/LintDetail' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { FilterPopover } from 'components/ui/FilterPopover' import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' +import { + Notification, + NotificationData, + useNotificationsV2Query, +} from 'data/notifications/notifications-v2-query' +import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { IS_PLATFORM } from 'lib/constants' import { AdvisorSeverity, AdvisorTab, useAdvisorStateSnapshot } from 'state/advisor-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { Badge, Button, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import { EmptyAdvisor } from './EmptyAdvisor' - -type AdvisorItem = { - id: string - title: string - severity: AdvisorSeverity - createdAt?: number - tab: Exclude - source: 'lint' - original: Lint -} - -const severityOptions = [ - { label: 'Critical', value: 'critical' }, - { label: 'Warning', value: 'warning' }, - { label: 'Info', value: 'info' }, -] +import { AdvisorDetail } from './AdvisorDetail' +import { AdvisorFilters } from './AdvisorFilters' +import { AdvisorPanelBody } from './AdvisorPanelBody' +import { AdvisorItem, AdvisorPanelHeader } from './AdvisorPanelHeader' const severityOrder: Record = { critical: 0, @@ -35,30 +25,6 @@ const severityOrder: Record = { info: 2, } -const severityLabels: Record = { - critical: 'Critical', - warning: 'Warning', - info: 'Info', -} - -const severityBadgeVariants: Record = { - critical: 'destructive', - warning: 'warning', - info: 'default', -} - -const severityColorClasses: Record = { - critical: 'text-destructive', - warning: 'text-warning', - info: 'text-foreground-light', -} - -const tabIconMap: Record, React.ElementType> = { - security: Shield, - performance: Gauge, - messages: Inbox, -} - const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { switch (level) { case 'ERROR': @@ -70,20 +36,39 @@ const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { } } +const notificationPriorityToSeverity = (priority: string | null | undefined): AdvisorSeverity => { + switch (priority) { + case 'Critical': + return 'critical' + case 'Warning': + return 'warning' + default: + return 'info' + } +} + export const AdvisorPanel = () => { const { activeTab, severityFilters, selectedItemId, + selectedItemSource, setActiveTab, setSeverityFilters, clearSeverityFilters, - setSelectedItemId, + setSelectedItem, + notificationFilterStatuses, + notificationFilterPriorities, + setNotificationFilters, + resetNotificationFilters, } = useAdvisorStateSnapshot() const { data: project } = useSelectedProjectQuery() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot() const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL + const markedRead = useRef([]) + const hasProjectRef = !!project?.ref const { data: lintData, @@ -91,9 +76,58 @@ export const AdvisorPanel = () => { isError: isLintsError, } = useProjectLintsQuery( { projectRef: project?.ref }, - { enabled: isSidebarOpen && !!project?.ref } + { enabled: isSidebarOpen && hasProjectRef && activeTab !== 'messages' } ) + // Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs) + const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM + + const notificationStatus = useMemo(() => { + if (notificationFilterStatuses.includes('archived')) { + return 'archived' + } + if (notificationFilterStatuses.includes('unread')) { + return 'new' + } + return undefined + }, [notificationFilterStatuses]) + + // Memoize filters to prevent query key changes on every render + // Use selected organization and project if they exist + const notificationFilters = useMemo( + () => ({ + priority: notificationFilterPriorities, + organizations: selectedOrganization?.slug ? [selectedOrganization.slug] : [], + projects: project?.ref ? [project.ref] : [], + }), + [notificationFilterPriorities, selectedOrganization?.slug, project?.ref] + ) + + const { + data: notificationsData, + isLoading: isNotificationsLoading, + isError: isNotificationsError, + } = useNotificationsV2Query( + { + status: notificationStatus, + filters: notificationFilters, + limit: 20, + }, + { enabled: shouldLoadNotifications } + ) + + const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() + + const notifications = useMemo(() => { + return notificationsData?.pages.flatMap((page) => page) ?? [] + }, [notificationsData?.pages]) + + const markNotificationsRead = () => { + if (markedRead.current.length > 0) { + updateNotifications({ ids: markedRead.current, status: 'seen' }) + } + } + const lintItems = useMemo(() => { if (!lintData) return [] @@ -121,8 +155,24 @@ export const AdvisorPanel = () => { .filter((item): item is AdvisorItem => item !== null) }, [lintData]) + const notificationItems = useMemo(() => { + if (!IS_PLATFORM) return [] + return notifications?.map((notification): AdvisorItem => { + const data = notification.data as NotificationData + return { + id: notification.id, + title: data.title, + severity: notificationPriorityToSeverity(notification.priority), + createdAt: dayjs(notification.inserted_at).valueOf(), + tab: 'messages' as const, + source: 'notification' as const, + original: notification, + } + }) + }, [notifications]) + const combinedItems = useMemo(() => { - const all = [...lintItems] + const all = [...lintItems, ...notificationItems] return all.sort((a, b) => { const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] @@ -133,80 +183,107 @@ export const AdvisorPanel = () => { return a.title.localeCompare(b.title) }) - }, [lintItems]) + }, [lintItems, notificationItems]) const filteredItems = useMemo(() => { return combinedItems.filter((item) => { + // Filter by severity if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) { return false } - if (activeTab === 'all') return true + // Filter by tab + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, severityFilters, activeTab]) + }, [combinedItems, severityFilters, activeTab, hasProjectRef]) const itemsFilteredByTabOnly = useMemo(() => { return combinedItems.filter((item) => { - if (activeTab === 'all') return true + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, activeTab]) + }, [combinedItems, activeTab, hasProjectRef]) const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length - const selectedItem = combinedItems.find((item) => item.id === selectedItemId) + const selectedItem = combinedItems.find( + (item) => item.id === selectedItemId && item.source === selectedItemSource + ) const isDetailView = !!selectedItem - const isLoading = isLintsLoading - const isError = isLintsError + // Only show loading state if the query is actually enabled + const isLintsActuallyLoading = + isSidebarOpen && hasProjectRef && activeTab !== 'messages' && isLintsLoading + const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading + const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading + const isError = isLintsError || isNotificationsError const handleTabChange = (tab: string) => { setActiveTab(tab as AdvisorTab) + setSelectedItem(undefined) } const handleBackToList = () => { - setSelectedItemId(undefined) + setSelectedItem(undefined) + markNotificationsRead() } const handleClose = () => { + markNotificationsRead() closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) } + const handleItemClick = (item: AdvisorItem) => { + setSelectedItem(item.id, item.source) + if (item.source === 'notification') { + const notification = item.original as Notification + if (notification.status === 'new' && !markedRead.current.includes(notification.id)) { + markedRead.current.push(notification.id) + } + } + } + + const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => { + updateNotifications({ ids: [id], status }) + } + + const handleClearAllFilters = () => { + clearSeverityFilters() + resetNotificationFilters() + } + + const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0 + return (
{isDetailView ? ( <> -
- } - onClick={handleBackToList} - tooltip={{ content: { side: 'bottom', text: 'Back to list' } }} - /> -
-
- {selectedItem?.title} -
- {selectedItem && ( - - {severityLabels[selectedItem.severity]} - - )} -
- } - onClick={handleClose} - tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} - /> -
+
{selectedItem ? ( - + ) : (

@@ -218,118 +295,40 @@ export const AdvisorPanel = () => { ) : ( <> -

-
- - - - All - - - Security - - - Performance - - - -
- setSeverityFilters(values as AdvisorSeverity[])} - /> - } - onClick={handleClose} - tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} - /> -
-
-
+ { + notificationFilterStatuses + .filter((status) => !values.includes(status)) + .forEach((status) => setNotificationFilters(status, 'status')) + values + .filter((status) => !notificationFilterStatuses.includes(status)) + .forEach((status) => setNotificationFilters(status, 'status')) + }} + hasProjectRef={hasProjectRef} + onClose={handleClose} + isPlatform={IS_PLATFORM} + />
- {isLoading ? ( -
- -
- ) : isError ? ( -
- -

Error loading advisories

-

Please try again later.

-
- ) : filteredItems.length === 0 ? ( - 0} - onClearFilters={clearSeverityFilters} - /> - ) : ( - <> -
- {filteredItems.map((item) => { - const SeverityIcon = tabIconMap[item.tab] - const severityClass = severityColorClasses[item.severity] - return ( -
- -
- ) - })} -
- {severityFilters.length > 0 && hiddenItemsCount > 0 && ( -
- -
- )} - - )} +
)}
) } - -interface AdvisorDetailProps { - item: AdvisorItem - projectRef: string -} - -const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => { - if (item.source === 'lint') { - const lint = item.original as Lint - return ( -
- -
- ) - } -} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx new file mode 100644 index 00000000000..7b00fce3e6a --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -0,0 +1,157 @@ +import dayjs from 'dayjs' +import { AlertTriangle, ChevronRight, Gauge, Inbox, Shield } from 'lucide-react' + +import { Notification } from 'data/notifications/notifications-v2-query' +import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state' +import { Button, cn } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import { AdvisorItem } from './AdvisorPanelHeader' +import { EmptyAdvisor } from './EmptyAdvisor' + +const NoProjectNotice = () => { + return ( +
+ +
+

Project required

+

+ Select a project to view security and performance advisories +

+
+
+ ) +} + +const tabIconMap: Record, React.ElementType> = { + security: Shield, + performance: Gauge, + messages: Inbox, +} + +const severityColorClasses: Record = { + critical: 'text-destructive', + warning: 'text-warning', + info: 'text-foreground-light', +} + +interface AdvisorPanelBodyProps { + isLoading: boolean + isError: boolean + filteredItems: AdvisorItem[] + activeTab: AdvisorTab + severityFilters: AdvisorSeverity[] + onItemClick: (item: AdvisorItem) => void + onClearFilters: () => void + hiddenItemsCount: number + hasAnyFilters: boolean + hasProjectRef?: boolean +} + +export const AdvisorPanelBody = ({ + isLoading, + isError, + filteredItems, + activeTab, + severityFilters, + onItemClick, + onClearFilters, + hiddenItemsCount, + hasAnyFilters, + hasProjectRef = true, +}: AdvisorPanelBodyProps) => { + // Show notice if no project ref and trying to view project-specific tabs + if (!hasProjectRef && activeTab !== 'messages') { + return + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError) { + return ( +
+ +

Error loading advisories

+

Please try again later.

+
+ ) + } + + if (filteredItems.length === 0) { + return ( + + ) + } + + return ( + <> +
+ {filteredItems.map((item) => { + const SeverityIcon = tabIconMap[item.tab] + const severityClass = severityColorClasses[item.severity] + const isNotification = item.source === 'notification' + const notification = isNotification ? (item.original as Notification) : null + const isUnread = notification?.status === 'new' + + return ( +
+ +
+ ) + })} +
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && ( +
+ +
+ )} + + ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx new file mode 100644 index 00000000000..ae060dddb93 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx @@ -0,0 +1,77 @@ +import dayjs from 'dayjs' +import { ChevronLeft, X } from 'lucide-react' + +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { AdvisorItemSource, AdvisorSeverity } from 'state/advisor-state' +import { Badge } from 'ui' + +export type AdvisorItem = { + id: string + title: string + severity: AdvisorSeverity + createdAt?: number + tab: 'security' | 'performance' | 'messages' + source: AdvisorItemSource + original: any +} + +export const severityBadgeVariants: Record = + { + critical: 'destructive', + warning: 'warning', + info: 'default', + } + +export const severityLabels: Record = { + critical: 'Critical', + warning: 'Warning', + info: 'Info', +} + +interface AdvisorPanelHeaderProps { + selectedItem: AdvisorItem | undefined + onBack: () => void + onClose: () => void +} + +export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => { + return ( +
+ } + onClick={onBack} + tooltip={{ content: { side: 'bottom', text: 'Back to list' } }} + /> +
+
+ {selectedItem?.title?.replace(/[`\\]/g, '')} + {selectedItem?.createdAt && ( + + {(() => { + const insertedAt = selectedItem.createdAt + const daysFromNow = dayjs().diff(dayjs(insertedAt), 'day') + const formattedTimeFromNow = dayjs(insertedAt).fromNow() + const formattedInsertedAt = dayjs(insertedAt).format('MMM DD, YYYY') + return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow + })()} + + )} +
+ {selectedItem && ( + + {severityLabels[selectedItem.severity]} + + )} +
+ } + onClick={onClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+ ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx index 3096b0b19a9..49c1647a6ae 100644 --- a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx +++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx @@ -33,9 +33,9 @@ export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdv case 'performance': return 'Congrats! There are no performance issues detected for this project' case 'messages': - return 'There are no messages for this project' + return 'Messages alert you of upcoming changes or potential issues with your project' default: - return 'Congrats! There are no issues detected for this project' + return 'Congrats! There are no issues detected' } } diff --git a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx new file mode 100644 index 00000000000..cb8cda263e4 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx @@ -0,0 +1,113 @@ +import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { Button } from 'ui' +import { Markdown } from 'components/interfaces/Markdown' +import { Notification, NotificationData } from 'data/notifications/notifications-v2-query' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' + +interface NotificationDetailProps { + notification: Notification + onUpdateStatus: (id: string, status: 'archived' | 'seen') => void +} + +export const NotificationDetail = ({ notification, onUpdateStatus }: NotificationDetailProps) => { + const data = notification.data as NotificationData + + const { data: project } = useProjectDetailQuery({ ref: data.project_ref }) + const { data: organizations } = useOrganizationsQuery() + + const organization = + data.org_slug !== undefined + ? organizations?.find((org) => org.slug === data.org_slug) + : project !== undefined + ? organizations?.find((org) => org.id === project.organization_id) + : undefined + + const onButtonAction = (type?: string) => { + // [Joshen] Implement accordingly - BE team will need to give us a heads up on this + console.log('Action', type) + } + + return ( +
+ {(project !== undefined || organization !== undefined) && ( + <> +

Context

+
+ {organization !== undefined && ( + + {organization.name} + + )} + {project !== undefined && ( + + {project.name} + + )} +
+ + )} + + {data.message !== undefined && ( + <> +

Message

+ + + )} + +

Actions

+
+ {(data.actions ?? []).map((action, idx) => { + const key = `${notification.id}-action-${idx}` + if (action.url !== undefined) { + const url = action.url.includes('[ref]') + ? action.url.replace('[ref]', project?.ref ?? '_') + : action.url.includes('[slug]') + ? action.url.replace('[slug]', organization?.slug ?? '_') + : action.url + return ( + + ) + } else if (action.action_type !== undefined) { + return ( + + ) + } else { + return null + } + })} + {notification.status === 'archived' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index 4ae0b2365a5..6ac0bf7ae46 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -30,6 +30,7 @@ interface FilterPopoverProps { maxHeightClass?: string clearButtonText?: string className?: string + isMinimized?: boolean onSaveFilters: (options: string[]) => void // [Joshen] These props are to support async data with infinite loading if applicable @@ -60,6 +61,7 @@ export const FilterPopover = >({ className, maxHeightClass = 'h-[205px]', clearButtonText = 'Clear', + isMinimized = false, onSaveFilters, search, @@ -127,14 +129,20 @@ export const FilterPopover = >({ >
{name} - {activeOptions.length > 0 && :} - {activeOptions.length >= 3 ? ( - - {formattedOptions[0]} and {activeOptions.length - 1} others - - ) : activeOptions.length > 0 ? ( - {formattedOptions.join(', ')} - ) : null} + {activeOptions.length > 0 && ( + <> + : + {isMinimized ? ( + {activeOptions.length} + ) : activeOptions.length >= 3 ? ( + + {formattedOptions[0]} and {activeOptions.length - 1} others + + ) : ( + {formattedOptions.join(', ')} + )} + + )}
diff --git a/apps/studio/data/etl/create-destination-pipeline-mutation.ts b/apps/studio/data/etl/create-destination-pipeline-mutation.ts index a313280119d..78476e137d7 100644 --- a/apps/studio/data/etl/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/etl/create-destination-pipeline-mutation.ts @@ -16,7 +16,7 @@ export type BigQueryDestinationConfig = { export type IcebergDestinationConfig = { projectRef: string warehouseName: string - namespace: string + namespace?: string catalogToken: string s3AccessKeyId: string s3SecretAccessKey: string @@ -59,6 +59,7 @@ async function createDestinationPipeline( if ('bigQuery' in destinationConfig) { const { projectId, datasetId, serviceAccountKey, maxStalenessMins } = destinationConfig.bigQuery + destination_config = { big_query: { project_id: projectId, @@ -70,19 +71,20 @@ async function createDestinationPipeline( } else if ('iceberg' in destinationConfig) { const { projectRef: icebergProjectRef, - warehouseName, namespace, + warehouseName, catalogToken, s3AccessKeyId, s3SecretAccessKey, s3Region, } = destinationConfig.iceberg + destination_config = { iceberg: { supabase: { + namespace, project_ref: icebergProjectRef, warehouse_name: warehouseName, - namespace: namespace, catalog_token: catalogToken, s3_access_key_id: s3AccessKeyId, s3_secret_access_key: s3SecretAccessKey, diff --git a/apps/studio/data/etl/destinations-query.ts b/apps/studio/data/etl/destinations-query.ts index 9c73561f751..961c49ebc7a 100644 --- a/apps/studio/data/etl/destinations-query.ts +++ b/apps/studio/data/etl/destinations-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' import { replicationKeys } from './keys' +import { checkReplicationFeatureFlagRetry } from './utils' type ReplicationDestinationsParams = { projectRef?: string } @@ -36,5 +37,8 @@ export const useReplicationDestinationsQuery = fetchReplicationDestinations({ projectRef }, signal), enabled: enabled && typeof projectRef !== 'undefined', + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: checkReplicationFeatureFlagRetry, ...options, }) diff --git a/apps/studio/data/etl/pipelines-query.ts b/apps/studio/data/etl/pipelines-query.ts index 5e9ed35d57a..7d1074409e1 100644 --- a/apps/studio/data/etl/pipelines-query.ts +++ b/apps/studio/data/etl/pipelines-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' import { replicationKeys } from './keys' +import { checkReplicationFeatureFlagRetry } from './utils' type ReplicationPipelinesParams = { projectRef?: string } @@ -16,10 +17,8 @@ async function fetchReplicationPipelines( params: { path: { ref: projectRef } }, signal, }) - if (error) { - handleError(error) - } + if (error) handleError(error) return data } @@ -37,5 +36,8 @@ export const useReplicationPipelinesQuery = ( queryKey: replicationKeys.pipelines(projectRef), queryFn: ({ signal }) => fetchReplicationPipelines({ projectRef }, signal), enabled: enabled && typeof projectRef !== 'undefined', + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: checkReplicationFeatureFlagRetry, ...options, }) diff --git a/apps/studio/data/etl/create-publication-mutation.ts b/apps/studio/data/etl/publication-create-mutation.ts similarity index 100% rename from apps/studio/data/etl/create-publication-mutation.ts rename to apps/studio/data/etl/publication-create-mutation.ts diff --git a/apps/studio/data/etl/publication-update-mutation.ts b/apps/studio/data/etl/publication-update-mutation.ts new file mode 100644 index 00000000000..7eb3789895e --- /dev/null +++ b/apps/studio/data/etl/publication-update-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from 'types' +import { replicationKeys } from './keys' + +export type UpdatePublicationParams = { + projectRef: string + sourceId: number + publicationName: string + tables: { schema: string; name: string }[] +} + +async function updatePublication( + { projectRef, sourceId, publicationName, tables }: UpdatePublicationParams, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await post( + '/platform/replication/{ref}/sources/{source_id}/publications/{publication_name}', + { + params: { path: { ref: projectRef, source_id: sourceId, publication_name: publicationName } }, + body: { tables }, + signal, + } + ) + if (error) { + handleError(error) + } + + return data +} + +type UpdatePublicationData = Awaited> + +export const useUpdatePublicationMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => updatePublication(vars), + async onSuccess(data, variables, context) { + const { projectRef, sourceId } = variables + await queryClient.invalidateQueries({ + queryKey: replicationKeys.publications(projectRef, sourceId), + }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update publication: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/etl/sources-query.ts b/apps/studio/data/etl/sources-query.ts index 8561af72a1d..54e5a942980 100644 --- a/apps/studio/data/etl/sources-query.ts +++ b/apps/studio/data/etl/sources-query.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' import { replicationKeys } from './keys' +import { checkReplicationFeatureFlagRetry } from './utils' type ReplicationSourcesParams = { projectRef?: string } @@ -36,5 +37,8 @@ export const useReplicationSourcesQuery = ( queryKey: replicationKeys.sources(projectRef), queryFn: ({ signal }) => fetchReplicationSources({ projectRef }, signal), enabled: enabled && typeof projectRef !== 'undefined', + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: checkReplicationFeatureFlagRetry, ...options, }) diff --git a/apps/studio/data/etl/tables-query.ts b/apps/studio/data/etl/tables-query.ts index 0635055aba3..bcfa3a9f79e 100644 --- a/apps/studio/data/etl/tables-query.ts +++ b/apps/studio/data/etl/tables-query.ts @@ -21,7 +21,7 @@ async function fetchReplicationTables( handleError(error) } - return data + return data.tables } export type ReplicationTablesData = Awaited> diff --git a/apps/studio/data/etl/utils.ts b/apps/studio/data/etl/utils.ts new file mode 100644 index 00000000000..76d48cda2b1 --- /dev/null +++ b/apps/studio/data/etl/utils.ts @@ -0,0 +1,22 @@ +import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client' +import { ResponseError } from 'types' + +export const checkReplicationFeatureFlagRetry = ( + failureCount: number, + error: ResponseError +): boolean => { + const isFeatureFlagRequiredError = + error instanceof ResponseError && + error.code === 503 && + error.message.includes('feature flag is required') + + if (isFeatureFlagRequiredError) { + return false + } + + if (failureCount < MAX_RETRY_FAILURE_COUNT) { + return true + } + + return false +} diff --git a/apps/studio/data/organizations/organization-billing-subscription-preview.ts b/apps/studio/data/organizations/organization-billing-subscription-preview.ts index e8e2e2d4903..de845902c8f 100644 --- a/apps/studio/data/organizations/organization-billing-subscription-preview.ts +++ b/apps/studio/data/organizations/organization-billing-subscription-preview.ts @@ -88,21 +88,4 @@ export const useOrganizationBillingSubscriptionPreview = < queryFn: () => previewOrganizationBillingSubscription({ organizationSlug, tier }), enabled: enabled && typeof organizationSlug !== 'undefined' && typeof tier !== 'undefined', ...options, - retry: (failureCount, error) => { - // Don't retry on 400s - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as any).code === 400 - ) { - return false - } - - if (failureCount < 3) { - return true - } - - return false - }, }) diff --git a/apps/studio/data/projects/project-transfer-preview-query.ts b/apps/studio/data/projects/project-transfer-preview-query.ts index 3f7d7be8bb7..71624745a4d 100644 --- a/apps/studio/data/projects/project-transfer-preview-query.ts +++ b/apps/studio/data/projects/project-transfer-preview-query.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { handleError, post } from 'data/fetchers' -import { projectKeys } from './keys' import { UseCustomQueryOptions } from 'types' +import { projectKeys } from './keys' export type ProjectTransferPreviewVariables = { projectRef?: string @@ -44,21 +44,4 @@ export const useProjectTransferPreviewQuery = { - // Don't retry on 400s - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as any).code === 400 - ) { - return false - } - - if (failureCount < 3) { - return true - } - - return false - }, }) diff --git a/apps/studio/data/query-client.ts b/apps/studio/data/query-client.ts index e539a31e24e..3587efc7948 100644 --- a/apps/studio/data/query-client.ts +++ b/apps/studio/data/query-client.ts @@ -18,6 +18,8 @@ const SKIP_RETRY_PATHNAME_MATCHERS = [ '/v1/projects/:ref/analytics/endpoints/logs.all', ].map((pathname) => match(pathname)) +export const MAX_RETRY_FAILURE_COUNT = 3 + let queryClient: QueryClient | undefined export function getQueryClient() { @@ -55,7 +57,7 @@ export function getQueryClient() { return false } - if (failureCount < 3) { + if (failureCount < MAX_RETRY_FAILURE_COUNT) { return true } diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts index 0cb30a07c19..158777e2e7b 100644 --- a/apps/studio/data/storage/buckets-query.ts +++ b/apps/studio/data/storage/buckets-query.ts @@ -2,9 +2,10 @@ import { useQuery } from '@tanstack/react-query' import { components } from 'api-types' import { get, handleError } from 'data/fetchers' +import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' -import type { ResponseError, UseCustomQueryOptions } from 'types' +import { ResponseError, type UseCustomQueryOptions } from 'types' import { storageKeys } from './keys' export type BucketsVariables = { projectRef?: string } @@ -41,15 +42,11 @@ export const useBucketsQuery = ( enabled: enabled && typeof projectRef !== 'undefined' && isActive, ...options, retry: (failureCount, error) => { - if ( - typeof error === 'object' && - error !== null && - error.message.includes('Missing tenant config') - ) { + if (error instanceof ResponseError && error.message.includes('Missing tenant config')) { return false } - if (failureCount < 3) { + if (failureCount < MAX_RETRY_FAILURE_COUNT) { return true } diff --git a/apps/studio/data/storage/iceberg-namespace-create-mutation.ts b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts index ce1a74696f3..9181128a1b5 100644 --- a/apps/studio/data/storage/iceberg-namespace-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts @@ -90,7 +90,7 @@ export const useIcebergNamespaceCreateMutation = ({ await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { - if ((data.message = 'Request failed with status code 409')) { + if (data.message === 'Request failed with status code 409') { toast.error(`A namespace named ${variables.namespace} already exists in the catalog.`) return } diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts index a5839330a32..809bdfb8180 100644 --- a/apps/studio/state/advisor-state.ts +++ b/apps/studio/state/advisor-state.ts @@ -2,11 +2,19 @@ import { proxy, snapshot, useSnapshot } from 'valtio' export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages' export type AdvisorSeverity = 'critical' | 'warning' | 'info' +export type AdvisorItemSource = 'lint' | 'notification' const initialState = { activeTab: 'all' as AdvisorTab, - severityFilters: ['critical'] as AdvisorSeverity[], + severityFilters: ['critical', 'warning'] as AdvisorSeverity[], selectedItemId: undefined as string | undefined, + selectedItemSource: undefined as AdvisorItemSource | undefined, + // Notification filters + notificationFilterStatuses: [] as string[], + notificationFilterPriorities: [] as string[], + get numNotificationFiltersApplied() { + return [...this.notificationFilterStatuses, ...this.notificationFilterPriorities].length + }, } export const advisorState = proxy({ @@ -20,14 +28,44 @@ export const advisorState = proxy({ clearSeverityFilters() { advisorState.severityFilters = [] }, - setSelectedItemId(id: string | undefined) { + setSelectedItem(id: string | undefined, source?: AdvisorItemSource) { advisorState.selectedItemId = id + advisorState.selectedItemSource = source }, - focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) { + focusItem({ id, tab, source }: { id: string; tab?: AdvisorTab; source?: AdvisorItemSource }) { if (tab) { advisorState.activeTab = tab } advisorState.selectedItemId = id + advisorState.selectedItemSource = source + }, + setNotificationFilters: (value: string, type: 'status' | 'priority') => { + switch (type) { + case 'status': + if (advisorState.notificationFilterStatuses.includes(value)) { + advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.filter( + (x) => x !== value + ) + } else { + advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.concat([ + value, + ]) + } + break + case 'priority': + if (advisorState.notificationFilterPriorities.includes(value)) { + advisorState.notificationFilterPriorities = + advisorState.notificationFilterPriorities.filter((x) => x !== value) + } else { + advisorState.notificationFilterPriorities = + advisorState.notificationFilterPriorities.concat([value]) + } + break + } + }, + resetNotificationFilters() { + advisorState.notificationFilterStatuses = [] + advisorState.notificationFilterPriorities = [] }, reset() { Object.assign(advisorState, initialState) diff --git a/apps/studio/state/notifications.ts b/apps/studio/state/notifications.ts deleted file mode 100644 index 4a1c0be56bb..00000000000 --- a/apps/studio/state/notifications.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { proxy, snapshot, useSnapshot } from 'valtio' - -export const notificationsState = proxy({ - filterStatuses: [] as string[], - filterPriorities: [] as string[], - filterOrganizations: [] as string[], - filterProjects: [] as string[], - get numFiltersApplied() { - return [ - ...this.filterStatuses, - ...this.filterPriorities, - ...this.filterOrganizations, - ...this.filterProjects, - ].length - }, - setFilters: (value: string, type: 'status' | 'priority' | 'organizations' | 'projects') => { - switch (type) { - case 'status': - if (notificationsState.filterStatuses.includes(value)) { - notificationsState.filterStatuses = notificationsState.filterStatuses.filter( - (x) => x !== value - ) - } else { - notificationsState.filterStatuses = notificationsState.filterStatuses.concat([value]) - } - break - case 'priority': - if (notificationsState.filterPriorities.includes(value)) { - notificationsState.filterPriorities = notificationsState.filterPriorities.filter( - (x) => x !== value - ) - } else { - notificationsState.filterPriorities = notificationsState.filterPriorities.concat([value]) - } - break - case 'organizations': - if (notificationsState.filterOrganizations.includes(value)) { - notificationsState.filterOrganizations = notificationsState.filterOrganizations.filter( - (x) => x !== value - ) - } else { - notificationsState.filterOrganizations = notificationsState.filterOrganizations.concat([ - value, - ]) - } - break - case 'projects': - if (notificationsState.filterProjects.includes(value)) { - notificationsState.filterProjects = notificationsState.filterProjects.filter( - (x) => x !== value - ) - } else { - notificationsState.filterProjects = notificationsState.filterProjects.concat([value]) - } - break - } - }, - resetFilters: () => { - notificationsState.filterStatuses = [] - notificationsState.filterPriorities = [] - notificationsState.filterOrganizations = [] - notificationsState.filterProjects = [] - }, -}) - -export const getNotificationsStateSnapshot = () => snapshot(notificationsState) - -export const useNotificationsStateSnapshot = (options?: Parameters[1]) => - useSnapshot(notificationsState, options) diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx index e0e48404090..5c12856a94b 100644 --- a/apps/studio/state/sidebar-manager-state.tsx +++ b/apps/studio/state/sidebar-manager-state.tsx @@ -1,6 +1,7 @@ import { LOCAL_STORAGE_KEYS } from 'common/constants' +import useLatest from 'hooks/misc/useLatest' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { ReactNode, useEffect } from 'react' +import { ReactNode, useEffect, useRef } from 'react' import { proxy, snapshot, useSnapshot } from 'valtio' type SidebarHandlers = { @@ -143,26 +144,26 @@ export const useRegisterSidebar = ( id: string, component: () => ReactNode, handlers: SidebarHandlers = {}, - hotKey?: string + hotKey?: string, + enabled?: boolean ) => { const [isSidebarHotkeyEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id), true ) - useEffect(() => { - const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState + const componentRef = useLatest(component) + const handlersRef = useLatest(handlers) - if (!sidebars[id]) { - registerSidebar(id, component, handlers) + useEffect(() => { + if (enabled) { + sidebarManagerState.registerSidebar(id, () => componentRef.current(), handlersRef.current) } return () => { - if (sidebars[id]) { - unregisterSidebar(id) - } + sidebarManagerState.unregisterSidebar(id) } - }, [id]) + }, [id, enabled]) useEffect(() => { if (!hotKey) return diff --git a/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx b/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx index a6937cad3a4..fc7df69afb4 100644 --- a/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx +++ b/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx @@ -21,13 +21,15 @@ speakers: 'dave_wilson,harry_roper,shahed_islam,omar_moulani' ## Stop Competing on Speed -Anyone can build an MVP in a week now. +AI tools like Lovable, Bolt, Cursor, and Claude can help anyone build an MVP in a week. -That's not your edge. It's the baseline. +That speed used to be your edge. Now it’s the baseline. -AI tools have made speed universal. Every agency can promise "we build fast." The agencies that thrive are the ones that sell what comes after the build. +The difference between a fast build and a successful product is the foundation it’s built on. -In this 45-minute session, three agencies running 40% or higher margins share how they position expertise as value. You'll learn how to turn technical depth, data architecture, and product strategy into the work clients actually pay for. +Supabase provides that foundation. Giving agencies the flexibility, scalability, and security they need to turn rapid prototypes into reliable, production-grade applications. + +In this 45-minute session, three agencies running 40%+ margins share how they combine AI development tech with Supabase to move beyond speed and deliver long-term client value. You’ll learn how to position technical depth, data architecture, and product strategy as the differentiators clients actually pay for. ### What You'll Learn diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 30a32b68b20..e86431605be 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -52,6 +52,7 @@ "docs:contribution": true, "docs:fdw": true, "docs:footer": true, + "docs:navigation_dropdown_links_home": true, "docs:self-hosting": true, "docs:framework_quickstarts": true, "docs:full_getting_started": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index e737c7c36d7..3c4520832aa 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -181,6 +181,10 @@ "type": "boolean", "description": "Enable footer on docs site" }, + "docs:navigation_dropdown_links_home": { + "type": "boolean", + "description": "Should the navigation dropdown homepage link go to Supabase.com or the dashboard" + }, "docs:framework_quickstarts": { "type": "boolean", "description": "Enable framework quickstarts documentation" diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 64f77f1d503..58290d6cbee 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -172,6 +172,9 @@ export const PageTelemetry = ({ // Handle initial page telemetry event const hasSentInitialPageTelemetryRef = useRef(false) + // Track previous pathname for App Router to detect actual changes + const previousAppPathnameRef = useRef(null) + // Initialize PostHog client when consent is accepted useEffect(() => { if (hasAcceptedConsent && IS_PLATFORM) { @@ -239,10 +242,17 @@ export const PageTelemetry = ({ // For app router if (router !== null) return - // Wait until we've sent the initial page telemetry event - if (appPathname && !hasSentInitialPageTelemetryRef.current) { + // Only track if pathname actually changed (not initial mount) + if ( + appPathname && + previousAppPathnameRef.current !== null && + previousAppPathnameRef.current !== appPathname + ) { sendPageTelemetry() } + + // Update previous pathname + previousAppPathnameRef.current = appPathname }, [appPathname, router, sendPageTelemetry]) useEffect(() => { diff --git a/packages/ui/src/components/shadcn/ui/badge.tsx b/packages/ui/src/components/shadcn/ui/badge.tsx index 343c1a5c804..0c32c9352db 100644 --- a/packages/ui/src/components/shadcn/ui/badge.tsx +++ b/packages/ui/src/components/shadcn/ui/badge.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import { cn } from '../../../lib/utils/cn' const badgeVariants = cva( - 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10', + 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10 whitespace-nowrap', { variants: { variant: { diff --git a/packages/ui/src/components/shadcn/ui/table.tsx b/packages/ui/src/components/shadcn/ui/table.tsx index 33fa0c8a9db..fefe8967216 100644 --- a/packages/ui/src/components/shadcn/ui/table.tsx +++ b/packages/ui/src/components/shadcn/ui/table.tsx @@ -68,7 +68,7 @@ const TableHead = React.forwardRef<