mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
Merge branch 'master' into chore/page-layout-consistent
This commit is contained in:
commit
09669484f9
80 files changed
+2553
-1793
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
<ComponentPreview name="table-demo" peekCode wide />
|
||||
|
||||
## 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.
|
||||
|
||||
<Tabs defaultValue="cli">
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">CLI</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn-ui@latest add table
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource name="table" />
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
`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 {
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
|
||||
@@ -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 (
|
||||
<Table>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.invoice}>
|
||||
<TableCell className="font-medium">{invoice.invoice}</TableCell>
|
||||
<TableCell>{invoice.paymentStatus}</TableCell>
|
||||
<TableCell>{invoice.paymentMethod}</TableCell>
|
||||
<TableCell className="text-right">{invoice.totalAmount}</TableCell>
|
||||
<Card className="w-full pb-4">
|
||||
<Table>
|
||||
<TableCaption>A list of your recent invoices</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Description</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>Total</TableCell>
|
||||
<TableCell className="text-right">$2,500.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.invoice}>
|
||||
<TableCell className="text-foreground">{invoice.invoice}</TableCell>
|
||||
<TableCell className="text-foreground-lighter">{invoice.paymentStatus}</TableCell>
|
||||
<TableCell className="text-foreground-lighter">{invoice.paymentMethod}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-foreground-muted">
|
||||
{invoice.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{invoice.totalAmount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter className="border-t">
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>Total</TableCell>
|
||||
<TableCell className="text-right">$2,250.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,12 +39,12 @@ It's strongly recommended you set up a custom domain and optionally verify your
|
||||
|
||||
</Admonition>
|
||||
|
||||
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 `<project-id>.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
|
||||
|
||||
|
||||
@@ -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 "<role_name>" set "<setting_name>" 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).
|
||||
@@ -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
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Event triggers run for each DDL command specified above and can consume resources which may cause performance issues if not used carefully.
|
||||
|
||||
</Admonition>
|
||||
|
||||
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)
|
||||
@@ -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`
|
||||
@@ -80,6 +80,7 @@ Jeff Smick
|
||||
Jenny Kibiri
|
||||
Jess Shears
|
||||
Jim Chanco Jr
|
||||
Joakim Ahrlin
|
||||
John Pena
|
||||
John Schaeffer
|
||||
Jon M
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -117,8 +117,6 @@ export const Destinations = () => {
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{(isSourcesError || isDestinationsError) && (
|
||||
<AlertError
|
||||
error={sourcesError || destinationsError}
|
||||
@@ -126,7 +124,9 @@ export const Destinations = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{replicationNotEnabled ? (
|
||||
{isSourcesLoading || isDestinationsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : replicationNotEnabled ? (
|
||||
<div className="border rounded-md p-4 md:p-12 flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h3>Stream data to external destinations in real-time</h3>
|
||||
|
||||
@@ -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
|
||||
</MultiSelector.Trigger>
|
||||
<MultiSelector.Content>
|
||||
<MultiSelector.List>
|
||||
{tables?.tables.map((table) => (
|
||||
{tables?.map((table) => (
|
||||
<MultiSelector.Item
|
||||
key={`${table.schema}.${table.name}`}
|
||||
value={`${table.schema}.${table.name}`}
|
||||
|
||||
@@ -147,25 +147,27 @@ export const PublicationsList = () => {
|
||||
{isSuccess &&
|
||||
publications.map((x) => (
|
||||
<TableRow key={x.name}>
|
||||
<TableCell className="flex items-center gap-x-2 items-center">
|
||||
{x.name}
|
||||
{/* [Joshen] Making this tooltip very specific for these 2 publications */}
|
||||
{['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
|
||||
x.name
|
||||
) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info size={14} className="text-foreground-light" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{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}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{x.name}
|
||||
{/* [Joshen] Making this tooltip very specific for these 2 publications */}
|
||||
{['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
|
||||
x.name
|
||||
) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info size={14} className="text-foreground-light" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{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}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{x.id}</TableCell>
|
||||
{publicationEvents.map((event) => (
|
||||
|
||||
@@ -78,8 +78,8 @@ export const PublicationsTableItem = ({
|
||||
return (
|
||||
<TableRow key={table.id}>
|
||||
<TableCell className="py-3 whitespace-nowrap">{table.name}</TableCell>
|
||||
<TableCell className="py-3 whitespace-nowrap">{table.schema}</TableCell>
|
||||
<TableCell className="py-3 hidden max-w-sm truncate whitespace-nowrap lg:table-cell">
|
||||
<TableCell className="py-3 whitespace-nowrap text-foreground-light">{table.schema}</TableCell>
|
||||
<TableCell className="py-3 whitespace-nowrap hidden lg:table-cell max-w-sm truncate text-foreground-light">
|
||||
{table.comment}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
|
||||
@@ -102,7 +102,7 @@ export const PublicationsTables = () => {
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Description</TableHead>
|
||||
{/*
|
||||
We've disabled All tables toggle for publications.
|
||||
See https://github.com/supabase/supabase/pull/7233.
|
||||
|
||||
@@ -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 = ({
|
||||
<TableHead key="size" className="hidden text-right xl:table-cell">
|
||||
Size (Estimated)
|
||||
</TableHead>
|
||||
<TableHead key="realtime" className="hidden xl:table-cell text-center">
|
||||
<TableHead key="realtime" className="hidden xl:table-cell text-right">
|
||||
Realtime Enabled
|
||||
</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
@@ -457,11 +457,11 @@ export const TableList = ({
|
||||
{(realtimePublication?.tables ?? []).find(
|
||||
(table) => table.id === x.id
|
||||
) ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-end">
|
||||
<Check size={18} strokeWidth={2} className="text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-end">
|
||||
<X size={18} strokeWidth={2} className="text-foreground-lighter" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+8
-4
@@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
|
||||
<form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add new secrets</CardTitle>
|
||||
<CardTitle>Add or replace secrets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fields.map((fieldItem, index) => (
|
||||
@@ -178,7 +178,7 @@ const AddNewSecretForm = () => {
|
||||
name={`secrets.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="w-full">
|
||||
<FormLabel_Shadcn_>Key</FormLabel_Shadcn_>
|
||||
<FormLabel_Shadcn_>Name</FormLabel_Shadcn_>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
{...field}
|
||||
@@ -246,9 +246,13 @@ const AddNewSecretForm = () => {
|
||||
Add another
|
||||
</Button>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
<CardFooter className="justify-between space-x-2">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Insert or update multiple secrets at once by pasting key-value pairs
|
||||
</p>
|
||||
|
||||
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
|
||||
{isCreating ? 'Saving...' : 'Save'}
|
||||
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
+71
-21
@@ -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)
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
icon={<Trash />}
|
||||
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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="More options"
|
||||
type="default"
|
||||
className="px-1"
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
<DropdownMenuItem asChild>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
icon={<Edit2 size={14} />}
|
||||
className="w-full justify-start group text-inherit"
|
||||
disabled={!canUpdateSecrets || isReservedSecret}
|
||||
onClick={() => onSelectEdit()}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: isReservedSecret
|
||||
? 'This is a reserved secret and cannot be changed'
|
||||
: !canUpdateSecrets
|
||||
? 'You need additional permissions to edit edge function secrets'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Edit secret
|
||||
</ButtonTooltip>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
icon={<Trash size={14} className="group-[&:not(:disabled)]:text-destructive" />}
|
||||
className="w-full justify-start group text-inherit"
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete secret
|
||||
</ButtonTooltip>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
+19
-6
@@ -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<ProjectSecret>()
|
||||
const [selectedSecret, setSelectedSecret] = useState<SelectedProjectSecret>()
|
||||
|
||||
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 = () => {
|
||||
<EdgeFunctionSecret
|
||||
key={secret.name}
|
||||
secret={secret}
|
||||
onSelectDelete={() => setSelectedSecret(secret)}
|
||||
onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })}
|
||||
onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })}
|
||||
/>
|
||||
))
|
||||
) : secrets.length === 0 && searchString.length > 0 ? (
|
||||
@@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditSecretSheet
|
||||
secret={selectedSecret?.secret}
|
||||
visible={selectedSecret?.op === 'edit'}
|
||||
onClose={() => setSelectedSecret(undefined)}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
visible={selectedSecret !== undefined}
|
||||
visible={selectedSecret?.op === 'delete'}
|
||||
confirmLabel="Delete secret"
|
||||
confirmLabelLoading="Deleting secret"
|
||||
title={`Confirm to delete secret "${selectedSecret?.name}"`}
|
||||
title={`Confirm to delete secret "${selectedSecret?.secret.name}"`}
|
||||
onCancel={() => setSelectedSecret(undefined)}
|
||||
onConfirm={() => {
|
||||
if (selectedSecret !== undefined) {
|
||||
deleteSecret({ projectRef, secrets: [selectedSecret.name] })
|
||||
deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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<typeof FormSchema>
|
||||
|
||||
interface EditSecretSheetProps {
|
||||
secret?: ProjectSecret
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) {
|
||||
const secretName = useLatest(secret?.name)
|
||||
const form = useForm<FormSchemaType>({
|
||||
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<FormSchemaType> = async ({ name, value }) => {
|
||||
updateSecret({
|
||||
projectRef,
|
||||
secrets: [{ name, value }],
|
||||
})
|
||||
}
|
||||
|
||||
const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({
|
||||
checkIsDirty: () => form.formState.isDirty,
|
||||
onClose,
|
||||
})
|
||||
|
||||
return (
|
||||
<Sheet open={visible} onOpenChange={confirmOnClose}>
|
||||
<SheetContent
|
||||
showClose={false}
|
||||
size={'default'}
|
||||
className={'!min-w-screen lg:!min-w-[600px] flex flex-col'}
|
||||
>
|
||||
<Header />
|
||||
<Separator />
|
||||
<FormBody form={form} onSubmit={onSubmit} />
|
||||
<SheetFooter>
|
||||
<Button disabled={isUpdating} type="default" onClick={confirmOnClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={FORM_ID} htmlType="submit" disabled={!isValid} loading={isUpdating}>
|
||||
Save
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
{closeConfirmationModal}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = (): ReactNode => {
|
||||
return (
|
||||
<SheetHeader className="py-3 flex flex-row gap-3 items-center border-b-0">
|
||||
<SheetClose
|
||||
className={cn(
|
||||
'text-muted hover:text ring-offset-background hover:opacity-100',
|
||||
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none data-[state=open]:bg-secondary',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetClose>
|
||||
<SheetTitle>Edit secret</SheetTitle>
|
||||
</SheetHeader>
|
||||
)
|
||||
}
|
||||
|
||||
type FormBodyProps = {
|
||||
form: UseFormReturn<FormSchemaType>
|
||||
onSubmit: SubmitHandler<FormSchemaType>
|
||||
}
|
||||
|
||||
const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => {
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={FORM_ID} className="flex-grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SheetSection>
|
||||
<NameField form={form} />
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection className="space-y-4">
|
||||
<SecretField form={form} />
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
type NameFieldProps = {
|
||||
form: UseFormReturn<FormSchemaType>
|
||||
}
|
||||
|
||||
const NameField = ({ form }: NameFieldProps): ReactNode => {
|
||||
return (
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="horizontal">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
readOnly
|
||||
className="!text-foreground-light cursor-not-allowed"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SecretFieldProps = {
|
||||
form: UseFormReturn<FormSchemaType>
|
||||
}
|
||||
|
||||
const SecretField = ({ form }: SecretFieldProps): ReactNode => {
|
||||
const [showSecretValue, setShowSecretValue] = useState(false)
|
||||
|
||||
return (
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Value"
|
||||
layout="horizontal"
|
||||
description="Secrets can’t be retrieved once saved. Enter a new value to overwrite the existing value."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
{...field}
|
||||
type={showSecretValue ? 'text' : 'password'}
|
||||
placeholder="my-secret-value"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
actions={
|
||||
<div className="mr-1">
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={showSecretValue ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setShowSecretValue(!showSecretValue)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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: (
|
||||
<CloseConfirmationModal
|
||||
visible={visible}
|
||||
onClose={onConfirm}
|
||||
onCancel={() => setVisible(false)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type CloseConfirmationModalProps = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const CloseConfirmationModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
onCancel,
|
||||
}: CloseConfirmationModalProps): ReactNode => {
|
||||
return (
|
||||
<ConfirmationModal
|
||||
visible={visible}
|
||||
title="Discard changes"
|
||||
confirmLabel="Discard"
|
||||
onCancel={onCancel}
|
||||
onConfirm={onClose}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
|
||||
lost.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
)
|
||||
}
|
||||
@@ -37,9 +37,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-foreground">{item.name}</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-nowrap">{item.name}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
|
||||
@@ -80,7 +78,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
</TableCell>
|
||||
<TableCell className="lg:table-cell">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm text-foreground-light">{dayjs(item.updated_at).fromNow()}</p>
|
||||
</div>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const IndexImprovementText = ({
|
||||
const improvement = calculateImprovement(totalCostBefore, totalCostAfter)
|
||||
|
||||
return (
|
||||
<p className={cn('text-sm text-foreground-light', className)} {...props}>
|
||||
<p className={cn('text-sm text-foreground-light mb-3', className)} {...props}>
|
||||
Query's performance can be improved by{' '}
|
||||
<span className="text-brand">{improvement.toFixed(2)}%</span> by creating this{' '}
|
||||
{indexStatements.length > 1 ? 'indexes' : 'index'}:
|
||||
|
||||
@@ -124,9 +124,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
|
||||
return (
|
||||
<QueryPanelContainer className="h-full">
|
||||
<QueryPanelSection>
|
||||
<div>
|
||||
<p className="text-sm">Indexes in use</p>
|
||||
<QueryPanelSection className="pt-2 mb-6">
|
||||
<div className="mb-4 flex flex-col gap-y-1">
|
||||
<h4>Indexes in use</h4>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This query is using the following index{(usedIndexes ?? []).length > 1 ? 's' : ''}:
|
||||
</p>
|
||||
@@ -171,12 +171,9 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</div>
|
||||
)}
|
||||
</QueryPanelSection>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
<QueryPanelSection className="flex flex-col gap-y-6">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-sm">New index recommendations</p>
|
||||
<QueryPanelSection className="flex flex-col gap-y-6 py-6 border-t">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>New index recommendations</h4>
|
||||
{isLoadingExtensions ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : !isIndexAdvisorEnabled ? (
|
||||
@@ -206,7 +203,7 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
{isLinterWarning ? (
|
||||
<Alert_Shadcn_
|
||||
variant="default"
|
||||
className="border-brand-400 bg-alternative [&>svg]:p-0.5 [&>svg]:bg-transparent [&>svg]:text-brand"
|
||||
className="border-brand-400 bg-alternative [&>svg]:p-0.5 [&>svg]:bg-transparent [&>svg]:text-brand my-3"
|
||||
>
|
||||
<Lightbulb />
|
||||
<AlertTitle_Shadcn_>
|
||||
@@ -238,10 +235,10 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap'
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<p className="text-sm text-foreground-light mt-3">
|
||||
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).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -250,11 +247,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isIndexAdvisorEnabled && hasIndexRecommendation && (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-sm">Query costs</p>
|
||||
<div className="border rounded-md flex flex-col bg-surface-100">
|
||||
</QueryPanelSection>
|
||||
{isIndexAdvisorEnabled && hasIndexRecommendation && (
|
||||
<>
|
||||
<QueryPanelSection className="py-6 border-t">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h4>Query costs</h4>
|
||||
<div className="border rounded-md flex flex-col bg-surface-100 mt-3">
|
||||
<QueryPanelScoreSection
|
||||
name="Total cost of query"
|
||||
description="An estimate of how long it will take to return all the rows (Includes start up cost)"
|
||||
@@ -278,8 +277,10 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</Collapsible_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
</QueryPanelSection>
|
||||
<QueryPanelSection className="py-6 border-t">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-sm">FAQ</p>
|
||||
<h4>FAQ</h4>
|
||||
<Accordion_Shadcn_ collapsible type="single" className="border rounded-md">
|
||||
<AccordionItem_Shadcn_ value="1">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-normal text-foreground-light hover:text-foreground transition [&[data-state=open]]:text-foreground">
|
||||
@@ -311,13 +312,13 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => {
|
||||
</AccordionItem_Shadcn_>
|
||||
</Accordion_Shadcn_>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryPanelSection>
|
||||
</QueryPanelSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isIndexAdvisorEnabled && hasIndexRecommendation && (
|
||||
<div className="bg-studio sticky bottom-0 border-t py-3 flex items-center justify-between px-5">
|
||||
<div className="flex flex-col gap-y-1 text-sm">
|
||||
<div className="flex flex-col gap-y-0.5 text-xs">
|
||||
<span>Apply index to database</span>
|
||||
<span className="text-xs text-foreground-light">
|
||||
This will run the SQL that is shown above
|
||||
|
||||
@@ -44,7 +44,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps)
|
||||
<p className="text-sm text-foreground-light">
|
||||
This chart is available from{' '}
|
||||
<span className="capitalize">
|
||||
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
|
||||
{Array.isArray(report.availableIn) && report.availableIn.length > 0
|
||||
? report.availableIn[0]
|
||||
: 'Pro'}
|
||||
</span>{' '}
|
||||
plan and above
|
||||
</p>
|
||||
@@ -58,7 +60,9 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps)
|
||||
<Link href={`/org/${orgSlug || '_'}/billing?panel=subscriptionPlan&source=reports`}>
|
||||
Upgrade to{' '}
|
||||
<span className="capitalize">
|
||||
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
|
||||
{Array.isArray(report.availableIn) && report.availableIn.length > 0
|
||||
? report.availableIn[0]
|
||||
: 'Pro'}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+341
-135
@@ -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<typeof FormSchema>
|
||||
|
||||
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 (
|
||||
<Dialog open={visible} onOpenChange={setVisible}>
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
disabled={!isEnabled}
|
||||
size="tiny"
|
||||
type="primary"
|
||||
icon={<Plus />}
|
||||
onClick={() => setVisible(true)}
|
||||
tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }}
|
||||
>
|
||||
{isEditingExistingPublication ? 'Add tables' : 'Connect tables'}
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
|
||||
{!sourceId ? (
|
||||
<EnableReplicationDialogContent onClose={() => setVisible(false)} />
|
||||
) : (
|
||||
<ConnectTablesDialogContent
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onSuccessConnectTables={onSuccessConnectTables}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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<PROGRESS_STAGE>()
|
||||
|
||||
const form = useForm<ConnectTablesForm>({
|
||||
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<ConnectTablesForm> = 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<ConnectTablesForm> = 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<ConnectTablesForm> = 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<ConnectTablesForm> = async (values) => {
|
||||
if (isEditingExistingPublication) {
|
||||
onSubmitUpdatePublication(values)
|
||||
} else {
|
||||
onSubmitNewPublication(values)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.reset()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
disabled={!isEnabled}
|
||||
size="tiny"
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => setVisible(true)}
|
||||
tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }}
|
||||
>
|
||||
Connect tables
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect tables</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogSection className="flex flex-col gap-y-4">
|
||||
<p className="text-sm">
|
||||
Select the database tables to send data from. A destination analytics table will be
|
||||
created for each, and data will replicate automatically.
|
||||
</p>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="overflow-visible">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="tables"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Tables">
|
||||
<FormControl_Shadcn_>
|
||||
<MultiSelector
|
||||
values={field.value}
|
||||
onValuesChange={field.onChange}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<MultiSelector.Trigger label="Select tables..." badgeLimit="wrap" />
|
||||
<MultiSelector.Content>
|
||||
<MultiSelector.List>
|
||||
{tables?.map((table) => (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogSection className="flex flex-col gap-y-4">
|
||||
<p className="text-sm">
|
||||
Select the database tables to send data from. A destination analytics table will be
|
||||
created for each, and data will replicate automatically.
|
||||
</p>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="overflow-visible">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="tables"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Tables">
|
||||
<FormControl_Shadcn_>
|
||||
<MultiSelector
|
||||
values={field.value}
|
||||
onValuesChange={field.onChange}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<MultiSelector.Trigger
|
||||
deletableBadge
|
||||
badgeLimit="wrap"
|
||||
mode="inline-combobox"
|
||||
label="Select tables..."
|
||||
/>
|
||||
<MultiSelector.Content>
|
||||
<MultiSelector.List>
|
||||
{tables?.map((table) => {
|
||||
const alreadyConnected = (publication?.tables ?? []).some(
|
||||
(x) => x.schema === table.schema && x.name === table.name
|
||||
)
|
||||
return (
|
||||
<MultiSelector.Item
|
||||
disabled={alreadyConnected}
|
||||
className="[&>div]:flex [&>div]:items-center [&>div]:justify-between"
|
||||
key={`${table.schema}.${table.name}`}
|
||||
value={`${table.schema}.${table.name}`}
|
||||
>
|
||||
{`${table.schema}.${table.name}`}
|
||||
<span>{`${table.schema}.${table.name}`}</span>
|
||||
{alreadyConnected && <span>Connected to analytics bucket</span>}
|
||||
</MultiSelector.Item>
|
||||
))}
|
||||
</MultiSelector.List>
|
||||
</MultiSelector.Content>
|
||||
</MultiSelector>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</DialogSection>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</MultiSelector.List>
|
||||
</MultiSelector.Content>
|
||||
</MultiSelector>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</DialogSection>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={isConnecting} onClick={() => setVisible(false)}>
|
||||
Cancel
|
||||
<AnimatePresence mode="wait">
|
||||
{isConnecting && !!progressDescription && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<div className="flex items-center gap-x-2 mb-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{'getDescription' in progressDescription
|
||||
? progressDescription.getDescription?.(selectedTables.length)
|
||||
: progressDescription.description}
|
||||
</p>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
<Progress value={(currentStep / (totalProgressSteps + 1)) * 100} />
|
||||
</DialogSection>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={isConnecting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" disabled={isConnecting}>
|
||||
Connect
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Database replication needs to be enabled</DialogTitle>
|
||||
<DialogDescription>
|
||||
Replication is used to sync data from your Postgres tables
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-y-2 !p-0">
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-0 mb-0"
|
||||
title={
|
||||
noAccessToReplication
|
||||
? 'Replication is currently unavailable for your project'
|
||||
: 'Replication is currently in Alpha'
|
||||
}
|
||||
>
|
||||
{noAccessToReplication ? (
|
||||
<p className="text-sm !leading-normal">
|
||||
Access to database replication is currently not available yet for public use. If
|
||||
you're interested, do reach out to us via support!
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm !leading-normal">
|
||||
This feature is in active development and may change as we gather feedback.
|
||||
Availability and behavior can evolve while in Alpha.
|
||||
</p>
|
||||
<p className="text-sm !leading-normal">
|
||||
Pricing has not been finalized yet. You can enable replication now; we’ll announce
|
||||
pricing later and notify you before any charges apply.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Admonition>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={creatingTenantSource} onClick={() => onClose()}>
|
||||
{noAccessToReplication ? 'Understood' : 'Cancel'}
|
||||
</Button>
|
||||
{!noAccessToReplication && (
|
||||
<Button type="primary" loading={creatingTenantSource} onClick={onEnableReplication}>
|
||||
Enable replication
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isConnecting} disabled={isConnecting}>
|
||||
Connect
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
-130
@@ -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<string, string>
|
||||
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 (
|
||||
<TableRow key={namespace}>
|
||||
<TableCell>{namespace}</TableCell>
|
||||
<TableCell className={cn(!schema ? 'text-foreground-lighter' : '')}>
|
||||
{schema ?? 'No schema'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<ButtonTooltip
|
||||
type={missingTables.length > 0 ? 'primary' : 'default'}
|
||||
icon={<RefreshCw className="h-3 w-3" />}
|
||||
loading={isImportingForeignSchema || isLoadingNamespaceTables}
|
||||
onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
|
||||
disabled={missingTables.length === 0}
|
||||
tooltip={{ content: { text: scanTooltip } }}
|
||||
>
|
||||
Sync
|
||||
</ButtonTooltip>
|
||||
{schema ? (
|
||||
<Button asChild type="default" icon={<SquareArrowOutUpRight />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
|
||||
>
|
||||
Open in Table Editor
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
icon={<SquareArrowOutUpRight />}
|
||||
disabled
|
||||
tooltip={{
|
||||
content: { text: 'There are no tables connected.' },
|
||||
}}
|
||||
>
|
||||
Open in Table Editor
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<ImportForeignSchemaDialog
|
||||
bucketName={bucketName}
|
||||
namespace={namespace}
|
||||
wrapperMeta={wrapperMeta}
|
||||
visible={importForeignSchemaShown}
|
||||
onClose={() => setImportForeignSchemaShown(false)}
|
||||
/>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
+240
@@ -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 (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="min-w-[120px]">{table.name}</TableCell>
|
||||
{!!publication && (
|
||||
<TableCell colSpan={table.isConnected ? 1 : 2} className="min-w-[150px]">
|
||||
<div className="flex flex-row items-center text-foregroung-lighter">
|
||||
<div className="relative mr-2 align-middle w-3 h-3">
|
||||
<span
|
||||
className={`absolute inset-0 rounded-full ${
|
||||
isReplicating
|
||||
? isLoading
|
||||
? 'bg-brand/20 animate-ping'
|
||||
: 'bg-brand/20 animate-ping'
|
||||
: isLoading
|
||||
? 'bg-selection/20 animate-ping'
|
||||
: 'hidden'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${1 + index * 0.15}s`,
|
||||
animationDuration: '2s',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 inline-block w-2 h-2 rounded-full ${
|
||||
isReplicating ? 'bg-brand' : 'bg-selection'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-foreground-lighter">
|
||||
{isLoading && !isReplicating
|
||||
? '-'
|
||||
: isReplicating
|
||||
? 'Replicating'
|
||||
: 'Not replicating'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{table.isConnected && (
|
||||
<TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
|
||||
<>
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${project?.ref}/editor/${table.id}?schema=${schema}`}>
|
||||
<p>View table</p>
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-fit min-w-[180px]">
|
||||
<DropdownMenuItem asChild className="flex items-center gap-x-2">
|
||||
<Link
|
||||
href={`/project/${project?.ref}/sql/new?content=${encodeURIComponent(`select * from ${schema}.${table.name};`)}`}
|
||||
>
|
||||
<SqlEditor size={12} className="text-foreground-lighter" />
|
||||
<p>Query in SQL Editor</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isReplicating ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-x-2"
|
||||
onClick={() => setShowStopReplicationModal(true)}
|
||||
>
|
||||
<Pause size={12} className="text-foreground-lighter" />
|
||||
<p>Stop replication</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-x-2"
|
||||
onClick={() => setShowStartReplicationModal(true)}
|
||||
>
|
||||
<Play size={12} className="text-foreground-lighter" />
|
||||
<p>Start replication</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
variant="warning"
|
||||
visible={showStopReplicationModal}
|
||||
loading={isUpdatingReplication}
|
||||
title="Confirm to stop replication for table"
|
||||
confirmLabel="Stop replication"
|
||||
onCancel={() => setShowStopReplicationModal(false)}
|
||||
onConfirm={() => onConfirmStopReplication()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Data within the "{table.name}" table will stop replicating. However do note that,
|
||||
restarting replication on the table will clear and re-sync all data in it. Are you sure?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
<ConfirmationModal
|
||||
size="medium"
|
||||
variant="warning"
|
||||
visible={showStartReplicationModal}
|
||||
loading={isUpdatingReplication}
|
||||
title="Confirm to start replication for table"
|
||||
confirmLabel="Start replication"
|
||||
onCancel={() => setShowStartReplicationModal(false)}
|
||||
onConfirm={() => onConfirmStartReplication()}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Restarting replication on the "{table.name}" table will clear and re-sync all data in it.
|
||||
Are you sure?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+67
-144
@@ -1,7 +1,7 @@
|
||||
import { ChevronRight, Code, Info, MoreVertical, Plus, Replace, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ChevronRight, Info, Loader2, Plus, RefreshCw } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
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'
|
||||
@@ -14,10 +14,7 @@ import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
LoadingLine,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -26,9 +23,10 @@ import {
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities'
|
||||
import { TableRowComponent } from './TableRowComponent'
|
||||
|
||||
type NamespaceWithTablesProps = {
|
||||
bucketName?: string
|
||||
@@ -40,108 +38,9 @@ type NamespaceWithTablesProps = {
|
||||
wrapperInstance: FDW
|
||||
wrapperValues: Record<string, string>
|
||||
wrapperMeta: WrapperMeta
|
||||
}
|
||||
|
||||
// Component for individual table rows within a namespace
|
||||
const TableRowComponent = ({
|
||||
index,
|
||||
tableName,
|
||||
isConnected,
|
||||
isLoading,
|
||||
}: {
|
||||
index: number
|
||||
tableName: string
|
||||
isConnected: boolean
|
||||
isLoading?: boolean
|
||||
}) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const handleQueryTable = () => {
|
||||
// TODO: Implement query table functionality
|
||||
console.log('Query table:', tableName)
|
||||
}
|
||||
|
||||
const handleDeleteTable = () => {
|
||||
// TODO: Implement delete table functionality
|
||||
console.log('Delete table:', tableName)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="min-w-[120px]">{tableName}</TableCell>
|
||||
<TableCell colSpan={isConnected ? 1 : 2} className="min-w-[150px]">
|
||||
<div className="flex flex-row items-center text-foregroung-lighter">
|
||||
<div className="relative mr-2 align-middle w-3 h-3">
|
||||
{/* Outer faded dot with pulsing background */}
|
||||
<span
|
||||
className={`absolute inset-0 rounded-full ${
|
||||
isConnected
|
||||
? isLoading
|
||||
? 'bg-brand/20 animate-ping'
|
||||
: 'bg-brand/20 animate-ping'
|
||||
: isLoading
|
||||
? 'bg-warning-500/20 animate-ping'
|
||||
: 'hidden'
|
||||
}`}
|
||||
style={{
|
||||
// Stagger the animation delay for each table
|
||||
animationDelay: `${1 + index * 0.15}s`,
|
||||
// Slow the animation
|
||||
animationDuration: '2s',
|
||||
}}
|
||||
/>
|
||||
{/* Inner colored dot */}
|
||||
<span
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 inline-block w-2 h-2 rounded-full ${
|
||||
isConnected ? 'bg-brand' : 'bg-warning-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-foreground-lighter">
|
||||
{isLoading && !isConnected
|
||||
? 'Connecting...'
|
||||
: isConnected
|
||||
? 'Connected'
|
||||
: 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{isConnected && (
|
||||
<TableCell className="text-right flex flex-row items-center gap-x-2 justify-end">
|
||||
<>
|
||||
<Button asChild type="default" size="tiny">
|
||||
<Link href={`/project/${project?.ref}/editor/${tableName}`}>
|
||||
<p>View table</p>
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-fit min-w-[180px]">
|
||||
<DropdownMenuItem className="flex items-center gap-x-2" onClick={handleQueryTable}>
|
||||
<Code size={12} className="text-foreground-lighter" />
|
||||
<p>Query in SQL Editor</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex items-center gap-x-2">
|
||||
<Link href={`/project/${project?.ref}/database/etl/${tableName}`}>
|
||||
<Replace size={12} className="text-foreground-lighter" />
|
||||
<p>View replication status</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem className="flex items-center gap-x-2" onClick={handleDeleteTable}>
|
||||
<Trash2 size={12} className="text-foreground-lighter" />
|
||||
<p>Delete table</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
tablesToPoll: { schema: string; name: string }[]
|
||||
pollIntervalNamespaceTables: number
|
||||
setPollIntervalNamespaceTables: (value: number) => void
|
||||
}
|
||||
|
||||
export const NamespaceWithTables = ({
|
||||
@@ -154,10 +53,16 @@ export const NamespaceWithTables = ({
|
||||
wrapperInstance,
|
||||
wrapperValues,
|
||||
wrapperMeta,
|
||||
tablesToPoll,
|
||||
pollIntervalNamespaceTables,
|
||||
setPollIntervalNamespaceTables,
|
||||
}: NamespaceWithTablesProps) => {
|
||||
const { ref: projectRef, bucketId } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
|
||||
|
||||
const { publication } = useAnalyticsBucketAssociatedEntities({ projectRef, bucketId })
|
||||
|
||||
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
|
||||
{
|
||||
catalogUri: wrapperValues.catalog_uri,
|
||||
@@ -165,7 +70,23 @@ export const NamespaceWithTables = ({
|
||||
token: token,
|
||||
namespace: namespace,
|
||||
},
|
||||
{ enabled: !!token }
|
||||
{
|
||||
enabled: !!token,
|
||||
refetchInterval: (data) => {
|
||||
if (pollIntervalNamespaceTables === 0) return false
|
||||
if (tablesToPoll.length > 0) {
|
||||
const hasMissingTables =
|
||||
(data ?? []).filter((t) => !tables.find((table) => table.table.split('.')[1] === t))
|
||||
.length > 0
|
||||
|
||||
if (hasMissingTables) {
|
||||
setPollIntervalNamespaceTables(0)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return pollIntervalNamespaceTables
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } =
|
||||
@@ -187,21 +108,13 @@ export const NamespaceWithTables = ({
|
||||
)
|
||||
}, [tablesData, tables])
|
||||
|
||||
let scanTooltip = useMemo(() => {
|
||||
if (isImportingForeignSchema) return 'Looking for new tables...'
|
||||
if (isLoadingNamespaceTables) return 'Loading new tables...'
|
||||
if (missingTables.length > 0)
|
||||
return `${missingTables.length} new table${missingTables.length > 1 ? 's' : ''} found`
|
||||
if (tables.length === 0) return 'No new tables found'
|
||||
return 'All tables are up to date'
|
||||
}, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length])
|
||||
|
||||
// Get all tables (connected + missing) for display
|
||||
const allTables = useMemo(() => {
|
||||
const connectedTableNames = tables.map((table) => table.table.split('.')[1])
|
||||
const allTableNames = [...new Set([...connectedTableNames, ...missingTables])]
|
||||
|
||||
return allTableNames.map((tableName) => ({
|
||||
id: tables.find((t) => t.table_name === tableName)?.id ?? 0,
|
||||
name: tableName,
|
||||
isConnected: connectedTableNames.includes(tableName),
|
||||
}))
|
||||
@@ -234,19 +147,17 @@ export const NamespaceWithTables = ({
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center px-4 py-5 space-y-0">
|
||||
<CardTitle className="text-sm font-normal font-sans normal-case leading-none flex flex-row items-center gap-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex flex-row items-center gap-x-1 text-foreground-lighter">
|
||||
{sourceType === 'direct' ? namespace : 'public'}
|
||||
<ChevronRight size={12} className="text-foreground-muted" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{sourceType === 'direct' ? 'Analytics' : 'Postgres'} schema</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex flex-row items-center gap-x-1 text-foreground-lighter">
|
||||
{sourceType === 'direct' ? namespace : 'public'}
|
||||
<ChevronRight size={12} className="text-foreground-muted" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{sourceType === 'direct' ? 'Analytics' : 'Postgres'} schema</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{validSchema && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -264,7 +175,7 @@ export const NamespaceWithTables = ({
|
||||
{tables.length === 0 && <Info size={12} />}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Postgres schema{tables.length === 0 && ' that will be created'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -272,29 +183,41 @@ export const NamespaceWithTables = ({
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex flex-row gap-x-2">
|
||||
{/* [Joshen] Temporarily commented out, will be introduced once language and UI details are finalized */}
|
||||
{/* {tables.length > 0 && <p className="text-sm text-foreground-muted">{scanTooltip}</p>} */}
|
||||
{/* {scanTooltip && <p className="text-sm text-foreground-muted">{scanTooltip}</p>} */}
|
||||
{tablesToPoll.length > 0 && (
|
||||
<div className="flex items-center gap-x-2 ml-6 text-foreground-lighter">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<p className="text-sm">
|
||||
Connecting {tablesToPoll.length} table{tablesToPoll.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{missingTables.length > 0 && (
|
||||
<Button
|
||||
type={missingTables.length > 0 ? 'warning' : 'default'}
|
||||
type={schema ? 'default' : 'warning'}
|
||||
size="tiny"
|
||||
icon={<Plus size={14} />}
|
||||
icon={schema ? <RefreshCw /> : <Plus size={14} />}
|
||||
onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
|
||||
loading={isImportingForeignSchema || isLoadingNamespaceTables}
|
||||
>
|
||||
Connect to table{missingTables.length > 1 ? 's' : ''}
|
||||
{schema ? 'Sync tables' : `Connect to table${missingTables.length > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{tablesToPoll.length > 0 && <LoadingLine loading />}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={allTables.length === 0 ? 'text-foreground-muted' : undefined}>
|
||||
Table name
|
||||
</TableHead>
|
||||
<TableHead className={allTables.length === 0 ? 'hidden' : undefined}>Status</TableHead>
|
||||
{!!publication && (
|
||||
<TableHead className={allTables.length === 0 ? 'hidden' : undefined}>
|
||||
Replication Status
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -311,11 +234,11 @@ export const NamespaceWithTables = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
allTables.map(({ name, isConnected }, index) => (
|
||||
allTables.map((table, index) => (
|
||||
<TableRowComponent
|
||||
key={name}
|
||||
tableName={name}
|
||||
isConnected={isConnected}
|
||||
key={table.name}
|
||||
table={table}
|
||||
schema={displaySchema}
|
||||
isLoading={isImportingForeignSchema || isLoadingNamespaceTables}
|
||||
index={index}
|
||||
/>
|
||||
+108
-27
@@ -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
|
||||
</ScaffoldSectionDescription>
|
||||
</div>
|
||||
{namespaces.length > 0 && <ConnectTablesDialog bucketId={bucket?.id} />}
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!!pipeline && (
|
||||
<Button asChild type="default">
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/etl/${pipeline.replicator_id}`}
|
||||
>
|
||||
View replication
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{namespaces.length > 0 && (
|
||||
<ConnectTablesDialog
|
||||
onSuccessConnectTables={(tables) => {
|
||||
setTablesToPoll(tables)
|
||||
setPollIntervalNamespaces(4000)
|
||||
setPollIntervalNamespaceTables(4000)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldHeader>
|
||||
|
||||
{isLoadingNamespaces || isLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : namespaces.length === 0 ? (
|
||||
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
|
||||
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h3>Connect database tables</h3>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Stream data from tables for archival, backups, or analytical queries.
|
||||
</p>
|
||||
</div>
|
||||
<ConnectTablesDialog bucketId={bucket?.id} />
|
||||
</aside>
|
||||
<>
|
||||
{tablesToPoll.length > 0 ? (
|
||||
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
|
||||
<Loader2
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
className="animate-spin text-foreground-muted"
|
||||
/>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h3>
|
||||
Connecting {tablesToPoll.length} table
|
||||
{tablesToPoll.length > 1 ? 's' : ''} to bucket
|
||||
</h3>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Tables will be shown here once the connection is complete
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<aside className="border border-dashed w-full bg-surface-100 rounded-lg px-4 py-10 flex flex-col gap-y-3 items-center text-center gap-1 text-balance">
|
||||
<SquarePlus size={24} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h3>Connect database tables</h3>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Stream data from tables for archival, backups, or analytical queries.
|
||||
</p>
|
||||
</div>
|
||||
<ConnectTablesDialog
|
||||
onSuccessConnectTables={(tables) => {
|
||||
setTablesToPoll(tables)
|
||||
setPollIntervalNamespaces(4000)
|
||||
setPollIntervalNamespaceTables(4000)
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-10">
|
||||
{namespaces.map(({ namespace, schema, tables }) => (
|
||||
@@ -190,6 +268,9 @@ export const AnalyticBucketDetails = () => {
|
||||
wrapperInstance={wrapperInstance}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
tablesToPoll={tablesToPoll}
|
||||
pollIntervalNamespaceTables={pollIntervalNamespaceTables}
|
||||
setPollIntervalNamespaceTables={setPollIntervalNamespaceTables}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -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 = () => {
|
||||
|
||||
+7
-3
@@ -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(() => {
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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<z.infer<typeof FormSchema>> = 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])
|
||||
|
||||
+7
-5
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between gap-0 space-y-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between gap-2 space-y-0">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-3">
|
||||
<BucketIcon className="text-foreground-muted" size={16} strokeWidth={1.5} />
|
||||
<CardTitle>{label}</CardTitle>
|
||||
{bucket?.public && <Badge variant="warning">Public</Badge>}
|
||||
<div className="flex flex-1 min-w-0 items-center gap-1.5">
|
||||
<CardTitle className="truncate">{label}</CardTitle>
|
||||
{bucket?.public && <Badge variant="warning">Public</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="outline" onClick={() => onSelectPolicyAdd(bucket?.name, table)}>
|
||||
New policy
|
||||
|
||||
@@ -274,15 +274,9 @@ export const S3Connection = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="description" className="min-w-40 lg:min-w-64">
|
||||
Description
|
||||
</TableHead>
|
||||
<TableHead key="access-key-id" className="min-w-64 w-full">
|
||||
Access key ID
|
||||
</TableHead>
|
||||
<TableHead key="created-at" className="min-w-48">
|
||||
Created at
|
||||
</TableHead>
|
||||
<TableHead key="description">Name</TableHead>
|
||||
<TableHead key="access-key-id">Key ID</TableHead>
|
||||
<TableHead key="created-at">Created at</TableHead>
|
||||
<TableHead key="actions" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -53,7 +53,9 @@ export const StorageCredItem = ({
|
||||
<TableCell>
|
||||
<Input readOnly copy value={access_key} className="font-mono" />
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-lighter">{daysSince(created_at)}</TableCell>
|
||||
<TableCell className="text-foreground-lighter whitespace-nowrap">
|
||||
{daysSince(created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canRemoveAccessKey && (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<ProjectContextProvider projectRef={ref}>
|
||||
<LayoutSidebarProvider>
|
||||
<LayoutSidebarProvider>
|
||||
<ProjectContextProvider projectRef={ref}>
|
||||
<AppBannerContextProvider>
|
||||
<div className="flex flex-col h-screen w-screen">
|
||||
{/* Top Banner */}
|
||||
@@ -75,13 +76,22 @@ const DefaultLayout = ({
|
||||
<div className="flex flex-1 w-full overflow-y-hidden">
|
||||
{/* Sidebar - Only show for project pages, not account pages */}
|
||||
{!router.pathname.startsWith('/account') && <Sidebar />}
|
||||
{/* Main Content */}
|
||||
<div className="flex-grow h-full overflow-y-auto">{children}</div>
|
||||
{/* Main Content with Layout Sidebar */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
|
||||
autoSaveId="default-layout-content"
|
||||
>
|
||||
<ResizablePanel id="panel-content" defaultSize={1} className="w-full">
|
||||
<div className="h-full overflow-y-auto">{children}</div>
|
||||
</ResizablePanel>
|
||||
<LayoutSidebar />
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</AppBannerContextProvider>
|
||||
</LayoutSidebarProvider>
|
||||
</ProjectContextProvider>
|
||||
</ProjectContextProvider>
|
||||
</LayoutSidebarProvider>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLSpanElement>) => (
|
||||
@@ -212,11 +210,10 @@ export const LayoutHeader = ({
|
||||
|
||||
<div className="overflow-hidden flex items-center gap-2">
|
||||
<HelpPopover />
|
||||
<NotificationsPopoverV2 />
|
||||
<AdvisorButton projectRef={projectRef} />
|
||||
<AnimatePresence initial={false}>
|
||||
{!!projectRef && (
|
||||
<>
|
||||
<AdvisorButton />
|
||||
<InlineEditorButton />
|
||||
<AssistantButton />
|
||||
</>
|
||||
@@ -229,10 +226,10 @@ export const LayoutHeader = ({
|
||||
<>
|
||||
<LocalVersionPopover />
|
||||
<div className="overflow-hidden flex items-center gap-2">
|
||||
<AdvisorButton projectRef={projectRef} />
|
||||
<AnimatePresence initial={false}>
|
||||
{!!projectRef && (
|
||||
<>
|
||||
<AdvisorButton />
|
||||
<InlineEditorButton />
|
||||
<AssistantButton />
|
||||
</>
|
||||
|
||||
-189
@@ -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 (
|
||||
<div
|
||||
ref={virtualizer.measureElement}
|
||||
className={cn(
|
||||
`p-4 flex justify-between gap-x-3 group`,
|
||||
index !== 0 ? 'border-t border-overlay' : '',
|
||||
status === 'new' ? 'bg-surface-100/50' : 'bg-background'
|
||||
)}
|
||||
>
|
||||
<div ref={viewRef} className="flex flex-col gap-y-2.5 w-full py-0.5">
|
||||
{(project !== undefined || organization !== undefined) && (
|
||||
<div className="flex items-center max-w-[350px]">
|
||||
{organization !== undefined && (
|
||||
<Link
|
||||
title={organization.name}
|
||||
href={`/org/${organization.slug}/general`}
|
||||
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
|
||||
>
|
||||
{organization.name}
|
||||
</Link>
|
||||
)}
|
||||
{organization !== undefined && project !== undefined && (
|
||||
<span className="text-foreground-lighter">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{project !== undefined && (
|
||||
<Link
|
||||
title={project.name}
|
||||
href={`/project/${project.ref}`}
|
||||
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm break-word">
|
||||
{data.title}{' '}
|
||||
<span className="ml-1 text-xs text-foreground-light capitalize-sentence">
|
||||
{daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{data.message !== undefined && (
|
||||
<Markdown className="text-foreground-light text-xs" content={data.message} />
|
||||
)}
|
||||
{(data.actions ?? []).length > 0 && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
{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 (
|
||||
<Button
|
||||
key={key}
|
||||
type="default"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
asChild
|
||||
>
|
||||
<Link href={url} target="_blank" rel="noreferrer">
|
||||
{action.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
} else if (action.action_type !== undefined) {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
type="default"
|
||||
onClick={() => onButtonAction(action.action_type)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
{priority === 'Warning' && <WarningIcon className="w-5 h-5" />}
|
||||
{priority === 'Critical' && <CriticalIcon className="w-5 h-5" />}
|
||||
{notification.status === 'archived' ? (
|
||||
<ButtonTooltip
|
||||
type="outline"
|
||||
icon={
|
||||
<ArchiveRestoreIcon size={13} strokeWidth={2} className="text-foreground-light" />
|
||||
}
|
||||
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
|
||||
onClick={() => onUpdateNotificationStatus(notification.id, 'seen')}
|
||||
tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }}
|
||||
/>
|
||||
) : (
|
||||
<ButtonTooltip
|
||||
type="outline"
|
||||
icon={<Archive size={13} strokeWidth={2} className="text-foreground-light" />}
|
||||
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
|
||||
onClick={() => onUpdateNotificationStatus(notification.id, 'archived')}
|
||||
tooltip={{ content: { text: 'Archive', side: 'bottom' } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationRow
|
||||
-245
@@ -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 (
|
||||
<Popover_Shadcn_ modal={true} open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type={snap.numFiltersApplied > 0 ? 'default' : 'text'}
|
||||
icon={<Settings2Icon strokeWidth={1} />}
|
||||
className="px-1 h-[26px]"
|
||||
>
|
||||
{snap.numFiltersApplied > 0 &&
|
||||
`${snap.numFiltersApplied} filter${snap.numFiltersApplied > 1 ? 's' : ''} applied`}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="end">
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Find filter..." />
|
||||
|
||||
<CommandEmpty_Shadcn_>No filters found that match your search</CommandEmpty_Shadcn_>
|
||||
|
||||
<CommandList_Shadcn_>
|
||||
<ScrollArea className="max-h-[240px] py-1 overflow-y-auto">
|
||||
<CommandGroup_Shadcn_>
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<CommandItem_Shadcn_
|
||||
disabled={activeTab === 'archived'}
|
||||
onSelect={() => {
|
||||
snap.setFilters('unread', 'status')
|
||||
}}
|
||||
>
|
||||
<Label_Shadcn_
|
||||
htmlFor={'unread'}
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
|
||||
snap.filterStatuses.includes('unread') && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
name="unread"
|
||||
checked={snap.filterStatuses.includes('unread')}
|
||||
/>
|
||||
Unread
|
||||
</Label_Shadcn_>
|
||||
</CommandItem_Shadcn_>
|
||||
</CommandGroup_Shadcn_>
|
||||
|
||||
<CommandSeparator_Shadcn_ />
|
||||
|
||||
<CommandGroup_Shadcn_>
|
||||
<DropdownMenuLabel>Priority</DropdownMenuLabel>
|
||||
<CommandItem_Shadcn_
|
||||
onSelect={() => {
|
||||
snap.setFilters('Warning', 'priority')
|
||||
}}
|
||||
className="flex items-center gap-x-2"
|
||||
>
|
||||
<Label_Shadcn_
|
||||
htmlFor={'warning'}
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
|
||||
snap.filterPriorities.includes('Warning') && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
name="warning"
|
||||
checked={snap.filterPriorities.includes('Warning')}
|
||||
/>
|
||||
<WarningIcon className="size-4" />
|
||||
Warning
|
||||
</Label_Shadcn_>
|
||||
</CommandItem_Shadcn_>
|
||||
<CommandItem_Shadcn_
|
||||
key={'critical'}
|
||||
onSelect={() => {
|
||||
snap.setFilters('Critical', 'priority')
|
||||
}}
|
||||
>
|
||||
<Label_Shadcn_
|
||||
htmlFor={'critical'}
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
|
||||
snap.filterPriorities.includes('Critical') && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
name="critical"
|
||||
checked={snap.filterPriorities.includes('Critical')}
|
||||
/>
|
||||
<CriticalIcon className="size-4" />
|
||||
Critical
|
||||
</Label_Shadcn_>
|
||||
</CommandItem_Shadcn_>
|
||||
</CommandGroup_Shadcn_>
|
||||
|
||||
<CommandSeparator_Shadcn_ />
|
||||
|
||||
<CommandGroup_Shadcn_>
|
||||
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
|
||||
{(organizations ?? []).map((org) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={org.slug}
|
||||
value={org.name.replaceAll('"', '')}
|
||||
className="flex items-center gap-x-2"
|
||||
onSelect={() => {
|
||||
snap.setFilters(org.slug, 'organizations')
|
||||
}}
|
||||
>
|
||||
<Label_Shadcn_
|
||||
htmlFor={`${org.slug}`}
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
|
||||
snap.filterOrganizations.includes(org.slug) && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
name={`${org.slug}`}
|
||||
checked={snap.filterOrganizations.includes(org.slug)}
|
||||
/>
|
||||
{org.name}
|
||||
</Label_Shadcn_>
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
|
||||
<CommandSeparator_Shadcn_ />
|
||||
|
||||
<CommandGroup_Shadcn_>
|
||||
<DropdownMenuLabel>Projects</DropdownMenuLabel>
|
||||
{/*
|
||||
[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
|
||||
*/}
|
||||
<div className="px-2 mb-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="rounded"
|
||||
placeholder="Search for a project"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
actions={
|
||||
search.length > 0 ? (
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer mr-1"
|
||||
onClick={() => setSearch('')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(projects ?? []).map((project) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={project.ref}
|
||||
value={project.ref}
|
||||
className="flex items-center gap-x-2"
|
||||
onSelect={() => {
|
||||
snap.setFilters(project.ref, 'projects')
|
||||
}}
|
||||
>
|
||||
<Label_Shadcn_
|
||||
htmlFor={`${project.ref}`}
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
|
||||
snap.filterProjects.includes(project.ref) && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
<Checkbox_Shadcn_
|
||||
name={`${project.ref}`}
|
||||
checked={snap.filterProjects.includes(project.ref)}
|
||||
/>
|
||||
{project.name}
|
||||
</Label_Shadcn_>
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
{projectCount > pageLimit && (
|
||||
<p className="text-foreground-lighter text-xs pt-2 px-2">
|
||||
Not all projects are shown here. Try searching to find a specific project.
|
||||
</p>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</ScrollArea>
|
||||
|
||||
<CommandSeparator_Shadcn_ />
|
||||
|
||||
<CommandGroup>
|
||||
<CommandItem_Shadcn_
|
||||
onSelect={() => snap.resetFilters()}
|
||||
className="flex gap-x-2 items-center"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Reset filters
|
||||
</CommandItem_Shadcn_>
|
||||
</CommandGroup>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
-269
@@ -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<string[]>([])
|
||||
|
||||
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 (
|
||||
<Popover_Shadcn_
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open)
|
||||
if (!open) markNotificationsRead()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<ButtonTooltip
|
||||
tooltip={{
|
||||
content: {
|
||||
text: 'Notifications',
|
||||
},
|
||||
}}
|
||||
type="outline"
|
||||
size="tiny"
|
||||
className={cn(
|
||||
'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0 group',
|
||||
open && 'bg-foreground text-background'
|
||||
)}
|
||||
icon={
|
||||
<div className="relative">
|
||||
<InboxIcon
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-foreground-light group-hover:text-foreground',
|
||||
open && 'text-background group-hover:text-background'
|
||||
)}
|
||||
/>
|
||||
{hasCritical && (
|
||||
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-[6px] border-r-[6px] border-b-[10px] border-l-transparent border-r-transparent border-b-destructive" />
|
||||
</div>
|
||||
)}
|
||||
{hasWarning && !hasCritical && (
|
||||
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-warning" />
|
||||
</div>
|
||||
)}
|
||||
{!!hasNewNotifications && !hasCritical && !hasWarning && (
|
||||
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-brand" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 w-screen md:w-[450px] overflow-hidden"
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<div className="px-4">
|
||||
<p className="pt-4 pb-1 text-sm">Notifications</p>
|
||||
<div className="flex items-center">
|
||||
<Tabs_Shadcn_
|
||||
className="w-full"
|
||||
onValueChange={(tab: string) => {
|
||||
setActiveTab(tab as 'inbox' | 'archived')
|
||||
if (tab === 'archived' && snap.filterStatuses.includes('unread')) {
|
||||
snap.setFilters('unread', 'status')
|
||||
}
|
||||
}}
|
||||
value={activeTab}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<TabsList_Shadcn_ className="flex gap-5 grow border-none">
|
||||
<TabsTrigger_Shadcn_
|
||||
id="inbox"
|
||||
value="inbox"
|
||||
className="px-0 data-[state=active]:bg-transparent flex gap-2"
|
||||
>
|
||||
Inbox
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs rounded-full bg-surface-300 h-4',
|
||||
(summary?.unread_count ?? 0) > 9 ? 'px-0.5 w-auto' : 'w-4'
|
||||
)}
|
||||
>
|
||||
{summary?.unread_count}
|
||||
</div>
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_
|
||||
id="archived"
|
||||
value="archived"
|
||||
className="px-0 data-[state=active]:bg-transparent"
|
||||
>
|
||||
Archived
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
<NotificationsFilter activeTab={activeTab} />
|
||||
</div>
|
||||
</Tabs_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t">
|
||||
{isLoading && (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="p-4">
|
||||
<AlertError subject="Failed to retrieve notifications" error={error} />
|
||||
</div>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<div className="flex flex-1 h-[400px] bg-background">
|
||||
{notifications.length > 0 &&
|
||||
!(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? (
|
||||
<InfiniteListDefault
|
||||
className="w-full"
|
||||
items={notifications}
|
||||
ItemComponent={NotificationRow}
|
||||
LoaderComponent={({ style }) => (
|
||||
<div style={style} className="p-4">
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)}
|
||||
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()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-4 items-center flex-grow justify-center">
|
||||
<InboxIcon size={32} className="text-foreground-light" />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p className="text-foreground-light text-sm mx-auto text-center">
|
||||
{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'}
|
||||
</p>
|
||||
<p className="text-foreground-lighter text-xs w-60 mx-auto text-center">
|
||||
{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'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{notifications.length > 0 && activeTab === 'inbox' && (
|
||||
<div className="flex items-center justify-center p-1.5 border-t">
|
||||
<Button
|
||||
disabled={isArchiving}
|
||||
loading={isArchiving}
|
||||
type="text"
|
||||
icon={<ArchiveIcon />}
|
||||
onClick={() => archiveAllNotifications()}
|
||||
>
|
||||
Archive all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
+7
-10
@@ -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, () => <AIAssistant />, {}, 'i')
|
||||
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e')
|
||||
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />)
|
||||
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => <AIAssistant />, {}, 'i', !!project)
|
||||
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e', !!project)
|
||||
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />, {}, undefined, true)
|
||||
|
||||
const router = useRouter()
|
||||
const { openSidebar, activeSidebar } = useSidebarManagerSnapshot()
|
||||
|
||||
@@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({
|
||||
AIAssistant: () => <div data-testid="ai-assistant-sidebar">AI Assistant</div>,
|
||||
}))
|
||||
|
||||
vi.mock('components/ui/EditorPanel/EditorPanel', () => ({
|
||||
EditorPanel: () => <div data-testid="editor-panel-sidebar">Editor Panel</div>,
|
||||
}))
|
||||
|
||||
vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({
|
||||
AdvisorPanel: () => <div data-testid="advisor-panel-sidebar">Advisor Panel</div>,
|
||||
}))
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement, PropsWithChildren<Projec
|
||||
<ResizablePanel
|
||||
defaultSize={1}
|
||||
order={2}
|
||||
id="panel-right"
|
||||
className="h-full flex flex-col w-full"
|
||||
id="panel-project-content"
|
||||
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
|
||||
autoSaveId="project-layout-content"
|
||||
<main
|
||||
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
|
||||
ref={ref}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="panel-content"
|
||||
defaultSize={1}
|
||||
className={cn('w-full xl:min-w-[600px] bg-dash-sidebar')}
|
||||
>
|
||||
<main
|
||||
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
|
||||
ref={ref}
|
||||
>
|
||||
{showPausedState ? (
|
||||
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
|
||||
<div className="w-full">
|
||||
<ProjectPausedState product={product} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
|
||||
<ResourceExhaustionWarningBanner />
|
||||
{children}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
<LayoutSidebar />
|
||||
</ResizablePanelGroup>
|
||||
{showPausedState ? (
|
||||
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
|
||||
<div className="w-full">
|
||||
<ProjectPausedState product={product} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
|
||||
<ResourceExhaustionWarningBanner />
|
||||
{children}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="px-6 py-6">
|
||||
<LintDetail lint={lint} projectRef={projectRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.source === 'notification') {
|
||||
const notification = item.original as Notification
|
||||
return (
|
||||
<div className="px-6 py-6">
|
||||
<NotificationDetail
|
||||
notification={notification}
|
||||
onUpdateStatus={onUpdateNotificationStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
|
||||
<Tabs_Shadcn_ value={activeTab} onValueChange={onTabChange} className="h-full">
|
||||
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
|
||||
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
|
||||
All
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
|
||||
Security
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
|
||||
Performance
|
||||
</TabsTrigger_Shadcn_>
|
||||
{isPlatform && (
|
||||
<TabsTrigger_Shadcn_
|
||||
value="messages"
|
||||
className="h-full text-xs flex items-center gap-2"
|
||||
>
|
||||
Messages
|
||||
</TabsTrigger_Shadcn_>
|
||||
)}
|
||||
</TabsList_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPlatform && (
|
||||
<FilterPopover
|
||||
name="Status"
|
||||
options={statusOptions}
|
||||
activeOptions={[...statusFilters]}
|
||||
valueKey="value"
|
||||
labelKey="label"
|
||||
isMinimized={true}
|
||||
onSaveFilters={onStatusFiltersChange}
|
||||
/>
|
||||
)}
|
||||
<FilterPopover
|
||||
name="Severity"
|
||||
options={severityOptions}
|
||||
activeOptions={[...severityFilters]}
|
||||
valueKey="value"
|
||||
labelKey="label"
|
||||
isMinimized={true}
|
||||
onSaveFilters={(values) => {
|
||||
onSeverityFiltersChange(values as AdvisorSeverity[])
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0"
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
onClick={onClose}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<AdvisorTab, 'all'>
|
||||
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<AdvisorSeverity, number> = {
|
||||
critical: 0,
|
||||
@@ -35,30 +25,6 @@ const severityOrder: Record<AdvisorSeverity, number> = {
|
||||
info: 2,
|
||||
}
|
||||
|
||||
const severityLabels: Record<AdvisorSeverity, string> = {
|
||||
critical: 'Critical',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
}
|
||||
|
||||
const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> = {
|
||||
critical: 'destructive',
|
||||
warning: 'warning',
|
||||
info: 'default',
|
||||
}
|
||||
|
||||
const severityColorClasses: Record<AdvisorSeverity, string> = {
|
||||
critical: 'text-destructive',
|
||||
warning: 'text-warning',
|
||||
info: 'text-foreground-light',
|
||||
}
|
||||
|
||||
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, 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<string[]>([])
|
||||
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<AdvisorItem[]>(() => {
|
||||
if (!lintData) return []
|
||||
|
||||
@@ -121,8 +155,24 @@ export const AdvisorPanel = () => {
|
||||
.filter((item): item is AdvisorItem => item !== null)
|
||||
}, [lintData])
|
||||
|
||||
const notificationItems = useMemo<AdvisorItem[]>(() => {
|
||||
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<AdvisorItem[]>(() => {
|
||||
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<AdvisorItem[]>(() => {
|
||||
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<AdvisorItem[]>(() => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{isDetailView ? (
|
||||
<>
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0 flex justify-center items-center"
|
||||
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
|
||||
onClick={handleBackToList}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
||||
<div className="flex-1">
|
||||
<span className="heading-default">{selectedItem?.title}</span>
|
||||
</div>
|
||||
{selectedItem && (
|
||||
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
|
||||
{severityLabels[selectedItem.severity]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0"
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
onClick={handleClose}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
|
||||
/>
|
||||
</div>
|
||||
<AdvisorPanelHeader
|
||||
selectedItem={selectedItem}
|
||||
onBack={handleBackToList}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedItem ? (
|
||||
<AdvisorDetail item={selectedItem} projectRef={project?.ref ?? ''} />
|
||||
<AdvisorDetail
|
||||
item={selectedItem}
|
||||
projectRef={project?.ref ?? ''}
|
||||
onUpdateNotificationStatus={handleUpdateNotificationStatus}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-6 py-8">
|
||||
<p className="text-sm text-foreground-light">
|
||||
@@ -218,118 +295,40 @@ export const AdvisorPanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-b">
|
||||
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
|
||||
<Tabs_Shadcn_ value={activeTab} onValueChange={handleTabChange} className="h-full">
|
||||
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
|
||||
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
|
||||
All
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
|
||||
Security
|
||||
</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
|
||||
Performance
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterPopover
|
||||
name="Severity"
|
||||
options={severityOptions}
|
||||
activeOptions={[...severityFilters]}
|
||||
valueKey="value"
|
||||
labelKey="label"
|
||||
onSaveFilters={(values) => setSeverityFilters(values as AdvisorSeverity[])}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0"
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
onClick={handleClose}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdvisorFilters
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
severityFilters={[...severityFilters]}
|
||||
onSeverityFiltersChange={setSeverityFilters}
|
||||
statusFilters={[...notificationFilterStatuses]}
|
||||
onStatusFiltersChange={(values) => {
|
||||
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}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div>
|
||||
<GenericSkeletonLoader className="w-full p-4" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="my-8 mx-4 flex flex-col items-center gap-2">
|
||||
<AlertTriangle className="text-destructive" />
|
||||
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
|
||||
<p className="text-sm text-foreground-lighter">Please try again later.</p>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyAdvisor
|
||||
activeTab={activeTab}
|
||||
hasFilters={severityFilters.length > 0}
|
||||
onClearFilters={clearSeverityFilters}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
{filteredItems.map((item) => {
|
||||
const SeverityIcon = tabIconMap[item.tab]
|
||||
const severityClass = severityColorClasses[item.severity]
|
||||
return (
|
||||
<div key={item.id} className="border-b">
|
||||
<Button
|
||||
type="text"
|
||||
className="justify-start w-full block rounded-none h-auto py-3 px-4 text-foreground-light hover:text-foreground"
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<SeverityIcon
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={cn('flex-shrink-0', severityClass)}
|
||||
/>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="flex-shrink-0 text-foreground-lighter"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<Button type="text" className="w-full" onClick={clearSeverityFilters}>
|
||||
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<AdvisorPanelBody
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
filteredItems={filteredItems}
|
||||
activeTab={activeTab}
|
||||
severityFilters={[...severityFilters]}
|
||||
onItemClick={handleItemClick}
|
||||
onClearFilters={handleClearAllFilters}
|
||||
hiddenItemsCount={hiddenItemsCount}
|
||||
hasAnyFilters={hasAnyFilters}
|
||||
hasProjectRef={hasProjectRef}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AdvisorDetailProps {
|
||||
item: AdvisorItem
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
|
||||
if (item.source === 'lint') {
|
||||
const lint = item.original as Lint
|
||||
return (
|
||||
<div className="px-6 py-6">
|
||||
<LintDetail lint={lint} projectRef={projectRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
|
||||
<Inbox className="text-foreground-muted" strokeWidth={1} />
|
||||
<div className="text-center">
|
||||
<p className="heading-default">Project required</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Select a project to view security and performance advisories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, React.ElementType> = {
|
||||
security: Shield,
|
||||
performance: Gauge,
|
||||
messages: Inbox,
|
||||
}
|
||||
|
||||
const severityColorClasses: Record<AdvisorSeverity, string> = {
|
||||
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 <NoProjectNotice />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<GenericSkeletonLoader className="w-full p-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-8 mx-4 flex flex-col items-center gap-2">
|
||||
<AlertTriangle className="text-destructive" />
|
||||
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
|
||||
<p className="text-sm text-foreground-lighter">Please try again later.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
return (
|
||||
<EmptyAdvisor
|
||||
activeTab={activeTab}
|
||||
hasFilters={hasAnyFilters}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
{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 (
|
||||
<div key={`${item.source}-${item.id}`} className="border-b">
|
||||
<Button
|
||||
type="text"
|
||||
className={cn(
|
||||
'justify-start w-full block rounded-none h-auto py-3 px-4 hover:text-foreground',
|
||||
isUnread && 'bg-surface-100/50'
|
||||
)}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<SeverityIcon
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className={cn('flex-shrink-0', severityClass)}
|
||||
/>
|
||||
<div className="text-left flex flex-col gap-0.5 truncate flex-1 min-w-0">
|
||||
<div className="truncate">{item.title.replace(/[`\\]/g, '')}</div>
|
||||
{item.createdAt && (
|
||||
<span className="text-xs text-foreground-light capitalize-sentence">
|
||||
{(() => {
|
||||
const insertedAt = item.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
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
className="flex-shrink-0 text-foreground-lighter"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<Button type="text" className="w-full" onClick={onClearFilters}>
|
||||
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
|
||||
{
|
||||
critical: 'destructive',
|
||||
warning: 'warning',
|
||||
info: 'default',
|
||||
}
|
||||
|
||||
export const severityLabels: Record<AdvisorSeverity, string> = {
|
||||
critical: 'Critical',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
}
|
||||
|
||||
interface AdvisorPanelHeaderProps {
|
||||
selectedItem: AdvisorItem | undefined
|
||||
onBack: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => {
|
||||
return (
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0 flex justify-center items-center"
|
||||
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
|
||||
onClick={onBack}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<span className="heading-default">{selectedItem?.title?.replace(/[`\\]/g, '')}</span>
|
||||
{selectedItem?.createdAt && (
|
||||
<span className="text-xs text-foreground-light capitalize-sentence">
|
||||
{(() => {
|
||||
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
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedItem && (
|
||||
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
|
||||
{severityLabels[selectedItem.severity]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7 p-0"
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
onClick={onClose}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{(project !== undefined || organization !== undefined) && (
|
||||
<>
|
||||
<h3 className="text-sm mb-2">Context</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-6">
|
||||
{organization !== undefined && (
|
||||
<Link
|
||||
title={organization.name}
|
||||
href={`/org/${organization.slug}/general`}
|
||||
className="text-link"
|
||||
>
|
||||
{organization.name}
|
||||
</Link>
|
||||
)}
|
||||
{project !== undefined && (
|
||||
<Link title={project.name} href={`/project/${project.ref}`} className="text-link">
|
||||
{project.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.message !== undefined && (
|
||||
<>
|
||||
<h3 className="text-sm mb-2">Message</h3>
|
||||
<Markdown
|
||||
className="leading-6 text-sm text-foreground-light mb-6"
|
||||
content={data.message}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm mb-2">Actions</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{(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 (
|
||||
<Button key={key} type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
|
||||
<Link href={url} target="_blank" rel="noreferrer">
|
||||
{action.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
} else if (action.action_type !== undefined) {
|
||||
return (
|
||||
<Button key={key} type="default" onClick={() => onButtonAction(action.action_type)}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})}
|
||||
{notification.status === 'archived' ? (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ArchiveRestoreIcon size={14} strokeWidth={1.5} />}
|
||||
onClick={() => onUpdateStatus(notification.id, 'seen')}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Archive size={14} strokeWidth={1.5} />}
|
||||
onClick={() => onUpdateStatus(notification.id, 'archived')}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ interface FilterPopoverProps<T> {
|
||||
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 = <T extends Record<string, any>>({
|
||||
className,
|
||||
maxHeightClass = 'h-[205px]',
|
||||
clearButtonText = 'Clear',
|
||||
isMinimized = false,
|
||||
onSaveFilters,
|
||||
|
||||
search,
|
||||
@@ -127,14 +129,20 @@ export const FilterPopover = <T extends Record<string, any>>({
|
||||
>
|
||||
<div>
|
||||
<span>{name}</span>
|
||||
{activeOptions.length > 0 && <span className="mr-1">:</span>}
|
||||
{activeOptions.length >= 3 ? (
|
||||
<span>
|
||||
{formattedOptions[0]} and {activeOptions.length - 1} others
|
||||
</span>
|
||||
) : activeOptions.length > 0 ? (
|
||||
<span>{formattedOptions.join(', ')}</span>
|
||||
) : null}
|
||||
{activeOptions.length > 0 && (
|
||||
<>
|
||||
<span className="mr-1">:</span>
|
||||
{isMinimized ? (
|
||||
<span>{activeOptions.length}</span>
|
||||
) : activeOptions.length >= 3 ? (
|
||||
<span>
|
||||
{formattedOptions[0]} and {activeOptions.length - 1} others
|
||||
</span>
|
||||
) : (
|
||||
<span>{formattedOptions.join(', ')}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <TData = ReplicationDestinationsD
|
||||
queryKey: replicationKeys.destinations(projectRef),
|
||||
queryFn: ({ signal }) => fetchReplicationDestinations({ projectRef }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: checkReplicationFeatureFlagRetry,
|
||||
...options,
|
||||
})
|
||||
@@ -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 = <TData = ReplicationPipelinesData>(
|
||||
queryKey: replicationKeys.pipelines(projectRef),
|
||||
queryFn: ({ signal }) => fetchReplicationPipelines({ projectRef }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: checkReplicationFeatureFlagRetry,
|
||||
...options,
|
||||
})
|
||||
File renamed without changes.
@@ -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<ReturnType<typeof updatePublication>>
|
||||
|
||||
export const useUpdatePublicationMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<UpdatePublicationData, ResponseError, UpdatePublicationParams>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<UpdatePublicationData, ResponseError, UpdatePublicationParams>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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 = <TData = ReplicationSourcesData>(
|
||||
queryKey: replicationKeys.sources(projectRef),
|
||||
queryFn: ({ signal }) => fetchReplicationSources({ projectRef }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: checkReplicationFeatureFlagRetry,
|
||||
...options,
|
||||
})
|
||||
@@ -21,7 +21,7 @@ async function fetchReplicationTables(
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
return data
|
||||
return data.tables
|
||||
}
|
||||
|
||||
export type ReplicationTablesData = Awaited<ReturnType<typeof fetchReplicationTables>>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 = <TData = ProjectTransferPreviewDat
|
||||
enabled:
|
||||
enabled && typeof projectRef !== 'undefined' && typeof targetOrganizationSlug !== '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
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = <TData = BucketsData>(
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof useSnapshot>[1]) =>
|
||||
useSnapshot(notificationsState, options)
|
||||
@@ -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<boolean>(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string | null>(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(() => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -68,7 +68,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle heading-meta text-foreground-lighter [&:has([role=checkbox])]:pr-0',
|
||||
'h-10 px-4 text-left align-middle heading-meta whitespace-nowrap text-foreground-lighter [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in new issue
Block a user