Merge branch 'master' into feature/table-query

This commit is contained in:
Saxon Fletcher committed 2025-07-30 12:21:18 +10:00
commit ee059d2df2
211 files changed
+13442 -29696

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ on:
permissions:
pull-requests: write
contents: read
contents: write
jobs:
update-docs:
-14
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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' },
+10 -7
View File
@@ -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>
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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.
![Supabase Angular](/docs/img/supabase-angular-demo.png)
![Screenshot of the Supabase Angular application running in a browser](/docs/img/supabase-angular-demo.png)
## 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 {
// ...
+11 -11
View File
@@ -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)
+1 -1
View File
@@ -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
+4
View File
@@ -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
File diff suppressed because it is too large. Load diff
+267 -3
View File
@@ -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>
)
}
@@ -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 }
}
@@ -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)}
@@ -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(),
})
@@ -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>
@@ -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}
@@ -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>
@@ -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"
@@ -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}
@@ -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>
@@ -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}
>
@@ -131,9 +131,7 @@ const RoleImpersonationSelector = ({
{selectedOption === 'authenticated' && (
<>
<DropdownMenuSeparator />
<div className={cn('py-5', padded && 'px-5')}>
<UserImpersonationSelector />
</div>
<UserImpersonationSelector />
</>
)}
</>
@@ -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}
</>
)
}
@@ -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
@@ -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())
}
},
})
@@ -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') &&
@@ -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