mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge branch 'master' into feature/table-query
This commit is contained in:
commit
ee059d2df2
211 files changed
+13442
-29696
No files matched your search
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
StylesPath = './vale/styles'
|
||||
MinAlertLevel = 'suggestion'
|
||||
|
||||
Vocab = Supabase
|
||||
|
||||
[formats]
|
||||
mdx = md
|
||||
|
||||
[*]
|
||||
BasedOnStyles = Vale, Custom
|
||||
BlockIgnores = (?s) *(<StepHikeCompact>.*?</StepHikeCompact>), \
|
||||
(?s) *(export\s+const\s+\S+\s*=\s*\[.*?\]), \
|
||||
(?s) *(<Image.*?/>)
|
||||
TokenIgnores = (href\s*([=:])\s*['"].*?\1), \
|
||||
+8
-8
@@ -65,7 +65,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
|
||||
### Install dependencies
|
||||
|
||||
1. Install the dependencies in the root of the repo.
|
||||
1. Install the dependencies in the root of the repo:
|
||||
|
||||
```sh
|
||||
pnpm install # install dependencies
|
||||
@@ -77,7 +77,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
cp apps/www/.env.local.example apps/www/.env.local
|
||||
```
|
||||
|
||||
3. After that you can run the apps simultaneously with the following.
|
||||
3. After that you can run the apps simultaneously with the following:
|
||||
```sh
|
||||
pnpm dev # start all the applications
|
||||
```
|
||||
@@ -117,8 +117,8 @@ Installing a package in a specific workspace requires you to move to the workspa
|
||||
|
||||
For example:
|
||||
|
||||
1. `cd apps/studio`: move to the `studio` workspace
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace
|
||||
1. `cd apps/studio`: move to the `studio` workspace.
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace.
|
||||
|
||||
|
||||
---
|
||||
@@ -145,7 +145,7 @@ First, make sure you have the Docker installed on your device. You can download
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Run docker
|
||||
3. Run docker:
|
||||
|
||||
```sh
|
||||
docker compose up
|
||||
@@ -186,9 +186,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/
|
||||
We support "federating" docs, meaning doc content can come directly from external repos other than [`supabase/supabase`](https://github.com/supabase/supabase).
|
||||
|
||||
- It's great for things like client libs who have their own set of docs that we don't want to duplicate on the official Supabase docs (eg. [`supabase/vecs`](https://github.com/supabase/vecs)).
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline.
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native.
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate.
|
||||
|
||||
Federated docs work using Next.js's build pipeline. We use `getStaticProps()` to fetch remote documentation (ie. markdown) at build time which is processed and passed to the respective page within the docs.
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
|
||||
- [Bangla / বাংলা](/i18n/README.bn.md)
|
||||
- [Bulgarian / Български](/i18n/README.bg.md)
|
||||
- [Catalan / Català](/i18n/README.ca.md)
|
||||
- [Croatian / Hrvatski](/i18n/README.hr.md)
|
||||
- [Czech / čeština](/i18n/README.cs.md)
|
||||
- [Danish / Dansk](/i18n/README.da.md)
|
||||
- [Dutch / Nederlands](/i18n/README.nl.md)
|
||||
|
||||
@@ -1443,7 +1443,7 @@ export const functions: NavMenuConstant = {
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Background Tasks', url: '/guides/functions/background-tasks' },
|
||||
{ name: 'Ephemeral Storage', url: '/guides/functions/ephemeral-storage' },
|
||||
{ name: 'File Storage', url: '/guides/functions/ephemeral-storage' },
|
||||
{ name: 'WebSockets', url: '/guides/functions/websockets' },
|
||||
{ name: 'Custom Routing', url: '/guides/functions/routing' },
|
||||
{ name: 'Wasm Modules', url: '/guides/functions/wasm' },
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
## Project setup
|
||||
|
||||
Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.
|
||||
Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
|
||||
|
||||
### Create a project
|
||||
|
||||
1. [Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard.
|
||||
1. Enter your project details.
|
||||
1. Wait for the new database to launch.
|
||||
2. Enter your project details.
|
||||
3. Wait for the new database to launch.
|
||||
|
||||
### Set up the database schema
|
||||
|
||||
Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.
|
||||
Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -29,7 +29,7 @@ Now we are going to set up the database schema. We can use the "User Management
|
||||
|
||||
You can pull the database schema down to your local project by running the `db pull` command. Read the [local development docs](/docs/guides/cli/local-development#link-your-project) for detailed instructions.
|
||||
|
||||
</Admonition>
|
||||
{/* TODO: discuss */}
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <project-id>
|
||||
@@ -37,6 +37,8 @@ supabase link --project-ref <project-id>
|
||||
supabase db pull
|
||||
```
|
||||
|
||||
</Admonition>
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
@@ -58,7 +60,8 @@ supabase migration new user_management_starter
|
||||
### Get the API keys
|
||||
|
||||
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
|
||||
We just need to get the Project URL and `anon` key from the API settings.
|
||||
|
||||
To do this, you need to get the Project URL and `anon` key from the API settings.
|
||||
|
||||
1. Go to the [API Settings](https://supabase.com/dashboard/project/_/settings/api) page in the Dashboard.
|
||||
1. Find your Project `URL`, `anon`, and `service_role` keys on this page.
|
||||
2. Find your Project `URL`, `anon`, and `service_role` keys on this page.
|
||||
@@ -269,6 +269,7 @@ using ( true );
|
||||
<Admonition type="note" label="Use restrictive policies">
|
||||
|
||||
RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies.
|
||||
Be aware that a single 'restrictive' RLS policy alone will fail unless combined with another policy that returns true, ensuring the combined condition is met.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -629,7 +629,7 @@ The PKCE flow allows for server-side authentication. Unlike the implicit flow, w
|
||||
|
||||
Update your reset password email template to send the token hash. See [Email Templates](/docs/guides/auth/auth-email-templates) for how to configure your email templates.
|
||||
|
||||
Your signup email template should contain the following HTML:
|
||||
Your reset password email template should contain the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Reset Password</h2>
|
||||
@@ -960,11 +960,11 @@ Consider configuring a custom SMTP server for production.
|
||||
|
||||
See the [Custom SMTP guide](/docs/guides/auth/auth-smtp) for instructions.
|
||||
|
||||
#### Local development with Inbucket
|
||||
#### Local development with Mailpit
|
||||
|
||||
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Inbucket](https://github.com/inbucket/inbucket).
|
||||
You can test email flows on your local machine. The Supabase CLI automatically captures emails sent locally by using [Mailpit](https://github.com/axllent/mailpit).
|
||||
|
||||
In your terminal, run `supabase status` to get the Inbucket URL. Go to this URL in your browser, and follow the instructions to find your emails.
|
||||
In your terminal, run `supabase status` to get the Mailpit URL. Go to this URL in your browser, and follow the instructions to find your emails.
|
||||
|
||||
## With phone
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ hideToc: true
|
||||
|
||||
|
||||
```ts name=lib/supabase.ts
|
||||
import { AppState } from 'react-native'
|
||||
import { AppState, Platform } from 'react-native'
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { createClient, processLock } from '@supabase/supabase-js'
|
||||
@@ -81,7 +81,7 @@ hideToc: true
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
@@ -94,13 +94,15 @@ hideToc: true
|
||||
// to receive `onAuthStateChange` events with the `TOKEN_REFRESHED` or
|
||||
// `SIGNED_OUT` event if the user's session is terminated. This should
|
||||
// only be registered once.
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
supabase.auth.startAutoRefresh()
|
||||
} else {
|
||||
supabase.auth.stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
if (Platform.OS !== "web") {
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
supabase.auth.startAutoRefresh()
|
||||
} else {
|
||||
supabase.auth.stopAutoRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
@@ -67,7 +67,7 @@ To access Supabase from your Next.js app, you need 2 types of Supabase clients:
|
||||
1. **Client Component client** - To access Supabase from Client Components, which run in the browser.
|
||||
1. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server.
|
||||
|
||||
Create a `utils/supabase` folder with a file for each type of client. Then copy the utility functions for each client type.
|
||||
Create a `utils/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, with a file for each type of client. Then copy the utility functions for each client type.
|
||||
|
||||
<Accordion
|
||||
type="default"
|
||||
|
||||
@@ -29,7 +29,7 @@ Under the hood, Supabase Cron uses the [`pg_cron`](https://github.com/citusdata/
|
||||
|
||||
The extension creates a `cron` schema in your database and all Jobs are stored on the `cron.job` table. Every Job's run and its status is recorded on the `cron.job_run_details` table.
|
||||
|
||||
The Supabase Dashboard provides and interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
|
||||
The Supabase Dashboard provides an interface for you to schedule Jobs and monitor Job runs. You can also do the same with SQL.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -436,12 +436,6 @@ val data = supabase.postgrest.rpc(
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
The [official PostGIS documentation](https://postgis.net/documentation/tips/tip-move-postgis-schema/) for relocating the schema will cause issues for Supabase projects. These issues might not be apparent immediately but will eventually surface. To relocate your schema, use the following steps instead.
|
||||
|
||||
</Admonition>
|
||||
|
||||
As of PostGIS 2.3 or newer, the PostGIS extension is no longer relocatable from one schema to another. If you need to move it from one schema to another for any reason (e.g. from the public schema to the extensions schema for security reasons), you would normally run a ALTER EXTENSION to relocate the schema. However, you will now to do the following steps:
|
||||
|
||||
1. Backup your Database to prevent data loss - You can do this through the [CLI](https://supabase.com/docs/reference/cli/supabase-db-dump) or Postgres backup tools such as [pg_dumpall](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL)
|
||||
@@ -452,6 +446,28 @@ As of PostGIS 2.3 or newer, the PostGIS extension is no longer relocatable from
|
||||
|
||||
4. Restore dropped data via the Backup if necessary from step 1 with your tool of choice.
|
||||
|
||||
Alternatively, you can contact the [Supabase Support Team](https://supabase.com/dashboard/support/new) and ask them to run the following SQL on your instance:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
UPDATE pg_extension
|
||||
SET extrelocatable = true
|
||||
WHERE extname = 'postgis';
|
||||
|
||||
ALTER EXTENSION postgis
|
||||
SET SCHEMA extensions;
|
||||
|
||||
ALTER EXTENSION postgis
|
||||
UPDATE TO "<POSTGIS_VERSION>next";
|
||||
|
||||
ALTER EXTENSION postgis UPDATE;
|
||||
|
||||
UPDATE pg_extension
|
||||
SET extrelocatable = false
|
||||
WHERE extname = 'postgis';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Official PostGIS documentation](https://postgis.net/documentation/)
|
||||
@@ -1,21 +1,52 @@
|
||||
---
|
||||
id: 'function-ephemeral-storage'
|
||||
title: 'Ephemeral Storage'
|
||||
description: 'Read and write from temporary directory'
|
||||
subtitle: 'Read and write from temporary directory'
|
||||
title: 'File Storage'
|
||||
description: 'Use persistent and ephemeral file storage'
|
||||
subtitle: 'Use persistent and ephemeral file storage'
|
||||
---
|
||||
|
||||
Edge Functions provides ephemeral file storage. You can read and write files to the `/tmp` directory.
|
||||
Edge Functions provides two flavors of file storage:
|
||||
|
||||
This allows you to:
|
||||
- Persistent - backed by S3 protocol, can read/write from any S3 compatible bucket, including Supabase Storage
|
||||
- Ephemeral - You can read and write files to the `/tmp` directory. Only suitable for temporary operations
|
||||
|
||||
You can use file storage to:
|
||||
|
||||
- Process uploaded files temporarily without permanent storage
|
||||
- Handle complex file transformations and workflows
|
||||
- Do data migrations between projects
|
||||
- Process user uploaded files and store them
|
||||
- Unzip archives and process contents before saving to database
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Persistent Storage
|
||||
|
||||
The persistent storage option is built on top of the S3 protocol. It allows you to mount any S3-compatible bucket, including Supabase Storage Buckets, as a directory for your Edge Functions.
|
||||
You can perform operations such as reading and writing files to the mounted buckets as you would in a POSIX file system.
|
||||
|
||||
To access an S3 bucket from Edge Functions, you must set the following for environment variables in Edge Function Secrets.
|
||||
|
||||
- `S3FS_ENDPOINT_URL`
|
||||
- `S3FS_REGION`
|
||||
- `S3FS_ACCESS_KEY_ID`
|
||||
- `S3FS_SECRET_ACCESS_KEY`
|
||||
|
||||
[Follow this guide](https://supabase.com/docs/guides/storage/s3/authentication) to enable and create an access key for Supabase Storage S3.
|
||||
|
||||
To access a file path in your mounted bucket from your Edge Function, use the prefix `/s3/YOUR-BUCKET-NAME`.
|
||||
|
||||
```tsx
|
||||
// read from S3 bucket
|
||||
const data = await Deno.readFile('/s3/my-bucket/results.csv')
|
||||
|
||||
// make a directory
|
||||
await Deno.mkdir('/s3/my-bucket/sub-dir')
|
||||
|
||||
// write to S3 bucket
|
||||
await Deno.writeTextFile('/s3/my-bucket/demo.txt', 'hello world')
|
||||
```
|
||||
|
||||
## Ephemeral storage
|
||||
|
||||
Ephemeral storage will reset on each function invocation. This means the files you write during an invocation can only be read within the same invocation.
|
||||
|
||||
@@ -128,30 +159,41 @@ Deno.serve(async (req) => {
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
## Using synchronous file APIs
|
||||
|
||||
Currently, the synchronous APIs for creating or writing files are not supported:
|
||||
You can safely use the following synchronous Deno APIs (and their Node counterparts) _during initial script evaluation_:
|
||||
|
||||
- Deno.statSync
|
||||
- Deno.removeSync
|
||||
- Deno.writeFileSync
|
||||
- Deno.writeTextFileSync
|
||||
- Deno.readFileSync
|
||||
- Deno.readTextFileSync
|
||||
- Deno.mkdirSync
|
||||
- Deno.makeTempDirSync
|
||||
- Deno.readDirSync
|
||||
|
||||
**Keep in mind** that the sync APIs are available only during initial script evaluation and aren’t supported in callbacks like HTTP handlers or `setTimeout`.
|
||||
|
||||
```tsx
|
||||
// ❌ Not supported
|
||||
Deno.writeFileSync('/tmp/file.txt', data)
|
||||
Deno.mkdirSync('/tmp/directory')
|
||||
Deno.statSync('...') // ✅
|
||||
|
||||
// ✅ Supported
|
||||
await Deno.writeFile('/tmp/file.txt', data)
|
||||
await Deno.mkdir('/tmp/directory')
|
||||
```
|
||||
setTimeout(() => {
|
||||
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
|
||||
})
|
||||
|
||||
You can use sync variations of read APIs:
|
||||
|
||||
```tsx
|
||||
// ✅ Supported
|
||||
const data = Deno.readFileSync('/tmp/file.txt')
|
||||
Deno.serve(() => {
|
||||
Deno.statSync('...') // 💣 ERROR! Deno.statSync is blocklisted on the current context
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
There are no limits on S3 buckets you mount for Persistent storage.
|
||||
|
||||
Ephemeral Storage:
|
||||
|
||||
- Free projects: Up to 256MB of ephemeral storage
|
||||
- Paid projects: Up to 512MB of ephemeral storage
|
||||
@@ -17,27 +17,27 @@ If you get stuck while working through this guide, refer to the [full example on
|
||||
|
||||
## Building the app
|
||||
|
||||
Let's start building the Angular app from scratch.
|
||||
Start with building the Angular app from scratch.
|
||||
|
||||
### Initialize an Angular app
|
||||
|
||||
We can use the [Angular CLI](https://angular.io/cli) to initialize
|
||||
an app called `supabase-angular`:
|
||||
You can use the [Angular CLI](https://angular.io/cli) to initialize
|
||||
an app called `supabase-angular`. The command sets some defaults, that you change to suit your needs:
|
||||
|
||||
```bash
|
||||
npx ng new supabase-angular --routing false --style css --standalone false
|
||||
npx ng new supabase-angular --routing false --style css --standalone false --zoneless true --ssr false
|
||||
cd supabase-angular
|
||||
```
|
||||
|
||||
Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
Then, install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js
|
||||
```
|
||||
|
||||
And finally we want to save the environment variables in the `src/environments/environment.ts` file.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
These variables will be exposed on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
||||
Finally, save the environment variables in the `src/environments/environment.ts` file.
|
||||
All you need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
The application exposes these variables in the browser, and that's fine as you have [Row Level Security](/docs/guides/auth#row-level-security) enabled on the Database.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const environment = {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Now that we have the API credentials in place, let's create a `SupabaseService` with `ng g s supabase` to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
Now you have the API credentials in place, create a `SupabaseService` with `ng g s supabase` and add the following code to initialize the Supabase client and implement functions to communicate with the Supabase API.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -133,37 +133,41 @@ export class SupabaseService {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Optionally, update [src/styles.css](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
|
||||
Optionally, update `src/styles.css` [with the following styles](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/angular-user-management/src/styles.css) to style the app.
|
||||
|
||||
### Set up a login component
|
||||
|
||||
Let's set up an Angular component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
Create an `AuthComponent` with `ng g c auth` Angular CLI command.
|
||||
Next, set up an Angular component to manage logins and sign ups. The component uses [Magic Links](/docs/guides/auth/auth-email-passwordless#with-magic-link), so users can sign in with their email without using passwords.
|
||||
|
||||
Create an `AuthComponent` with the `ng g c auth` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/auth/auth.component.ts
|
||||
```ts name=src/app/auth/auth.ts
|
||||
import { Component } from '@angular/core'
|
||||
import { FormBuilder } from '@angular/forms'
|
||||
import { FormBuilder, FormGroup } from '@angular/forms'
|
||||
import { SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrls: ['./auth.component.css'],
|
||||
templateUrl: './auth.html',
|
||||
styleUrls: ['./auth.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AuthComponent {
|
||||
loading = false
|
||||
|
||||
signInForm = this.formBuilder.group({
|
||||
email: '',
|
||||
})
|
||||
|
||||
signInForm!: FormGroup
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private readonly formBuilder: FormBuilder
|
||||
) {}
|
||||
|
||||
loading = false
|
||||
ngOnInit() {
|
||||
this.signInForm = this.formBuilder.group({
|
||||
email: '',
|
||||
})
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
@@ -183,7 +187,7 @@ export class AuthComponent {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/auth/auth.component.html
|
||||
```html name=src/app/auth/auth.html
|
||||
<div class="row flex-center flex">
|
||||
<div class="col-6 form-widget" aria-live="polite">
|
||||
<h1 class="header">Supabase + Angular</h1>
|
||||
@@ -201,7 +205,7 @@ export class AuthComponent {
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="button block" [disabled]="loading">
|
||||
{{ loading ? 'Loading' : 'Send magic link' }}
|
||||
{{ loading ? "Loading" : "Send magic link" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -214,38 +218,50 @@ export class AuthComponent {
|
||||
### Account page
|
||||
|
||||
Users also need a way to edit their profile details and manage their accounts after signing in.
|
||||
Create an `AccountComponent` with the `ng g c account` Angular CLI command.
|
||||
Create an `AccountComponent` with the `ng g c account` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/account/account.component.ts
|
||||
```ts name=src/app/account/account.ts
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { FormBuilder } from '@angular/forms'
|
||||
import { FormBuilder, FormGroup } from '@angular/forms'
|
||||
import { AuthSession } from '@supabase/supabase-js'
|
||||
import { Profile, SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
templateUrl: './account.html',
|
||||
styleUrls: ['./account.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
loading = false
|
||||
profile!: Profile
|
||||
updateProfileForm!: FormGroup
|
||||
|
||||
get avatarUrl() {
|
||||
return this.updateProfileForm.value.avatar_url as string
|
||||
}
|
||||
async updateAvatar(event: string): Promise<void> {
|
||||
this.updateProfileForm.patchValue({
|
||||
avatar_url: event,
|
||||
})
|
||||
await this.updateProfile()
|
||||
}
|
||||
|
||||
@Input()
|
||||
session!: AuthSession
|
||||
|
||||
updateProfileForm = this.formBuilder.group({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
})
|
||||
|
||||
constructor(
|
||||
private readonly supabase: SupabaseService,
|
||||
private formBuilder: FormBuilder
|
||||
) {}
|
||||
) {
|
||||
this.updateProfileForm = this.formBuilder.group({
|
||||
username: '',
|
||||
website: '',
|
||||
avatar_url: '',
|
||||
})
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.getProfile()
|
||||
@@ -311,8 +327,9 @@ export class AccountComponent implements OnInit {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/account/account.component.html
|
||||
```html name=src/app/account/account.html
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="text" [value]="session.user.email" disabled />
|
||||
@@ -328,7 +345,7 @@ export class AccountComponent implements OnInit {
|
||||
|
||||
<div>
|
||||
<button type="submit" class="button primary block" [disabled]="loading">
|
||||
{{ loading ? 'Loading ...' : 'Update' }}
|
||||
{{ loading ? "Loading ..." : "Update" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -342,33 +359,34 @@ export class AccountComponent implements OnInit {
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `AppComponent`:
|
||||
Now you have all the components in place, update `AppComponent`:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/app.component.ts
|
||||
```ts name=src/app/app.ts
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { SupabaseService } from './supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
templateUrl: './app.html',
|
||||
styleUrls: ['./app.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
title = 'angular-user-management'
|
||||
|
||||
session = this.supabase.session
|
||||
|
||||
constructor(private readonly supabase: SupabaseService) {}
|
||||
|
||||
title = 'angular-user-management'
|
||||
session: any
|
||||
|
||||
ngOnInit() {
|
||||
this.session = this.supabase.session
|
||||
this.supabase.authChanges((_, session) => (this.session = session))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/app.component.html
|
||||
```html name=src/app/app.html
|
||||
<div class="container" style="padding: 50px 0 100px 0">
|
||||
<app-account *ngIf="session; else auth" [session]="session"></app-account>
|
||||
<ng-template #auth>
|
||||
@@ -379,7 +397,7 @@ export class AppComponent implements OnInit {
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
`app.module.ts` also needs to be modified to include the `ReactiveFormsModule` from the `@angular/forms` package.
|
||||
You also need to change `app.module.ts` to include the `ReactiveFormsModule` from the `@angular/forms` package.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
@@ -387,32 +405,33 @@ export class AppComponent implements OnInit {
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
|
||||
import { AppComponent } from './app.component'
|
||||
import { AuthComponent } from './auth/auth.component'
|
||||
import { AccountComponent } from './account/account.component'
|
||||
import { AppComponent } from './app'
|
||||
import { AuthComponent } from './auth/auth'
|
||||
import { AccountComponent } from './account/account'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { AvatarComponent } from './avatar/avatar.component'
|
||||
import { AvatarComponent } from './avatar/avatar'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
|
||||
imports: [BrowserModule, ReactiveFormsModule],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent],
|
||||
exports: [AppComponent, AuthComponent, AccountComponent, AvatarComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
Once that's done, run the application in a terminal:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
|
||||
Open the browser to [localhost:4200](http://localhost:4200) and you should see the completed app.
|
||||
|
||||

|
||||

|
||||
|
||||
## Bonus: Profile photos
|
||||
|
||||
@@ -420,20 +439,21 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma
|
||||
|
||||
### Create an upload widget
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo.
|
||||
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command.
|
||||
Create an avatar for the user so that they can upload a profile photo.
|
||||
Create an `AvatarComponent` with `ng g c avatar` Angular CLI command and add the following code.
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/avatar/avatar.component.ts
|
||||
```ts name=src/app/avatar/avatar.ts
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { SafeResourceUrl, DomSanitizer } from '@angular/platform-browser'
|
||||
import { SupabaseService } from '../supabase.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-avatar',
|
||||
templateUrl: './avatar.component.html',
|
||||
styleUrls: ['./avatar.component.css'],
|
||||
templateUrl: './avatar.html',
|
||||
styleUrls: ['./avatar.css'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AvatarComponent {
|
||||
_avatarUrl: SafeResourceUrl | undefined
|
||||
@@ -490,7 +510,7 @@ export class AvatarComponent {
|
||||
}
|
||||
```
|
||||
|
||||
```html name=src/app/avatar/avatar.component.html
|
||||
```html name=src/app/avatar/avatar.html
|
||||
<div>
|
||||
<img
|
||||
*ngIf="_avatarUrl"
|
||||
@@ -503,10 +523,10 @@ export class AvatarComponent {
|
||||
<div *ngIf="!_avatarUrl" class="avatar no-image" style="height: 150px; width: 150px"></div>
|
||||
<div style="width: 150px">
|
||||
<label class="button primary block" for="single">
|
||||
{{ uploading ? 'Uploading ...' : 'Upload' }}
|
||||
{{ uploading ? "Uploading ..." : "Upload" }}
|
||||
</label>
|
||||
<input
|
||||
style="visibility: hidden;position: absolute"
|
||||
style="visibility: hidden; position: absolute"
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
@@ -524,9 +544,9 @@ And then we can add the widget on top of the `AccountComponent` HTML template:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```html name=src/app/account.component.html
|
||||
```html name=src/app/account.html
|
||||
<form [formGroup]="updateProfileForm" (ngSubmit)="updateProfile()" class="form-widget">
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"> </app-avatar>
|
||||
<app-avatar [avatarUrl]="this.avatarUrl" (upload)="updateAvatar($event)"></app-avatar>
|
||||
<!-- input fields -->
|
||||
</form>
|
||||
```
|
||||
@@ -537,11 +557,11 @@ And add an `updateAvatar` function along with an `avatarUrl` getter to the `Acco
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=src/app/account.component.ts
|
||||
```ts name=src/app/account.ts
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.css'],
|
||||
templateUrl: './account.html',
|
||||
styleUrls: ['./account.css'],
|
||||
})
|
||||
export class AccountComponent implements OnInit {
|
||||
// ...
|
||||
|
||||
@@ -5,6 +5,17 @@ subtitle: Learn how to develop locally and use the Supabase CLI
|
||||
|
||||
Develop locally while running the Supabase stack on your machine.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
As a prerequisite, you must install a container runtime compatible with Docker APIs.
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
|
||||
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
|
||||
- [Podman](https://podman.io/) (macOS, Windows, Linux)
|
||||
- [OrbStack](https://orbstack.dev/) (macOS)
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Install the Supabase CLI:
|
||||
@@ -101,17 +112,6 @@ Develop locally while running the Supabase stack on your machine.
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
As a prerequisite, you must install a container runtime compatible with Docker APIs.
|
||||
|
||||
- [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux)
|
||||
- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux)
|
||||
- [Podman](https://podman.io/) (macOS, Windows, Linux)
|
||||
- [OrbStack](https://orbstack.dev/) (macOS)
|
||||
|
||||
</Admonition>
|
||||
|
||||
4. View your local Supabase instance at [http://localhost:54323](http://localhost:54323).
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -217,7 +217,7 @@ Started supabase local development setup.
|
||||
API URL: http://localhost:54321
|
||||
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
|
||||
Studio URL: http://localhost:54323
|
||||
Inbucket URL: http://localhost:54324
|
||||
Mailpit URL: http://localhost:54324
|
||||
anon key: eyJh......
|
||||
service_role key: eyJh......
|
||||
|
||||
|
||||
@@ -36,11 +36,11 @@ Edge Functions are powered by Deno, which provides a [native set of testing tool
|
||||
|
||||
## Testing Auth emails
|
||||
|
||||
The Supabase CLI uses [Inbucket](https://github.com/inbucket/inbucket) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
|
||||
The Supabase CLI uses [Mailpit](https://github.com/axllent/mailpit) to capture emails sent from your local machine. This is useful for testing emails sent from Supabase Auth.
|
||||
|
||||
### Accessing Inbucket
|
||||
### Accessing Mailpit
|
||||
|
||||
By default, Inbucket is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
|
||||
By default, Mailpit is available at [localhost:54324](http://localhost:54324) when you run `supabase start`. Open this URL in your browser to view the emails.
|
||||
|
||||
### Going into production
|
||||
|
||||
|
||||
@@ -260,20 +260,13 @@ The table below shows the actions each role can take on the resources belonging
|
||||
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Run | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| Events Collections | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | View | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| Warehouse Access Tokens | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | Revoke | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| <a href="#branching-permissions" id="branching-permissions">**Branching**</a> | | | | | |
|
||||
| Enable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| Disable branching | - | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> | <IconX size={14} /> |
|
||||
| Production Branch | Read | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Write | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| Development Branches | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
| | Create | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | Update | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | Delete | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconX size={14} /> |
|
||||
| | List | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> | <IconCheck size={14} color="#3FCF8E" /> |
|
||||
|
||||
[^1]: Available on the Team and Enterprise Plans.
|
||||
[^2]: Sending anonymous data to OpenAI is opt in and can improve Studio AI Assistant's responses.
|
||||
|
||||
@@ -131,6 +131,14 @@ If you enable PITR, Daily Backups will no longer be taken. PITR provides a finer
|
||||
|
||||
</Admonition>
|
||||
|
||||
When you disable PITR, all new backups will still be taken as physical backups only. Physical backups can still be used for restoration, but they are not available for direct download. If you need to download a backup after PITR is disabled, you’ll need to take a manual [logical backup using the Supabase CLI or pg_dump](https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore#backup-database-using-the-cli).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
If PITR has been disabled, logical backups remain available until they pass the backup retention period for your plan. After that window passes, only physical backups will be shown.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Backup process [#pitr-backup-process]
|
||||
|
||||
As discussed [here](https://supabase.com/blog/postgresql-physical-logical-backups), PITR is made possible by a combination of taking physical backups of a project, as well as archiving [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html) files. Physical backups provide a snapshot of the underlying directory of the database, while WAL files contain records of every change made in the database.
|
||||
|
||||
@@ -27,7 +27,7 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t
|
||||
|
||||
## Credit top-ups
|
||||
|
||||
You can top up credits at any time, with a maximum of <Price price="999" /> per top-up. These credits do not expire and are non-refundable.
|
||||
You can top up credits at any time, with a maximum of <Price price="2000" /> per top-up. These credits do not expire and are non-refundable.
|
||||
|
||||
If you have any outstanding invoices, we’ll automatically use your credits to pay them off. Any remaining credits will be applied to future invoices.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: 'firestore-data'
|
||||
title: 'Migrated from Firebase Firestore to Supabase'
|
||||
title: 'Migrate from Firebase Firestore to Supabase'
|
||||
description: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
subtitle: 'Migrate your Firebase Firestore database to a Supabase Postgres database.'
|
||||
---
|
||||
|
||||
@@ -227,7 +227,7 @@ supabase db diff --linked --schema auth,storage > changes.sql
|
||||
The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another.
|
||||
|
||||
```js
|
||||
// npm install @supabase/supabase-js@1
|
||||
// npm install @supabase/supabase-js@2
|
||||
const { createClient } = require('@supabase/supabase-js')
|
||||
|
||||
const OLD_PROJECT_URL = 'https://xxx.supabase.co'
|
||||
@@ -238,7 +238,9 @@ const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
|
||||
|
||||
;(async () => {
|
||||
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
|
||||
schema: 'storage',
|
||||
db: {
|
||||
schema: 'storage',
|
||||
},
|
||||
})
|
||||
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
|
||||
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
|
||||
|
||||
@@ -207,7 +207,7 @@ select * from pgmq.send_batch(
|
||||
|
||||
#### `read`
|
||||
|
||||
Read 1 or more messages from a queue. The VT specifies the delay in seconds between reading and the message becoming invisible to other consumers.
|
||||
Read 1 or more messages from a queue. The VT specifies the duration of time in seconds that the message is invisible to other consumers. At the end of that duration, the message is visible again and could be read by other consumers.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
|
||||
@@ -25,6 +25,14 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
|
||||
<img alt="Storage S3 Access keys" src="/docs/img/storage/s3-credentials.png" width="100%" />
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
|
||||
|
||||
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
@@ -39,7 +47,7 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
const client = new S3Client({
|
||||
forcePathStyle: true,
|
||||
region: 'project_region',
|
||||
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
|
||||
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
|
||||
credentials: {
|
||||
accessKeyId: 'your_access_key_id',
|
||||
secretAccessKey: 'your_secret_access_key',
|
||||
@@ -55,7 +63,7 @@ This is all the information you need to connect to Supabase Storage using any S3
|
||||
[supabase]
|
||||
aws_access_key_id = your_access_key_id
|
||||
aws_secret_access_key = your_secret_access_key
|
||||
endpoint_url = https://project_ref.supabase.co/storage/v1/s3
|
||||
endpoint_url = https://project_ref.storage.supabase.co/storage/v1/s3
|
||||
region = project_region
|
||||
```
|
||||
</TabPanel>
|
||||
@@ -86,7 +94,7 @@ const {
|
||||
const client = new S3Client({
|
||||
forcePathStyle: true,
|
||||
region: 'project_region',
|
||||
endpoint: 'https://project_ref.supabase.co/storage/v1/s3',
|
||||
endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
|
||||
credentials: {
|
||||
accessKeyId: 'project_ref',
|
||||
secretAccessKey: 'anonKey',
|
||||
|
||||
@@ -14,6 +14,14 @@ The resumable upload method is recommended when:
|
||||
|
||||
Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resumable uploads. TUS stands for The Upload Server and is an open protocol for supporting resumable uploads. The protocol allows the upload process to be resumed from where it left off in case of interruptions. This method can be implemented using the [`tus-js-client`](https://github.com/tus/tus-js-client) library, or other client-side libraries like [Uppy](https://uppy.io/docs/tus/) that support the TUS protocol.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
For optimal performance when uploading large files you should always use the direct storage hostname. This provides several performance enhancements that will greatly improve performance when uploading large files.
|
||||
|
||||
Instead of `https://project-id.supabase.co` use `https://project-id.storage.supabase.co`
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
@@ -35,7 +43,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
var upload = new tus.Upload(file, {
|
||||
endpoint: `https://${projectId}.supabase.co/storage/v1/upload/resumable`,
|
||||
// Supabase TUS endpoint (with direct storage hostname)
|
||||
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
authorization: `Bearer ${session.access_token}`,
|
||||
@@ -125,7 +134,7 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
// Initialize Uppy instance only once
|
||||
const [uppy] = useState(() => new Uppy());
|
||||
// Initialize Supabase client with project URL and anon key
|
||||
const supabase = createClient(projectURL, anonKey);
|
||||
const supabase = createClient(`https://${projectId}.supabase.co`, anonKey);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeUppy = async () => {
|
||||
@@ -135,7 +144,8 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
uppy.use(Tus, {
|
||||
endpoint: `${projectURL}/storage/v1/upload/resumable`, // Supabase TUS endpoint
|
||||
// Supabase TUS endpoint (with direct storage hostname)
|
||||
endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000], // Retry delays for resumable uploads
|
||||
headers: {
|
||||
authorization: `Bearer ${session?.access_token}`, // User session access token
|
||||
|
||||
@@ -26,6 +26,7 @@ Chris Caruso
|
||||
Chris Chandler
|
||||
Chris Copplestone
|
||||
Chris Gwilliams
|
||||
Chris Martin
|
||||
Chris Stockton
|
||||
Chris Ward
|
||||
Craig Cannon
|
||||
@@ -40,6 +41,7 @@ Divit D
|
||||
Eduardo Gurgel
|
||||
Egor Romanov
|
||||
Eleftheria Trivyzaki
|
||||
Emmett Folger
|
||||
Etienne Stalmans
|
||||
Fabrizio Fenoglio
|
||||
Felipe Stival
|
||||
@@ -83,6 +85,7 @@ Manan Gupta
|
||||
Margarita Sandomirskaia
|
||||
Mark Burggraf
|
||||
Monica Khoury
|
||||
Mykhailo Mischa Lieibenson
|
||||
Nyannyacha
|
||||
Oli R
|
||||
Pamela Chia
|
||||
@@ -106,6 +109,7 @@ Stanislav M
|
||||
Stephen Morgan
|
||||
Steve Chavez
|
||||
Stojan Dimitrovski
|
||||
Sugu Sougoumarane
|
||||
Supun Sudaraka Kalidasa
|
||||
Taryn King
|
||||
Terry Sutton
|
||||
|
||||
+2897
-960
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,48 @@
|
||||
"type": "markdown",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Advisors",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-get-performance-advisors",
|
||||
"title": "Get performance advisors",
|
||||
"slug": "v1-get-performance-advisors",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-security-advisors",
|
||||
"title": "Get security advisors",
|
||||
"slug": "v1-get-security-advisors",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Analytics",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-get-project-logs",
|
||||
"title": "Get project logs",
|
||||
"slug": "v1-get-project-logs",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-usage-api-count",
|
||||
"title": "Get project usage api count",
|
||||
"slug": "v1-get-project-usage-api-count",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-usage-request-count",
|
||||
"title": "Get project usage request count",
|
||||
"slug": "v1-get-project-usage-request-count",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Auth",
|
||||
@@ -16,12 +58,36 @@
|
||||
"slug": "v1-create-a-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-legacy-signing-key",
|
||||
"title": "Create legacy signing key",
|
||||
"slug": "v1-create-legacy-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-signing-key",
|
||||
"title": "Create project signing key",
|
||||
"slug": "v1-create-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-tpa-integration",
|
||||
"title": "Create project tpa integration",
|
||||
"slug": "v1-create-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-a-sso-provider",
|
||||
"title": "Delete a sso provider",
|
||||
"slug": "v1-delete-a-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-tpa-integration",
|
||||
"title": "Delete project tpa integration",
|
||||
"slug": "v1-delete-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-sso-provider",
|
||||
"title": "Get a sso provider",
|
||||
@@ -34,12 +100,48 @@
|
||||
"slug": "v1-get-auth-service-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-legacy-signing-key",
|
||||
"title": "Get legacy signing key",
|
||||
"slug": "v1-get-legacy-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-signing-key",
|
||||
"title": "Get project signing key",
|
||||
"slug": "v1-get-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-signing-keys",
|
||||
"title": "Get project signing keys",
|
||||
"slug": "v1-get-project-signing-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-tpa-integration",
|
||||
"title": "Get project tpa integration",
|
||||
"slug": "v1-get-project-tpa-integration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-sso-provider",
|
||||
"title": "List all sso provider",
|
||||
"slug": "v1-list-all-sso-provider",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-project-tpa-integrations",
|
||||
"title": "List project tpa integrations",
|
||||
"slug": "v1-list-project-tpa-integrations",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-remove-project-signing-key",
|
||||
"title": "Remove project signing key",
|
||||
"slug": "v1-remove-project-signing-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-a-sso-provider",
|
||||
"title": "Update a sso provider",
|
||||
@@ -51,6 +153,36 @@
|
||||
"title": "Update auth service config",
|
||||
"slug": "v1-update-auth-service-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-signing-key",
|
||||
"title": "Update project signing key",
|
||||
"slug": "v1-update-project-signing-key",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"title": "Billing",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-apply-project-addon",
|
||||
"title": "Apply project addon",
|
||||
"slug": "v1-apply-project-addon",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-project-addons",
|
||||
"title": "List project addons",
|
||||
"slug": "v1-list-project-addons",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-remove-project-addon",
|
||||
"title": "Remove project addon",
|
||||
"slug": "v1-remove-project-addon",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +196,12 @@
|
||||
"slug": "v1-apply-a-migration",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-restore-point",
|
||||
"title": "Create restore point",
|
||||
"slug": "v1-create-restore-point",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-disable-readonly-mode-temporarily",
|
||||
"title": "Disable readonly mode temporarily",
|
||||
@@ -88,6 +226,18 @@
|
||||
"slug": "v1-get-a-snippet",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-database-metadata",
|
||||
"title": "Get database metadata",
|
||||
"slug": "v1-get-database-metadata",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-pooler-config",
|
||||
"title": "Get pooler config",
|
||||
"slug": "v1-get-pooler-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-postgres-config",
|
||||
"title": "Get postgres config",
|
||||
@@ -106,6 +256,12 @@
|
||||
"slug": "v1-get-readonly-mode-status",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-restore-point",
|
||||
"title": "Get restore point",
|
||||
"slug": "v1-get-restore-point",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-ssl-enforcement-config",
|
||||
"title": "Get ssl enforcement config",
|
||||
@@ -125,9 +281,9 @@
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-migrations",
|
||||
"title": "List migrations",
|
||||
"slug": "v1-list-migrations",
|
||||
"id": "v1-list-migration-history",
|
||||
"title": "List migration history",
|
||||
"slug": "v1-list-migration-history",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
@@ -154,6 +310,18 @@
|
||||
"slug": "v1-setup-a-read-replica",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-undo",
|
||||
"title": "Undo",
|
||||
"slug": "v1-undo",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-pooler-config",
|
||||
"title": "Update pooler config",
|
||||
"slug": "v1-update-pooler-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-postgres-config",
|
||||
"title": "Update postgres config",
|
||||
@@ -165,6 +333,12 @@
|
||||
"title": "Update ssl enforcement config",
|
||||
"slug": "v1-update-ssl-enforcement-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-upsert-a-migration",
|
||||
"title": "Upsert a migration",
|
||||
"slug": "v1-upsert-a-migration",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -292,12 +466,24 @@
|
||||
"slug": "v1-delete-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-diff-a-branch",
|
||||
"title": "Diff a branch",
|
||||
"slug": "v1-diff-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-disable-preview-branching",
|
||||
"title": "Disable preview branching",
|
||||
"slug": "v1-disable-preview-branching",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-branch",
|
||||
"title": "Get a branch",
|
||||
"slug": "v1-get-a-branch",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-a-branch-config",
|
||||
"title": "Get a branch config",
|
||||
@@ -352,6 +538,12 @@
|
||||
"slug": "v1-exchange-oauth-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-oauth-authorize-project-claim",
|
||||
"title": "Oauth authorize project claim",
|
||||
"slug": "v1-oauth-authorize-project-claim",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-revoke-token",
|
||||
"title": "Revoke token",
|
||||
@@ -364,6 +556,12 @@
|
||||
"type": "category",
|
||||
"title": "Organizations",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-claim-project-for-organization",
|
||||
"title": "Claim project for organization",
|
||||
"slug": "v1-claim-project-for-organization",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-an-organization",
|
||||
"title": "Create an organization",
|
||||
@@ -376,6 +574,12 @@
|
||||
"slug": "v1-get-an-organization",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-organization-project-claim",
|
||||
"title": "Get organization project claim",
|
||||
"slug": "v1-get-organization-project-claim",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-organizations",
|
||||
"title": "List all organizations",
|
||||
@@ -406,6 +610,12 @@
|
||||
"slug": "v1-create-a-project",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-claim-token",
|
||||
"title": "Create project claim token",
|
||||
"slug": "v1-create-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-a-project",
|
||||
"title": "Delete a project",
|
||||
@@ -418,6 +628,12 @@
|
||||
"slug": "v1-delete-network-bans",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-claim-token",
|
||||
"title": "Delete project claim token",
|
||||
"slug": "v1-delete-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-network-restrictions",
|
||||
"title": "Get network restrictions",
|
||||
@@ -442,6 +658,12 @@
|
||||
"slug": "v1-get-project",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-claim-token",
|
||||
"title": "Get project claim token",
|
||||
"slug": "v1-get-project-claim-token",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-services-health",
|
||||
"title": "Get services health",
|
||||
@@ -454,6 +676,12 @@
|
||||
"slug": "v1-list-all-network-bans",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-network-bans-enriched",
|
||||
"title": "List all network bans enriched",
|
||||
"slug": "v1-list-all-network-bans-enriched",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-projects",
|
||||
"title": "List all projects",
|
||||
@@ -526,18 +754,42 @@
|
||||
"slug": "v1-bulk-delete-secrets",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-project-api-key",
|
||||
"title": "Create project api key",
|
||||
"slug": "v1-create-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-delete-project-api-key",
|
||||
"title": "Delete project api key",
|
||||
"slug": "v1-delete-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-pgsodium-config",
|
||||
"title": "Get pgsodium config",
|
||||
"slug": "v1-get-pgsodium-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-api-key",
|
||||
"title": "Get project api key",
|
||||
"slug": "v1-get-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-api-keys",
|
||||
"title": "Get project api keys",
|
||||
"slug": "v1-get-project-api-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-project-legacy-api-keys",
|
||||
"title": "Get project legacy api keys",
|
||||
"slug": "v1-get-project-legacy-api-keys",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-secrets",
|
||||
"title": "List all secrets",
|
||||
@@ -549,6 +801,18 @@
|
||||
"title": "Update pgsodium config",
|
||||
"slug": "v1-update-pgsodium-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-api-key",
|
||||
"title": "Update project api key",
|
||||
"slug": "v1-update-project-api-key",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-project-legacy-api-keys",
|
||||
"title": "Update project legacy api keys",
|
||||
"slug": "v1-update-project-legacy-api-keys",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@ import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSett
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { pluckObjectFields } from 'lib/helpers'
|
||||
import { useState } from 'react'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -23,13 +22,14 @@ import {
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
interface ExportDialogProps {
|
||||
table?: { name: string; schema: string }
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
|
||||
export const ExportDialog = ({ table, open, onOpenChange }: ExportDialogProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string>('csv')
|
||||
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef })
|
||||
@@ -47,14 +47,14 @@ export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => {
|
||||
poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' },
|
||||
})
|
||||
|
||||
const outputName = `${snap.table.name}_rows`
|
||||
const outputName = `${table?.name}_rows`
|
||||
|
||||
const csvExportCommand = `
|
||||
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
|
||||
${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${table?.schema}"."${table?.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv
|
||||
`.trim()
|
||||
|
||||
const sqlExportCommand = `
|
||||
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql
|
||||
pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${table?.schema}.${table?.name}" --data-only --column-inserts > ${outputName}.sql
|
||||
`.trim()
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { RoleImpersonationState } from 'lib/role-impersonation'
|
||||
import {
|
||||
useRoleImpersonationStateSnapshot,
|
||||
@@ -88,10 +88,12 @@ export default Header
|
||||
|
||||
const DefaultHeader = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const org = useSelectedOrganization()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const onAddRow =
|
||||
@@ -285,7 +287,7 @@ const RowHeader = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function onRowsExportCSV() {
|
||||
const onRowsExportCSV = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
@@ -357,7 +359,7 @@ const RowHeader = () => {
|
||||
columns: snap.table!.columns.map((column) => column.name),
|
||||
})
|
||||
const csvData = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
toast.success(`Downloaded ${rows.length} rows to CSV`, {
|
||||
toast.success(`Downloaded ${rows.length} rows as CSV`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
@@ -366,7 +368,7 @@ const RowHeader = () => {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
async function onRowsExportSQL() {
|
||||
const onRowsExportSQL = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
if (snap.allRowsSelected && totalRows > MAX_EXPORT_ROW_COUNT) {
|
||||
@@ -431,7 +433,7 @@ const RowHeader = () => {
|
||||
|
||||
const sqlStatements = formatTableRowsToSQL(snap.table, rows)
|
||||
const sqlData = new Blob([sqlStatements], { type: 'text/sql;charset=utf-8;' })
|
||||
toast.success(`Downloading ${rows.length} rows to SQL`, {
|
||||
toast.success(`Downloading ${rows.length} rows as SQL`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
@@ -440,6 +442,27 @@ const RowHeader = () => {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
const onRowsExportJSON = async () => {
|
||||
if (!project) {
|
||||
return toast.error('Project is required')
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
const toastId = toast.loading(
|
||||
`Exporting ${snap.selectedRows.size} row${snap.selectedRows.size > 1 ? 's' : ''} from ${snap.table.name}`
|
||||
)
|
||||
const rows = allRows.filter((x) => snap.selectedRows.has(x.idx))
|
||||
const sqlData = new Blob([JSON.stringify(rows)], { type: 'text/sql;charset=utf-8;' })
|
||||
toast.success(`Downloading ${rows.length} rows as JSON`, {
|
||||
id: toastId,
|
||||
closeButton: true,
|
||||
duration: 4000,
|
||||
})
|
||||
saveAs(sqlData, `${snap.table!.name}_rows.json`)
|
||||
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
function deselectRows() {
|
||||
snap.setSelectedRows(new Set())
|
||||
}
|
||||
@@ -494,13 +517,15 @@ const RowHeader = () => {
|
||||
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
|
||||
{/* [Joshen] Should make this available for all cases, but that'll involve updating
|
||||
the Dialog's SQL output to be dynamic based on any filters applied */}
|
||||
{snap.allRowsSelected && (
|
||||
{snap.allRowsSelected ? (
|
||||
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
|
||||
<div>
|
||||
<p className="group-hover:text-foreground">Export via CLI</p>
|
||||
<p className="text-foreground-lighter">Recommended for large tables</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={onRowsExportJSON}>Export as JSON</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -517,7 +542,11 @@ const RowHeader = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportDialog open={showExportModal} onOpenChange={() => setShowExportModal(false)} />
|
||||
<ExportDialog
|
||||
table={{ name: snap.table.name, schema: snap.table.schema ?? '' }}
|
||||
open={showExportModal}
|
||||
onOpenChange={() => setShowExportModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -42,13 +42,16 @@ import {
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_ as Label,
|
||||
Switch,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
|
||||
export const CreateBranchModal = () => {
|
||||
const allowDataBranching = useFlag('allowDataBranching')
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -141,6 +144,7 @@ export const CreateBranchModal = () => {
|
||||
(val) => gitlessBranching || !githubConnection || (val && val.length > 0),
|
||||
'Git branch name is required when GitHub is connected'
|
||||
),
|
||||
withData: z.boolean().default(false).optional(),
|
||||
})
|
||||
.superRefine(async (val, ctx) => {
|
||||
if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.repository.id) {
|
||||
@@ -164,7 +168,7 @@ export const CreateBranchModal = () => {
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { branchName: '', gitBranchName: '' },
|
||||
defaultValues: { branchName: '', gitBranchName: '', withData: false },
|
||||
})
|
||||
|
||||
const canSubmit = !isCreating && !isChecking
|
||||
@@ -182,7 +186,9 @@ export const CreateBranchModal = () => {
|
||||
createBranch({
|
||||
projectRef,
|
||||
branchName: data.branchName,
|
||||
is_default: false,
|
||||
...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
|
||||
...(allowDataBranching ? { withData: data.withData } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +300,7 @@ export const CreateBranchModal = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingConnections && <GenericSkeletonLoader />}
|
||||
{isErrorConnections && (
|
||||
<AlertError
|
||||
@@ -314,7 +321,7 @@ export const CreateBranchModal = () => {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Keep this preview branch in sync with a chosen GitHub branch
|
||||
</p>
|
||||
</div>
|
||||
@@ -325,6 +332,23 @@ export const CreateBranchModal = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{allowDataBranching && (
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="withData"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Include data"
|
||||
layout="flex-row-reverse"
|
||||
description="Clone production data into this branch"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
export interface CollectionFormValues {
|
||||
name: string
|
||||
retention_days: number
|
||||
}
|
||||
|
||||
interface CollectionFormProps {
|
||||
initialValues?: Partial<CollectionFormValues>
|
||||
onSubmit: (values: CollectionFormValues) => void
|
||||
isLoading?: boolean
|
||||
onCancelClick: () => void
|
||||
}
|
||||
|
||||
export const CollectionForm = ({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
onCancelClick,
|
||||
}: CollectionFormProps) => {
|
||||
const { plan, isLoading: planLoading } = useCurrentOrgPlan()
|
||||
const router = useRouter()
|
||||
const currentOrg = useSelectedOrganization()
|
||||
|
||||
function getMaxRetentionDays() {
|
||||
if (planLoading) return 1
|
||||
if (plan?.id === 'free') return 3
|
||||
else return 90
|
||||
}
|
||||
const maxRetentionDays = getMaxRetentionDays()
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
retention_days: z.coerce.number().min(1).max(maxRetentionDays).multipleOf(1).positive().int(),
|
||||
})
|
||||
|
||||
const form = useForm<CollectionFormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: initialValues?.name || '',
|
||||
retention_days: initialValues?.retention_days || maxRetentionDays,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(onSubmit)
|
||||
|
||||
const formatDays = (days: number) => {
|
||||
return days === 1 ? `1 day` : `${days} days`
|
||||
}
|
||||
|
||||
const retentionDescription = (days: number) => {
|
||||
if (!Boolean(days) || days < 1)
|
||||
return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}.</>
|
||||
return (
|
||||
<>
|
||||
Your logs will be removed after {formatDays(days)}. <br />
|
||||
Your plan allows for a maximum of {formatDays(maxRetentionDays)}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Modal.Content className="space-y-3">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Collection name" layout="horizontal">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ autoFocus placeholder="Events" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="retention_days"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Retention"
|
||||
layout="horizontal"
|
||||
description={retentionDescription(Number(field.value))}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Input_Shadcn_ type="number" {...field} />
|
||||
<span className="absolute right-3 text-foreground-lighter text-sm pointer-events-none">
|
||||
days
|
||||
</span>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</Modal.Content>
|
||||
|
||||
{plan?.id === 'free' && (
|
||||
<>
|
||||
<Modal.Separator />
|
||||
<Admonition
|
||||
className="border-none bg-transparent"
|
||||
type="default"
|
||||
title="Upgrade your plan to increase retention"
|
||||
description={
|
||||
<div>
|
||||
Upgrade to the pro plan to increase retention to 90 days.
|
||||
<Button
|
||||
className="mt-2"
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention`
|
||||
)
|
||||
}
|
||||
>
|
||||
Upgrade plan
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<FormMessage_Shadcn_ />
|
||||
<Modal.Content className="py-4 border-t flex items-center justify-end gap-2">
|
||||
<Button size="tiny" type="default" onClick={onCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="tiny" loading={isLoading} disabled={isLoading} htmlType="submit">
|
||||
Create collection
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useCreateCollection } from 'data/analytics/warehouse-collections-create-mutation'
|
||||
import { Modal } from 'ui'
|
||||
import { CollectionForm } from './CollectionForm'
|
||||
|
||||
export const CreateWarehouseCollectionModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
|
||||
const { mutate: createCollection, isLoading } = useCreateCollection({
|
||||
onSuccess: (data) => {
|
||||
onOpenChange(false)
|
||||
toast.success('Collection created successfully')
|
||||
router.push(`/project/${ref}/logs/collections/${data.token}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="medium"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
header="Create collection"
|
||||
visible={open}
|
||||
hideFooter
|
||||
>
|
||||
<CollectionForm
|
||||
onCancelClick={() => onOpenChange(false)}
|
||||
isLoading={isLoading}
|
||||
onSubmit={(vals) => {
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
createCollection({
|
||||
projectRef: ref,
|
||||
name: vals.name,
|
||||
retention_days: vals.retention_days,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@ui/components/shadcn/ui/select'
|
||||
import CopyButton from 'components/ui/CopyButton'
|
||||
import type { WarehouseAccessTokensData } from 'data/analytics/warehouse-access-tokens-query'
|
||||
import type { WarehouseCollectionsData } from 'data/analytics/warehouse-collections-query'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
CodeBlock,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
export function TestCollectionDialog({
|
||||
accessTokens,
|
||||
collectionToken,
|
||||
collections,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
accessTokens: WarehouseAccessTokensData['data']
|
||||
collections: WarehouseCollectionsData
|
||||
collectionToken: string
|
||||
projectRef: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const BASE_WAREHOUSE_URL = `https://api.warehouse.tech/api/events`
|
||||
const [testAccessToken, setTestAccessToken] = useState('')
|
||||
const [selectedCollection, setSelectedCollection] = useState(collectionToken || '')
|
||||
const [showAccessToken, setShowAccessToken] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCollection(collectionToken)
|
||||
}, [collectionToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (accessTokens.length > 0) {
|
||||
setTestAccessToken(accessTokens[0].token)
|
||||
}
|
||||
}, [accessTokens])
|
||||
|
||||
const selectedAccessToken = accessTokens.find((token) => token.token === testAccessToken)
|
||||
const selectedCollectionName = collections?.find((col) => col.token === selectedCollection)?.name
|
||||
|
||||
function getcURL(accessToken: string) {
|
||||
return `curl -X "POST" "${BASE_WAREHOUSE_URL}?source=${selectedCollection}" \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer ${accessToken}' \\
|
||||
-d $'{
|
||||
"event_message": "Test event message",
|
||||
"metadata": {
|
||||
"ip_address": "100.100.100.100",
|
||||
"request_method": "POST",
|
||||
"custom_user_data": {
|
||||
"foo": "bar"
|
||||
},
|
||||
"datacenter": "aws",
|
||||
"request_headers": {
|
||||
"connection": "close",
|
||||
"user_agent": "chrome"
|
||||
}
|
||||
}
|
||||
}'
|
||||
`
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SheetTrigger asChild>
|
||||
<Button disabled={accessTokens.length === 0} type="outline">
|
||||
Connect
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</TooltipTrigger>
|
||||
{accessTokens.length === 0 && (
|
||||
<TooltipContent>Create an access token to connect to your collection</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<SheetContent size="lg" className="h-screen overflow-auto flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Send events to this collection</SheetTitle>
|
||||
<SheetDescription>
|
||||
Use the following cURL command to send events to this collection
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetSection className="flex flex-col gap-4 overflow-auto">
|
||||
<FormItemLayout label="Collection" isReactForm={false} layout="horizontal">
|
||||
<Select value={selectedCollection} onValueChange={setSelectedCollection}>
|
||||
<SelectTrigger size="tiny">
|
||||
<span className="truncate">{selectedCollectionName || 'Collection'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[260px]">
|
||||
{collections?.map((col) => (
|
||||
<SelectItem key={col.id} value={col.token}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
<FormItemLayout label="Token" isReactForm={false} layout="horizontal">
|
||||
<Select value={testAccessToken} onValueChange={setTestAccessToken}>
|
||||
<SelectTrigger size="tiny">
|
||||
<span className="truncate">
|
||||
{selectedAccessToken?.description || 'Access token'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[260px]">
|
||||
{accessTokens?.map((token: any) => (
|
||||
<SelectItem key={token.id + '-token'} value={token.token}>
|
||||
{token.description || 'No description'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout layout="horizontal" label="Ingest API URL" isReactForm={false}>
|
||||
<Input_Shadcn_ size="tiny" className="font-mono" value={BASE_WAREHOUSE_URL} readOnly />
|
||||
</FormItemLayout>
|
||||
<FormItemLayout layout="horizontal" label="Collection ID" isReactForm={false}>
|
||||
<Input_Shadcn_ size="tiny" className="font-mono" value={selectedCollection} readOnly />
|
||||
</FormItemLayout>
|
||||
</SheetSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SheetSection className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox_Shadcn_
|
||||
name="showAccessToken"
|
||||
id="showAccessToken"
|
||||
checked={showAccessToken}
|
||||
onCheckedChange={() => setShowAccessToken(!showAccessToken)}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor="showAccessToken">Show access token</Label_Shadcn_>
|
||||
</div>
|
||||
<CopyButton type="default" text={getcURL(testAccessToken)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 relative">
|
||||
<CodeBlock
|
||||
hideCopy
|
||||
className={'p-1 language-bash prose max-w-full transition-colors'}
|
||||
language="bash"
|
||||
>
|
||||
{getcURL(showAccessToken ? testAccessToken : '********************')}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetFooter className="mt-auto">
|
||||
<p className="text-sm text-foreground-light mr-auto">
|
||||
Once you send an event, refresh the page to see it in the collection.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSubmit}>Refresh results</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import LoadingOpacity from 'components/ui/LoadingOpacity'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
import { useWarehouseAccessTokensQuery } from 'data/analytics/warehouse-access-tokens-query'
|
||||
import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query'
|
||||
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
|
||||
import { useWarehouseQueryQuery } from 'data/analytics/warehouse-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { RefreshCcw, Rewind, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
|
||||
import { DatetimeHelper } from '../Settings/Logs/Logs.types'
|
||||
import LogTable from '../Settings/Logs/LogTable'
|
||||
import { TestCollectionDialog } from './TestCollectionDialog'
|
||||
|
||||
const INTERVALS: DatetimeHelper[] = [
|
||||
{
|
||||
text: 'Last hour',
|
||||
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
text: 'Last 12 hours',
|
||||
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last day',
|
||||
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 7 days',
|
||||
calcFrom: () => dayjs().subtract(7, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
export const WarehouseCollectionDetail = () => {
|
||||
const router = useRouter()
|
||||
const collectionToken = router.query.collectionToken as string
|
||||
const projectRef = router.query.ref as string
|
||||
const accessTokens = useWarehouseAccessTokensQuery({ projectRef })
|
||||
const [testDialogOpen, setTestDialogOpen] = useState(false)
|
||||
const hasWarehouse = useFlag('warehouse')
|
||||
|
||||
const { data: collections, isLoading: collectionsLoading } = useWarehouseCollectionsQuery(
|
||||
{ projectRef },
|
||||
{ enabled: hasWarehouse }
|
||||
)
|
||||
const collection = collections?.find((c) => c.token === collectionToken)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filters, setFilters] = useState({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
search: '',
|
||||
interval: {
|
||||
to: INTERVALS[0].calcTo,
|
||||
from: INTERVALS[0].calcFrom,
|
||||
},
|
||||
})
|
||||
const [sql, setSQL] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (collection) {
|
||||
const from = filters.interval.from()
|
||||
const to = filters.interval.to()
|
||||
|
||||
const sql = `select id, timestamp from \`${collection.name}\`
|
||||
where timestamp >= TIMESTAMP('${from}')
|
||||
and timestamp <= TIMESTAMP('${to}')
|
||||
order by timestamp desc limit ${filters.limit} offset ${filters.offset}
|
||||
`
|
||||
|
||||
setSQL(sql)
|
||||
}
|
||||
}, [filters.interval, filters.limit, filters.offset, collection])
|
||||
|
||||
const {
|
||||
isLoading: queryLoading,
|
||||
data: queryData,
|
||||
isError,
|
||||
isRefetching,
|
||||
} = useWarehouseQueryQuery(
|
||||
{ ref: projectRef, sql },
|
||||
{
|
||||
enabled: !!sql && hasWarehouse,
|
||||
}
|
||||
)
|
||||
|
||||
const formatResults = (results: any) => {
|
||||
if (!results || !results.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const r = results.map(({ timestamp, ...r }: any) => {
|
||||
return {
|
||||
timestamp: new Date(timestamp / 1000).toLocaleString(),
|
||||
...r,
|
||||
}
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
const results = formatResults(queryData?.result)
|
||||
|
||||
function loadMore() {
|
||||
setFilters({ ...filters, offset: filters.offset + filters.limit })
|
||||
}
|
||||
|
||||
const isLoading = queryLoading || collectionsLoading || isRefetching
|
||||
|
||||
function refreshResults(e?: React.FormEvent<HTMLFormElement>) {
|
||||
e?.preventDefault()
|
||||
setFilters({
|
||||
...filters,
|
||||
search,
|
||||
interval: {
|
||||
to: () => dayjs().toISOString(),
|
||||
from: () => filters.interval.from() || dayjs().subtract(1, 'hour').toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const queryUrl = `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
|
||||
sql || ''
|
||||
)}&source=warehouse`
|
||||
|
||||
useKeyboardShortcuts(
|
||||
{
|
||||
r: () => {
|
||||
document.getElementById('refresh-button')?.focus()
|
||||
},
|
||||
s: () => {
|
||||
document.getElementById('search-input')?.focus()
|
||||
},
|
||||
},
|
||||
['INPUT', 'BUTTON', 'TEXTAREA']
|
||||
)
|
||||
|
||||
function KeyboardShortcut({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 font-medium text-xs font-mono text-foreground-light border rounded-md px-1.5 py-0.5 border-b-2">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Selected Log Detail
|
||||
const [selectedLogId, setSelectedLogId] = useSelectedLog()
|
||||
const {
|
||||
data: selectedLogDetail,
|
||||
isLoading: selectedLogDetailLoading,
|
||||
error: selectedLogDetailError,
|
||||
} = useWarehouseLogDetailQuery({
|
||||
ref: projectRef,
|
||||
collectionName: collection?.name,
|
||||
logId: selectedLogId ?? undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-grow h-full">
|
||||
<ShimmerLine active={isLoading} />
|
||||
<LoadingOpacity active={isLoading}>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex justify-between items-center h-12 px-5">
|
||||
<form onSubmit={refreshResults} className="flex items-center gap-2">
|
||||
<h2 className="text-foreground-light max-w-60 truncate" title={collection?.name}>
|
||||
{collection?.name}
|
||||
</h2>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
id="refresh-button"
|
||||
htmlType="submit"
|
||||
icon={<RefreshCcw />}
|
||||
type="text"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
className="px-1.5"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
Refresh <KeyboardShortcut>R</KeyboardShortcut>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* <Input
|
||||
type="search"
|
||||
id="search-input"
|
||||
className="w-52"
|
||||
size="tiny"
|
||||
placeholder="Search by event message"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/> */}
|
||||
<LogsDatePicker
|
||||
helpers={INTERVALS}
|
||||
value={{
|
||||
from: filters.interval.from(),
|
||||
to: filters.interval.to(),
|
||||
}}
|
||||
onSubmit={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
interval: {
|
||||
to: () => e.to || '',
|
||||
from: () => e.from || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="px-1.5" type="outline" icon={<Terminal />}>
|
||||
<Link href={queryUrl} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Open query in Collections Explorer
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<TestCollectionDialog
|
||||
accessTokens={accessTokens.data?.data || []}
|
||||
collectionToken={collectionToken}
|
||||
projectRef={projectRef}
|
||||
collections={collections || []}
|
||||
open={testDialogOpen}
|
||||
onOpenChange={setTestDialogOpen}
|
||||
onSubmit={() => {
|
||||
refreshResults()
|
||||
setTestDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="p-4">
|
||||
<AlertError subject="Failed to load query results" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && (
|
||||
<LogTable
|
||||
collectionName={collection?.name}
|
||||
queryType="warehouse"
|
||||
hasEditorValue={false}
|
||||
projectRef={projectRef}
|
||||
isLoading={isLoading}
|
||||
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
|
||||
selectedLog={selectedLogDetail?.result[0] ?? undefined}
|
||||
selectedLogError={selectedLogDetailError ?? undefined}
|
||||
isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading}
|
||||
data={results}
|
||||
showHeader={false}
|
||||
EmptyState={
|
||||
<ProductEmptyState title="No events found" size="large">
|
||||
<div className="space-y-4">
|
||||
<p>Try adjusting your filters, send a test event or refresh the results.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTestDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Send test event
|
||||
</Button>
|
||||
<Button type="outline" onClick={() => refreshResults()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ProductEmptyState>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LoadingOpacity>
|
||||
|
||||
{!isError && (
|
||||
<div className="border-t flex flex-row justify-between">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{results.length > 0 && (
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
icon={<Rewind />}
|
||||
type="default"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
{filters.offset !== 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setFilters({ ...filters, offset: 0 })}
|
||||
type="default"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Load latest
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Database, EditIcon, TrashIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useDeleteCollectionMutation } from 'data/analytics/warehouse-collections-delete-mutation'
|
||||
import { useUpdateCollection } from 'data/analytics/warehouse-collections-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
Modal,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { LogsSidebarItem } from '../Settings/Logs/SidebarV2/SidebarItem'
|
||||
import { CollectionForm } from './CollectionForm'
|
||||
|
||||
type Props = {
|
||||
item: {
|
||||
id: number
|
||||
token: string
|
||||
name: string
|
||||
retention_days: number
|
||||
}
|
||||
}
|
||||
|
||||
export const WarehouseMenuItem = ({ item }: Props) => {
|
||||
const router = useRouter()
|
||||
const { ref, collectionToken } = useParams()
|
||||
const projectRef = ref || 'default'
|
||||
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||
const [showDeleteDialog, setDeleteDialog] = useState(false)
|
||||
|
||||
const canUpdateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
|
||||
|
||||
const updateCollection = useUpdateCollection({
|
||||
onSuccess: () => {
|
||||
toast.success('Collection updated successfully')
|
||||
setShowUpdateDialog(false)
|
||||
},
|
||||
})
|
||||
const deleteCollection = useDeleteCollectionMutation({
|
||||
onSuccess: () => {
|
||||
// If the current collection is deleted, redirect to default logs view
|
||||
if (item.token === collectionToken) {
|
||||
router.push(`/project/${projectRef}/logs/explorer`)
|
||||
}
|
||||
setDeleteDialog(false)
|
||||
toast.success('Collection deleted successfully')
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = updateCollection.isLoading || deleteCollection.isLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogsSidebarItem
|
||||
isActive={item.token === collectionToken}
|
||||
label={item.name}
|
||||
icon={<Database size="14" className="text-foreground-light" />}
|
||||
href={`/project/${projectRef}/logs/collections/${item.token}`}
|
||||
dropdownItems={
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
disabled={!canUpdateCollection}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setShowUpdateDialog(true)
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2" size={14} />
|
||||
<div>Update collection</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateCollection && (
|
||||
<TooltipContent side="right">
|
||||
You need additional permissions to update collections
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<DropdownMenuSeparator />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
disabled={!canUpdateCollection}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-2" size={14} />
|
||||
<div>Delete collection</div>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{!canUpdateCollection && (
|
||||
<TooltipContent side="right">
|
||||
You need additional permissions to delete collections
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
></LogsSidebarItem>
|
||||
|
||||
<Modal
|
||||
size="medium"
|
||||
visible={showUpdateDialog}
|
||||
onCancel={() => setShowUpdateDialog(false)}
|
||||
hideFooter
|
||||
header={`Update collection "${item.name}"`}
|
||||
>
|
||||
<CollectionForm
|
||||
onCancelClick={() => setShowUpdateDialog(false)}
|
||||
isLoading={isLoading}
|
||||
initialValues={{ name: item.name, retention_days: item.retention_days }}
|
||||
onSubmit={({ name, retention_days }) => {
|
||||
updateCollection.mutate({
|
||||
projectRef,
|
||||
collectionToken: item.token,
|
||||
name,
|
||||
retention_days,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={showDeleteDialog}
|
||||
onCancel={() => setDeleteDialog(false)}
|
||||
confirmLabel="Delete collection"
|
||||
title={`Delete collection "${item.name}"`}
|
||||
size="small"
|
||||
onConfirm={() => {
|
||||
deleteCollection.mutate({ projectRef, collectionToken: item.token })
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
All data in this collection will be deleted.
|
||||
<br />
|
||||
<div className="mt-2">This action cannot be undone.</div>
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { Button, Input } from 'ui'
|
||||
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Button, Input_Shadcn_ } from 'ui'
|
||||
import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
|
||||
|
||||
const STATIC_NODES = [
|
||||
@@ -62,6 +64,14 @@ const STATIC_EDGES = [
|
||||
{ id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true },
|
||||
]
|
||||
|
||||
export const ReplicationComingSoon = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ReplicationStaticMockup />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const ReplicationStaticMockup = () => {
|
||||
const nodes = useMemo(() => STATIC_NODES, [])
|
||||
const edges = useMemo(() => STATIC_EDGES, [])
|
||||
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ReplicationComingSoon = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ReplicationStaticMockup />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplicationComingSoon
|
||||
|
||||
const PrimaryNode = ({
|
||||
data,
|
||||
}: {
|
||||
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col bg-surface-100 px-6 pt-6 border-t relative h-full">
|
||||
<div className="bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30"></div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
size="small"
|
||||
className="w-52 bg-transparent"
|
||||
iconContainerClassName="pl-2"
|
||||
icon={<Search size={14} className="text-foreground-lighter" />}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Button
|
||||
icon={<Plus size={16} />}
|
||||
type="primary"
|
||||
className="flex items-center"
|
||||
onClick={() => {}}
|
||||
>
|
||||
New destination
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="lag">Lag</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
className="mt-4"
|
||||
body={mockRows.map((row, i) => (
|
||||
<Table.tr key={i}>
|
||||
<Table.td>{row.name}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-bold">All</span>
|
||||
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td>{row.lag}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-3">
|
||||
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
|
||||
{row.status}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
<button className="p-1">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
/>
|
||||
<div className="flex flex-col bg-surface-100 px-6 py-6 border-t relative ">
|
||||
<div className="z-10 bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30" />
|
||||
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection className="!py-0">
|
||||
<div className="col-span-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-52">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-9 bg-transparent h-8 pointer-events-none"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
className="flex items-center pointer-events-none"
|
||||
>
|
||||
New destination
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="lag">Lag</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
className="mt-4"
|
||||
body={mockRows.map((row, i) => (
|
||||
<Table.tr key={i}>
|
||||
<Table.td>{row.name}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-bold">All</span>
|
||||
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td>{row.lag}</Table.td>
|
||||
<Table.td>
|
||||
<span className="flex items-center gap-3">
|
||||
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
|
||||
{row.status}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
<button className="p-1">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
|
||||
import { ResponseError } from 'types'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import RowMenu from './RowMenu'
|
||||
import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
ReplicationPipelineStatusData,
|
||||
useReplicationPipelineStatusQuery,
|
||||
} from 'data/replication/pipeline-status-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { Pipeline } from 'data/replication/pipelines-query'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import DeleteDestination from './DeleteDestination'
|
||||
import DestinationPanel from './DestinationPanel'
|
||||
|
||||
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
|
||||
|
||||
const refreshFrequencyMs: number = 5000
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { RowMenu } from './RowMenu'
|
||||
|
||||
interface DestinationRowProps {
|
||||
sourceId: number | undefined
|
||||
@@ -34,7 +35,7 @@ interface DestinationRowProps {
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
const DestinationRow = ({
|
||||
export const DestinationRow = ({
|
||||
sourceId,
|
||||
destinationId,
|
||||
destinationName,
|
||||
@@ -60,78 +61,25 @@ const DestinationRow = ({
|
||||
projectRef,
|
||||
pipelineId: pipeline?.id,
|
||||
},
|
||||
{ refetchInterval: refreshFrequencyMs }
|
||||
{ refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
|
||||
)
|
||||
const [requestStatus, setRequestStatus] = useState<PipelineStatusRequestStatus>(
|
||||
PipelineStatusRequestStatus.None
|
||||
)
|
||||
const { mutateAsync: startPipeline } = useStartPipelineMutation()
|
||||
const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
|
||||
const requestStatus = pipeline?.id
|
||||
? getRequestStatus(pipeline.id)
|
||||
: PipelineStatusRequestStatus.None
|
||||
|
||||
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
const getStatusName = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): string | undefined => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
if (
|
||||
(requestStatus === PipelineStatusRequestStatus.EnableRequested &&
|
||||
(statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) ||
|
||||
(requestStatus === PipelineStatusRequestStatus.DisableRequested &&
|
||||
(statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED))
|
||||
) {
|
||||
setRequestStatus(PipelineStatusRequestStatus.None)
|
||||
}
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to enable destination')
|
||||
}
|
||||
setRequestStatus(PipelineStatusRequestStatus.EnableRequested)
|
||||
}
|
||||
const onDisableClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
toast.error('Failed to disable destination')
|
||||
}
|
||||
setRequestStatus(PipelineStatusRequestStatus.DisableRequested)
|
||||
}
|
||||
const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({})
|
||||
|
||||
const pipelineStatus = pipelineStatusData?.status
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error('No pipeline found')
|
||||
return
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -140,24 +88,28 @@ const DestinationRow = ({
|
||||
// so we don't need to call deletePipeline explicitly
|
||||
await deleteDestination({ projectRef, destinationId: destinationId })
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete destination')
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pipeline?.id) {
|
||||
updatePipelineStatus(pipeline.id, statusName)
|
||||
}
|
||||
}, [pipeline?.id, statusName, updatePipelineStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
)}
|
||||
{isPipelineSuccess && (
|
||||
<Table.tr>
|
||||
<Table.td>
|
||||
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : destinationName}
|
||||
</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : destinationName}</Table.td>
|
||||
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : type}</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
<ShimmeringLoader />
|
||||
) : (
|
||||
<PipelineStatus
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
@@ -166,27 +118,33 @@ const DestinationRow = ({
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
></PipelineStatus>
|
||||
/>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader></ShimmeringLoader>
|
||||
<ShimmeringLoader />
|
||||
) : (
|
||||
pipeline.config.publication_name
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<RowMenu
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
onEnableClick={onEnableClick}
|
||||
onDisableClick={onDisableClick}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
onEditClick={() => setShowEditDestinationPanel(true)}
|
||||
></RowMenu>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
|
||||
View status
|
||||
</Link>
|
||||
</Button>
|
||||
<RowMenu
|
||||
pipeline={pipeline}
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
||||
onEditClick={() => setShowEditDestinationPanel(true)}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
@@ -211,5 +169,3 @@ const DestinationRow = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationRow
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useReplicationDestinationsQuery } from 'data/replication/destinations-query'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import DestinationRow from './DestinationRow'
|
||||
import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
|
||||
import { useState } from 'react'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { Button, cn, Input_Shadcn_ } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import NewDestinationPanel from './DestinationPanel'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
|
||||
const Destinations = () => {
|
||||
export const Destinations = () => {
|
||||
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const {
|
||||
@@ -21,12 +23,11 @@ const Destinations = () => {
|
||||
error: sourcesError,
|
||||
isLoading: isSourcesLoading,
|
||||
isError: isSourcesError,
|
||||
isSuccess: isSourcesSuccess,
|
||||
} = useReplicationSourcesQuery({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
|
||||
const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
|
||||
|
||||
const {
|
||||
data: destinationsData,
|
||||
@@ -50,21 +51,44 @@ const Destinations = () => {
|
||||
|
||||
const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0
|
||||
|
||||
const filteredDestinations =
|
||||
filterString.length === 0
|
||||
? destinationsData?.destinations ?? []
|
||||
: (destinationsData?.destinations ?? []).filter((destination) =>
|
||||
destination.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
size={14}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-9 h-7"
|
||||
placeholder={'Filter destinations'}
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{(isSourcesError || isDestinationsError) && (
|
||||
<AlertError
|
||||
error={sourcesError || destinationsError}
|
||||
subject="Failed to retrieve destinations"
|
||||
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -77,7 +101,7 @@ const Destinations = () => {
|
||||
<Table.th key="publication">Publication</Table.th>,
|
||||
<Table.th key="actions"></Table.th>,
|
||||
]}
|
||||
body={destinationsData.destinations.map((destination) => {
|
||||
body={filteredDestinations.map((destination) => {
|
||||
const pipeline = pipelinesData?.pipelines.find(
|
||||
(p) => p.destination_id === destination.id
|
||||
)
|
||||
@@ -93,7 +117,7 @@ const Destinations = () => {
|
||||
isLoading={isPipelinesLoading}
|
||||
isError={isPipelinesError}
|
||||
isSuccess={isPipelinesSuccess}
|
||||
></DestinationRow>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
></Table>
|
||||
@@ -124,15 +148,22 @@ const Destinations = () => {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
</div>
|
||||
|
||||
{!isSourcesLoading &&
|
||||
!isDestinationsLoading &&
|
||||
filteredDestinations.length === 0 &&
|
||||
anyDestinations && (
|
||||
<div className="text-center py-8 text-foreground-light">
|
||||
<p>No destinations match "{filterString}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewDestinationPanel
|
||||
visible={showNewDestinationPanel}
|
||||
sourceId={sourceId}
|
||||
onClose={() => setShowNewDestinationPanel(false)}
|
||||
></NewDestinationPanel>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Destinations
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
|
||||
export const PIPELINE_ERROR_MESSAGES = {
|
||||
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
|
||||
RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
|
||||
RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
|
||||
RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
|
||||
ENABLE_DESTINATION: 'Failed to enable destination',
|
||||
DISABLE_DESTINATION: 'Failed to disable destination',
|
||||
DELETE_DESTINATION: 'Failed to delete destination',
|
||||
NO_PIPELINE_FOUND: 'No pipeline found',
|
||||
COPY_TABLE_STATUS: 'Failed to copy table status',
|
||||
} as const
|
||||
|
||||
export const getStatusName = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): ReplicationPipelineStatusData['status']['name'] | undefined => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PIPELINE_STATE_MESSAGES = {
|
||||
enabling: {
|
||||
title: 'Pipeline Enabling',
|
||||
message: 'Starting the pipeline. Table replication will resume once enabled.',
|
||||
badge: 'Enabling',
|
||||
},
|
||||
disabling: {
|
||||
title: 'Pipeline Disabling',
|
||||
message: 'Stopping the pipeline. Table replication will be paused once disabled.',
|
||||
badge: 'Disabling',
|
||||
},
|
||||
failed: {
|
||||
title: 'Pipeline Failed',
|
||||
message: 'Replication has encountered an error. Check the logs for details.',
|
||||
badge: 'Failed',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Pipeline Stopped',
|
||||
message: 'Replication is paused. Enable the pipeline to resume data synchronization.',
|
||||
badge: 'Stopped',
|
||||
},
|
||||
starting: {
|
||||
title: 'Pipeline Starting',
|
||||
message: 'Initializing replication. Table status will be available once running.',
|
||||
badge: 'Starting',
|
||||
},
|
||||
running: {
|
||||
title: 'Pipeline Running',
|
||||
message: 'Replication is active and processing data',
|
||||
badge: 'Running',
|
||||
},
|
||||
unknown: {
|
||||
title: 'Pipeline Status Unknown',
|
||||
message: 'Unable to determine replication status. Check the logs for more information.',
|
||||
badge: 'Unknown',
|
||||
},
|
||||
notRunning: {
|
||||
title: 'Pipeline Not Running',
|
||||
message: 'Replication is not active. Enable the pipeline to start data synchronization.',
|
||||
badge: 'Disabled',
|
||||
},
|
||||
} as const
|
||||
|
||||
export const getPipelineStateMessages = (
|
||||
requestStatus: PipelineStatusRequestStatus | undefined,
|
||||
statusName: string | undefined
|
||||
) => {
|
||||
// Always prioritize request status (enabling/disabling) over pipeline status
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.enabling
|
||||
}
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
|
||||
return PIPELINE_STATE_MESSAGES.disabling
|
||||
}
|
||||
|
||||
// Only check pipeline status if no request is in progress
|
||||
switch (statusName) {
|
||||
case 'failed':
|
||||
return PIPELINE_STATE_MESSAGES.failed
|
||||
case 'stopped':
|
||||
return PIPELINE_STATE_MESSAGES.stopped
|
||||
case 'starting':
|
||||
return PIPELINE_STATE_MESSAGES.starting
|
||||
case 'started':
|
||||
return PIPELINE_STATE_MESSAGES.running
|
||||
case 'unknown':
|
||||
return PIPELINE_STATE_MESSAGES.unknown
|
||||
default:
|
||||
return PIPELINE_STATE_MESSAGES.notRunning
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,11 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { cn } from 'ui'
|
||||
import { ResponseError } from 'types'
|
||||
import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Button,
|
||||
Collapsible_Shadcn_ as Collapsible,
|
||||
CollapsibleContent_Shadcn_ as CollapsibleContent,
|
||||
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
|
||||
} from 'ui'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { copyToClipboard } from 'ui'
|
||||
import { toast } from 'sonner'
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
|
||||
export enum PipelineStatusRequestStatus {
|
||||
None = 'None',
|
||||
EnableRequested = 'EnableRequested',
|
||||
DisableRequested = 'DisableRequested',
|
||||
}
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
|
||||
export enum PipelineStatusName {
|
||||
FAILED = 'failed',
|
||||
@@ -41,10 +24,10 @@ interface PipelineStatusProps {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
requestStatus: PipelineStatusRequestStatus
|
||||
requestStatus?: PipelineStatusRequestStatus
|
||||
}
|
||||
|
||||
const PipelineStatus = ({
|
||||
export const PipelineStatus = ({
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
@@ -52,34 +35,6 @@ const PipelineStatus = ({
|
||||
isSuccess,
|
||||
requestStatus,
|
||||
}: PipelineStatusProps) => {
|
||||
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false)
|
||||
const errorDetailsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) {
|
||||
setIsErrorDetailsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isErrorDetailsOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isErrorDetailsOpen])
|
||||
|
||||
const handleCopyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await copyToClipboard(text)
|
||||
toast.success('Error details copied to clipboard')
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy error details')
|
||||
}
|
||||
}
|
||||
|
||||
const isFailedStatus = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): status is FailedStatus => {
|
||||
@@ -91,144 +46,37 @@ const PipelineStatus = ({
|
||||
)
|
||||
}
|
||||
|
||||
const isLogLoadingState = (failedStatus: FailedStatus): boolean => {
|
||||
// Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems
|
||||
// to be a transient error. In case we find a way to properly handle this in the backend, this hack will not
|
||||
// be needed anymore.
|
||||
const renderFailedStatus = () => {
|
||||
return (
|
||||
failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') ===
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const renderFailedStatus = (failedStatus: FailedStatus) => {
|
||||
const hasDetails =
|
||||
failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined
|
||||
const isLoadingLogs = isLogLoadingState(failedStatus)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
{isLoadingLogs ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
)}
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isLoadingLogs
|
||||
? 'Pipeline failed - logs are being retrieved from container'
|
||||
: 'Pipeline has failed - expand for error details'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{hasDetails && (
|
||||
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
|
||||
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
|
||||
<div
|
||||
ref={errorDetailsRef}
|
||||
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
|
||||
>
|
||||
{isLoadingLogs ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground mb-1">
|
||||
Pipeline Failed - Loading logs...
|
||||
</div>
|
||||
<div className="text-xs text-foreground-light">
|
||||
Error logs are being retrieved from the container. This may take a few
|
||||
moments.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Pipeline Error Details
|
||||
</span>
|
||||
{(failedStatus.message || failedStatus.reason) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
onClick={async () => {
|
||||
await handleCopyToClipboard(
|
||||
[
|
||||
failedStatus.exit_code !== undefined
|
||||
? `Exit Code: ${failedStatus.exit_code}`
|
||||
: null,
|
||||
failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
|
||||
failedStatus.message ? `Message: ${failedStatus.message}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{failedStatus.exit_code !== undefined && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">
|
||||
Exit Code:
|
||||
</span>
|
||||
<div className="font-mono text-sm text-foreground mt-1">
|
||||
{failedStatus.exit_code}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failedStatus.reason && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">Reason:</span>
|
||||
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
|
||||
{failedStatus.reason}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{failedStatus.message && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground-light">
|
||||
Message:
|
||||
</span>
|
||||
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
|
||||
{failedStatus.message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{getPipelineStateMessages(requestStatus, 'failed').message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
// Map backend statuses to UX-friendly display
|
||||
const getStatusConfig = () => {
|
||||
const statusName =
|
||||
pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus
|
||||
? pipelineStatus.name
|
||||
: undefined
|
||||
|
||||
// Get consistent tooltip message using the same logic as other components
|
||||
const stateMessages = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
|
||||
return {
|
||||
label: 'Enabling...',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
|
||||
color: 'text-brand-600',
|
||||
tooltip: 'Pipeline is being enabled and will start shortly',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +85,7 @@ const PipelineStatus = ({
|
||||
label: 'Disabling...',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline is being disabled and will stop shortly',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,35 +103,35 @@ const PipelineStatus = ({
|
||||
label: 'Starting',
|
||||
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline is initializing and will be ready soon',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.STARTED:
|
||||
return {
|
||||
label: 'Running',
|
||||
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
|
||||
color: 'text-brand-600',
|
||||
tooltip: 'Pipeline is active and processing data',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.STOPPED:
|
||||
return {
|
||||
label: 'Stopped',
|
||||
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
|
||||
color: 'text-foreground-light',
|
||||
tooltip: 'Pipeline is not running - enable to start processing',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
case PipelineStatusName.UNKNOWN:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
|
||||
color: 'text-warning-600',
|
||||
tooltip: 'Pipeline status could not be determined',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
|
||||
color: 'text-destructive-600',
|
||||
tooltip: 'Pipeline status is unclear - check logs for details',
|
||||
tooltip: stateMessages.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,31 +149,27 @@ const PipelineStatus = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
{isLoading && <ShimmeringLoader />}
|
||||
{isError && (
|
||||
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
|
||||
)}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{statusConfig.isFailedStatus ? (
|
||||
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
|
||||
<div className="relative">{renderFailedStatus()}</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
|
||||
{statusConfig.dot}
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{statusConfig.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
|
||||
{statusConfig.dot}
|
||||
<span>{statusConfig.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineStatus
|
||||
@@ -0,0 +1 @@
|
||||
export const STATUS_REFRESH_FREQUENCY_MS: number = 5000
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
|
||||
import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query'
|
||||
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { TableState } from './ReplicationPipelineStatus.types'
|
||||
import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
|
||||
|
||||
export const ReplicationPipelineStatus = () => {
|
||||
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const pipelineId = Number(_pipelineId)
|
||||
const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
|
||||
const requestStatus = getRequestStatus(pipelineId)
|
||||
|
||||
const {
|
||||
data: pipeline,
|
||||
error: pipelineError,
|
||||
isLoading: isPipelineLoading,
|
||||
isError: isPipelineError,
|
||||
} = useReplicationPipelineByIdQuery({
|
||||
projectRef,
|
||||
pipelineId,
|
||||
})
|
||||
|
||||
const {
|
||||
data: pipelineStatusData,
|
||||
error: pipelineStatusError,
|
||||
isLoading: isPipelineStatusLoading,
|
||||
isError: isPipelineStatusError,
|
||||
isSuccess: isPipelineStatusSuccess,
|
||||
} = useReplicationPipelineStatusQuery(
|
||||
{ projectRef, pipelineId },
|
||||
{
|
||||
enabled: !!pipelineId,
|
||||
refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: replicationStatusData,
|
||||
error: statusError,
|
||||
isLoading: isStatusLoading,
|
||||
isError: isStatusError,
|
||||
} = useReplicationPipelineReplicationStatusQuery(
|
||||
{ projectRef, pipelineId },
|
||||
{
|
||||
enabled: !!pipelineId,
|
||||
refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
|
||||
}
|
||||
)
|
||||
|
||||
const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation()
|
||||
|
||||
const destinationName = pipeline?.destination_name
|
||||
const statusName = getStatusName(pipelineStatusData?.status)
|
||||
const config = getDisabledStateConfig({ requestStatus, statusName })
|
||||
|
||||
const tableStatuses = replicationStatusData?.table_statuses || []
|
||||
const filteredTableStatuses =
|
||||
filterString.length === 0
|
||||
? tableStatuses
|
||||
: tableStatuses.filter((table: TableState) =>
|
||||
table.table_name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
|
||||
const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error')
|
||||
const hasErrors = errorTables.length > 0
|
||||
const isPipelineRunning = statusName === 'started'
|
||||
const hasTableData = tableStatuses.length > 0
|
||||
const isEnablingDisabling =
|
||||
requestStatus === PipelineStatusRequestStatus.EnableRequested ||
|
||||
requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
const showDisabledState = !isPipelineRunning || isEnablingDisabling
|
||||
|
||||
const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => {
|
||||
const statusText = `Table: ${tableName}\nStatus: ${state.name}${
|
||||
'message' in state ? `\nError: ${state.message}` : ''
|
||||
}${'lag' in state ? `\nLag: ${state.lag}ms` : ''}`
|
||||
|
||||
try {
|
||||
await copyToClipboard(statusText)
|
||||
toast.success('Table status copied to clipboard')
|
||||
} catch {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS)
|
||||
}
|
||||
}
|
||||
|
||||
const onTogglePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusName === 'stopped') {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} else if (statusName === 'started') {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updatePipelineStatus(pipelineId, statusName)
|
||||
}, [pipelineId, statusName, updatePipelineStatus])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Button asChild type="outline" icon={<ChevronLeft />} style={{ padding: '5px' }}>
|
||||
<Link href={`/project/${projectRef}/database/replication`} />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
|
||||
<PipelineStatus
|
||||
pipelineStatus={pipelineStatusData?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
|
||||
size={14}
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
className="pl-7 h-[26px] text-xs"
|
||||
placeholder="Search for tables"
|
||||
value={filterString}
|
||||
disabled={isPipelineError}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type={statusName === 'stopped' ? 'primary' : 'default'}
|
||||
onClick={() => onTogglePipeline()}
|
||||
loading={isPipelineError || isStartingPipeline || isStoppingPipeline}
|
||||
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
|
||||
>
|
||||
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isPipelineLoading || isStatusLoading) && <GenericSkeletonLoader />}
|
||||
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
)}
|
||||
|
||||
{isStatusError && (
|
||||
<AlertError
|
||||
error={statusError}
|
||||
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_REPLICATION_STATUS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-destructive-900 mb-1">
|
||||
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
|
||||
</h4>
|
||||
<p className="text-sm text-destructive-700 mb-3">
|
||||
Some tables encountered replication errors. Check the logs for detailed error
|
||||
information.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
type="outline"
|
||||
size="tiny"
|
||||
icon={<ExternalLink className="w-3 h-3" />}
|
||||
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${projectRef}/logs/postgres-logs`}
|
||||
>
|
||||
View Logs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTableData && (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{showDisabledState && (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border border-default rounded-lg flex items-center justify-between',
|
||||
config.colors.bg
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
config.colors.iconBg
|
||||
)}
|
||||
>
|
||||
<div className={config.colors.icon}>{config.icon}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${config.colors.text}`}>{config.title}</h4>
|
||||
<p className={`text-sm ${config.colors.subtext}`}>{config.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="table">Table</Table.th>,
|
||||
<Table.th key="status">Status</Table.th>,
|
||||
<Table.th key="details">Details</Table.th>,
|
||||
<Table.th key="actions" className="w-16 text-center">
|
||||
Actions
|
||||
</Table.th>,
|
||||
]}
|
||||
body={filteredTableStatuses.map((table: TableState, index: number) => {
|
||||
const statusConfig = getStatusConfig(table.state)
|
||||
return (
|
||||
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
|
||||
<Table.td>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p>{table.table_name}</p>
|
||||
|
||||
<ButtonTooltip
|
||||
asChild
|
||||
type="text"
|
||||
className="px-1.5"
|
||||
icon={<ExternalLink />}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/project/${projectRef}/editor/${table.table_id}`}
|
||||
/>
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{showDisabledState ? (
|
||||
<Badge variant="default">Not Available</Badge>
|
||||
) : (
|
||||
statusConfig.badge
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{showDisabledState ? (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Status unavailable while pipeline is {config.badge.toLowerCase()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-foreground">{statusConfig.description}</div>
|
||||
{'lag' in table.state && (
|
||||
<div className="text-xs text-foreground-light">
|
||||
Lag: {table.state.lag}ms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="text-center">
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<Copy className="w-3 h-3" />}
|
||||
className="px-1.5"
|
||||
disabled={showDisabledState}
|
||||
onClick={() => handleCopyTableStatus(table.table_name, table.state)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: showDisabledState
|
||||
? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}`
|
||||
: 'Copy status details',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTableStatuses.length === 0 && hasTableData && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
|
||||
{!isStatusLoading && tableStatuses.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div className="w-full max-w-sm mx-auto text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
|
||||
<Activity className="w-8 h-8 text-foreground-lighter" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-lg font-semibold text-foreground">
|
||||
{showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-light leading-relaxed">
|
||||
{showDisabledState
|
||||
? `The replication pipeline is currently ${statusName || 'not active'}. Table status
|
||||
information is not available while the pipeline is in this state.`
|
||||
: `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Data refreshes automatically every 2 seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type TableState = {
|
||||
table_id: number
|
||||
table_name: string
|
||||
state:
|
||||
| { name: 'queued' }
|
||||
| { name: 'copying_table' }
|
||||
| { name: 'copied_table' }
|
||||
| { name: 'following_wal'; lag: number }
|
||||
| { name: 'error'; message: string }
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
import { Badge } from 'ui'
|
||||
import { getPipelineStateMessages } from './Pipeline.utils'
|
||||
import { TableState } from './ReplicationPipelineStatus.types'
|
||||
|
||||
export const getStatusConfig = (state: TableState['state']) => {
|
||||
switch (state.name) {
|
||||
case 'queued':
|
||||
return {
|
||||
badge: <Badge variant="warning">Queued</Badge>,
|
||||
description: 'Waiting to start replication',
|
||||
color: 'text-warning-600',
|
||||
}
|
||||
case 'copying_table':
|
||||
return {
|
||||
badge: <Badge variant="brand">Copying</Badge>,
|
||||
description: 'Initial data copy in progress',
|
||||
color: 'text-brand-600',
|
||||
}
|
||||
case 'copied_table':
|
||||
return {
|
||||
badge: <Badge variant="success">Copied</Badge>,
|
||||
description: 'Initial copy completed',
|
||||
color: 'text-success-600',
|
||||
}
|
||||
case 'following_wal':
|
||||
return {
|
||||
badge: <Badge variant="success">Live</Badge>,
|
||||
description: `Replicating live changes`,
|
||||
color: 'text-success-600',
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
badge: <Badge variant="destructive">Error</Badge>,
|
||||
description: state.message,
|
||||
color: 'text-destructive-600',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
badge: <Badge variant="warning">Unknown</Badge>,
|
||||
description: 'Unknown status',
|
||||
color: 'text-warning-600',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getDisabledStateConfig = ({
|
||||
requestStatus,
|
||||
statusName,
|
||||
}: {
|
||||
requestStatus: PipelineStatusRequestStatus
|
||||
statusName?: ReplicationPipelineStatusData['status']['name']
|
||||
}) => {
|
||||
const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName)
|
||||
|
||||
// Get icon and colors based on current state
|
||||
const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested
|
||||
const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested
|
||||
const isTransitioning = isEnabling || isDisabling
|
||||
|
||||
const icon = isTransitioning ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : statusName === 'failed' ? (
|
||||
<XCircle className="w-6 h-6" />
|
||||
) : statusName === 'starting' ? (
|
||||
<Clock className="w-6 h-6" />
|
||||
) : statusName === 'unknown' ? (
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<Activity className="w-6 h-6" />
|
||||
)
|
||||
|
||||
const colors = isEnabling
|
||||
? {
|
||||
bg: 'bg-brand-50',
|
||||
text: 'text-brand-900',
|
||||
subtext: 'text-brand-700',
|
||||
iconBg: 'bg-brand-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: isDisabling || statusName === 'starting' || statusName === 'unknown'
|
||||
? {
|
||||
bg: 'bg-warning-50',
|
||||
text: 'text-warning-900',
|
||||
subtext: 'text-warning-700',
|
||||
iconBg: 'bg-warning-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: statusName === 'failed'
|
||||
? {
|
||||
bg: 'bg-destructive-50',
|
||||
text: 'text-destructive-900',
|
||||
subtext: 'text-destructive-700',
|
||||
iconBg: 'bg-destructive-600',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-surface-100',
|
||||
text: 'text-foreground',
|
||||
subtext: 'text-foreground-light',
|
||||
iconBg: 'bg-foreground-lighter',
|
||||
icon: 'text-white dark:text-black',
|
||||
}
|
||||
|
||||
return { title, message, badge, icon, colors }
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { Pipeline } from 'data/replication/pipelines-query'
|
||||
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
|
||||
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
|
||||
import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import { ResponseError } from 'types'
|
||||
import {
|
||||
Button,
|
||||
@@ -10,29 +20,30 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
|
||||
interface RowMenuProps {
|
||||
pipeline: Pipeline | undefined
|
||||
pipelineStatus: any
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
onEnableClick: () => void
|
||||
onDisableClick: () => void
|
||||
onEditClick: () => void
|
||||
onDeleteClick: () => void
|
||||
}
|
||||
|
||||
const RowMenu = ({
|
||||
export const RowMenu = ({
|
||||
pipeline,
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
onEnableClick,
|
||||
onDisableClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: RowMenuProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const getStatusName = (status: any) => {
|
||||
if (status && typeof status === 'object' && 'name' in status) {
|
||||
return status.name
|
||||
@@ -43,10 +54,52 @@ const RowMenu = ({
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
|
||||
|
||||
const { mutateAsync: startPipeline } = useStartPipelineMutation()
|
||||
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
||||
const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
|
||||
|
||||
const onEnablePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
}
|
||||
|
||||
try {
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Enabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
const onDisablePipeline = async () => {
|
||||
if (!projectRef) {
|
||||
console.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
if (!pipeline) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast(`Disabling pipeline ${pipeline.destination_name}`)
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
{isLoading && <ShimmeringLoader />}
|
||||
{isError && (
|
||||
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -54,14 +107,14 @@ const RowMenu = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
{pipelineEnabled ? (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}>
|
||||
<Pause size={14} />
|
||||
<p>Disable</p>
|
||||
<p>Disable pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onEnablePipeline}>
|
||||
<Play size={14} />
|
||||
<p>Enable</p>
|
||||
<p>Enable pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -78,5 +131,3 @@ const RowMenu = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowMenu
|
||||
@@ -42,7 +42,7 @@ export const MarkdownContent = ({ integrationId }: { integrationId: string }) =>
|
||||
/>
|
||||
)}
|
||||
{supportExpanding && (
|
||||
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
|
||||
<div className={cn('bottom-0', !isExpanded ? 'absolute' : 'relative mt-3')}>
|
||||
<button
|
||||
className="text-foreground-light hover:text-foreground underline text-sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
||||
+77
-52
@@ -2,18 +2,17 @@ import { isEmpty } from 'lodash'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Label_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Separator,
|
||||
@@ -25,7 +24,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
|
||||
import InputField from './InputField'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
const FORM_ID = 'create-wrapper-form'
|
||||
|
||||
@@ -68,12 +66,11 @@ export const CreateIcebergWrapperSheet = ({
|
||||
const org = useSelectedOrganization()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
|
||||
const [selectedTarget, setSelectedTarget] = useState<Target>('S3Tables')
|
||||
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
|
||||
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
|
||||
onClose()
|
||||
@@ -100,8 +97,10 @@ export const CreateIcebergWrapperSheet = ({
|
||||
}
|
||||
}, [wrapperMetaOriginal, selectedTarget])
|
||||
|
||||
// prefetch schemas to make sure the schema selector is populated
|
||||
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
wrapper_name: '',
|
||||
@@ -113,41 +112,72 @@ export const CreateIcebergWrapperSheet = ({
|
||||
),
|
||||
}
|
||||
|
||||
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
const validate = makeValidateRequired(wrapperMeta.server.options)
|
||||
const errors: any = validate(values)
|
||||
|
||||
if (values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
errors.source_schema = 'Please provide a namespace name'
|
||||
}
|
||||
if (values.wrapper_name.length === 0) {
|
||||
errors.wrapper_name = 'Please provide a name for your wrapper'
|
||||
}
|
||||
if (!isEmpty(errors)) return setFormErrors(errors)
|
||||
|
||||
createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: { ...values, server_name: `${values.wrapper_name}_server` },
|
||||
mode: 'schema',
|
||||
tables: [],
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
if (values.target_schema.length === 0) {
|
||||
errors.target_schema = 'Please provide an unique target schema'
|
||||
}
|
||||
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
|
||||
if (foundSchema) {
|
||||
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
setFormErrors(errors)
|
||||
if (!isEmpty(errors)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSchema({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.target_schema,
|
||||
})
|
||||
|
||||
await createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: {
|
||||
...values,
|
||||
server_name: `${values.wrapper_name}_server`,
|
||||
supabase_target_schema: values.target_schema,
|
||||
},
|
||||
mode: 'schema',
|
||||
tables: [],
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// The error will be handled by the mutation onError callback (toast.error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = isCreatingWrapper || isCreatingSchema
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full" tabIndex={-1}>
|
||||
@@ -272,7 +302,8 @@ export const CreateIcebergWrapperSheet = ({
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
All wrapper tables will be created in the specified target schema.
|
||||
You can query your data from the foreign tables in the specified schema
|
||||
after the wrapper is created.
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
@@ -292,18 +323,21 @@ export const CreateIcebergWrapperSheet = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label_Shadcn_ className="text-foreground-light">
|
||||
Target Schema
|
||||
</Label_Shadcn_>
|
||||
<SchemaSelector
|
||||
portal={false}
|
||||
size="small"
|
||||
selectedSchemaName={values.target_schema}
|
||||
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
|
||||
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
|
||||
<InputField
|
||||
key="target_schema"
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
loading={false}
|
||||
error={formErrors['target_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Be careful not to use an API exposed schema.
|
||||
A new schema will be created. For security purposes, the wrapper tables
|
||||
from the foreign schema cannot be created within an existing schema
|
||||
</p>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
@@ -316,7 +350,7 @@ export const CreateIcebergWrapperSheet = ({
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -325,20 +359,11 @@ export const CreateIcebergWrapperSheet = ({
|
||||
type="primary"
|
||||
form={FORM_ID}
|
||||
htmlType="submit"
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create wrapper
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
<SchemaEditor
|
||||
visible={createSchemaSheetOpen}
|
||||
closePanel={() => setCreateSchemaSheetOpen(false)}
|
||||
onSuccess={(schema) => {
|
||||
setFieldValue('target_schema', schema)
|
||||
setCreateSchemaSheetOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -4,19 +4,18 @@ import { Edit, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import SchemaEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
|
||||
import { invalidateSchemasQuery, useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Label_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Separator,
|
||||
@@ -30,7 +29,6 @@ import InputField from './InputField'
|
||||
import { WrapperMeta } from './Wrappers.types'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import WrapperTableEditor from './WrapperTableEditor'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
export interface CreateWrapperSheetProps {
|
||||
isClosing: boolean
|
||||
@@ -48,6 +46,7 @@ export const CreateWrapperSheet = ({
|
||||
onClose,
|
||||
}: CreateWrapperSheetProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const org = useSelectedOrganization()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
@@ -73,7 +72,7 @@ export const CreateWrapperSheet = ({
|
||||
|
||||
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
|
||||
|
||||
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
|
||||
const { mutateAsync: createFDW, isLoading: isCreatingWrapper } = useFDWCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
|
||||
setNewTables([])
|
||||
@@ -85,8 +84,10 @@ export const CreateWrapperSheet = ({
|
||||
},
|
||||
})
|
||||
|
||||
// prefetch schemas to make sure the schema selector is populated
|
||||
useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString })
|
||||
const { data: schemas } = useSchemasQuery({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
wrapper_name: '',
|
||||
@@ -98,6 +99,8 @@ export const CreateWrapperSheet = ({
|
||||
),
|
||||
}
|
||||
|
||||
const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation()
|
||||
|
||||
const onUpdateTable = (values: any) => {
|
||||
setNewTables((prev) => {
|
||||
// if the new values have tableIndex, we are editing an existing table
|
||||
@@ -121,37 +124,70 @@ export const CreateWrapperSheet = ({
|
||||
if (values.wrapper_name.length === 0) {
|
||||
errors.wrapper_name = 'Please provide a name for your wrapper'
|
||||
}
|
||||
if (selectedMode === 'tables' && newTables.length === 0) {
|
||||
errors.tables = 'Please add at least one table'
|
||||
}
|
||||
if (selectedMode === 'schema' && values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
}
|
||||
if (!isEmpty(errors)) return setFormErrors(errors)
|
||||
|
||||
createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: { ...values, server_name: `${values.wrapper_name}_server` },
|
||||
mode: selectedMode,
|
||||
tables: newTables,
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
if (selectedMode === 'tables') {
|
||||
if (newTables.length === 0) {
|
||||
errors.tables = 'Please provide at least one table'
|
||||
}
|
||||
}
|
||||
if (selectedMode === 'schema') {
|
||||
if (values.source_schema.length === 0) {
|
||||
errors.source_schema = 'Please provide a source schema'
|
||||
}
|
||||
if (values.target_schema.length === 0) {
|
||||
errors.target_schema = 'Please provide an unique target schema'
|
||||
}
|
||||
const foundSchema = schemas?.find((s) => s.name === values.target_schema)
|
||||
if (foundSchema) {
|
||||
errors.target_schema = 'This schema already exists. Please specify a unique schema name.'
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
setFormErrors(errors)
|
||||
if (!isEmpty(errors)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createSchema({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.target_schema,
|
||||
})
|
||||
|
||||
await createFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapperMeta,
|
||||
formState: {
|
||||
...values,
|
||||
server_name: `${values.wrapper_name}_server`,
|
||||
supabase_target_schema: selectedMode === 'schema' ? values.target_schema : undefined,
|
||||
},
|
||||
mode: selectedMode,
|
||||
tables: newTables,
|
||||
sourceSchema: values.source_schema,
|
||||
targetSchema: values.target_schema,
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: 'foreign_data_wrapper_created',
|
||||
properties: {
|
||||
wrapperType: wrapperMeta.label,
|
||||
},
|
||||
groups: {
|
||||
project: project?.ref ?? 'Unknown',
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// The error will be handled by the mutation onError callback (toast.error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = isCreatingWrapper || isCreatingSchema
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full" tabIndex={-1}>
|
||||
@@ -161,7 +197,7 @@ export const CreateWrapperSheet = ({
|
||||
onSubmit={onSubmit}
|
||||
className="flex-grow flex flex-col h-full"
|
||||
>
|
||||
{({ handleReset, values, initialValues, setFieldValue }: any) => {
|
||||
{({ values, initialValues, setFieldValue }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
const onClosePanel = () => {
|
||||
@@ -236,7 +272,7 @@ export const CreateWrapperSheet = ({
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Define tables where the wrapper data will be shown.
|
||||
Create foreign tables to query data from {wrapperMeta.label}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +297,8 @@ export const CreateWrapperSheet = ({
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Specify schema in which the wrapper will create tables.
|
||||
Create all foreign tables from {wrapperMeta.label} in a specified
|
||||
schema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,38 +403,44 @@ export const CreateWrapperSheet = ({
|
||||
<FormSectionLabel>
|
||||
<p>Foreign Schema</p>
|
||||
<p className="text-foreground-light mt-2 w-[90%]">
|
||||
All wrapper tables will be created in the specified target schema.
|
||||
You can query your data from the foreign tables in the specified schema
|
||||
after the wrapper is created.
|
||||
</p>
|
||||
</FormSectionLabel>
|
||||
}
|
||||
>
|
||||
<FormSectionContent loading={false}>
|
||||
{wrapperMeta.sourceSchemaOption && (
|
||||
<div>
|
||||
<InputField
|
||||
key="source_schema"
|
||||
option={wrapperMeta.sourceSchemaOption}
|
||||
loading={false}
|
||||
error={formErrors['source_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
{wrapperMeta.sourceSchemaOption.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{wrapperMeta.sourceSchemaOption &&
|
||||
!wrapperMeta.sourceSchemaOption?.readOnly && (
|
||||
// Hide the field if the source schema is read-only
|
||||
<div>
|
||||
<InputField
|
||||
key="source_schema"
|
||||
option={wrapperMeta.sourceSchemaOption}
|
||||
loading={false}
|
||||
error={formErrors['source_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
{wrapperMeta.sourceSchemaOption.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label_Shadcn_ className="text-foreground-light">
|
||||
Target Schema
|
||||
</Label_Shadcn_>
|
||||
<SchemaSelector
|
||||
portal={false}
|
||||
size="small"
|
||||
selectedSchemaName={values.target_schema}
|
||||
onSelectSchema={(schema) => setFieldValue('target_schema', schema)}
|
||||
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
|
||||
<InputField
|
||||
key="target_schema"
|
||||
option={{
|
||||
name: 'target_schema',
|
||||
label: 'Specify a new schema to create all wrapper tables in',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
}}
|
||||
loading={false}
|
||||
error={formErrors['target_schema']}
|
||||
/>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Be careful not to use an API exposed schema.
|
||||
A new schema will be created. For security purposes, the wrapper tables
|
||||
from the foreign schema cannot be created within an existing schema
|
||||
</p>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
@@ -411,7 +454,7 @@ export const CreateWrapperSheet = ({
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onClosePanel}
|
||||
disabled={isCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -420,20 +463,11 @@ export const CreateWrapperSheet = ({
|
||||
type="primary"
|
||||
form={FORM_ID}
|
||||
htmlType="submit"
|
||||
disabled={isCreating}
|
||||
loading={isCreating}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create wrapper
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
<SchemaEditor
|
||||
visible={createSchemaSheetOpen}
|
||||
closePanel={() => setCreateSchemaSheetOpen(false)}
|
||||
onSuccess={(schema) => {
|
||||
setFieldValue('target_schema', schema)
|
||||
setCreateSchemaSheetOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -107,37 +107,39 @@ const WrapperRow = ({ wrapper }: WrapperRowProps) => {
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 flex-nowrap">
|
||||
<ButtonTooltip
|
||||
disabled={!canManageWrappers}
|
||||
type="default"
|
||||
icon={<Edit strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setEditWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to edit wrappers'
|
||||
: 'Edit wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canManageWrappers}
|
||||
icon={<Trash strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setDeleteWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to delete wrappers'
|
||||
: 'Delete wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TableCell className="flex-nowrap">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
disabled={!canManageWrappers}
|
||||
type="default"
|
||||
icon={<Edit strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setEditWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to edit wrappers'
|
||||
: 'Edit wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!canManageWrappers}
|
||||
icon={<Trash strokeWidth={1.5} />}
|
||||
className="px-1.5"
|
||||
onClick={() => setDeleteWrapperShown(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageWrappers
|
||||
? 'You need additional permissions to delete wrappers'
|
||||
: 'Delete wrapper',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Sheet open={editWrapperShown} onOpenChange={() => setIsClosingEditWrapper(true)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
|
||||
import type { WrapperMeta } from './Wrappers.types'
|
||||
import type { ServerOption, WrapperMeta } from './Wrappers.types'
|
||||
|
||||
export const WRAPPER_HANDLERS = {
|
||||
STRIPE: 'stripe_fdw_handler',
|
||||
@@ -27,6 +27,16 @@ export const WRAPPER_HANDLERS = {
|
||||
ORB: 'wasm_fdw_handler',
|
||||
}
|
||||
|
||||
const SUPABASE_TARGET_SCHEMA_OPTION: ServerOption = {
|
||||
name: 'supabase_target_schema',
|
||||
label: 'Target Schema',
|
||||
required: false,
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
readOnly: true,
|
||||
hidden: true,
|
||||
}
|
||||
|
||||
export const WRAPPERS: WrapperMeta[] = [
|
||||
{
|
||||
name: 'stripe_wrapper',
|
||||
@@ -55,6 +65,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
tables: [
|
||||
@@ -1775,6 +1786,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
canTargetSchema: true,
|
||||
@@ -2224,12 +2236,13 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
encrypted: false,
|
||||
secureEntry: false,
|
||||
},
|
||||
SUPABASE_TARGET_SCHEMA_OPTION,
|
||||
],
|
||||
},
|
||||
canTargetSchema: true,
|
||||
sourceSchemaOption: {
|
||||
name: 'source_schema',
|
||||
label: 'Source Schema',
|
||||
label: 'Namespace',
|
||||
description: 'It should match the namespace of the Iceberg catalog.',
|
||||
required: true,
|
||||
encrypted: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ export const makeValidateRequired = (options: { name: string; required: boolean
|
||||
)
|
||||
const requiredArrayOptions = Array.from(requiredArrayOptionsSet)
|
||||
|
||||
return (values: any) => {
|
||||
return (values: Record<string, any>) => {
|
||||
const errors = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.flatMap(([key, value]) =>
|
||||
|
||||
@@ -48,8 +48,8 @@ const FORM_ID = 'credit-top-up'
|
||||
const FormSchema = z.object({
|
||||
amount: z.coerce
|
||||
.number()
|
||||
.gte(100, 'Amount must be between $100 - $999.')
|
||||
.lte(999, 'Amount must be between $100 - $999.')
|
||||
.gte(100, 'Amount must be between $100 - $2000.')
|
||||
.lte(2000, 'Amount must be between $100 - $2000.')
|
||||
.int('Amount must be a whole number.'),
|
||||
paymentMethod: z.string(),
|
||||
})
|
||||
|
||||
+14
-14
@@ -1,6 +1,8 @@
|
||||
import { OAuthScope } from '@supabase/shared-types/out/constants'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
import { PERMISSIONS_DESCRIPTIONS } from './OAuthApps.constants'
|
||||
|
||||
export interface AuthorizeRequesterDetailsProps {
|
||||
icon: string | null
|
||||
name: string
|
||||
@@ -48,7 +50,7 @@ export const ScopeSection = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const AuthorizeRequesterDetails = ({
|
||||
export const AuthorizeRequesterDetails = ({
|
||||
icon,
|
||||
name,
|
||||
domain,
|
||||
@@ -79,57 +81,57 @@ const AuthorizeRequesterDetails = ({
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<ScopeSection
|
||||
description="access to analytics logs."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS}
|
||||
hasReadScope={scopes.includes(OAuthScope.ANALYTICS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.ANALYTICS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to auth configurations and SSO providers."
|
||||
description={PERMISSIONS_DESCRIPTIONS.AUTH}
|
||||
hasReadScope={scopes.includes(OAuthScope.AUTH_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.AUTH_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DATABASE}
|
||||
hasReadScope={scopes.includes(OAuthScope.DATABASE_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.DATABASE_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to custom domains and vanity subdomains."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DOMAINS}
|
||||
hasReadScope={scopes.includes(OAuthScope.DOMAINS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.DOMAINS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to edge functions."
|
||||
description={PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS}
|
||||
hasReadScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.EDGE_FUNCTIONS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to environments/branches."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ENVIRONMENT}
|
||||
hasReadScope={scopes.includes(OAuthScope.ENVIRONMENT_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.ENVIRONMENT_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to the organization and all its members."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS}
|
||||
hasReadScope={scopes.includes(OAuthScope.ORGANIZATIONS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.ORGANIZATIONS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to metadata, its upgrade status, network restrictions and network bans."
|
||||
description={PERMISSIONS_DESCRIPTIONS.PROJECTS}
|
||||
hasReadScope={scopes.includes(OAuthScope.PROJECTS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.PROJECTS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to PostgREST configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.REST}
|
||||
hasReadScope={scopes.includes(OAuthScope.REST_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.REST_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to API keys, secrets and pgsodium configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.SECRETS}
|
||||
hasReadScope={scopes.includes(OAuthScope.SECRETS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.SECRETS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to storage buckets and files."
|
||||
description={PERMISSIONS_DESCRIPTIONS.STORAGE}
|
||||
hasReadScope={scopes.includes(OAuthScope.STORAGE_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.STORAGE_WRITE)}
|
||||
/>
|
||||
@@ -138,5 +140,3 @@ const AuthorizeRequesterDetails = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorizeRequesterDetails
|
||||
@@ -0,0 +1,15 @@
|
||||
export const PERMISSIONS_DESCRIPTIONS = {
|
||||
ANALYTICS: 'access to analytics logs.',
|
||||
AUTH: 'access to auth configurations and SSO providers.',
|
||||
DATABASE:
|
||||
'access to Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types.',
|
||||
DOMAINS: 'access to custom domains and vanity subdomains.',
|
||||
EDGE_FUNCTIONS: 'access to edge functions.',
|
||||
ENVIRONMENT: 'access to environments/branches.',
|
||||
ORGANIZATIONS: 'access to the organization and all its members.',
|
||||
PROJECTS:
|
||||
"access to creation and deletion of projects, each project's metadata, upgrade status, network restrictions and network bans.",
|
||||
REST: 'access to PostgREST configurations.',
|
||||
SECRETS: 'access to API keys, secrets and pgsodium configurations.',
|
||||
STORAGE: 'access to storage buckets and files.',
|
||||
}
|
||||
@@ -74,6 +74,14 @@ export const SecretRow = ({ secret, appId }: SecretRowProps) => {
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Added {isNew ? 'now' : dayjs(secret.created_at).fromNow()} by {generatedByName}
|
||||
</p>
|
||||
{secret.last_used_at && (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Last used {dayjs(secret.last_used_at).fromNow()}
|
||||
</p>
|
||||
)}
|
||||
{!secret.last_used_at && !isNew && (
|
||||
<p className="text-sm text-foreground-lighter">Never used</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+14
-13
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { PERMISSIONS_DESCRIPTIONS } from '../OAuthApps.constants'
|
||||
|
||||
const ScopeDropdownCheckboxItem = ({
|
||||
children,
|
||||
@@ -66,12 +67,12 @@ const Scope = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row justify-between p-4 border first:rounded-t last:rounded-b"
|
||||
className="flex flex-row justify-between p-4 border first:rounded-t last:rounded-b gap-x-2"
|
||||
key={title}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground text-sm">{title}</span>
|
||||
<span className="text-foreground-light text-xs">{description}</span>
|
||||
<span className="text-foreground-light text-xs capitalize-sentence">{description}</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -109,7 +110,7 @@ export const ScopesPanel = ({
|
||||
<div className="-space-y-px">
|
||||
<Scope
|
||||
title="Analytics"
|
||||
description="Analytics logs."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS}
|
||||
readScopeName={OAuthScope.ANALYTICS_READ}
|
||||
writeScopeName={OAuthScope.ANALYTICS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -117,7 +118,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Auth"
|
||||
description="Auth configurations and SSO providers."
|
||||
description={PERMISSIONS_DESCRIPTIONS.AUTH}
|
||||
readScopeName={OAuthScope.AUTH_READ}
|
||||
writeScopeName={OAuthScope.AUTH_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -125,7 +126,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Database"
|
||||
description="Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DATABASE}
|
||||
readScopeName={OAuthScope.DATABASE_READ}
|
||||
writeScopeName={OAuthScope.DATABASE_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -133,7 +134,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Domains"
|
||||
description="Custom domains and vanity subdomains."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DOMAINS}
|
||||
readScopeName={OAuthScope.DOMAINS_READ}
|
||||
writeScopeName={OAuthScope.DOMAINS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -141,7 +142,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Edge Functions"
|
||||
description="Edge functions."
|
||||
description={PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS}
|
||||
readScopeName={OAuthScope.EDGE_FUNCTIONS_READ}
|
||||
writeScopeName={OAuthScope.EDGE_FUNCTIONS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -149,7 +150,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Environment"
|
||||
description="Environments/branches."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ENVIRONMENT}
|
||||
readScopeName={OAuthScope.ENVIRONMENT_READ}
|
||||
writeScopeName={OAuthScope.ENVIRONMENT_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -157,7 +158,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Organizations"
|
||||
description="Organizations and all its members."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS}
|
||||
readScopeName={OAuthScope.ORGANIZATIONS_READ}
|
||||
writeScopeName={OAuthScope.ORGANIZATIONS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -165,7 +166,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Projects"
|
||||
description="Metadata, upgrade status, network restrictions and network bans."
|
||||
description={PERMISSIONS_DESCRIPTIONS.PROJECTS}
|
||||
readScopeName={OAuthScope.PROJECTS_READ}
|
||||
writeScopeName={OAuthScope.PROJECTS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -173,7 +174,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="REST"
|
||||
description="PostgREST configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.REST}
|
||||
readScopeName={OAuthScope.REST_READ}
|
||||
writeScopeName={OAuthScope.REST_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -181,7 +182,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Secrets"
|
||||
description="API keys, secrets and pgsodium configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.SECRETS}
|
||||
readScopeName={OAuthScope.SECRETS_READ}
|
||||
writeScopeName={OAuthScope.SECRETS_WRITE}
|
||||
scopes={scopes}
|
||||
@@ -189,7 +190,7 @@ export const ScopesPanel = ({
|
||||
/>
|
||||
<Scope
|
||||
title="Storage"
|
||||
description="Storage buckets and files."
|
||||
description={PERMISSIONS_DESCRIPTIONS.STORAGE}
|
||||
readScopeName={OAuthScope.STORAGE_READ}
|
||||
writeScopeName={OAuthScope.STORAGE_WRITE}
|
||||
scopes={scopes}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import {
|
||||
SidePanel,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import AuthorizeRequesterDetails from '../AuthorizeRequesterDetails'
|
||||
import { AuthorizeRequesterDetails } from '../AuthorizeRequesterDetails'
|
||||
import { OAuthSecrets } from '../OAuthSecrets/OAuthSecrets'
|
||||
import { ScopesPanel } from './Scopes'
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { OAuthScope } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CheckCircle2, ChevronRight, ChevronsLeftRight } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-authorization-approve-mutation'
|
||||
import { ApiAuthorizationResponse } from 'data/api-authorization/api-authorization-query'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { ScopeSection } from '../OAuthApps/AuthorizeRequesterDetails'
|
||||
import { PERMISSIONS_DESCRIPTIONS } from '../OAuthApps/OAuthApps.constants'
|
||||
import { ProjectClaimLayout } from './layout'
|
||||
|
||||
export const ProjectClaimConfirm = ({
|
||||
@@ -188,57 +189,57 @@ export const ProjectClaimConfirm = ({
|
||||
>
|
||||
<div>
|
||||
<ScopeSection
|
||||
description="access to analytics logs."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ANALYTICS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.ANALYTICS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.ANALYTICS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to auth configurations and SSO providers."
|
||||
description={PERMISSIONS_DESCRIPTIONS.AUTH}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.AUTH_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.AUTH_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to Postgres configurations, SQL snippets, SSL enforcement configurations and Typescript schema types."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DATABASE}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.DATABASE_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.DATABASE_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to custom domains and vanity subdomains."
|
||||
description={PERMISSIONS_DESCRIPTIONS.DOMAINS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.DOMAINS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.DOMAINS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to edge functions."
|
||||
description={PERMISSIONS_DESCRIPTIONS.EDGE_FUNCTIONS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.EDGE_FUNCTIONS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.EDGE_FUNCTIONS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to environments/branches."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ENVIRONMENT}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.ENVIRONMENT_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.ENVIRONMENT_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to the organization and all its members."
|
||||
description={PERMISSIONS_DESCRIPTIONS.ORGANIZATIONS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.ORGANIZATIONS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.ORGANIZATIONS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to metadata, its upgrade status, network restrictions and network bans."
|
||||
description={PERMISSIONS_DESCRIPTIONS.PROJECTS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.PROJECTS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.PROJECTS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to PostgREST configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.REST}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.REST_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.REST_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to API keys, secrets and pgsodium configurations."
|
||||
description={PERMISSIONS_DESCRIPTIONS.SECRETS}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.SECRETS_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.SECRETS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to storage buckets and files."
|
||||
description={PERMISSIONS_DESCRIPTIONS.STORAGE}
|
||||
hasReadScope={requester.scopes.includes(OAuthScope.STORAGE_READ)}
|
||||
hasWriteScope={requester.scopes.includes(OAuthScope.STORAGE_WRITE)}
|
||||
/>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
/>
|
||||
<div className="flex item-center gap-x-2">
|
||||
<p className="text-foreground-light truncate">{member.primary_email}</p>
|
||||
{member.primary_email === profile?.primary_email && <Badge color="scale">You</Badge>}
|
||||
{member.gotrue_id === profile?.gotrue_id && <Badge color="scale">You</Badge>}
|
||||
</div>
|
||||
|
||||
{(member.metadata as any)?.origin && (
|
||||
|
||||
@@ -54,13 +54,13 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
|
||||
const [[user], otherMembers] = partition(
|
||||
filteredMembers,
|
||||
(m) => m.primary_email === profile?.primary_email
|
||||
(m) => m.gotrue_id === profile?.gotrue_id
|
||||
)
|
||||
const sortedMembers = otherMembers.sort((a, b) =>
|
||||
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
|
||||
)
|
||||
|
||||
const userMember = members.find((m) => m.primary_email === profile?.primary_email)
|
||||
const userMember = members.find((m) => m.gotrue_id === profile?.gotrue_id)
|
||||
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
|
||||
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScaffoldSectionContent,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { InviteMemberButton } from './InviteMemberButton'
|
||||
import MembersView from './MembersView'
|
||||
@@ -32,6 +33,7 @@ export const TeamSettings = () => {
|
||||
placeholder="Filter members"
|
||||
/>
|
||||
<ScaffoldActionsGroup className="w-full md:w-auto">
|
||||
<DocsButton href="https://supabase.com/docs/guides/platform/access-control" />
|
||||
<InviteMemberButton />
|
||||
</ScaffoldActionsGroup>
|
||||
</ScaffoldActionsContainer>
|
||||
|
||||
+6
-3
@@ -90,10 +90,11 @@ export const UpdateRolesConfirmationModal = ({
|
||||
})
|
||||
toast.success(`Successfully updated role for ${member.username}`)
|
||||
onClose(true)
|
||||
return
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update role: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
return toast.error(`Failed to update role: ${error.message}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +131,10 @@ export const UpdateRolesConfirmationModal = ({
|
||||
toast.success(`Successfully updated role for ${member.username}`)
|
||||
onClose(true)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update role: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
return toast.error(`Failed to update role: ${error.message}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,8 +195,8 @@ const Usage = () => {
|
||||
title="Usage filtered by project"
|
||||
description={
|
||||
<div>
|
||||
You are currently viewing usage for the "
|
||||
{selectedProject?.name || selectedProjectRef}" project. Supabase uses{' '}
|
||||
You are currently viewing usage for the
|
||||
{selectedProject?.name || selectedProjectRef} project. Supabase uses{' '}
|
||||
<Link
|
||||
href="/docs/guides/platform/billing-on-supabase#organization-based-billing"
|
||||
target="_blank"
|
||||
|
||||
+1
-1
@@ -224,7 +224,7 @@ const AttributeUsage = ({
|
||||
</div>
|
||||
) : chartData.length > 0 && notAllValuesZero ? (
|
||||
<UsageBarChart
|
||||
name={`${attribute.chartPrefix || ''}${attribute.name}`}
|
||||
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
|
||||
unit={attribute.unit}
|
||||
attributes={attribute.attributes}
|
||||
data={chartData}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export const RealtimeFilterPopover = ({ config, onChangeConfig }: RealtimeFilter
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-[365px]" align="start">
|
||||
<PopoverContent_Shadcn_ className="p-0 w-[365px]" align="start" portal={true}>
|
||||
<div className="border-b border-overlay text-xs px-4 py-3 text-foreground">
|
||||
Listen to event types
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
@@ -11,6 +12,7 @@ import { ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
|
||||
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
|
||||
import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation'
|
||||
import {
|
||||
@@ -50,8 +52,21 @@ export const RealtimeSettings = () => {
|
||||
projectRef,
|
||||
})
|
||||
|
||||
const { data: policies } = useDatabasePoliciesQuery({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
schema: 'realtime',
|
||||
})
|
||||
|
||||
const isUsageBillingEnabled = organization?.usage_billing_enabled
|
||||
|
||||
// Check if RLS policies exist for realtime.messages table
|
||||
const realtimeMessagesPolicies = policies?.filter(
|
||||
(policy) => policy.schema === 'realtime' && policy.table === 'messages'
|
||||
)
|
||||
const hasRealtimeMessagesPolicies =
|
||||
realtimeMessagesPolicies && realtimeMessagesPolicies.length > 0
|
||||
|
||||
const { mutate: updateRealtimeConfig, isLoading: isUpdatingConfig } =
|
||||
useRealtimeConfigurationUpdateMutation({
|
||||
onSuccess: () => {
|
||||
@@ -83,6 +98,9 @@ export const RealtimeSettings = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { allow_public } = form.watch()
|
||||
const isSettingToPrivate = !data?.private_only && !allow_public
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (data) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
updateRealtimeConfig({
|
||||
@@ -132,6 +150,30 @@ export const RealtimeSettings = () => {
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
{!hasRealtimeMessagesPolicies && !allow_public && (
|
||||
<Admonition
|
||||
showIcon={false}
|
||||
type="warning"
|
||||
title="No Realtime RLS policies found"
|
||||
description={
|
||||
<>
|
||||
<p className="prose max-w-full text-sm">
|
||||
Private mode is {isSettingToPrivate ? 'being ' : ''}
|
||||
enabled, but no RLS policies exists on the{' '}
|
||||
<code className="text-xs">realtime.messages</code> table. No
|
||||
messages will be received by users.
|
||||
</p>
|
||||
|
||||
<Button asChild type="default" className="mt-2">
|
||||
<Link href={`/project/${projectRef}/realtime/policies`}>
|
||||
Create policy
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
* This component acts as a bridge between the data-fetching logic and the
|
||||
* presentational chart component.
|
||||
*/
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useChartData } from 'hooks/useChartData'
|
||||
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import Link from 'next/link'
|
||||
import { Button, cn } from 'ui'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
const ReportChart = ({
|
||||
chart,
|
||||
@@ -25,9 +28,7 @@ const ReportChart = ({
|
||||
interval,
|
||||
updateDateRange,
|
||||
functionIds,
|
||||
orgPlanId,
|
||||
isLoading,
|
||||
availableIn,
|
||||
className,
|
||||
}: {
|
||||
chart: any
|
||||
@@ -36,15 +37,17 @@ const ReportChart = ({
|
||||
interval: string
|
||||
updateDateRange: UpdateDateRange
|
||||
functionIds?: string[]
|
||||
orgPlanId?: string
|
||||
isLoading?: boolean
|
||||
availableIn?: string[]
|
||||
className?: string
|
||||
}) => {
|
||||
const org = useSelectedOrganization()
|
||||
const { plan: orgPlan } = useCurrentOrgPlan()
|
||||
const orgPlanId = orgPlan?.id
|
||||
|
||||
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
|
||||
const isAvailable =
|
||||
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
|
||||
|
||||
const canFetch = orgPlanId !== undefined
|
||||
const {
|
||||
data,
|
||||
@@ -114,7 +117,9 @@ const ReportChart = ({
|
||||
<h2 className="">{chart.label}</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This chart is available from{' '}
|
||||
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>{' '}
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>{' '}
|
||||
plan and above
|
||||
</p>
|
||||
<Button
|
||||
@@ -125,7 +130,9 @@ const ReportChart = ({
|
||||
>
|
||||
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=reports`}>
|
||||
Upgrade to{' '}
|
||||
<span className="capitalize">{!!availableIn?.length ? availableIn[0] : 'Pro'}</span>
|
||||
<span className="capitalize">
|
||||
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ const RoleImpersonationPopover = ({
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
portal={portal}
|
||||
className="p-0 w-full overflow-hidden"
|
||||
className="p-0 w-[592px] overflow-hidden"
|
||||
side="bottom"
|
||||
align={align}
|
||||
>
|
||||
|
||||
+1
-3
@@ -131,9 +131,7 @@ const RoleImpersonationSelector = ({
|
||||
{selectedOption === 'authenticated' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className={cn('py-5', padded && 'px-5')}>
|
||||
<UserImpersonationSelector />
|
||||
</div>
|
||||
<UserImpersonationSelector />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
+244
-244
@@ -1,11 +1,12 @@
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { ChevronDown, ExternalLink, User as IconUser, Loader2, Search, X } from 'lucide-react'
|
||||
import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
|
||||
import { useCustomAccessTokenHookDetails } from 'hooks/misc/useCustomAccessTokenHookDetails'
|
||||
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
|
||||
@@ -13,13 +14,18 @@ import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-stat
|
||||
import { ResponseError } from 'types'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
DropdownMenuSeparator,
|
||||
Input,
|
||||
Switch,
|
||||
ScrollArea,
|
||||
cn,
|
||||
Switch,
|
||||
Tabs_Shadcn_,
|
||||
TabsContent_Shadcn_,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils'
|
||||
@@ -33,13 +39,13 @@ const UserImpersonationSelector = () => {
|
||||
const [additionalClaims, setAdditionalClaims] = useState('')
|
||||
|
||||
const { id: tableId } = useParams()
|
||||
const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user')
|
||||
|
||||
const [previousSearches, setPreviousSearches] = useLocalStorage<User[]>(
|
||||
LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!),
|
||||
[]
|
||||
)
|
||||
|
||||
const [showExternalAuth, setShowExternalAuth] = useState(false)
|
||||
const state = useRoleImpersonationStateSnapshot()
|
||||
const debouncedSearchText = useDebounce(searchText, 300)
|
||||
|
||||
@@ -146,7 +152,6 @@ const UserImpersonationSelector = () => {
|
||||
|
||||
function stopImpersonating() {
|
||||
state.setRole(undefined)
|
||||
setShowExternalAuth(false) // Reset external auth impersonation when stopping impersonation
|
||||
}
|
||||
|
||||
function toggleAalState() {
|
||||
@@ -167,251 +172,246 @@ const UserImpersonationSelector = () => {
|
||||
setPreviousSearches([])
|
||||
}
|
||||
|
||||
// Select a previous search
|
||||
function selectPreviousSearch(prevUser: User) {
|
||||
setSearchText(prevUser.email ?? prevUser.phone ?? prevUser.id ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-foreground text-sm">
|
||||
{displayName ? `Impersonating ${displayName}` : 'Impersonate a User'}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{!impersonatingUser && !isExternalAuthImpersonating
|
||||
? "Select a user to respect your database's Row-Level Security policies for that particular user."
|
||||
: "Results will respect your database's Row-Level Security policies for this user."}
|
||||
</p>
|
||||
<>
|
||||
<div className="px-5 py-3">
|
||||
<h2 className="text-foreground text-sm">
|
||||
{displayName ? `Impersonating ${displayName}` : 'Impersonate a User'}
|
||||
</h2>
|
||||
<p className="text-sm text-foreground-light">
|
||||
{!impersonatingUser && !isExternalAuthImpersonating
|
||||
? "Select a user to respect your database's Row-Level Security policies for that particular user."
|
||||
: "Results will respect your database's Row-Level Security policies for this user."}
|
||||
</p>
|
||||
|
||||
{impersonatingUser && (
|
||||
<UserImpersonatingRow
|
||||
user={impersonatingUser}
|
||||
onClick={stopImpersonating}
|
||||
isImpersonating={true}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
{isExternalAuthImpersonating && (
|
||||
<ExternalAuthImpersonatingRow
|
||||
sub={state.role.externalAuth.sub}
|
||||
onClick={stopImpersonating}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!impersonatingUser && !isExternalAuthImpersonating && (
|
||||
<Tabs_Shadcn_ value={selectedTab} onValueChange={(value: any) => setSelectedTab(value)}>
|
||||
<TabsList_Shadcn_ className="gap-x-3">
|
||||
<TabsTrigger_Shadcn_ value="user">Project user</TabsTrigger_Shadcn_>
|
||||
<TabsTrigger_Shadcn_ value="external" className="gap-x-1.5">
|
||||
External user
|
||||
<InfoTooltip side="bottom" className="flex flex-col gap-1 max-w-96">
|
||||
Test RLS policies with external auth providers like Clerk or Auth0 by providing a
|
||||
user ID and optional claims.
|
||||
</InfoTooltip>
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
|
||||
<TabsContent_Shadcn_ value="user">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="table-editor-search border-none"
|
||||
icon={
|
||||
isSearching ? (
|
||||
<Loader2
|
||||
className="animate-spin text-foreground-lighter"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
placeholder="Search by id, email, phone, or name..."
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
actions={
|
||||
searchText && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
className="px-1"
|
||||
onClick={() => setSearchText('')}
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-foreground-light">Loading users...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
||||
|
||||
{isSuccess &&
|
||||
(users.length > 0 ? (
|
||||
<div>
|
||||
<ul className="divide-y max-h-[150px] overflow-y-scroll" role="list">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} role="listitem">
|
||||
<UserRow
|
||||
user={user}
|
||||
onClick={impersonateUser}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<p className="text-foreground-light text-xs" role="status">
|
||||
No users found
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<>
|
||||
{previousSearches.length > 0 && (
|
||||
<div>
|
||||
{previousSearches.length > 0 ? (
|
||||
<>
|
||||
<Collapsible_Shadcn_ className="relative">
|
||||
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Recents
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
|
||||
<CollapsibleContent_Shadcn_ className="mt-1 flex flex-col gap-y-4">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
className="absolute right-0 top-0 py-2 hover:bg-muted flex items-center text"
|
||||
onClick={clearSearchHistory}
|
||||
>
|
||||
<span className="flex items-center">Clear</span>
|
||||
</Button>
|
||||
<ScrollArea
|
||||
className={cn(previousSearches.length > 3 ? 'h-36' : 'h-auto')}
|
||||
>
|
||||
<ul className="grid gap-2 ">
|
||||
{previousSearches.map((search) => (
|
||||
<li key={search.id}>
|
||||
<UserRow user={search} onClick={impersonateUser} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No recent searches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
|
||||
<TabsContent_Shadcn_ value="external">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Input
|
||||
size="small"
|
||||
layout="horizontal"
|
||||
label="External User ID"
|
||||
descriptionText="The user ID from your external auth provider"
|
||||
placeholder="e.g. user_abc123"
|
||||
value={externalUserId}
|
||||
onChange={(e) => setExternalUserId(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
layout="horizontal"
|
||||
label="Additional Claims (JSON)"
|
||||
descriptionText="Optional: Add custom claims like org_id or roles"
|
||||
placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
|
||||
value={additionalClaims}
|
||||
onChange={(e) => setAdditionalClaims(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!externalUserId}
|
||||
onClick={impersonateExternalUser}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */}
|
||||
{!impersonatingUser && !isExternalAuthImpersonating ? (
|
||||
<div className="flex flex-col gap-2 mt-2 relative">
|
||||
<Input
|
||||
className="table-editor-search border-none"
|
||||
icon={
|
||||
isSearching ? (
|
||||
<Loader2
|
||||
className="animate-spin text-foreground-lighter"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
placeholder="Search by id, email, phone, or name..."
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
size="small"
|
||||
actions={
|
||||
searchText && (
|
||||
<Button size="tiny" type="text" className="px-1" onClick={() => setSearchText('')}>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapsible_Shadcn_>
|
||||
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Advanced options
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="mt-1 flex flex-col gap-y-4">
|
||||
<div className="flex flex-row items-center gap-x-4 text-sm text-foreground-light">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<h3>MFA assurance level</h3>
|
||||
<InfoTooltip side="top" className="flex flex-col gap-1 max-w-96">
|
||||
<p>
|
||||
AAL1 verifies users via standard login methods, while AAL2 adds a second
|
||||
authentication factor.
|
||||
<br />
|
||||
If you're not using MFA, you can leave this on AAL1.
|
||||
</p>
|
||||
<a
|
||||
href="/docs/guides/auth/auth-mfa"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-x-1 opacity-50 hover:opacity-100 transition"
|
||||
>
|
||||
Learn more about MFA <ExternalLink size={14} strokeWidth={2} />
|
||||
</a>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2 text-xs font-bold">
|
||||
<p className={aal === 'aal1' ? undefined : 'text-foreground-lighter'}>AAL1</p>
|
||||
<Switch checked={aal === 'aal2'} onCheckedChange={toggleAalState} />
|
||||
<p className={aal === 'aal2' ? undefined : 'text-foreground-lighter'}>AAL2</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<h3 className="text-sm text-foreground-light">External Auth Impersonation</h3>
|
||||
<InfoTooltip side="top" className="flex flex-col gap-1 max-w-96">
|
||||
<p>
|
||||
Test RLS policies with external auth providers like Clerk or Auth0 by
|
||||
providing a user ID and optional claims.
|
||||
</p>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<Switch checked={showExternalAuth} onCheckedChange={setShowExternalAuth} />
|
||||
<p className="text-xs text-foreground-light">
|
||||
Enable external auth impersonation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showExternalAuth && (
|
||||
<div className="flex flex-col gap-y-4 mt-2 border rounded-md p-4 bg-surface-100">
|
||||
<Input
|
||||
className="border-strong"
|
||||
label="External User ID"
|
||||
descriptionText="The user ID from your external auth provider"
|
||||
placeholder="e.g. user_abc123"
|
||||
value={externalUserId}
|
||||
onChange={(e) => setExternalUserId(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Input
|
||||
className="border-strong"
|
||||
label="Additional Claims (JSON)"
|
||||
descriptionText="Optional: Add custom claims like org_id or roles"
|
||||
placeholder='e.g. {"app_metadata": {"org_id": "org_456"}}'
|
||||
value={additionalClaims}
|
||||
onChange={(e) => setAdditionalClaims(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!externalUserId}
|
||||
onClick={impersonateExternalUser}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
|
||||
{!showExternalAuth && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-foreground-light">Loading users...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
||||
|
||||
{isSuccess &&
|
||||
(users.length > 0 ? (
|
||||
<div>
|
||||
<ul className="divide-y max-h-[150px] overflow-y-scroll" role="list">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} role="listitem">
|
||||
<UserRow
|
||||
user={user}
|
||||
onClick={impersonateUser}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<p className="text-foreground-light text-xs" role="status">
|
||||
No users found
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{previousSearches.length > 0 && (
|
||||
<div className="border-t">
|
||||
{previousSearches.length > 0 ? (
|
||||
<>
|
||||
<Collapsible_Shadcn_ className="mt-2 relative">
|
||||
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Recents
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
|
||||
<CollapsibleContent_Shadcn_ className="mt-1 flex flex-col gap-y-4">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
className="absolute right-0 top-0 py-2 hover:bg-muted flex items-center text"
|
||||
onClick={clearSearchHistory}
|
||||
>
|
||||
<span className="flex items-center">Clear</span>
|
||||
</Button>
|
||||
<ScrollArea
|
||||
className={cn(previousSearches.length > 3 ? 'h-36' : 'h-auto')}
|
||||
>
|
||||
<ul className="grid gap-2 ">
|
||||
{previousSearches.map((search) => (
|
||||
<li key={search.id}>
|
||||
<UserRow user={search} onClick={impersonateUser} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">No recent searches</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{impersonatingUser && (
|
||||
<UserImpersonatingRow
|
||||
user={impersonatingUser}
|
||||
onClick={stopImpersonating}
|
||||
isImpersonating={true}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
{isExternalAuthImpersonating && (
|
||||
<ExternalAuthImpersonatingRow
|
||||
sub={state.role.externalAuth.sub}
|
||||
onClick={stopImpersonating}
|
||||
aal={aal}
|
||||
isLoading={isImpersonateLoading}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenuSeparator className="m-0" />
|
||||
<div className="px-5 py-2 flex flex-col gap-2 relative">
|
||||
<Collapsible_Shadcn_>
|
||||
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
|
||||
<div className="flex items-center gap-x-1 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
Advanced options
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="mt-1 flex flex-col gap-y-4">
|
||||
<div className="flex flex-row items-center gap-x-4 text-sm text-foreground-light">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<h3>MFA assurance level</h3>
|
||||
<InfoTooltip side="top" className="max-w-96">
|
||||
AAL1 verifies users via standard login methods, while AAL2 adds a second
|
||||
authentication factor. If you're not using MFA, you can leave this on AAL1.
|
||||
Learn more about MFA{' '}
|
||||
<InlineLink href="https://supabase.com/docs/guides/auth/auth-mfa">
|
||||
here
|
||||
</InlineLink>
|
||||
.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-x-2 text-xs font-bold">
|
||||
<p className={aal === 'aal1' ? undefined : 'text-foreground-lighter'}>AAL1</p>
|
||||
<Switch checked={aal === 'aal2'} onCheckedChange={toggleAalState} />
|
||||
<p className={aal === 'aal2' ? undefined : 'text-foreground-lighter'}>AAL2</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ const InfrastructureActivity = () => {
|
||||
</div>
|
||||
) : chartData.length ? (
|
||||
<UsageBarChart
|
||||
name={`${attribute.chartPrefix || ''}${attribute.name}`}
|
||||
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
|
||||
unit={attribute.unit}
|
||||
attributes={attribute.attributes}
|
||||
data={chartData}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface LogSelectionProps {
|
||||
onClose: () => void
|
||||
queryType?: QueryType
|
||||
projectRef: string
|
||||
collectionName?: string
|
||||
isLoading: boolean
|
||||
error?: string | object
|
||||
}
|
||||
@@ -41,8 +40,6 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
|
||||
if (!log) return <LogDetailEmptyState />
|
||||
|
||||
switch (queryType) {
|
||||
// case 'warehouse':
|
||||
// return <WarehouseSelectionRenderer log={log} />
|
||||
case 'api':
|
||||
const status = log?.metadata?.[0]?.response?.[0]?.status_code
|
||||
const method = log?.metadata?.[0]?.request?.[0]?.method
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { cn } from 'ui'
|
||||
import { LOGS_TAILWIND_CLASSES } from '../Logs.constants'
|
||||
import { jsonSyntaxHighlight, SelectionDetailedRow } from '../LogsFormatters'
|
||||
|
||||
const DetailedJsonRow = ({
|
||||
label,
|
||||
value,
|
||||
code,
|
||||
}: {
|
||||
label: string
|
||||
value: Object
|
||||
code?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid grid-cols-12">
|
||||
<span className="text-foreground-lighter text-sm col-span-3">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground text-sm col-span-9 overflow-x-auto',
|
||||
code && 'text-xs font-mono'
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: jsonSyntaxHighlight(value),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WarehouseSelectionRenderer = ({ log }: any) => {
|
||||
return (
|
||||
<div className="overflow-hidden overflow-x-auto space-y-6">
|
||||
{Object.entries(log).map(([key, value], index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${key}-${index}`}
|
||||
className={`${LOGS_TAILWIND_CLASSES.log_selection_x_padding}`}
|
||||
>
|
||||
{value && typeof value === 'object' ? (
|
||||
<DetailedJsonRow label={key} value={value} />
|
||||
) : (
|
||||
<SelectionDetailedRow label={key} value={value === null ? 'NULL ' : String(value)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,8 +49,6 @@ interface Props {
|
||||
onSave?: () => void
|
||||
hasEditorValue?: boolean
|
||||
className?: string
|
||||
collectionName?: string // Used for warehouse queries
|
||||
warehouseError?: string
|
||||
EmptyState?: ReactNode
|
||||
showHeader?: boolean
|
||||
showHistogramToggle?: boolean
|
||||
@@ -79,7 +77,6 @@ const LogTable = ({
|
||||
onSave,
|
||||
hasEditorValue,
|
||||
className,
|
||||
collectionName,
|
||||
EmptyState,
|
||||
showHeader = true,
|
||||
showHistogramToggle = true,
|
||||
@@ -154,9 +151,6 @@ const LogTable = ({
|
||||
columns
|
||||
} else {
|
||||
switch (queryType) {
|
||||
case 'warehouse':
|
||||
columns = DEFAULT_COLUMNS
|
||||
break
|
||||
case 'api':
|
||||
columns = DatabaseApiColumnRender
|
||||
break
|
||||
@@ -460,7 +454,6 @@ const LogTable = ({
|
||||
log={selectedLog}
|
||||
error={selectedLogError}
|
||||
queryType={queryType}
|
||||
collectionName={collectionName}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
|
||||
@@ -369,7 +369,6 @@ export enum LogsTableName {
|
||||
POSTGREST = 'postgrest_logs',
|
||||
SUPAVISOR = 'supavisor_logs',
|
||||
PGBOUNCER = 'pgbouncer_logs',
|
||||
WAREHOUSE = 'warehouse_logs',
|
||||
PG_UPGRADE = 'pg_upgrade_logs',
|
||||
PG_CRON = 'pg_cron_logs',
|
||||
}
|
||||
@@ -384,7 +383,6 @@ export const LOGS_TABLES = {
|
||||
storage: LogsTableName.STORAGE,
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
supavisor: LogsTableName.SUPAVISOR,
|
||||
warehouse: LogsTableName.WAREHOUSE,
|
||||
pg_upgrade: LogsTableName.PG_UPGRADE,
|
||||
pg_cron: LogsTableName.POSTGRES,
|
||||
pgbouncer: LogsTableName.PGBOUNCER,
|
||||
@@ -401,7 +399,6 @@ export const LOGS_SOURCE_DESCRIPTION = {
|
||||
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
|
||||
[LogsTableName.SUPAVISOR]: 'Shared connection pooler logs for PostgreSQL',
|
||||
[LogsTableName.PGBOUNCER]: 'Dedicated connection pooler for PostgreSQL',
|
||||
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
|
||||
[LogsTableName.PG_UPGRADE]: 'Logs generated by the Postgres version upgrade process',
|
||||
[LogsTableName.PG_CRON]: 'Postgres logs from pg_cron cron jobs',
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ export type QueryType =
|
||||
| 'storage'
|
||||
| 'supavisor'
|
||||
| 'postgrest'
|
||||
| 'warehouse'
|
||||
| 'pg_upgrade'
|
||||
| 'pg_cron'
|
||||
| 'pgbouncer'
|
||||
@@ -145,9 +144,3 @@ export interface DatetimeHelper {
|
||||
disabled?: boolean
|
||||
availableIn?: PlanId[]
|
||||
}
|
||||
|
||||
export interface WarehouseCollection {
|
||||
name: string
|
||||
id: number
|
||||
token: string
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { ReactNode, useState } from 'react'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { logConstants } from 'shared-data'
|
||||
import {
|
||||
Badge,
|
||||
@@ -28,23 +27,16 @@ import {
|
||||
LogsTableName,
|
||||
} from './Logs.constants'
|
||||
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
|
||||
import { LogsWarning, LogTemplate, WarehouseCollection } from './Logs.types'
|
||||
import { WarehouseQueryTemplate } from './Warehouse.utils'
|
||||
import { LogsWarning, LogTemplate } from './Logs.types'
|
||||
|
||||
export type SourceType = 'logs' | 'warehouse'
|
||||
export interface LogsQueryPanelProps {
|
||||
templates?: LogTemplate[]
|
||||
warehouseTemplates?: WarehouseQueryTemplate[]
|
||||
defaultFrom: string
|
||||
defaultTo: string
|
||||
warnings: LogsWarning[]
|
||||
warehouseCollections: WarehouseCollection[]
|
||||
dataSource: SourceType
|
||||
onSelectTemplate: (template: LogTemplate) => void
|
||||
onSelectWarehouseTemplate: (template: WarehouseQueryTemplate) => void
|
||||
onSelectSource: (source: string) => void
|
||||
onDateChange: (value: DatePickerValue) => void
|
||||
onDataSourceChange: (sourceType: SourceType) => void
|
||||
}
|
||||
|
||||
function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {
|
||||
@@ -58,17 +50,12 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
|
||||
|
||||
const LogsQueryPanel = ({
|
||||
templates = [],
|
||||
warehouseTemplates = [],
|
||||
defaultFrom,
|
||||
defaultTo,
|
||||
warnings,
|
||||
warehouseCollections,
|
||||
dataSource,
|
||||
onSelectTemplate,
|
||||
onSelectWarehouseTemplate,
|
||||
onSelectSource,
|
||||
onDateChange,
|
||||
onDataSourceChange,
|
||||
}: LogsQueryPanelProps) => {
|
||||
const [showReference, setShowReference] = useState(false)
|
||||
|
||||
@@ -78,15 +65,12 @@ const LogsQueryPanel = ({
|
||||
projectEdgeFunctionAll: edgeFunctionsEnabled,
|
||||
} = useIsFeatureEnabled(['project_auth:all', 'project_storage:all', 'project_edge_function:all'])
|
||||
|
||||
const warehouseEnabled = useFlag('warehouse')
|
||||
|
||||
const logsTableNames = Object.entries(LogsTableName)
|
||||
.filter(([key]) => {
|
||||
if (key === 'AUTH') return authEnabled
|
||||
if (key === 'STORAGE') return storageEnabled
|
||||
if (key === 'FN_EDGE') return edgeFunctionsEnabled
|
||||
if (key === 'PG_CRON') return false
|
||||
if (key === 'WAREHOUSE') return false
|
||||
return true
|
||||
})
|
||||
.map(([, value]) => value)
|
||||
@@ -117,90 +101,31 @@ const LogsQueryPanel = ({
|
||||
<div className="flex w-full items-center justify-between px-4 md:px-5 py-2 overflow-x-scroll no-scrollbar">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-x-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{warehouseEnabled && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Data source{' '}
|
||||
<span className="ml-2 font-mono opacity-50">
|
||||
{dataSource === 'warehouse' ? 'collections' : 'logs'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuItem onClick={() => onDataSourceChange('logs')}>
|
||||
<DropdownMenuItemContent name="Logs" desc="Logs for all Supabase products" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDataSourceChange('warehouse')}>
|
||||
<DropdownMenuItemContent
|
||||
name={
|
||||
<span>
|
||||
Collections <Badge variant="warning">NEW</Badge>
|
||||
</span>
|
||||
}
|
||||
desc="Query your collections"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'warehouse' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Templates
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-96 overflow-auto" side="bottom" align="start">
|
||||
{warehouseTemplates.map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.name}
|
||||
onClick={() => onSelectWarehouseTemplate(template)}
|
||||
>
|
||||
<DropdownMenuItemContent name={template.name} desc={template.description} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{warehouseCollections.length === 0 && (
|
||||
<DropdownMenuItem className="hover:bg-transparent cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Insert source
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[70vh] overflow-auto"
|
||||
>
|
||||
{logsTableNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((source) => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
|
||||
<DropdownMenuItemContent
|
||||
name="No collections found"
|
||||
desc="You can create collections in the left sidebar."
|
||||
name={source}
|
||||
desc={LOGS_SOURCE_DESCRIPTION[source]}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{dataSource === 'logs' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
Insert source
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[70vh] overflow-auto"
|
||||
>
|
||||
{dataSource === 'logs' &&
|
||||
logsTableNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((source) => (
|
||||
<DropdownMenuItem key={source} onClick={() => onSelectSource(source)}>
|
||||
<DropdownMenuItemContent
|
||||
name={source}
|
||||
desc={LOGS_SOURCE_DESCRIPTION[source]}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'logs' && IS_PLATFORM && (
|
||||
{IS_PLATFORM && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown />}>
|
||||
@@ -222,16 +147,14 @@ const LogsQueryPanel = ({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{dataSource === 'logs' && (
|
||||
<LogsDatePicker
|
||||
value={selectedDatePickerValue}
|
||||
onSubmit={(value) => {
|
||||
setSelectedDatePickerValue(value)
|
||||
onDateChange(value)
|
||||
}}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
)}
|
||||
<LogsDatePicker
|
||||
value={selectedDatePickerValue}
|
||||
onSubmit={(value) => {
|
||||
setSelectedDatePickerValue(value)
|
||||
onDateChange(value)
|
||||
}}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-testid="log-explorer-warnings"
|
||||
@@ -261,93 +184,92 @@ const LogsQueryPanel = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{dataSource === 'logs' && (
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<X />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
asChild // ?: we don't want a button inside a button
|
||||
type="text"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<BookOpen />}
|
||||
className="px-2"
|
||||
>
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline -translate-y-[2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="edge_logs"
|
||||
listClassNames="px-2"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<X />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
<Button
|
||||
asChild // ?: we don't want a button inside a button
|
||||
type="text"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<BookOpen />}
|
||||
className="px-2"
|
||||
>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<Tabs.Panel
|
||||
key={schema.reference}
|
||||
id={schema.reference}
|
||||
label={schema.name}
|
||||
className="px-4 pb-4"
|
||||
<span>Field Reference</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform
|
||||
the necessary{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand"
|
||||
>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs !p-2" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs !p-2">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={schema.fields
|
||||
.sort((a: any, b: any) => a.path - b.path)
|
||||
.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
unnesting joins
|
||||
<ExternalLink
|
||||
size="14"
|
||||
className="ml-1 inline -translate-y-[2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</SidePanel>
|
||||
)}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="edge_logs"
|
||||
listClassNames="px-2"
|
||||
>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<Tabs.Panel
|
||||
key={schema.reference}
|
||||
id={schema.reference}
|
||||
label={schema.name}
|
||||
className="px-4 pb-4"
|
||||
>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs !p-2" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs !p-2">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={schema.fields
|
||||
.sort((a: any, b: any) => a.path - b.path)
|
||||
.map((field) => (
|
||||
<Field key={field.path} field={field} />
|
||||
))}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export type WarehouseQueryTemplate = {
|
||||
query: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function createWarehouseQueryTemplates(
|
||||
collections: { name: string }[]
|
||||
): WarehouseQueryTemplate[] {
|
||||
return collections.flatMap((collection) => [
|
||||
{
|
||||
query: `select count(*) as event_count from \`${collection.name}\`
|
||||
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)`,
|
||||
name: `${collection.name} - Daily Event Count`,
|
||||
description: `Count events in the last 24 hours from ${collection.name} collection`,
|
||||
},
|
||||
{
|
||||
query: `select id, timestamp, event_message from \`${collection.name}\`
|
||||
where timestamp > timestamp_sub(current_timestamp(), interval 1 day)
|
||||
and event_message like '%YOUR_TEXT_HERE%'
|
||||
order by timestamp desc limit 10`,
|
||||
name: `${collection.name} - Filter by text`,
|
||||
description: `Select last 10 events from ${collection.name} collection`,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -223,8 +223,6 @@ const ProjectLinks = () => {
|
||||
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
|
||||
const { securityLints, errorLints } = useLints()
|
||||
|
||||
const showWarehouse = useFlag('warehouse')
|
||||
|
||||
const activeRoute = router.pathname.split('/')[3]
|
||||
|
||||
const {
|
||||
@@ -310,14 +308,11 @@ const ProjectLinks = () => {
|
||||
</div>
|
||||
)
|
||||
} else if (route.key === 'logs') {
|
||||
// TODO: Undo this when warehouse flag is removed
|
||||
const label = showWarehouse ? 'Logs & Analytics' : route.label
|
||||
const newRoute = { ...route, label }
|
||||
return (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={newRoute}
|
||||
active={activeRoute === newRoute.key}
|
||||
route={route}
|
||||
active={activeRoute === route.key}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -60,10 +60,13 @@ const ConfirmResetCodeForm = ({ email }: { email: string }) => {
|
||||
if (user?.factors?.length) {
|
||||
await router.push({
|
||||
pathname: '/forgot-password-mfa',
|
||||
query: { returnTo: '/reset-password' },
|
||||
query: router.query,
|
||||
})
|
||||
} else {
|
||||
await router.push('reset-password')
|
||||
await router.push({
|
||||
pathname: '/reset-password',
|
||||
query: router.query,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { auth } from 'lib/gotrue'
|
||||
import { auth, getReturnToPath } from 'lib/gotrue'
|
||||
import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
@@ -67,7 +67,7 @@ const ResetPasswordForm = () => {
|
||||
|
||||
// logout all other sessions after changing password
|
||||
await auth.signOut({ scope: 'others' })
|
||||
await router.push('/organizations')
|
||||
await router.push(getReturnToPath('/organizations'))
|
||||
} else {
|
||||
toast.error(`Failed to save password: ${error.message}`, { id: toastId })
|
||||
if (!WHITELIST_ERRORS.some((e) => error.message.includes(e))) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthError } from '@supabase/supabase-js'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
@@ -28,10 +28,22 @@ const SignInForm = () => {
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
const [returnTo, setReturnTo] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only call getReturnToPath after component mounts client-side
|
||||
setReturnTo(getReturnToPath())
|
||||
}, [])
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const { mutate: addLoginEvent } = useAddLoginEvent()
|
||||
|
||||
let forgotPasswordUrl = `/forgot-password`
|
||||
|
||||
if (returnTo && !returnTo.includes('/forgot-password')) {
|
||||
forgotPasswordUrl = `${forgotPasswordUrl}?returnTo=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
const onSignIn = async ({ email, password }: { email: string; password: string }) => {
|
||||
const toastId = toast.loading('Signing in...')
|
||||
|
||||
@@ -68,9 +80,12 @@ const SignInForm = () => {
|
||||
addLoginEvent({})
|
||||
|
||||
await queryClient.resetQueries()
|
||||
const returnTo = getReturnToPath()
|
||||
// since we're already on the /sign-in page, prevent redirect loops
|
||||
router.push(returnTo === '/sign-in' ? '/organizations' : returnTo)
|
||||
let redirectPath = '/organizations'
|
||||
if (returnTo && returnTo !== '/sign-in') {
|
||||
redirectPath = returnTo
|
||||
}
|
||||
router.push(redirectPath)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
|
||||
Sentry.captureMessage('[CRITICAL] Failed to sign in via EP: ' + error.message)
|
||||
@@ -124,7 +139,7 @@ const SignInForm = () => {
|
||||
|
||||
{/* positioned using absolute instead of labelOptional prop so tabbing between inputs works smoothly */}
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
href={forgotPasswordUrl}
|
||||
className="absolute top-0 right-0 text-sm text-foreground-lighter"
|
||||
>
|
||||
Forgot Password?
|
||||
|
||||
@@ -18,7 +18,11 @@ const signInSchema = object({
|
||||
code: string().required('MFA Code is required'),
|
||||
})
|
||||
|
||||
const SignInMfaForm = () => {
|
||||
interface SignInMfaFormProps {
|
||||
context?: 'forgot-password' | 'sign-in'
|
||||
}
|
||||
|
||||
const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
const router = useRouter()
|
||||
const signOut = useSignOut()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -38,7 +42,15 @@ const SignInMfaForm = () => {
|
||||
} = useMfaChallengeAndVerifyMutation({
|
||||
onSuccess: async () => {
|
||||
await queryClient.resetQueries()
|
||||
router.push(getReturnToPath())
|
||||
|
||||
if (context === 'forgot-password') {
|
||||
router.push({
|
||||
pathname: '/reset-password',
|
||||
query: router.query,
|
||||
})
|
||||
} else {
|
||||
router.push(getReturnToPath())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+35
-14
@@ -1,8 +1,10 @@
|
||||
import { ExternalLink, Eye, EyeOff, Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Input } from 'ui'
|
||||
import { Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
export const DecryptedReadOnlyInput = ({
|
||||
value,
|
||||
@@ -15,10 +17,11 @@ export const DecryptedReadOnlyInput = ({
|
||||
descriptionText: string
|
||||
label: string
|
||||
}) => {
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
|
||||
const { isLoading: isDecryptedValueLoading, data: decryptedValue } =
|
||||
const { data: decryptedValue, isLoading: isDecryptedValueLoading } =
|
||||
useVaultSecretDecryptedValueQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -29,22 +32,40 @@ export const DecryptedReadOnlyInput = ({
|
||||
)
|
||||
|
||||
const isLoading = isDecryptedValueLoading && showHidden
|
||||
const renderedValue = secureEntry
|
||||
? isLoading
|
||||
? 'Fetching value from Vault...'
|
||||
: showHidden
|
||||
? decryptedValue
|
||||
: value
|
||||
: value
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
readOnly
|
||||
copy
|
||||
disabled
|
||||
value={
|
||||
secureEntry
|
||||
? isLoading
|
||||
? 'Fetching value from Vault...'
|
||||
: showHidden
|
||||
? decryptedValue
|
||||
: value
|
||||
: value
|
||||
label={
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`/project/${ref}/integrations/vault/secrets?search=${value}`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-foreground-lighter hover:text-foreground-light transition"
|
||||
/>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">View parameter in Vault</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={renderedValue}
|
||||
type={secureEntry ? (isLoading ? 'text' : showHidden ? 'text' : 'password') : 'text'}
|
||||
descriptionText={descriptionText}
|
||||
layout="horizontal"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
@@ -9,34 +10,32 @@ import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-s
|
||||
import { FDW } from 'data/fdw/fdws-query'
|
||||
import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Badge, Button, TableCell, TableRow } from 'ui'
|
||||
import { Button, cn, TableCell, TableRow } from 'ui'
|
||||
|
||||
type NamespaceRowProps = {
|
||||
bucketName: string
|
||||
namespace: string
|
||||
schema: string
|
||||
excludedSchemas: string[]
|
||||
tables: (FormattedWrapperTable & { id: number })[]
|
||||
token: string
|
||||
wrapperInstance: FDW
|
||||
wrapperValues: Record<string, string>
|
||||
wrapperMeta: WrapperMeta
|
||||
}
|
||||
|
||||
export const NamespaceRow = ({
|
||||
bucketName,
|
||||
namespace,
|
||||
schema,
|
||||
excludedSchemas,
|
||||
tables,
|
||||
token,
|
||||
wrapperInstance,
|
||||
wrapperValues,
|
||||
wrapperMeta,
|
||||
}: NamespaceRowProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
|
||||
|
||||
const targetSchema = tables[0]?.schema_name ?? ''
|
||||
|
||||
const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
|
||||
{
|
||||
catalogUri: wrapperValues.catalog_uri,
|
||||
@@ -56,7 +55,7 @@ export const NamespaceRow = ({
|
||||
connectionString: project?.connectionString,
|
||||
serverName: wrapperInstance.server_name,
|
||||
sourceSchema: namespace,
|
||||
targetSchema: targetSchema,
|
||||
targetSchema: schema,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,11 +75,11 @@ export const NamespaceRow = ({
|
||||
|
||||
return (
|
||||
<TableRow key={namespace}>
|
||||
<TableCell>
|
||||
<Badge variant="brand">{namespace}</Badge>
|
||||
<TableCell>{namespace}</TableCell>
|
||||
<TableCell className={cn(!schema ? 'text-foreground-lighter' : '')}>
|
||||
{schema ?? 'No schema'}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">{schema && <Badge>{schema}</Badge>}</TableCell>
|
||||
<TableCell className="text-foreground-light text-center">
|
||||
<TableCell>
|
||||
{tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -96,15 +95,15 @@ export const NamespaceRow = ({
|
||||
Sync
|
||||
</ButtonTooltip>
|
||||
{schema ? (
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${project?.ref}/editor?schema=${schema}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button type="default" icon={<SquareArrowOutUpRight />}>
|
||||
Table Editor
|
||||
</Button>
|
||||
</a>
|
||||
<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"
|
||||
@@ -114,7 +113,7 @@ export const NamespaceRow = ({
|
||||
content: { text: 'There are no tables connected.' },
|
||||
}}
|
||||
>
|
||||
Table Editor
|
||||
Open in Table Editor
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,8 +121,7 @@ export const NamespaceRow = ({
|
||||
<ImportForeignSchemaDialog
|
||||
bucketName={bucketName}
|
||||
namespace={namespace}
|
||||
excludedSchemas={excludedSchemas}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
visible={importForeignSchemaShown}
|
||||
onClose={() => setImportForeignSchemaShown(false)}
|
||||
/>
|
||||
|
||||
@@ -127,7 +127,6 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
}
|
||||
})
|
||||
}, [wrapperTables, namespacesData])
|
||||
const excludedSchemas = uniq(namespaces.map((n) => n.schema))
|
||||
|
||||
const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
|
||||
|
||||
@@ -196,10 +195,10 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">Namespace</TableHead>
|
||||
<TableHead className="text-center">Schema</TableHead>
|
||||
<TableHead className="text-center">Tables</TableHead>
|
||||
<TableHead className="text-center">Actions</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Tables</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -209,11 +208,11 @@ export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
|
||||
bucketName={bucket.name}
|
||||
namespace={namespace}
|
||||
schema={schema}
|
||||
excludedSchemas={excludedSchemas}
|
||||
tables={tables as any}
|
||||
token={token!}
|
||||
wrapperInstance={wrapperInstance}
|
||||
wrapperValues={wrapperValues}
|
||||
wrapperMeta={wrapperMeta}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -11,10 +11,7 @@ import z from 'zod'
|
||||
import { useParams } from 'common'
|
||||
import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper'
|
||||
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
|
||||
import {
|
||||
convertFromBytes,
|
||||
convertToBytes,
|
||||
} from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
|
||||
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
|
||||
import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
|
||||
@@ -42,6 +39,7 @@ import {
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
|
||||
|
||||
export interface CreateBucketModalProps {
|
||||
visible: boolean
|
||||
@@ -80,7 +78,10 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const router = useRouter()
|
||||
|
||||
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation()
|
||||
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({
|
||||
// [Joshen] Silencing the error here as it's being handled in onSubmit
|
||||
onError: () => {},
|
||||
})
|
||||
const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
|
||||
useIcebergWrapperCreateMutation()
|
||||
|
||||
@@ -150,9 +151,8 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
toast.success(`Successfully created bucket ${values.name}`)
|
||||
router.push(`/project/${ref}/storage/buckets/${values.name}`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Failed to create bucket')
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to create bucket: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,20 +235,16 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{icebergCatalogEnabled ? null : (
|
||||
{!icebergCatalogEnabled && (
|
||||
<div className="w-full flex gap-x-2 py-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left">
|
||||
This feature is currently in alpha and not yet enabled for your
|
||||
project. Sign up{' '}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://forms.supabase.com/analytics-buckets"
|
||||
>
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
This is currently in alpha and not enabled for your project. Sign
|
||||
up{' '}
|
||||
<InlineLink href="https://forms.supabase.com/analytics-buckets">
|
||||
here
|
||||
</a>
|
||||
</InlineLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { snakeCase } from 'lodash'
|
||||
import { snakeCase, uniq } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
@@ -7,41 +7,30 @@ import z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { useSchemaCreateMutation } from 'data/database/schema-create-mutation'
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
|
||||
import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
|
||||
import { getFDWs } from 'data/fdw/fdws-query'
|
||||
import { Button, Form_Shadcn_, FormField_Shadcn_, Input_Shadcn_, Modal } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import type { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types'
|
||||
import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils'
|
||||
import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor'
|
||||
import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils'
|
||||
|
||||
export interface ImportForeignSchemaDialogProps {
|
||||
bucketName: string
|
||||
namespace: string
|
||||
excludedSchemas: string[]
|
||||
wrapperValues: Record<string, string>
|
||||
wrapperMeta: WrapperMeta
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
bucketName: z.string().trim(),
|
||||
sourceNamespace: z.string().trim(),
|
||||
targetSchema: z.string().trim(),
|
||||
})
|
||||
|
||||
export type ImportForeignSchemaForm = z.infer<typeof FormSchema>
|
||||
|
||||
export const ImportForeignSchemaDialog = ({
|
||||
bucketName,
|
||||
namespace,
|
||||
excludedSchemas,
|
||||
wrapperValues,
|
||||
wrapperMeta,
|
||||
visible,
|
||||
onClose,
|
||||
}: ImportForeignSchemaDialogProps) => {
|
||||
@@ -50,14 +39,34 @@ export const ImportForeignSchemaDialog = ({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
|
||||
|
||||
const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({
|
||||
const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({})
|
||||
const { mutateAsync: updateFDW } = useFDWUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully connected ${bucketName} to the database.`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const form = useForm<ImportForeignSchemaForm>({
|
||||
const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! })
|
||||
|
||||
const FormSchema = z.object({
|
||||
bucketName: z.string().trim(),
|
||||
sourceNamespace: z.string().trim(),
|
||||
targetSchema: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Schema name is required')
|
||||
.refine(
|
||||
(val) => {
|
||||
return !schemas?.find((s) => s.name === val)
|
||||
},
|
||||
{
|
||||
message: 'This schema already exists. Please specify a unique schema name.',
|
||||
}
|
||||
),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
bucketName,
|
||||
@@ -66,18 +75,67 @@ export const ImportForeignSchemaDialog = ({
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ImportForeignSchemaForm> = async (values) => {
|
||||
const { mutateAsync: createSchema } = useSchemaCreateMutation()
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
|
||||
const serverName = `${snakeCase(values.bucketName)}_fdw_server`
|
||||
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await createSchema({
|
||||
projectRef: ref,
|
||||
connectionString: project?.connectionString,
|
||||
name: values.targetSchema,
|
||||
})
|
||||
|
||||
await importForeignSchema({
|
||||
projectRef: ref,
|
||||
connectionString: project?.connectionString,
|
||||
serverName: `${snakeCase(values.bucketName)}_fdw_server`,
|
||||
serverName: serverName,
|
||||
sourceSchema: values.sourceNamespace,
|
||||
targetSchema: values.targetSchema,
|
||||
})
|
||||
|
||||
const FDWs = await getFDWs({ projectRef: ref, connectionString: project?.connectionString })
|
||||
const wrapper = FDWs.find((fdw) => fdw.server_name === serverName)
|
||||
if (!wrapper) {
|
||||
throw new Error(`Foreign data wrapper with server name ${serverName} not found`)
|
||||
}
|
||||
|
||||
const serverOptions = await getDecryptedParameters({
|
||||
ref: project?.ref,
|
||||
connectionString: project?.connectionString ?? undefined,
|
||||
serverName,
|
||||
})
|
||||
|
||||
const formValues: Record<string, string> = {
|
||||
wrapper_name: wrapper.name,
|
||||
server_name: wrapper.server_name,
|
||||
...serverOptions,
|
||||
}
|
||||
|
||||
const targetSchemas = (formValues['supabase_target_schema'] || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
|
||||
const wrapperTables = formatWrapperTables(wrapper, wrapperMeta)
|
||||
|
||||
await updateFDW({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
wrapper: wrapper,
|
||||
wrapperMeta: wrapperMeta,
|
||||
formState: {
|
||||
...formValues,
|
||||
server_name: serverName,
|
||||
supabase_target_schema: uniq([...targetSchemas, values.targetSchema])
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
},
|
||||
tables: wrapperTables,
|
||||
})
|
||||
} catch (error: any) {
|
||||
// error will be handled by the mutation onError callback
|
||||
} finally {
|
||||
@@ -110,35 +168,16 @@ export const ImportForeignSchemaDialog = ({
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Modal.Content className="flex flex-col gap-y-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="sourceNamespace"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Namespace" layout="vertical">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Enter namespace name" disabled />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="targetSchema"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Target Schema"
|
||||
description="Select the database schema where the Iceberg data will be accessible. Each schema can only be connected to one namespace."
|
||||
description="Enter a schema name under which the Iceberg data will be accessible. The schema will be created."
|
||||
layout="vertical"
|
||||
>
|
||||
<SchemaSelector
|
||||
portal={false}
|
||||
size="small"
|
||||
selectedSchemaName={field.value}
|
||||
excludedSchemas={excludedSchemas}
|
||||
onSelectSchema={(schema) => field.onChange(schema)}
|
||||
onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
|
||||
/>
|
||||
<Input_Shadcn_ {...field} placeholder="Enter schema name" />
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getFDWs } from 'data/fdw/fdws-query'
|
||||
import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { INTEGRATIONS } from '../Integrations/Landing/Integrations.constants'
|
||||
import { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types'
|
||||
import { convertKVStringArrayToJson } from '../Integrations/Wrappers/Wrappers.utils'
|
||||
|
||||
export const getDecryptedParameters = async ({
|
||||
ref,
|
||||
connectionString,
|
||||
serverName,
|
||||
}: {
|
||||
ref?: string
|
||||
connectionString?: string
|
||||
serverName: string
|
||||
}) => {
|
||||
const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
|
||||
const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
|
||||
const wrapperServerOptions = wrapperMeta.server.options
|
||||
|
||||
const FDWs = await getFDWs({ projectRef: ref, connectionString: connectionString })
|
||||
const wrapper = FDWs.find((fdw) => fdw.server_name === serverName)
|
||||
const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? [])
|
||||
|
||||
const paramsToBeDecrypted = Object.fromEntries(
|
||||
new Map(
|
||||
Object.entries(serverOptions).filter(([key, value]) => {
|
||||
return wrapperServerOptions.find((option) => option.name === key)?.encrypted
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const decryptedValues = await getDecryptedValues({
|
||||
projectRef: ref,
|
||||
connectionString: connectionString,
|
||||
ids: Object.values(paramsToBeDecrypted),
|
||||
})
|
||||
|
||||
const paramsWithDecryptedValues = Object.fromEntries(
|
||||
new Map(
|
||||
Object.entries(paramsToBeDecrypted).map(([name, id]) => {
|
||||
const decryptedValue = decryptedValues[id]
|
||||
return [name, decryptedValue]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
...serverOptions,
|
||||
...paramsWithDecryptedValues,
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren<APIAuthorization
|
||||
<Head>
|
||||
<title>Authorize API access | Supabase</title>
|
||||
</Head>
|
||||
<main className="flex-grow flex flex-col w-full h-full overflow-y-auto">
|
||||
<main className="h-screen flex flex-col w-full h-full overflow-y-auto">
|
||||
<div>
|
||||
<div className="mx-auto px-4 sm:px-6">
|
||||
<div className="max-w-xl flex justify-between items-center mx-auto py-4">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { AppBannerWrapper } from 'components/interfaces/App'
|
||||
import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext'
|
||||
import { Sidebar } from 'components/interfaces/Sidebar'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy'
|
||||
import { SidebarProvider } from 'ui'
|
||||
import { LayoutHeader } from './ProjectLayout/LayoutHeader'
|
||||
import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar'
|
||||
@@ -29,6 +30,8 @@ const DefaultLayout = ({ children, headerTitle }: PropsWithChildren<DefaultLayou
|
||||
const router = useRouter()
|
||||
const showProductMenu = !!ref && router.pathname !== '/project/[ref]'
|
||||
|
||||
useCheckLatestDeploy()
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<ProjectContextProvider projectRef={ref}>
|
||||
|
||||
@@ -9,13 +9,9 @@ import {
|
||||
useFeaturePreviewModal,
|
||||
useUnifiedLogsPreview,
|
||||
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { CreateWarehouseCollectionModal } from 'components/interfaces/DataWarehouse/CreateWarehouseCollection'
|
||||
import { WarehouseMenuItem } from 'components/interfaces/DataWarehouse/WarehouseMenuItem'
|
||||
import SavedQueriesItem from 'components/interfaces/Settings/Logs/Logs.SavedQueriesItem'
|
||||
import { LogsSidebarItem } from 'components/interfaces/Settings/Logs/SidebarV2/SidebarItem'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
|
||||
import { useWarehouseTenantQuery } from 'data/analytics/warehouse-tenant-query'
|
||||
import { useContentQuery } from 'data/content/content-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
@@ -27,14 +23,7 @@ import {
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import {
|
||||
InnerSideBarEmptyPanel,
|
||||
@@ -90,16 +79,11 @@ export function LogsSidebarMenuV2() {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams() as { ref: string }
|
||||
|
||||
const warehouseEnabled = useFlag('warehouse')
|
||||
const unifiedLogsFlagEnabled = useFlag('unifiedLogs')
|
||||
const { selectFeaturePreview } = useFeaturePreviewModal()
|
||||
const { enable: enableUnifiedLogs } = useUnifiedLogsPreview()
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [createCollectionOpen, setCreateCollectionOpen] = useState(false)
|
||||
const canCreateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare')
|
||||
|
||||
const { data: tenantData } = useWarehouseTenantQuery({ projectRef: ref })
|
||||
|
||||
const {
|
||||
projectAuthAll: authEnabled,
|
||||
@@ -107,11 +91,6 @@ export function LogsSidebarMenuV2() {
|
||||
realtimeAll: realtimeEnabled,
|
||||
} = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all'])
|
||||
|
||||
const { data: whCollections, isLoading: whCollectionsLoading } = useWarehouseCollectionsQuery(
|
||||
{ projectRef: ref },
|
||||
{ enabled: IS_PLATFORM && warehouseEnabled && !!tenantData }
|
||||
)
|
||||
|
||||
const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan()
|
||||
const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free'
|
||||
|
||||
@@ -223,9 +202,6 @@ export function LogsSidebarMenuV2() {
|
||||
const filteredOperationalLogs = OPERATIONAL_COLLECTIONS.filter((collection) => {
|
||||
return collection?.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
})
|
||||
const filteredWarehouse = whCollections?.filter((collection) => {
|
||||
return collection.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pb-12 relative">
|
||||
@@ -285,56 +261,11 @@ export function LogsSidebarMenuV2() {
|
||||
></InnerSideBarFilterSearchInput>
|
||||
</InnerSideBarFilters>
|
||||
|
||||
{warehouseEnabled ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem className="gap-x-2" asChild>
|
||||
<Link href={`/project/${ref}/logs/explorer`}>
|
||||
<FilePlus size={14} />
|
||||
Create query
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem className="gap-x-2" asChild>
|
||||
<button
|
||||
onClick={() => setCreateCollectionOpen(true)}
|
||||
className="w-full flex items-center text-xs px-2 py-1"
|
||||
disabled={!canCreateCollection}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create collection
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
{!canCreateCollection && (
|
||||
<TooltipContent>
|
||||
You need additional permissions to create a collection
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
onClick={() => router.push(`/project/${ref}/logs/explorer`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateWarehouseCollectionModal
|
||||
open={createCollectionOpen}
|
||||
onOpenChange={setCreateCollectionOpen}
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus className="text-foreground" />}
|
||||
className="w-[26px]"
|
||||
onClick={() => router.push(`/project/${ref}/logs/explorer`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
@@ -361,17 +292,6 @@ export function LogsSidebarMenuV2() {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{whCollectionsLoading && warehouseEnabled ? (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : filteredWarehouse?.length ? (
|
||||
<div>
|
||||
{filteredWarehouse.map((collection) => (
|
||||
<WarehouseMenuItem item={collection} key={collection.token}></WarehouseMenuItem>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SidebarCollapsible>
|
||||
{OPERATIONAL_COLLECTIONS.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface EntityListItemProps {
|
||||
projectRef: string
|
||||
isLocked: boolean
|
||||
isActive?: boolean
|
||||
onExportCLI: () => void
|
||||
}
|
||||
|
||||
// [jordi] Used to determine the entity is a table and not a view or other unsupported entity type
|
||||
@@ -64,6 +65,7 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
item: entity,
|
||||
isLocked,
|
||||
isActive: _isActive,
|
||||
onExportCLI,
|
||||
}) => {
|
||||
const { project } = useProjectContext()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
@@ -386,6 +388,16 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
>
|
||||
<span>Export table as SQL</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="download-table-cli"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExportCLI()
|
||||
}}
|
||||
>
|
||||
<span>Export table via CLI</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useBreakpoint } from 'common/hooks/useBreakpoint'
|
||||
import { ExportDialog } from 'components/grid/components/header/ExportDialog'
|
||||
import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning'
|
||||
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
@@ -52,6 +53,7 @@ const TableEditorMenu = () => {
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [tableToExport, setTableToExport] = useState<{ name: string; schema: string }>()
|
||||
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
|
||||
const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
|
||||
'table-editor-sort',
|
||||
@@ -281,6 +283,11 @@ const TableEditorMenu = () => {
|
||||
projectRef: project?.ref!,
|
||||
id: Number(id),
|
||||
isLocked,
|
||||
onExportCLI: () => {
|
||||
const entity = entityTypes?.find((x) => x.id === id)
|
||||
if (!entity) return
|
||||
setTableToExport({ name: entity.name, schema: entity.schema })
|
||||
},
|
||||
}}
|
||||
getItemSize={() => 28}
|
||||
hasNextPage={hasNextPage}
|
||||
@@ -294,6 +301,13 @@ const TableEditorMenu = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportDialog
|
||||
table={tableToExport}
|
||||
open={!!tableToExport}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTableToExport(undefined)
|
||||
}}
|
||||
/>
|
||||
<ProtectedSchemaModal visible={showModal} onClose={() => setShowModal(false)} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -235,7 +235,10 @@ export default function ComposedChart({
|
||||
})
|
||||
: []
|
||||
|
||||
const stackedAttributes = chartData.filter((att) => !att.name.includes('max'))
|
||||
const stackedAttributes = chartData.filter((att) => {
|
||||
const attribute = attributes.find((attr) => attr.attribute === att.name)
|
||||
return !attribute?.isMaxValue
|
||||
})
|
||||
const isPercentage = format === '%'
|
||||
const isRamChart =
|
||||
!chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
|
||||
|
||||
+18
-21
@@ -15,7 +15,6 @@ import {
|
||||
DrawerTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
@@ -32,26 +31,24 @@ export function DataTableFilterControlsDrawer() {
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="h-9 w-9" ref={isMobile ? triggerButtonRef : null}>
|
||||
<FilterIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>
|
||||
Toggle controls with{' '}
|
||||
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
|
||||
<span className="mr-1">⌘</span>
|
||||
<span>B</span>
|
||||
</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DrawerTrigger asChild>
|
||||
<Button className="h-9 w-9" ref={isMobile ? triggerButtonRef : null}>
|
||||
<FilterIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>
|
||||
Toggle controls with{' '}
|
||||
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
|
||||
<span className="mr-1">⌘</span>
|
||||
<span>B</span>
|
||||
</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DrawerContent className="max-h-[calc(100dvh-4rem)]">
|
||||
<VisuallyHidden>
|
||||
<DrawerHeader>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { useHotKey } from 'hooks/ui/useHotKey'
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Kbd } from './primitives/Kbd'
|
||||
import { useDataTable } from './providers/DataTableProvider'
|
||||
|
||||
@@ -10,28 +10,21 @@ export function DataTableResetButton() {
|
||||
useHotKey(table.resetColumnFilters, 'Escape')
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
icon={<X />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>
|
||||
Reset filters with{' '}
|
||||
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
|
||||
<span className="mr-1">⌘</span>
|
||||
<span>Esc</span>
|
||||
</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="default" size="tiny" onClick={() => table.resetColumnFilters()} icon={<X />}>
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>
|
||||
Reset filters with{' '}
|
||||
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
|
||||
<span className="mr-1">⌘</span>
|
||||
<span>Esc</span>
|
||||
</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Separator,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Kbd } from './primitives/Kbd'
|
||||
import { useDataTable } from './providers/DataTableProvider'
|
||||
|
||||
@@ -79,44 +70,41 @@ export function DataTableSheetDetails({
|
||||
{isLoading && !selectedRowKey ? <Skeleton className="h-7 w-36" /> : title}
|
||||
</h5>
|
||||
<div className="flex h-7 items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
disabled={!prevId}
|
||||
onClick={onPrev}
|
||||
className="px-1"
|
||||
icon={<ChevronUp />}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Navigate <Kbd>↑</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
disabled={!nextId}
|
||||
onClick={onNext}
|
||||
className="px-1"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Navigate <Kbd>↓</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
disabled={!prevId}
|
||||
onClick={onPrev}
|
||||
className="px-1"
|
||||
icon={<ChevronUp />}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Navigate <Kbd>↑</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
disabled={!nextId}
|
||||
onClick={onNext}
|
||||
className="px-1"
|
||||
icon={<ChevronDown />}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Navigate <Kbd>↓</Kbd>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
|
||||
<Button
|
||||
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user