mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 22:59:11 +03:00
Merge branch 'master' into feat/report-v2
This commit is contained in:
commit
e477a4517d
167 files changed
+4190
-4217
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: Comment on external PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment-on-external-pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const commentBody = `
|
||||
Thanks for contributing to Supabase! ❤️ Our team will review your PR.
|
||||
|
||||
A few tips for a smoother review process:
|
||||
- If you have a local version of the repo, run \`pnpm run format\` to make sure formatting checks pass.
|
||||
- Once we've reviewed your PR, please don't trivially merge master (don't click \`Update branch\` if there are no merge conflicts to be fixed). This invalidates any pre-merge checks we've run.
|
||||
`;
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
+3
-1
@@ -16,4 +16,6 @@ apps/docs/pages/guides/integrations/*.mdx
|
||||
apps/studio/public
|
||||
apps/**/.turbo
|
||||
apps/docs/CONTRIBUTING.md
|
||||
apps/design-system/__registry__
|
||||
apps/design-system/__registry__
|
||||
packages/icons/__registry__
|
||||
packages/icons/src/icons/*.ts
|
||||
@@ -23,7 +23,7 @@
|
||||
"lodash.template": "4.5.0",
|
||||
"lucide-react": "*",
|
||||
"markdown-wasm": "^1.2.0",
|
||||
"next": "^14.2.10",
|
||||
"next": "catalog:",
|
||||
"next-contentlayer2": "0.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -49,7 +49,6 @@
|
||||
"devDependencies": {
|
||||
"@shikijs/compat": "^1.1.7",
|
||||
"@types/lodash.template": "4.5.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18.2.65",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"autoprefixer": "^10.0.1",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getAiPrompts } from './getting-started/ai-prompts/[slug]/AiPrompts.util
|
||||
|
||||
// Revalidate occasionally to pick up changes to partners
|
||||
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
|
||||
export const revalidate = 86_400
|
||||
// export const revalidate = 86_400
|
||||
|
||||
const GuidesLayout = async ({ children }: PropsWithChildren) => {
|
||||
const [partnerNavItems, promptNavItems] = await Promise.all([getPartners(), getPrompts()])
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Tr
|
||||
import { PROD_URL } from '~/lib/constants'
|
||||
|
||||
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
|
||||
export const revalidate = 86_400
|
||||
// export const revalidate = 86_400
|
||||
export const dynamicParams = false
|
||||
|
||||
export default async function TroubleshootingEntryPage({
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { PROD_URL } from '~/lib/constants'
|
||||
|
||||
// 60 seconds/minute * 60 minutes/hour * 24 hours/day
|
||||
export const revalidate = 86_400
|
||||
// export const revalidate = 86_400
|
||||
|
||||
export default async function GlobalTroubleshootingPage() {
|
||||
const troubleshootingEntries = await getAllTroubleshootingEntries()
|
||||
|
||||
@@ -771,6 +771,10 @@ const guiQuickstarts: NavMenuSection = {
|
||||
name: 'Metabase',
|
||||
url: '/guides/database/metabase',
|
||||
},
|
||||
{
|
||||
name: 'Beekeeper Studio',
|
||||
url: '/guides/database/beekeeper-studio',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1020,6 +1024,10 @@ export const database: NavMenuConstant = {
|
||||
name: 'pg_stat_statements: SQL Planning and Execution Statistics',
|
||||
url: '/guides/database/extensions/pg_stat_statements',
|
||||
},
|
||||
{
|
||||
name: 'pg_repack: Storage Optimization',
|
||||
url: '/guides/database/extensions/pg_repack',
|
||||
},
|
||||
{
|
||||
name: 'PostGIS: Geo queries',
|
||||
url: '/guides/database/extensions/postgis',
|
||||
|
||||
@@ -79,11 +79,11 @@ revoke execute
|
||||
from authenticated, anon, public;
|
||||
```
|
||||
|
||||
<Admonition type="caution" label="Create postgres functions with security invoker">
|
||||
You will need to alter your row-level security (RLS) policies to allow the `supabase_auth_admin` role to access tables that you have RLS policies on. You can read more about RLS policies [here](/docs/guides/database/postgres/row-level-security).
|
||||
|
||||
We do not recommend creating postgres functions with the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it. Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker).
|
||||
Alternatively, you can create your postgres function via the dashboard with the `security definer` tag. The `security definer` tag specifies that the function is to be executed with the privileges of the user that owns it.
|
||||
|
||||
</Admonition>
|
||||
Currently, functions created via the dashboard take on the `postgres` role. Read more about the `security definer` tag [in our database guide](/docs/guides/database/functions#security-definer-vs-invoker)
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="http" label="HTTP">
|
||||
|
||||
@@ -9,7 +9,7 @@ Many Auth methods involve a redirect to your app. For example:
|
||||
- Signup confirmation emails, Magic Link signins, and password reset emails contain a link that redirects to your app.
|
||||
- In OAuth signins, an automatic redirect occurs to your app.
|
||||
|
||||
With Deep Linking, you can configure this redirect to open a specific page. This is necessary if, for example, you need to display a form for password reset, or to manually exchange a token hash.
|
||||
With Deep Linking, you can configure this redirect to open a specific page. This is necessary if, for example, you need to display a form for [password reset](/docs/guides/auth/passwords#resetting-a-users-password-forgot-password), or to manually exchange a token hash.
|
||||
|
||||
## Setting up deep linking
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: 'Beekeeper Studio'
|
||||
title: 'Connecting with Beekeeper Studio'
|
||||
breadcrumb: 'GUI Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
[`Beekeeper Studio Community`](https://www.beekeeperstudio.io/get-community) is a free GUI tool for interacting with databases.
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
|
||||
<StepHikeCompact.Details title="Create a new connection">
|
||||
|
||||
In Beekeeper, create a new Postgres connection.
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||

|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={2}>
|
||||
|
||||
<StepHikeCompact.Details title="Get your connection credentials">
|
||||
|
||||
Get your connection credentials from the [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database). You will need:
|
||||
- host
|
||||
- username
|
||||
- password
|
||||
- port
|
||||
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
Add your credentials to Beekeeper's connection form
|
||||
|
||||

|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
|
||||
<StepHikeCompact.Details title="Download your SSL Certificate">
|
||||
|
||||
Download your SSL certificate from the Dashboard's [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database)
|
||||

|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
Add your SSL to the connection form
|
||||

|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={4}>
|
||||
|
||||
<StepHikeCompact.Details title="Test and connect">
|
||||
|
||||
Test your connection and then connect
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||

|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
</StepHikeCompact>
|
||||
@@ -100,7 +100,7 @@ Get your project's session mode string from the [Database Settings](/dashboard/p
|
||||
|
||||
## Supavisor transaction mode
|
||||
|
||||
The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is deal for serverless or edge functions, which require many transient connections.
|
||||
The transaction mode connection string connects to your Postgres instance via a proxy which serves as a connection pooler. This is ideal for serverless or edge functions, which require many transient connections.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: 'pg_repack'
|
||||
title: 'pg_repack: Physical storage optimization and maintenance'
|
||||
description: 'A tool to remove bloat from tables and indexes and optimize physical data order and physical storage'
|
||||
---
|
||||
|
||||
[pg_repack](https://github.com/reorg/pg_repack) is a Postgres extension to remove bloat from tables and indexes, and optionally restore the physical order of clustered indexes. Unlike CLUSTER and VACUUM FULL, pg_repack runs "online" and does not hold a exclusive locks on the processed tables that could prevent ongoing database operations. pg_repack's efficiency is comparable to using CLUSTER directly.
|
||||
|
||||
pg_repack provides the following methods to optimize physical storage:
|
||||
|
||||
- Online CLUSTER: ordering table data by cluster index in a non-blocking way
|
||||
- Ordering table data by specified columns
|
||||
- Online VACUUM FULL: packing rows only in a non-blocking way
|
||||
- Rebuild or relocate only the indexes of a table
|
||||
|
||||
pg_repack has 2 components, the database extension and a client-side CLI to control it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A target table must have a PRIMARY KEY, or a UNIQUE total index on a NOT NULL column.
|
||||
- Performing a full-table repack requires free disk space about twice as large as the target table and its indexes.
|
||||
|
||||
pg_repack requires the Postgres superuser role by default. That role is not available to users on the Supabase platform. To avoid that requirement, use the `-k` or `--no-superuser-check` flags on every `pg_repack` CLI command.
|
||||
|
||||
The first version of pg_repack with full support for non-superuser repacking is 1.5.2. You can check the version installed on your Supabase instance using
|
||||
|
||||
```sql
|
||||
select default_version
|
||||
from pg_available_extensions
|
||||
where name = 'pg_repack';
|
||||
```
|
||||
|
||||
If pg_repack is not present, or the version is < 1.5.2, [upgrade to the latest version](/docs/guides/platform/upgrading) of Supabase to gain access.
|
||||
|
||||
## Usage
|
||||
|
||||
### Enable the extension
|
||||
|
||||
Get started with pg_repack by enabling the extension in the Supabase Dashboard.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="dashboard"
|
||||
queryGroup="database-method"
|
||||
>
|
||||
<TabPanel id="dashboard" label="Dashboard">
|
||||
|
||||
1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard.
|
||||
2. Click on **Extensions** in the sidebar.
|
||||
3. Search for "pg_repack" and enable the extension.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sql
|
||||
-- Example: enable the "pg_repack" extension
|
||||
create extension pg_repack with schema extensions;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Install the CLI
|
||||
|
||||
Select an option from the pg_repack docs to [install the client CLI](https://reorg.github.io/pg_repack/#download).
|
||||
|
||||
### Syntax
|
||||
|
||||
All pg_repack commands should include the `-k` flag to skip the client-side superuser check.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sh
|
||||
pg_repack -k [OPTION]... [DBNAME]
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Perform an online `VACUUM FULL` on the tables `public.foo` and `public.bar` in the database `postgres`:
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```sh
|
||||
pg_repack -k -h db.<PROJECT_REF>.supabase.co -p 5432 -U postgres -d postgres --no-order --table public.foo --table public.bar
|
||||
```
|
||||
|
||||
See the [official pg_repack documentation](https://reorg.github.io/pg_repack/) for the full list of options.
|
||||
|
||||
## Limitations
|
||||
|
||||
- pg_repack cannot reorganize temporary tables.
|
||||
- pg_repack cannot cluster tables by GiST indexes.
|
||||
- You cannot perform DDL commands of the target tables except VACUUM or ANALYZE while pg_repack is working.
|
||||
pg_repack holds an ACCESS SHARE lock on the target table to enforce this restriction.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Official pg_repack documentation](https://reorg.github.io/pg_repack/)
|
||||
@@ -532,6 +532,57 @@ Migrations are run in sequential order. Each migration builds upon the previous
|
||||
|
||||
The preview branch has a record of which migrations have been applied, and only applies new migrations for each commit. This can create an issue when rolling back migrations.
|
||||
|
||||
## Branch configuration with remotes
|
||||
|
||||
When Branching is enabled, your `config.toml` settings automatically sync to all ephemeral branches through a one-to-one mapping between your Git and Supabase branches.
|
||||
|
||||
### Basic configuration
|
||||
|
||||
To update configuration for a Supabase branch, simply modify `config.toml` and push to git. The Supabase integration will detect the changes and apply them to the corresponding branch.
|
||||
|
||||
### Remote-specific configuration
|
||||
|
||||
For persistent branches that need specific settings, you can use the `[remotes]` block in your `config.toml`. Each remote configuration must reference an existing project ID.
|
||||
|
||||
Here's an example of configuring a separate seed script for a staging environment:
|
||||
|
||||
```toml
|
||||
[remotes.staging]
|
||||
project_id = "your-project-ref"
|
||||
|
||||
[remotes.staging.db.seed]
|
||||
sql_paths = ["./seeds/staging.sql"]
|
||||
```
|
||||
|
||||
Since the `project_id` field must reference an existing branch, you need to create the persistent branch before adding its configuration. Use the CLI to create a persistent branch first:
|
||||
|
||||
```bash
|
||||
supabase --experimental branches create --persistent
|
||||
# Do you want to create a branch named develop? [Y/n]
|
||||
```
|
||||
|
||||
### Configuration merging
|
||||
|
||||
When merging a PR into a persistent branch, the Supabase integration:
|
||||
|
||||
1. Checks for configuration changes
|
||||
2. Logs the changes
|
||||
3. Applies them to the target remote
|
||||
|
||||
If no remote is declared or the project ID is incorrect, the configuration step is skipped.
|
||||
|
||||
### Available configuration options
|
||||
|
||||
All standard configuration options are available in the `[remotes]` block. This includes:
|
||||
|
||||
- Database settings
|
||||
- API configurations
|
||||
- Authentication settings
|
||||
- Edge Functions configuration
|
||||
- And more
|
||||
|
||||
You can use this to maintain different configurations for different environments while keeping them all in version control.
|
||||
|
||||
### Rolling back migrations
|
||||
|
||||
You might want to roll back changes you've made in an earlier migration change. For example, you may have pushed a migration file containing schema changes you no longer want.
|
||||
|
||||
@@ -49,7 +49,7 @@ select from pgmq.create('my_queue');
|
||||
|
||||
#### `create_unlogged`
|
||||
|
||||
Creates an unlogged table. This is useful when write throughput is more important that durability.
|
||||
Creates an unlogged table. This is useful when write throughput is more important than durability.
|
||||
See Postgres documentation for [unlogged tables](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for more information.
|
||||
|
||||
{/* prettier-ignore */}
|
||||
|
||||
@@ -652,7 +652,7 @@ Broadcast Changes allows you to trigger messages from your database. To achieve
|
||||
|
||||
It uses partitioned tables per day which allows the deletion your previous images in a performant way by dropping the physical tables of this partitioned table. Tables older than 3 days old are deleted.
|
||||
|
||||
Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization]/docs/guides/realtime/authorization) is required and enabled by default to protect your data.
|
||||
Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data.
|
||||
|
||||
The database broadcast feature provides two functions to help you send messages:
|
||||
|
||||
|
||||
@@ -205,6 +205,53 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="py" label="Python">
|
||||
|
||||
Here's an example of how to upload a file using [`tus-py-client`](https://github.com/tus/tus-py-client):
|
||||
|
||||
```python
|
||||
from io import BufferedReader
|
||||
from tusclient import client
|
||||
from supabase import create_client
|
||||
|
||||
def upload_file(
|
||||
bucket_name: str, file_name: str, file: BufferedReader, access_token: str
|
||||
):
|
||||
# create Tus client
|
||||
my_client = client.TusClient(
|
||||
f"{supabase_url}/storage/v1/upload/resumable",
|
||||
headers={"Authorization": f"Bearer {access_token}", "x-upsert": "true"},
|
||||
)
|
||||
uploader = my_client.uploader(
|
||||
file_stream=file,
|
||||
chunk_size=(6 * 1024 * 1024),
|
||||
metadata={
|
||||
"bucketName": bucket_name,
|
||||
"objectName": file_name,
|
||||
"contentType": "image/png",
|
||||
"cacheControl": "3600",
|
||||
},
|
||||
)
|
||||
uploader.upload()
|
||||
|
||||
# create client and sign in
|
||||
supabase = create_client(supabase_url, supabase_key)
|
||||
|
||||
# retrieve the current user's session for authentication
|
||||
session = supabase.auth.get_session()
|
||||
|
||||
# open file and send file stream to upload
|
||||
with open("./assets/40mb.jpg", "rb") as fs:
|
||||
upload_file(
|
||||
bucket_name="assets",
|
||||
file_name="large_file",
|
||||
file=fs,
|
||||
access_token=session.access_token,
|
||||
)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
### Upload URL
|
||||
|
||||
@@ -17,9 +17,19 @@ hideTitle: true
|
||||
|
||||
## Authentication
|
||||
|
||||
All API requests require a Supabase Personal token to be included in the Authorization header: `Authorization Bearer <supabase_personal_token>`.
|
||||
To generate or manage your API token, visit your [account](https://supabase.com/dashboard/account/tokens) page.
|
||||
Your API tokens carry the same privileges as your user account, so be sure to keep it secret.
|
||||
All API requests require an access token to be included in the Authorization header: `Authorization Bearer <access_token>`.
|
||||
|
||||
There are two ways to generate an access token:
|
||||
|
||||
1. **Personal access token (PAT):**
|
||||
PATs are long-lived tokens that you manually generate to access the Management API. They are useful for automating workflows or developing against the Management API. PATs carry the same privileges as your user account, so be sure to keep it secret.
|
||||
|
||||
To generate or manage your personal access tokens, visit your [account](/dashboard/account/tokens) page.
|
||||
|
||||
2. **OAuth2:**
|
||||
OAuth2 allows your application to generate tokens on behalf of a Supabase user, providing secure and limited access to their account without requiring their credentials. Use this if you're building a third-party app that needs to create or manage Supabase projects on behalf of your users. Tokens generated via OAuth2 are short-lived and tied to specific scopes to ensure your app can only perform actions that are explicitly approved by the user.
|
||||
|
||||
See [Build a Supabase Integration](/docs/guides/integrations/build-a-supabase-integration) to set up OAuth2 for your application.
|
||||
|
||||
```bash
|
||||
curl https://api.supabase.com/v1/projects \
|
||||
|
||||
@@ -18,6 +18,9 @@ function fetchWithNextOptions({
|
||||
return (info: RequestInfo) => fetch(info, { next, cache })
|
||||
}
|
||||
|
||||
const fetchRevalidatePerDay = fetchWithNextOptions({ next: { revalidate: ONE_DAY_IN_SECONDS } })
|
||||
// const fetchRevalidatePerDay = fetchWithNextOptions({ next: { revalidate: ONE_DAY_IN_SECONDS } })
|
||||
// [Charis 2024-12-28]
|
||||
// Temporarily disabling revalidation as a hotfix for Vercel NFT problem
|
||||
const fetchRevalidatePerDay = fetch
|
||||
|
||||
export { fetchWithNextOptions, fetchRevalidatePerDay }
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-check
|
||||
import { remarkCodeHike } from '@code-hike/mdx'
|
||||
import nextMdx from '@next/mdx'
|
||||
import os from 'node:os'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
@@ -61,30 +60,7 @@ const nextConfig = {
|
||||
'/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*'],
|
||||
'/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'],
|
||||
},
|
||||
},
|
||||
webpack: (config, options) => {
|
||||
/**
|
||||
* The SQL to REST API translator relies on libpg-query, which packages a
|
||||
* native Node.js module that wraps the Postgres query parser.
|
||||
*
|
||||
* The default webpack config can't load native modules, so we need a custom
|
||||
* loader for it, which calls process.dlopen to load C++ Addons.
|
||||
*
|
||||
* See https://github.com/eisberg-labs/nextjs-node-loader
|
||||
*/
|
||||
config.module.rules.push({
|
||||
test: /\.node$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'nextjs-node-loader',
|
||||
options: {
|
||||
flags: os.constants.dlopen.RTLD_NOW,
|
||||
outputPath: config.output.path,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
return config
|
||||
serverComponentsExternalPackages: ['libpg-query'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"js-yaml": "^3.14.1",
|
||||
"jsrsasign": "^11.0.0",
|
||||
"katex": "^0.16.10",
|
||||
"libpg-query": "15.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "*",
|
||||
"markdown-toc": "^1.2.0",
|
||||
@@ -74,7 +75,7 @@
|
||||
"mdast-util-to-string": "^3.1.1",
|
||||
"micromark-extension-gfm": "^2.0.3",
|
||||
"micromark-extension-mdxjs": "^1.0.0",
|
||||
"next": "^14.2.10",
|
||||
"next": "catalog:",
|
||||
"next-mdx-remote": "^4.4.1",
|
||||
"next-plugin-yaml": "^1.0.1",
|
||||
"next-seo": "^6.5.0",
|
||||
@@ -115,7 +116,7 @@
|
||||
"@types/hast": "^2.3.4",
|
||||
"@types/jsrsasign": "^10.5.12",
|
||||
"@types/mdast": "^3.0.0",
|
||||
"@types/node": "^20.11.16",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "^18.2.24",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/unist": "^2.0.6",
|
||||
@@ -125,7 +126,6 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"ejs": "^3.1.10",
|
||||
"globby": "^13.2.2",
|
||||
"nextjs-node-loader": "^1.1.5",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"openapi-types": "^12.1.3",
|
||||
"simple-git": "^3.24.0",
|
||||
|
||||
@@ -41,7 +41,6 @@ Inian P
|
||||
Ivan Vasilov
|
||||
Jenny Kibiri
|
||||
Jess Shears
|
||||
Joel Lee
|
||||
John Pena
|
||||
Jon M
|
||||
Jonny Summers-Muir
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -336,6 +336,25 @@
|
||||
"operationId": "v1-list-all-snippets",
|
||||
"summary": "Lists SQL snippets for the logged in user",
|
||||
"parameters": [
|
||||
{ "name": "cursor", "required": false, "in": "query", "schema": { "type": "string" } },
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": { "type": "string", "minimum": 1, "maximum": 100 }
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": { "enum": ["name", "inserted_at"], "type": "string" }
|
||||
},
|
||||
{
|
||||
"name": "sort_order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": { "enum": ["asc", "desc"], "type": "string" }
|
||||
},
|
||||
{
|
||||
"name": "project_ref",
|
||||
"required": false,
|
||||
@@ -3062,7 +3081,8 @@
|
||||
"SnippetList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/SnippetMeta" } }
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/SnippetMeta" } },
|
||||
"cursor": { "type": "string" }
|
||||
},
|
||||
"required": ["data"]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 2.1.1
|
||||
version: 2.2.1
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
|
||||
@@ -50,6 +50,9 @@ info:
|
||||
- id: local
|
||||
title: Local Development
|
||||
description: Local Development settings.
|
||||
- id: branching
|
||||
title: Branching
|
||||
description: Branching configurations settings.
|
||||
|
||||
# This section is an array of public functions which a user might need to execute.
|
||||
parameters:
|
||||
@@ -500,6 +503,18 @@ parameters:
|
||||
External URL of the API server that frontend connects to.
|
||||
links: []
|
||||
|
||||
- id: 'studio.openai_api_key'
|
||||
title: 'studio.openai_api_key'
|
||||
tags: ['dashboard']
|
||||
required: false
|
||||
default: 'env(OPENAI_API_KEY)'
|
||||
description: |
|
||||
OpenAI API key used for AI features in the Studio dashboard.
|
||||
DO NOT commit your OpenAI API key to git. Use environment variable substitution instead.
|
||||
links:
|
||||
- name: 'OpenAI API Keys'
|
||||
link: 'https://platform.openai.com/api-keys'
|
||||
|
||||
- id: 'inbucket.enabled'
|
||||
title: 'inbucket.enabled'
|
||||
tags: ['local']
|
||||
@@ -1587,3 +1602,19 @@ parameters:
|
||||
DO NOT commit your AWS secret key to git. Use environment variable substitution instead.
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'remotes.branch_name.project_id'
|
||||
title: 'remotes.<branch_name>.project_id'
|
||||
tags: ['branching']
|
||||
required: true
|
||||
description: |
|
||||
The project reference ID for a specific persistent Supabase branch.
|
||||
This ID is used to configure branch-specific settings in your config.toml file for branches deployments.
|
||||
All other configuration options available in the root config are also supported in the remotes block.
|
||||
For example, you can specify branch-specific database settings like so:
|
||||
usage: |
|
||||
[remotes.<branch_name>]
|
||||
project_id = "your-project-ref"
|
||||
|
||||
[remotes.<branch_name>.db.seed]
|
||||
sql_paths = ["./seeds/staging.sql"]
|
||||
@@ -990,6 +990,42 @@
|
||||
"operationId": "v1-list-all-snippets",
|
||||
"summary": "Lists SQL snippets for the logged in user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cursor",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": ["name", "inserted_at"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort_order",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": ["asc", "desc"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_ref",
|
||||
"required": false,
|
||||
@@ -1087,6 +1123,9 @@
|
||||
"updated_by"
|
||||
]
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["data"]
|
||||
@@ -10798,6 +10837,9 @@
|
||||
"updated_by"
|
||||
]
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["data"]
|
||||
|
||||
@@ -30,7 +30,7 @@ WORKDIR /app
|
||||
FROM base AS turbo
|
||||
COPY . .
|
||||
|
||||
RUN pnpm dlx turbo@2.0.4 prune studio --docker
|
||||
RUN pnpm dlx turbo@2.3.3 prune studio --docker
|
||||
|
||||
# Install dev dependencies (only if needed)
|
||||
FROM base AS deps
|
||||
@@ -50,7 +50,7 @@ CMD ["pnpm", "dev:studio"]
|
||||
# Compile Next.js
|
||||
FROM dev AS builder
|
||||
|
||||
RUN pnpm dlx turbo@2.0.4 run build --filter studio -- --no-lint
|
||||
RUN pnpm dlx turbo@2.3.3 run build --filter studio -- --no-lint
|
||||
|
||||
# Copy only compiled code and dependencies
|
||||
FROM base AS production
|
||||
|
||||
@@ -51,7 +51,11 @@ function BaseEditor<TRow, TSummaryRow = unknown>({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.focus()
|
||||
try {
|
||||
ref.current?.focus({ preventScroll: true })
|
||||
} catch (e) {
|
||||
ref.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -69,7 +73,7 @@ function BaseEditor<TRow, TSummaryRow = unknown>({
|
||||
onEnter={saveChanges}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
ref={ref}
|
||||
value={inputValue ?? ''}
|
||||
placeholder={FORMAT_MAP[type]}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isNil } from 'lodash'
|
||||
import { Maximize } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { RenderEditCellProps } from 'react-data-grid'
|
||||
@@ -65,8 +64,15 @@ export const JsonEditor = <TRow, TSummaryRow = unknown>({
|
||||
})
|
||||
|
||||
const gridColumn = state.gridColumns.find((x) => x.name == column.key)
|
||||
const initialValue = row[column.key as keyof TRow] as string
|
||||
const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '')
|
||||
|
||||
const rawInitialValue = row[column.key as keyof TRow] as unknown
|
||||
const initialValue =
|
||||
rawInitialValue === null || rawInitialValue === undefined || typeof rawInitialValue === 'string'
|
||||
? rawInitialValue
|
||||
: JSON.stringify(rawInitialValue)
|
||||
|
||||
const jsonString = prettifyJSON(initialValue ? tryFormatInitialValue(initialValue) : '')
|
||||
|
||||
const isTruncated =
|
||||
typeof initialValue === 'string' &&
|
||||
initialValue.endsWith('...') &&
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react'
|
||||
|
||||
import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
DialogFooter,
|
||||
DropdownMenu,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
export interface NewAccessTokenButtonProps {
|
||||
onCreateToken: (token: any) => void
|
||||
@@ -105,27 +105,30 @@ const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => {
|
||||
<>
|
||||
{tokenScope === 'V0' && (
|
||||
<Modal.Content>
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
|
||||
>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone. As
|
||||
such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="The experimental API provides additional endpoints which allows you to manage
|
||||
your organizations and projects."
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
These include deleting organizations and projects which cannot be undone.
|
||||
As such, be very careful when using this API.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
href="https://api.supabase.com/api/v0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Experimental API documentation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
)}
|
||||
<Modal.Content>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import {
|
||||
Alert,
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
@@ -29,6 +28,7 @@ import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants'
|
||||
import type { Provider } from './AuthProvidersForm.types'
|
||||
import FormField from './FormField'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
export interface ProviderFormProps {
|
||||
config: components['schemas']['GoTrueConfigResponse']
|
||||
@@ -264,9 +264,15 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => {
|
||||
))}
|
||||
|
||||
{provider?.misc?.alert && (
|
||||
<Alert title={provider.misc.alert.title} variant="warning" withIcon>
|
||||
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title={provider.misc.alert.title}
|
||||
description={
|
||||
<>
|
||||
<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{provider.misc.requiresRedirect && (
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@ const BasicAuthSettingsForm = () => {
|
||||
className="w-min"
|
||||
icon={<ExternalLink />}
|
||||
>
|
||||
<Link href="/docs/guides/auth/auth-anonymous#access-control">
|
||||
<Link href="https://supabase.com/docs/guides/auth/auth-anonymous#access-control">
|
||||
View access control docs
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -44,52 +44,49 @@ const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
closable
|
||||
hideFooter
|
||||
size="small"
|
||||
key="invite-user-modal"
|
||||
visible={visible}
|
||||
header="Invite a new user"
|
||||
onCancel={handleToggle}
|
||||
<Modal
|
||||
hideFooter
|
||||
size="small"
|
||||
key="invite-user-modal"
|
||||
visible={visible}
|
||||
header="Invite a new user"
|
||||
onCancel={handleToggle}
|
||||
>
|
||||
<Form
|
||||
validateOnBlur={false}
|
||||
initialValues={{ email: '' }}
|
||||
validate={validate}
|
||||
onSubmit={onInviteUser}
|
||||
>
|
||||
<Form
|
||||
validateOnBlur={false}
|
||||
initialValues={{ email: '' }}
|
||||
validate={validate}
|
||||
onSubmit={onInviteUser}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<Input
|
||||
id="email"
|
||||
className="w-full"
|
||||
label="User email"
|
||||
icon={<Mail />}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="User email"
|
||||
/>
|
||||
</Modal.Content>
|
||||
{() => (
|
||||
<>
|
||||
<Modal.Content>
|
||||
<Input
|
||||
id="email"
|
||||
className="w-full"
|
||||
label="User email"
|
||||
icon={<Mail />}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="User email"
|
||||
/>
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Content>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
htmlType="submit"
|
||||
loading={isInviting}
|
||||
disabled={!canInviteUsers || isInviting}
|
||||
>
|
||||
Invite user
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
<Modal.Content>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
htmlType="submit"
|
||||
loading={isInviting}
|
||||
disabled={!canInviteUsers || isInviting}
|
||||
>
|
||||
Invite user
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
|
||||
|
||||
// [Joshen] Temporary fix as bulk delete will fire n requests since Auth + API do not have a bulk delete endpoint yet
|
||||
export const MAX_BULK_DELETE = 20
|
||||
|
||||
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
|
||||
name: provider.title,
|
||||
value:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
import { UIEvent } from 'react'
|
||||
import { Column } from 'react-data-grid'
|
||||
import { Column, useRowSelection } from 'react-data-grid'
|
||||
|
||||
import { User } from 'data/auth/users-infinite-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { cn } from 'ui'
|
||||
import { HeaderCell } from './UsersGridComponents'
|
||||
import { Checkbox_Shadcn_, cn } from 'ui'
|
||||
import { HeaderCell, SelectHeaderCell } from './UsersGridComponents'
|
||||
import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './UsersV2'
|
||||
|
||||
const SUPPORTED_CSP_AVATAR_URLS = [
|
||||
@@ -262,10 +262,18 @@ export const formatUserColumns = ({
|
||||
minWidth: col.minWidth ?? 120,
|
||||
headerCellClass: 'z-50 outline-none !shadow-none',
|
||||
renderHeaderCell: () => {
|
||||
// [Joshen] I'm on the fence to support "Select all" for users, as the results are infinitely paginated
|
||||
// "Select all" wouldn't be an accurate representation if not all the pages have been fetched, but if decide
|
||||
// to support - the component is ready as such: Just pass selectedUsers and allRowsSelected as props from parent
|
||||
// <SelectHeaderCell selectedUsers={selectedUsers} allRowsSelected={allRowsSelected} />
|
||||
if (col.id === 'img') return undefined
|
||||
return <HeaderCell col={col} setSortByValue={setSortByValue} />
|
||||
},
|
||||
renderCell: ({ row }) => {
|
||||
// This is actually a valid React component, so we can use hooks here
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [isRowSelected, onRowSelectionChange] = useRowSelection()
|
||||
|
||||
const value = row?.[col.id]
|
||||
const user = users?.find((u) => u.id === row.id)
|
||||
const formattedValue =
|
||||
@@ -278,7 +286,19 @@ export const formatUserColumns = ({
|
||||
|
||||
if (col.id === 'img') {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<Checkbox_Shadcn_
|
||||
checked={isRowSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRowSelectionChange({
|
||||
row,
|
||||
type: 'ROW',
|
||||
checked: !isRowSelected,
|
||||
isShiftClick: e.shiftKey,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-6 h-6 rounded-full bg-center bg-cover bg-no-repeat',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, SortAsc, SortDesc } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRowSelection } from 'react-data-grid'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -8,6 +9,40 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
// [Joshen] Currently not being used, refer to Users.utils -> formatUserColumns for more info
|
||||
export const SelectHeaderCell = ({
|
||||
selectedUsers,
|
||||
allRowsSelected,
|
||||
}: {
|
||||
selectedUsers: Set<any>
|
||||
allRowsSelected: boolean
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isRowSelected, onRowSelectionChange] = useRowSelection()
|
||||
|
||||
const isIndeterminate = selectedUsers.size > 0 && !allRowsSelected
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) inputRef.current.indeterminate = isIndeterminate
|
||||
}, [isIndeterminate])
|
||||
|
||||
return (
|
||||
<div className="px-4 flex items-center sb-grid">
|
||||
<div className="sb-grid-select-cell__header">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
aria-label="Select-all"
|
||||
className="sb-grid-select-cell__header__input"
|
||||
disabled={false}
|
||||
checked={isRowSelected}
|
||||
onChange={(e) => onRowSelectionChange({ type: 'HEADER', checked: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const HeaderCell = ({
|
||||
col,
|
||||
setSortByValue,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Users, X } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Trash, Users, X } from 'lucide-react'
|
||||
import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
@@ -10,6 +12,8 @@ import AlertError from 'components/ui/AlertError'
|
||||
import APIDocsButton from 'components/ui/APIDocsButton'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { authKeys } from 'data/auth/keys'
|
||||
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
|
||||
import { useUsersCountQuery } from 'data/auth/users-count-query'
|
||||
import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
@@ -36,13 +40,15 @@ import {
|
||||
SelectValue_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import AddUserDropdown from './AddUserDropdown'
|
||||
import { UserPanel } from './UserPanel'
|
||||
import { PROVIDER_FILTER_OPTIONS } from './Users.constants'
|
||||
import { MAX_BULK_DELETE, PROVIDER_FILTER_OPTIONS } from './Users.constants'
|
||||
import { formatUserColumns, formatUsersData, isAtBottom } from './Users.utils'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
|
||||
type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
|
||||
export type Filter = 'all' | 'verified' | 'unverified' | 'anonymous'
|
||||
export type UsersTableColumn = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -52,7 +58,7 @@ export type UsersTableColumn = {
|
||||
}
|
||||
export type ColumnConfiguration = { id: string; width?: number }
|
||||
export const USERS_TABLE_COLUMNS: UsersTableColumn[] = [
|
||||
{ id: 'img', name: '', minWidth: 65, width: 65, resizable: false },
|
||||
{ id: 'img', name: '', minWidth: 95, width: 95, resizable: false },
|
||||
{ id: 'id', name: 'UID', width: 280 },
|
||||
{ id: 'name', name: 'Display name', minWidth: 0, width: 150 },
|
||||
{ id: 'email', name: 'Email', width: 300 },
|
||||
@@ -66,6 +72,7 @@ export const USERS_TABLE_COLUMNS: UsersTableColumn[] = [
|
||||
// [Joshen] Just naming it as V2 as its a rewrite of the old one, to make it easier for reviews
|
||||
// Can change it to remove V2 thereafter
|
||||
export const UsersV2 = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
@@ -75,10 +82,13 @@ export const UsersV2 = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [filterKeywords, setFilterKeywords] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
||||
const [selectedRow, setSelectedRow] = useState<number>()
|
||||
const [selectedUser, setSelectedUser] = useState<string>()
|
||||
const [sortByValue, setSortByValue] = useState<string>('created_at:desc')
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
|
||||
const [
|
||||
columnConfiguration,
|
||||
setColumnConfiguration,
|
||||
@@ -123,8 +133,12 @@ export const UsersV2 = () => {
|
||||
providers: selectedProviders,
|
||||
})
|
||||
|
||||
const { mutateAsync: deleteUser } = useUserDeleteMutation()
|
||||
|
||||
const totalUsers = countData ?? 0
|
||||
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
||||
// [Joshen] Only relevant for when selecting one user only
|
||||
const selectedUserToDelete = users.find((u) => u.id === [...selectedUsers][0])
|
||||
|
||||
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
if (isLoading || !isAtBottom(event)) return
|
||||
@@ -169,6 +183,33 @@ export const UsersV2 = () => {
|
||||
500
|
||||
)
|
||||
|
||||
const handleDeleteUsers = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
const userIds = [...selectedUsers]
|
||||
|
||||
setIsDeletingUsers(true)
|
||||
try {
|
||||
await Promise.all(
|
||||
userIds.map((id) => deleteUser({ projectRef, userId: id, skipInvalidation: true }))
|
||||
)
|
||||
// [Joshen] Skip invalidation within RQ to prevent multiple requests, then invalidate once at the end
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
|
||||
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
|
||||
])
|
||||
toast.success(
|
||||
`Successfully deleted the selected ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`
|
||||
)
|
||||
setShowDeleteModal(false)
|
||||
setSelectedUsers(new Set([]))
|
||||
|
||||
if (userIds.includes(selectedUser)) setSelectedUser(undefined)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to delete selected users: ${error.message}`)
|
||||
setIsDeletingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isRefetching &&
|
||||
@@ -187,278 +228,346 @@ export const UsersV2 = () => {
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess, isRefetching, isSuccessStorage, isErrorStorage, errorStorage, users])
|
||||
}, [
|
||||
isSuccess,
|
||||
isRefetching,
|
||||
isSuccessStorage,
|
||||
isErrorStorage,
|
||||
errorStorage,
|
||||
users,
|
||||
selectedUsers,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
|
||||
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-52 pl-7 bg-transparent"
|
||||
iconContainerClassName="pl-2"
|
||||
icon={<Search size={14} className="text-foreground-lighter" />}
|
||||
placeholder="Search email, phone or UID"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Enter') {
|
||||
setSearch(search.trim())
|
||||
setFilterKeywords(search.trim().toLocaleLowerCase())
|
||||
}
|
||||
}}
|
||||
actions={[
|
||||
search && (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader className="py-4 px-6 !mb-0" title="Users" />
|
||||
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
|
||||
{selectedUsers.size > 0 ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="default" icon={<Trash />} onClick={() => setShowDeleteModal(true)}>
|
||||
Delete {selectedUsers.size} users
|
||||
</Button>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
icon={<X />}
|
||||
className="px-1.5"
|
||||
onClick={() => setSelectedUsers(new Set([]))}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Cancel selection' } }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-52 pl-7 bg-transparent"
|
||||
iconContainerClassName="pl-2"
|
||||
icon={<Search size={14} className="text-foreground-lighter" />}
|
||||
placeholder="Search email, phone or UID"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === 'Enter') {
|
||||
setSearch(search.trim())
|
||||
setFilterKeywords(search.trim().toLocaleLowerCase())
|
||||
}
|
||||
}}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => clearSearch()}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select_Shadcn_ value={filter} onValueChange={(val) => setFilter(val as Filter)}>
|
||||
<SelectTrigger_Shadcn_
|
||||
size="tiny"
|
||||
className={cn('w-[140px] !bg-transparent', filter === 'all' && 'border-dashed')}
|
||||
>
|
||||
<SelectValue_Shadcn_ />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="all" className="text-xs">
|
||||
All users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="verified" className="text-xs">
|
||||
Verified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="unverified" className="text-xs">
|
||||
Unverified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
|
||||
Anonymous users
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
|
||||
<FilterPopover
|
||||
name="Provider"
|
||||
options={PROVIDER_FILTER_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={selectedProviders}
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
onSaveFilters={setSelectedProviders}
|
||||
/>
|
||||
|
||||
<div className="border-r border-strong h-6" />
|
||||
|
||||
<FilterPopover
|
||||
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
|
||||
title="Select columns to show"
|
||||
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
|
||||
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
clearButtonText="Reset"
|
||||
activeOptions={selectedColumns}
|
||||
onSaveFilters={(value) => {
|
||||
// When adding back hidden columns:
|
||||
// (1) width set to default value if any
|
||||
// (2) they will just get appended to the end
|
||||
// (3) If "clearing", reset order of the columns to original
|
||||
|
||||
let updatedConfig = (columnConfiguration ?? []).slice()
|
||||
if (value.length === 0) {
|
||||
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
|
||||
} else {
|
||||
value.forEach((col) => {
|
||||
const hasExisting = updatedConfig.find((c) => c.id === col)
|
||||
if (!hasExisting)
|
||||
updatedConfig.push({
|
||||
id: col,
|
||||
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updatedColumns = formatUserColumns({
|
||||
config: updatedConfig,
|
||||
users: users ?? [],
|
||||
visibleColumns: value,
|
||||
setSortByValue,
|
||||
})
|
||||
|
||||
setSelectedColumns(value)
|
||||
setColumns(updatedColumns)
|
||||
saveColumnConfiguration('toggle', { columns: updatedColumns })
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}>
|
||||
Sorted by {sortColumn.replaceAll('_', ' ')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44" align="start">
|
||||
<DropdownMenuRadioGroup value={sortByValue} onValueChange={setSortByValue}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by created at</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="created_at:asc">
|
||||
Ascending
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="created_at:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by last sign in at</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="last_sign_in_at:asc">
|
||||
Ascending
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="last_sign_in_at:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by email</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="email:asc">Ascending</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="email:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by phone</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="phone:asc">Ascending</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="phone:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isNewAPIDocsEnabled && <APIDocsButton section={['user-management']} />}
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => clearSearch()}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select_Shadcn_ value={filter} onValueChange={(val) => setFilter(val as Filter)}>
|
||||
<SelectTrigger_Shadcn_
|
||||
size="tiny"
|
||||
className={cn('w-[140px] !bg-transparent', filter === 'all' && 'border-dashed')}
|
||||
>
|
||||
<SelectValue_Shadcn_ />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectGroup_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="all" className="text-xs">
|
||||
All users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="verified" className="text-xs">
|
||||
Verified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="unverified" className="text-xs">
|
||||
Unverified users
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="anonymous" className="text-xs">
|
||||
Anonymous users
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectGroup_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
|
||||
<FilterPopover
|
||||
name="Provider"
|
||||
options={PROVIDER_FILTER_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={selectedProviders}
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
onSaveFilters={setSelectedProviders}
|
||||
/>
|
||||
|
||||
<div className="border-r border-strong h-6" />
|
||||
|
||||
<FilterPopover
|
||||
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
|
||||
title="Select columns to show"
|
||||
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
|
||||
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
|
||||
labelKey="name"
|
||||
valueKey="id"
|
||||
labelClass="text-xs"
|
||||
maxHeightClass="h-[190px]"
|
||||
clearButtonText="Reset"
|
||||
activeOptions={selectedColumns}
|
||||
onSaveFilters={(value) => {
|
||||
// When adding back hidden columns:
|
||||
// (1) width set to default value if any
|
||||
// (2) they will just get appended to the end
|
||||
// (3) If "clearing", reset order of the columns to original
|
||||
|
||||
let updatedConfig = (columnConfiguration ?? []).slice()
|
||||
if (value.length === 0) {
|
||||
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
|
||||
} else {
|
||||
value.forEach((col) => {
|
||||
const hasExisting = updatedConfig.find((c) => c.id === col)
|
||||
if (!hasExisting)
|
||||
updatedConfig.push({
|
||||
id: col,
|
||||
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updatedColumns = formatUserColumns({
|
||||
config: updatedConfig,
|
||||
users: users ?? [],
|
||||
visibleColumns: value,
|
||||
setSortByValue,
|
||||
})
|
||||
|
||||
setSelectedColumns(value)
|
||||
setColumns(updatedColumns)
|
||||
saveColumnConfiguration('toggle', { columns: updatedColumns })
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}>
|
||||
Sorted by {sortColumn.replaceAll('_', ' ')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-44" align="start">
|
||||
<DropdownMenuRadioGroup value={sortByValue} onValueChange={setSortByValue}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by created at</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="created_at:asc">Ascending</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="created_at:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by last sign in at</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="last_sign_in_at:asc">
|
||||
Ascending
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="last_sign_in_at:desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by email</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="email:asc">Ascending</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="email:desc">Descending</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Sort by phone</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioItem value="phone:asc">Ascending</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="phone:desc">Descending</DropdownMenuRadioItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
icon={<RefreshCw />}
|
||||
type="default"
|
||||
loading={isRefetching && !isFetchingNextPage}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<AddUserDropdown />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="relative flex flex-grow bg-alternative min-h-0"
|
||||
autoSaveId="query-performance-layout-v1"
|
||||
>
|
||||
<ResizablePanel defaultSize={1}>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<DataGrid
|
||||
ref={gridRef}
|
||||
className="flex-grow border-t-0"
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={formatUsersData(users ?? [])}
|
||||
rowClass={(row) => {
|
||||
const isSelected = row.id === selectedUser
|
||||
return [
|
||||
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-4',
|
||||
].join(' ')
|
||||
}}
|
||||
rowKeyGetter={(row) => row.id}
|
||||
selectedRows={selectedUsers}
|
||||
onScroll={handleScroll}
|
||||
onSelectedRowsChange={(rows) => {
|
||||
if (rows.size > MAX_BULK_DELETE) {
|
||||
toast(`Only up to ${MAX_BULK_DELETE} users can be selected at a time`)
|
||||
} else setSelectedUsers(rows)
|
||||
}}
|
||||
onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
|
||||
onColumnsReorder={(source, target) => {
|
||||
const sourceIdx = columns.findIndex((col) => col.key === source)
|
||||
const targetIdx = columns.findIndex((col) => col.key === target)
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isNewAPIDocsEnabled && <APIDocsButton section={['user-management']} />}
|
||||
<Button
|
||||
size="tiny"
|
||||
icon={<RefreshCw />}
|
||||
type="default"
|
||||
loading={isRefetching && !isFetchingNextPage}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<AddUserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<LoadingLine loading={isLoading || isRefetching || isFetchingNextPage} />
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="relative flex flex-grow bg-alternative min-h-0"
|
||||
autoSaveId="query-performance-layout-v1"
|
||||
>
|
||||
<ResizablePanel defaultSize={1}>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<DataGrid
|
||||
ref={gridRef}
|
||||
className="flex-grow border-t-0"
|
||||
rowHeight={44}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={formatUsersData(users ?? [])}
|
||||
rowClass={(_, idx) => {
|
||||
const isSelected = idx === selectedRow
|
||||
return [
|
||||
`${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`,
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:ml-4',
|
||||
].join(' ')
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onColumnResize={(idx, width) => saveColumnConfiguration('resize', { idx, width })}
|
||||
onColumnsReorder={(source, target) => {
|
||||
const sourceIdx = columns.findIndex((col) => col.key === source)
|
||||
const targetIdx = columns.findIndex((col) => col.key === target)
|
||||
const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
|
||||
setColumns(updatedColumns)
|
||||
|
||||
const updatedColumns = swapColumns(columns, sourceIdx, targetIdx)
|
||||
setColumns(updatedColumns)
|
||||
|
||||
saveColumnConfiguration('reorder', { columns: updatedColumns })
|
||||
}}
|
||||
renderers={{
|
||||
renderRow(idx, props) {
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
key={props.row.id}
|
||||
onClick={() => {
|
||||
if (typeof idx === 'number' && idx >= 0) {
|
||||
setSelectedRow(idx)
|
||||
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
noRowsFallback: isLoading ? (
|
||||
<div className="absolute top-14 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
|
||||
<AlertError subject="Failed to retrieve users" error={error} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
|
||||
<Users className="text-foreground-lighter" strokeWidth={1} />
|
||||
<div className="text-center">
|
||||
<p className="text-foreground">
|
||||
{filter !== 'all' || filterKeywords.length > 0
|
||||
? 'No users found'
|
||||
: 'No users in your project'}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{filter !== 'all' || filterKeywords.length > 0
|
||||
? 'There are currently no users based on the filters applied'
|
||||
: 'There are currently no users who signed up to your project'}
|
||||
</p>
|
||||
saveColumnConfiguration('reorder', { columns: updatedColumns })
|
||||
}}
|
||||
renderers={{
|
||||
renderRow(id, props) {
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
key={props.row.id}
|
||||
onClick={() => {
|
||||
const idx = users.indexOf(users.find((u) => u.id === id) ?? {})
|
||||
if (props.row.id) {
|
||||
setSelectedUser(props.row.id)
|
||||
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
noRowsFallback: isLoading ? (
|
||||
<div className="absolute top-14 px-6 w-full">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
) : isError ? (
|
||||
<div className="absolute top-14 px-6 flex flex-col items-center justify-center w-full">
|
||||
<AlertError subject="Failed to retrieve users" error={error} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-20 px-6 flex flex-col items-center justify-center w-full gap-y-2">
|
||||
<Users className="text-foreground-lighter" strokeWidth={1} />
|
||||
<div className="text-center">
|
||||
<p className="text-foreground">
|
||||
{filter !== 'all' || filterKeywords.length > 0
|
||||
? 'No users found'
|
||||
: 'No users in your project'}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{filter !== 'all' || filterKeywords.length > 0
|
||||
? 'There are currently no users based on the filters applied'
|
||||
: 'There are currently no users who signed up to your project'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{selectedUser !== undefined && (
|
||||
<UserPanel
|
||||
selectedUser={users.find((u) => u.id === selectedUser)}
|
||||
onClose={() => setSelectedUser(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{selectedRow !== undefined && (
|
||||
<UserPanel
|
||||
selectedUser={users?.[selectedRow]}
|
||||
onClose={() => setSelectedRow(undefined)}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
|
||||
{isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`}
|
||||
{(isLoading || isRefetching || isFetchingNextPage) && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
)}
|
||||
<div className="flex justify-between min-h-9 h-9 overflow-hidden items-center px-6 w-full border-t text-xs text-foreground-light">
|
||||
{isLoading || isRefetching ? 'Loading users...' : `Total: ${totalUsers} users`}
|
||||
{(isLoading || isRefetching || isFetchingNextPage) && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" /> Loading...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showDeleteModal}
|
||||
variant="destructive"
|
||||
title={`Confirm to delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`}
|
||||
loading={isDeletingUsers}
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onConfirm={() => handleDeleteUsers()}
|
||||
alert={{
|
||||
title: `Deleting ${selectedUsers.size === 1 ? 'a user' : 'users'} is irreversible`,
|
||||
description: `This will remove the selected ${selectedUsers.size === 1 ? '' : `${selectedUsers.size} `}user${selectedUsers.size > 1 ? 's' : ''} from the project and all associated data.`,
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This is permanent! Are you sure you want to delete the{' '}
|
||||
{selectedUsers.size === 1 ? '' : `selected ${selectedUsers.size} `}user
|
||||
{selectedUsers.size > 1 ? 's' : ''}
|
||||
{selectedUsers.size === 1 ? (
|
||||
<span className="text-foreground">
|
||||
{' '}
|
||||
{selectedUserToDelete?.email ?? selectedUserToDelete?.phone ?? 'this user'}
|
||||
</span>
|
||||
) : null}
|
||||
?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+75
-45
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { Alert, Button } from 'ui'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
// [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth
|
||||
interface DiskIOBandwidthWarningsProps {
|
||||
@@ -21,66 +22,95 @@ const DiskIOBandwidthWarnings = ({
|
||||
}: DiskIOBandwidthWarningsProps) => {
|
||||
if (hasLatest && latestIoBudgetConsumption >= 100) {
|
||||
return (
|
||||
<Alert withIcon variant="danger" title="Your Disk IO Budget has been used up">
|
||||
<p className="mb-4">
|
||||
Your workload has used up all your Disk IO Budget and is now running at the baseline
|
||||
performance. If you need consistent disk performance, consider upgrading to a larger
|
||||
compute add-on.
|
||||
</p>
|
||||
<Button asChild type="danger">
|
||||
<Link href={upgradeUrl}>{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Your Disk IO Budget has been used up"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Your workload has used up all your Disk IO Budget and is now running at the baseline
|
||||
performance. If you need consistent disk performance, consider upgrading to a larger
|
||||
compute add-on.
|
||||
</p>
|
||||
<Button asChild type="danger">
|
||||
<Link href={upgradeUrl}>
|
||||
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasLatest && latestIoBudgetConsumption >= 80) {
|
||||
return (
|
||||
<Alert withIcon variant="danger" title="You are close to running out of Disk IO Budget">
|
||||
<p className="mb-4">
|
||||
Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO Budget. If you use
|
||||
up all your Disk IO Budget, your instance will reverted to baseline performance. If you
|
||||
need consistent disk performance, consider upgrading to a larger compute add-on.
|
||||
</p>
|
||||
<Button asChild type="danger">
|
||||
<Link href={upgradeUrl}>{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="You are close to running out of Disk IO Budget"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO Budget. If you
|
||||
use up all your Disk IO Budget, your instance will reverted to baseline performance.
|
||||
If you need consistent disk performance, consider upgrading to a larger compute
|
||||
add-on.
|
||||
</p>
|
||||
<Button asChild type="danger">
|
||||
<Link href={upgradeUrl}>
|
||||
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 100) {
|
||||
return (
|
||||
<Alert withIcon variant="warning" title="You ran out of IO Budget at least once">
|
||||
<p className="mb-4">
|
||||
Your workload has used up all your Disk IO Budget and reverted to baseline performance at
|
||||
least once during this billing cycle. If you need consistent disk performance, consider
|
||||
upgrading to a larger compute add-on.
|
||||
</p>
|
||||
<Button asChild type="warning">
|
||||
<Link href={upgradeUrl}>{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="You ran out of IO Budget at least once"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Your workload has used up all your Disk IO Budget and reverted to baseline performance
|
||||
at least once during this billing cycle. If you need consistent disk performance,
|
||||
consider upgrading to a larger compute add-on.
|
||||
</p>
|
||||
<Button asChild type="warning">
|
||||
<Link href={upgradeUrl}>
|
||||
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 80) {
|
||||
return (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="You were close to using all your IO Budget at least once"
|
||||
>
|
||||
<p className="mb-4">
|
||||
Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO budget during
|
||||
this billing cycle. If you use up all your Disk IO Budget, your instance will reverted to
|
||||
baseline performance. If you need consistent disk performance, consider upgrading to a
|
||||
larger compute add-on.
|
||||
</p>
|
||||
<Button asChild type="warning">
|
||||
<Link href={upgradeUrl}>{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
description={
|
||||
<>
|
||||
<p className="mb-4">
|
||||
Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO budget during
|
||||
this billing cycle. If you use up all your Disk IO Budget, your instance will reverted
|
||||
to baseline performance. If you need consistent disk performance, consider upgrading
|
||||
to a larger compute add-on.
|
||||
</p>
|
||||
<Button asChild type="warning">
|
||||
<Link href={upgradeUrl}>
|
||||
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ const Connect = () => {
|
||||
/>
|
||||
<Panel.Notice
|
||||
className="border border-t-0 rounded-lg rounded-t-none"
|
||||
title="New API keys coming Q4 2024"
|
||||
title="New API keys coming 2025"
|
||||
description={`
|
||||
\`anon\` and \`service_role\` API keys will be changing to \`publishable\` and \`secret\` API keys.
|
||||
`}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const getConnectionStrings = (
|
||||
|
||||
const directUriString = `postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}`
|
||||
|
||||
const directGolangString = `DATABASE_URL=${poolingInfo.connectionString}`
|
||||
const directGolangString = `DATABASE_URL=${directUriString}`
|
||||
|
||||
const directJdbcString = `jdbc:postgresql://${directHost}:${directPort}/${directName}?user=${directUser}&password=${password}`
|
||||
|
||||
@@ -69,6 +69,8 @@ export const getConnectionStrings = (
|
||||
}
|
||||
}`
|
||||
|
||||
const directNodejsString = `DATABASE_URL=${directUriString}`
|
||||
|
||||
// Pooler connection strings
|
||||
const poolerPsqlString = isMd5
|
||||
? `psql "postgresql://${poolerUser}:${password}@${poolerHost}:${poolerPort}/${poolerName}?options=reference%3D${projectRef}"`
|
||||
@@ -105,7 +107,7 @@ dbname=${poolerName}`
|
||||
golang: directGolangString,
|
||||
jdbc: directJdbcString,
|
||||
dotnet: directDotNetString,
|
||||
nodejs: nodejsPoolerUriString,
|
||||
nodejs: directNodejsString,
|
||||
php: directGolangString,
|
||||
python: directGolangString,
|
||||
sqlalchemy: sqlalchemyString,
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
|
||||
function DatabaseBackupsNav({ active }: Props) {
|
||||
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
|
||||
const ref = useProjectContext()?.project?.ref
|
||||
const { ref, cloud_provider } = useProjectContext()?.project || {}
|
||||
|
||||
const navMenuItems = [
|
||||
{
|
||||
@@ -27,7 +27,7 @@ function DatabaseBackupsNav({ active }: Props) {
|
||||
href: `/project/${ref}/database/backups/pitr`,
|
||||
},
|
||||
{
|
||||
enabled: isCloneToNewProjectEnabled,
|
||||
enabled: isCloneToNewProjectEnabled && cloud_provider !== 'FLY',
|
||||
id: 'rtnp',
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -7,9 +7,10 @@ import Table from 'components/to-be-cleaned/Table'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { Alert, Button, Input, SidePanel } from 'ui'
|
||||
import { Button, Input, SidePanel } from 'ui'
|
||||
import MigrationsEmptyState from './MigrationsEmptyState'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
const Migrations = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -39,26 +40,27 @@ const Migrations = () => {
|
||||
|
||||
<div>
|
||||
{isError && (
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Failed to retrieve migration history for database"
|
||||
actions={[
|
||||
<Button key="contact-support" asChild type="default" className="ml-4">
|
||||
<Link
|
||||
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
|
||||
>
|
||||
Contact support
|
||||
</Link>
|
||||
</Button>,
|
||||
]}
|
||||
description={
|
||||
<>
|
||||
<p className="mb-1">
|
||||
Try refreshing your browser, but if the issue persists, please reach out to us via
|
||||
support.
|
||||
</p>
|
||||
<p className="mb-4">Error: {(error as any)?.message ?? 'Unknown'}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-1">
|
||||
Try refreshing your browser, but if the issue persists, please reach out to us via
|
||||
support.
|
||||
</p>
|
||||
<p>Error: {(error as any)?.message ?? 'Unknown'}</p>
|
||||
</Alert>
|
||||
<Button key="contact-support" asChild type="default">
|
||||
<Link
|
||||
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
|
||||
>
|
||||
Contact support
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<div>
|
||||
|
||||
@@ -48,7 +48,7 @@ const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
|
||||
</div>
|
||||
|
||||
<h2 className="doc-heading">Client API Keys</h2>
|
||||
<div className="doc-section ">
|
||||
<div className="doc-section">
|
||||
<article className="code-column text-foreground">
|
||||
<p>
|
||||
Client keys allow "anonymous access" to your database, until the user has logged in.
|
||||
@@ -79,7 +79,7 @@ const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
|
||||
</div>
|
||||
|
||||
<h2 className="doc-heading">Service Keys</h2>
|
||||
<div className="doc-section ">
|
||||
<div className="doc-section">
|
||||
<article className="code-column text-foreground">
|
||||
<p>
|
||||
Service keys have FULL access to your data, bypassing any security policies. Be VERY
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useParams } from 'common'
|
||||
import Snippets from 'components/interfaces/Docs/Snippets'
|
||||
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
|
||||
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import CodeSnippet from './CodeSnippet'
|
||||
import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert'
|
||||
@@ -26,12 +27,26 @@ export default function Introduction({ selectedLang }: Props) {
|
||||
.includes('public')
|
||||
|
||||
return (
|
||||
<div className="doc-section doc-section--client-libraries">
|
||||
<article className="code">
|
||||
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(endpoint)} />
|
||||
|
||||
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
|
||||
</article>
|
||||
</div>
|
||||
<>
|
||||
<h2 className="doc-heading">Connect to your project</h2>
|
||||
<div className="doc-section">
|
||||
<article className="code-column text-foreground">
|
||||
<p>
|
||||
All projects have a RESTful endpoint that you can use with your project's API key to
|
||||
query and manage your database. These can be obtained from the{' '}
|
||||
<InlineLink href={`/project/${projectRef}/settings/api`}>API settings</InlineLink>.
|
||||
</p>
|
||||
<p>
|
||||
You can initialize a new Supabase client using the <code>createClient()</code> method.
|
||||
The Supabase client is your entrypoint to the rest of the Supabase functionality and is
|
||||
the easiest way to interact with everything we offer within the Supabase ecosystem.
|
||||
</p>
|
||||
</article>
|
||||
<article className="code">
|
||||
<CodeSnippet selectedLang={selectedLang} snippet={Snippets.init(endpoint)} />
|
||||
{!isPublicSchemaEnabled && <PublicSchemaNotEnabledAlert />}
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -101,7 +101,7 @@ const EdgeFunctionDetails = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 pb-16">
|
||||
<div className="space-y-4 pb-16 pt-3">
|
||||
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
|
||||
{({ handleReset, values, initialValues, resetForm }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
@@ -36,7 +36,7 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
|
||||
<Table.tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
router.push(`/project/${ref}/functions/${item.slug}/details`)
|
||||
router.push(`/project/${ref}/functions/${item.slug}`)
|
||||
}}
|
||||
>
|
||||
<Table.td>
|
||||
|
||||
@@ -11,17 +11,18 @@ const FunctionsNav = ({ item }: any) => {
|
||||
defaultActiveId="1"
|
||||
type="underlined"
|
||||
size="medium"
|
||||
activeId={!activeRoute ? 'metrics' : activeRoute}
|
||||
baseClassNames="!space-y-0"
|
||||
activeId={!activeRoute ? 'overview' : activeRoute}
|
||||
onChange={(e: string) => {
|
||||
if (item?.slug) {
|
||||
router.push(`/project/${ref}/functions/${item.slug}/${e === 'metrics' ? '' : e}`)
|
||||
router.push(`/project/${ref}/functions/${item.slug}/${e === 'overview' ? '' : e}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs.Panel id="details" label="Details" />
|
||||
<Tabs.Panel id="metrics" label="Metrics" />
|
||||
<Tabs.Panel id="overview" label="Overview" />
|
||||
<Tabs.Panel id="invocations" label="Invocations" />
|
||||
<Tabs.Panel id="logs" label="Logs" />
|
||||
<Tabs.Panel id="details" label="Details" />
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ const ProjectUsage = () => {
|
||||
],
|
||||
0,
|
||||
startDateLocal.toISOString(),
|
||||
endDateLocal.toISOString()
|
||||
endDateLocal.toISOString(),
|
||||
5
|
||||
)
|
||||
const datetimeFormat = selectedInterval.format || 'MMM D, ha'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Clock, History, Loader2, MoreVertical } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
@@ -7,21 +7,26 @@ import { useParams } from 'common'
|
||||
import { SQLCodeBlock } from 'components/interfaces/Auth/ThirdPartyAuthForm/SqlCodeBlock'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
|
||||
import { useCronJobRunQuery } from 'data/database-cron-jobs/database-cron-jobs-run-query'
|
||||
import { useDatabaseCronJobToggleMutation } from 'data/database-cron-jobs/database-cron-jobs-toggle-mutation'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { TelemetryActions } from 'lib/constants/telemetry'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { convertCronToString, getNextRun } from './CronJobs.utils'
|
||||
|
||||
interface CronJobCardProps {
|
||||
job: CronJob
|
||||
@@ -35,17 +40,18 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
|
||||
|
||||
const [toggleConfirmationModalShown, showToggleConfirmationModal] = useState(false)
|
||||
|
||||
const { data } = useCronJobRunQuery({
|
||||
projectRef: ref,
|
||||
connectionString: selectedProject?.connectionString,
|
||||
jobId: job.jobid,
|
||||
})
|
||||
const lastRun = data?.start_time ? dayjs(data.start_time).valueOf() : undefined
|
||||
const nextRun = getNextRun(job.schedule, data?.start_time)
|
||||
const schedule = convertCronToString(job.schedule)
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const { mutate: toggleDatabaseCronJob, isLoading } = useDatabaseCronJobToggleMutation()
|
||||
|
||||
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
|
||||
// original schedule when cronstrue throws
|
||||
let schedule = job.schedule
|
||||
try {
|
||||
const scheduleString = CronToString(job.schedule)
|
||||
schedule = scheduleString
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-surface-100 border-default overflow-hidden border shadow px-5 py-4 flex flex-row rounded-md space-x-4">
|
||||
@@ -120,12 +126,59 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
|
||||
<div className="grid grid-cols-10 gap-3 items-center">
|
||||
<span className="text-foreground-light col-span-1">Schedule</span>
|
||||
<div className="col-span-9">
|
||||
<Input
|
||||
title={schedule}
|
||||
readOnly
|
||||
className="input-mono [&>div>div>div>input]:text-xs [&>div>div>div>input]:opacity-100 flex-1"
|
||||
value={schedule}
|
||||
/>
|
||||
<Input readOnly title={schedule} value={schedule} className="w-96" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-10 gap-3 items-center">
|
||||
<span className="text-foreground-light col-span-1">Last run</span>
|
||||
<div className="col-span-9">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-control bg-foreground/[0.026] rounded-md px-3 py-1.5 w-96',
|
||||
!lastRun && 'text-foreground-lighter'
|
||||
)}
|
||||
>
|
||||
{lastRun ? (
|
||||
<>
|
||||
<TimestampInfo
|
||||
utcTimestamp={lastRun}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
className="font-sans text-sm"
|
||||
/>
|
||||
{data?.status && (
|
||||
<Badge
|
||||
variant={data.status === 'failed' ? 'destructive' : 'success'}
|
||||
className="capitalize ml-2"
|
||||
>
|
||||
{data.status}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'Job has not been run yet'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-10 gap-3 items-center">
|
||||
<span className="text-foreground-light col-span-1">Next run</span>
|
||||
<div className="col-span-9">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-control bg-foreground/[0.026] rounded-md px-3 py-1.5 w-96',
|
||||
!nextRun && 'text-foreground-lighter'
|
||||
)}
|
||||
>
|
||||
{nextRun ? (
|
||||
<TimestampInfo
|
||||
utcTimestamp={nextRun}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
className="font-sans text-sm"
|
||||
/>
|
||||
) : (
|
||||
'Unable to parse next run for job'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-10 gap-3">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import parser from 'cron-parser'
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { CronJobType } from './CreateCronJobSheet'
|
||||
import { HTTPHeader } from './CronJobs.constants'
|
||||
@@ -50,7 +52,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
|
||||
/select net\.([^']+)\(\s*url:='([^']+)',\s*headers:=jsonb_build_object\(([^)]*)\),(?:\s*body:='(.*)',)?\s*timeout_milliseconds:=(\d+) \)/i
|
||||
) || []
|
||||
|
||||
// if the match has been unsuccesful, the cron may be created with the previous encoding/parsing.
|
||||
// if the match has been unsuccessful, the cron may be created with the previous encoding/parsing.
|
||||
if (matches.length === 0) {
|
||||
matches =
|
||||
command.match(
|
||||
@@ -190,3 +192,41 @@ export const formatScheduleString = (value: string) => {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const convertCronToString = (schedule: string) => {
|
||||
// pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the
|
||||
// original schedule when cronstrue throws
|
||||
try {
|
||||
return CronToString(schedule)
|
||||
} catch (error) {
|
||||
return schedule
|
||||
}
|
||||
}
|
||||
|
||||
export const getNextRun = (schedule: string, lastRun?: string) => {
|
||||
// cron-parser can only deal with the traditional cron syntax but technically users can also
|
||||
// use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
|
||||
// (can't guarantee as scope is quite big)
|
||||
if (schedule.includes('*')) {
|
||||
try {
|
||||
const interval = parser.parseExpression(schedule)
|
||||
return interval.next().getTime()
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n seconds", "n minutes", or "n days"
|
||||
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
|
||||
const [value, unit] = schedule.split(' ')
|
||||
if (
|
||||
['seconds', 'minutes', 'days'].includes(unit) &&
|
||||
!Number.isNaN(Number(value)) &&
|
||||
lastRun !== undefined
|
||||
) {
|
||||
const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
|
||||
return parsedLastRun.valueOf()
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -14,7 +14,8 @@ import { usePgSodiumKeyDeleteMutation } from 'data/pg-sodium-keys/pg-sodium-key-
|
||||
import { usePgSodiumKeysQuery } from 'data/pg-sodium-keys/pg-sodium-keys-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { Key, Loader, Search, Trash, X } from 'lucide-react'
|
||||
import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui'
|
||||
import { Button, Form, Input, Listbox, Modal, Separator } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
const DEFAULT_KEY_NAME = 'No description provided'
|
||||
|
||||
@@ -256,13 +257,12 @@ export const EncryptionKeysManagement = () => {
|
||||
header="Confirm to delete key"
|
||||
>
|
||||
<Modal.Content className="space-y-4">
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="Deleting a key that's in use will cause any secret or column which depends on it to be unusable."
|
||||
>
|
||||
Do ensure that the key is not currently in use to prevent any issues.
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Deleting a key that's in use will cause any secret or column which depends on it to be
|
||||
unusable."
|
||||
description="Do ensure that the key is not currently in use to prevent any issues."
|
||||
/>
|
||||
<p className="text-sm">
|
||||
The following key will be permanently removed and cannot be recovered.
|
||||
</p>
|
||||
|
||||
+7
-4
@@ -1,9 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, Button, Modal } from 'ui'
|
||||
import { Button, Modal } from 'ui'
|
||||
|
||||
import { useOrganizationPaymentMethodDeleteMutation } from 'data/organizations/organization-payment-method-delete-mutation'
|
||||
import type { OrganizationPaymentMethod } from 'data/organizations/organization-payment-methods-query'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
export interface DeletePaymentMethodModalProps {
|
||||
selectedPaymentMethod?: OrganizationPaymentMethod
|
||||
@@ -55,9 +56,11 @@ const DeletePaymentMethodModal = ({
|
||||
}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Alert withIcon variant="info" title="This will permanently delete your payment method.">
|
||||
<p>You can re-add the payment method any time.</p>
|
||||
</Alert>
|
||||
<Admonition
|
||||
type="default"
|
||||
title="This will permanently delete your payment method."
|
||||
description="You can re-add the payment method any time."
|
||||
/>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -78,6 +78,11 @@ const AuthorizeRequesterDetails = ({
|
||||
<span className="text-amber-900">selected organization and all of its projects.</span>
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<ScopeSection
|
||||
description="access to analytics logs."
|
||||
hasReadScope={scopes.includes(OAuthScope.ANALYTICS_READ)}
|
||||
hasWriteScope={scopes.includes(OAuthScope.ANALYTICS_WRITE)}
|
||||
/>
|
||||
<ScopeSection
|
||||
description="access to auth configurations and SSO providers."
|
||||
hasReadScope={scopes.includes(OAuthScope.AUTH_READ)}
|
||||
|
||||
+8
@@ -106,6 +106,14 @@ export const ScopesPanel = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="-space-y-px">
|
||||
<Scope
|
||||
title="Analytics"
|
||||
description="Analytics logs."
|
||||
readScopeName={OAuthScope.ANALYTICS_READ}
|
||||
writeScopeName={OAuthScope.ANALYTICS_WRITE}
|
||||
scopes={scopes}
|
||||
setScopes={setScopes}
|
||||
/>
|
||||
<Scope
|
||||
title="Auth"
|
||||
description="Auth configurations and SSO providers."
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { getContentById } from 'data/content/content-id-query'
|
||||
import { useContentUpsertV2Mutation } from 'data/content/content-upsert-v2-mutation'
|
||||
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
|
||||
import { useSQLSnippetFolderCreateMutation } from 'data/content/sql-folder-create-mutation'
|
||||
import { Snippet } from 'data/content/sql-folders-query'
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryMod
|
||||
|
||||
const { mutateAsync: createFolder, isLoading: isCreatingFolder } =
|
||||
useSQLSnippetFolderCreateMutation()
|
||||
const { mutateAsync: moveSnippetAsync, isLoading: isMovingSnippet } = useContentUpsertV2Mutation({
|
||||
const { mutateAsync: moveSnippetAsync, isLoading: isMovingSnippet } = useContentUpsertMutation({
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to move query: ${error.message}`)
|
||||
},
|
||||
|
||||
@@ -426,7 +426,10 @@ export const SQLEditor = () => {
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent({ action: TelemetryActions.ASSISTANT_SUGGESTION_ACCEPTED })
|
||||
sendEvent({
|
||||
action: TelemetryActions.ASSISTANT_SQL_DIFF_HANDLER_EVALUATED,
|
||||
properties: { handlerAccepted: true },
|
||||
})
|
||||
|
||||
setSelectedDiffType(DiffType.Modification)
|
||||
resetPrompt()
|
||||
@@ -447,7 +450,10 @@ export const SQLEditor = () => {
|
||||
])
|
||||
|
||||
const discardAiHandler = useCallback(() => {
|
||||
sendEvent({ action: TelemetryActions.ASSISTANT_SUGGESTION_REJECTED })
|
||||
sendEvent({
|
||||
action: TelemetryActions.ASSISTANT_SQL_DIFF_HANDLER_EVALUATED,
|
||||
properties: { handlerAccepted: false },
|
||||
})
|
||||
resetPrompt()
|
||||
closeDiff()
|
||||
}, [closeDiff, resetPrompt, sendEvent])
|
||||
|
||||
@@ -78,7 +78,7 @@ const SQLQuickstarts = () => {
|
||||
handleNewQuery(sql, title)
|
||||
sendEvent({
|
||||
action: TelemetryActions.SQL_EDITOR_QUICKSTART_CLICKED,
|
||||
properties: { title },
|
||||
properties: { quickstartName: title },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ const SQLTemplates = () => {
|
||||
handleNewQuery(sql, title)
|
||||
sendEvent({
|
||||
action: TelemetryActions.SQL_EDITOR_TEMPLATE_CLICKED,
|
||||
properties: { title },
|
||||
properties: { templateName: title },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronDown, Clipboard, Download, Eye, EyeOff, Play } from 'lucide-reac
|
||||
import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
import Papa from 'papaparse'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import CSVButton from 'components/ui/CSVButton'
|
||||
@@ -92,15 +93,10 @@ const LogTable = ({
|
||||
const { profile } = useProfile()
|
||||
const { show: showContextMenu } = useContextMenu()
|
||||
|
||||
const downloadCsvRef = useRef<HTMLDivElement>(null)
|
||||
const [cellPosition, setCellPosition] = useState<any>()
|
||||
|
||||
const [selectionOpen, setSelectionOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLog || isSelectedLogLoading) {
|
||||
setSelectionOpen(true)
|
||||
}
|
||||
}, [selectedLog, isSelectedLogLoading])
|
||||
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
||||
|
||||
const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
|
||||
resource: { type: 'log_sql', owner_id: profile?.id },
|
||||
@@ -148,6 +144,7 @@ const LogTable = ({
|
||||
})
|
||||
|
||||
let columns = DEFAULT_COLUMNS
|
||||
|
||||
if (!queryType) {
|
||||
columns
|
||||
} else {
|
||||
@@ -235,21 +232,10 @@ const LogTable = ({
|
||||
}
|
||||
}
|
||||
|
||||
const copyResultsToClipboard = () => {
|
||||
copyToClipboard(stringData, () => {
|
||||
toast.success('Results copied to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
const downloadCsvRef = useRef<HTMLDivElement>(null)
|
||||
function downloadCSV() {
|
||||
downloadCsvRef.current?.click()
|
||||
}
|
||||
|
||||
const LogsExplorerTableHeader = () => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
|
||||
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
|
||||
className,
|
||||
{ hidden: !showHeader }
|
||||
)}
|
||||
@@ -262,13 +248,37 @@ const LogTable = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={downloadCSV} className="space-x-2">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
downloadCsvRef.current?.click()
|
||||
}}
|
||||
className="space-x-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
<div>Download CSV</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyResultsToClipboard} className="space-x-2">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const csvData = Papa.unparse(data)
|
||||
copyToClipboard(csvData, () => {
|
||||
toast.success('Results copied to clipboard')
|
||||
})
|
||||
}}
|
||||
className="space-x-2"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
<div>Copy to clipboard</div>
|
||||
<div>Copy as CSV</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(stringData, () => {
|
||||
toast.success('Results copied to clipboard')
|
||||
})
|
||||
}}
|
||||
className="space-x-2"
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
<div>Copy as JSON</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -352,14 +362,22 @@ const LogTable = ({
|
||||
else return <LogsTableEmptyState />
|
||||
}
|
||||
|
||||
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
||||
|
||||
function onRowClick(row: LogData) {
|
||||
setSelectedRow(row)
|
||||
onSelectedLogChange?.(row)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedLog || isSelectedLogLoading) {
|
||||
setSelectionOpen(true)
|
||||
}
|
||||
if (!isSelectedLogLoading && !selectedLog) {
|
||||
setSelectedRow(null)
|
||||
}
|
||||
}, [selectedLog, isSelectedLogLoading])
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<section className={'h-full flex w-full flex-col flex-1'}>
|
||||
{!queryType && <LogsExplorerTableHeader />}
|
||||
@@ -369,7 +387,7 @@ const LogTable = ({
|
||||
<DataGrid
|
||||
role="table"
|
||||
style={{ height: '100%' }}
|
||||
className={cn('flex-1 flex-grow h-full border-none', {
|
||||
className={cn('flex-1 flex-grow h-full border-0', {
|
||||
'data-grid--simple-logs': queryType,
|
||||
'data-grid--logs-explorer': !queryType,
|
||||
})}
|
||||
|
||||
@@ -350,6 +350,9 @@ export const SQL_FILTER_TEMPLATES: any = {
|
||||
..._SQL_FILTER_COMMON,
|
||||
database: (value: string) => `m.project like '${value}%'`,
|
||||
},
|
||||
pg_cron_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
},
|
||||
}
|
||||
|
||||
export enum LogsTableName {
|
||||
|
||||
@@ -458,8 +458,18 @@ export const fillTimeseries = (
|
||||
valueKey: string | string[],
|
||||
defaultValue: number,
|
||||
min?: string,
|
||||
max?: string
|
||||
max?: string,
|
||||
minPointsToFill: number = 20
|
||||
) => {
|
||||
// If we have more points than minPointsToFill, just normalize timestamps and return
|
||||
if (timeseriesData.length > minPointsToFill) {
|
||||
return timeseriesData.map((datum) => {
|
||||
const iso = dayjs.utc(datum[timestampKey]).toISOString()
|
||||
datum[timestampKey] = iso
|
||||
return datum
|
||||
})
|
||||
}
|
||||
|
||||
if (timeseriesData.length <= 1 && !(min || max)) return timeseriesData
|
||||
const dates: unknown[] = timeseriesData.map((datum) => dayjs.utc(datum[timestampKey]))
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface LogsPreviewerProps {
|
||||
condensedLayout?: boolean
|
||||
tableName?: LogsTableName
|
||||
EmptyState?: React.ReactNode
|
||||
filterPanelClassName?: string
|
||||
}
|
||||
export const LogsPreviewer = ({
|
||||
projectRef,
|
||||
@@ -48,6 +49,7 @@ export const LogsPreviewer = ({
|
||||
tableName,
|
||||
children,
|
||||
EmptyState,
|
||||
filterPanelClassName,
|
||||
}: PropsWithChildren<LogsPreviewerProps>) => {
|
||||
const router = useRouter()
|
||||
const { s, ite, its, db } = useParams()
|
||||
@@ -150,6 +152,7 @@ export const LogsPreviewer = ({
|
||||
}
|
||||
const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
|
||||
if (event === 'search-input-change') {
|
||||
setSelectedLogId(null)
|
||||
setFilters((prev) => ({ ...prev, search_query: query }))
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
@@ -199,6 +202,7 @@ export const LogsPreviewer = ({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<PreviewFilterPanel
|
||||
className={filterPanelClassName}
|
||||
csvData={logData}
|
||||
isLoading={isLoading}
|
||||
newCount={newCount}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal } from 'lucide-react'
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import CSVButton from 'components/ui/CSVButton'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
TooltipContent_Shadcn_,
|
||||
TooltipTrigger_Shadcn_,
|
||||
Tooltip_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import DatePickers from './Logs.DatePickers'
|
||||
import {
|
||||
FILTER_OPTIONS,
|
||||
@@ -38,6 +46,7 @@ interface PreviewFilterPanelProps {
|
||||
onFiltersChange: (filters: Filters) => void
|
||||
filters: Filters
|
||||
onSelectedDatabaseChange: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +70,7 @@ const PreviewFilterPanel = ({
|
||||
filters,
|
||||
table,
|
||||
onSelectedDatabaseChange,
|
||||
className,
|
||||
}: PreviewFilterPanelProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
@@ -121,7 +131,13 @@ const PreviewFilterPanel = ({
|
||||
const handleInputSearch = (query: string) => onSearch('search-input-change', { query })
|
||||
|
||||
return (
|
||||
<div className={'flex w-full items-center justify-between' + (condensedLayout ? ' p-3' : '')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between',
|
||||
condensedLayout ? ' p-3' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<form
|
||||
id="log-panel-search"
|
||||
@@ -147,14 +163,27 @@ const PreviewFilterPanel = ({
|
||||
}
|
||||
value={search}
|
||||
actions={
|
||||
hasEdits && (
|
||||
<button
|
||||
onClick={() => handleInputSearch(search)}
|
||||
className="mx-2 text-foreground-light hover:text-foreground"
|
||||
>
|
||||
{'↲'}
|
||||
</button>
|
||||
)
|
||||
<div className="flex items-center gap-x-1 mr-0.5">
|
||||
{hasEdits && (
|
||||
<ButtonTooltip
|
||||
icon={<span>↲</span>}
|
||||
type="text"
|
||||
className="px-1 h-[20px]"
|
||||
onClick={() => handleInputSearch(search)}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Search for events' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{search.length > 0 && (
|
||||
<ButtonTooltip
|
||||
icon={<X />}
|
||||
type="text"
|
||||
className="p-[1px] h-[20px]"
|
||||
onClick={() => handleInputSearch('')}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Clear search' } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -9,9 +9,11 @@ import { toast } from 'sonner'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
import { getMfaAuthenticatorAssuranceLevel } from 'data/profile/mfa-authenticator-assurance-level-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
|
||||
import { TelemetryActions } from 'lib/constants/telemetry'
|
||||
import { auth, buildPathWithParams, getReturnToPath } from 'lib/gotrue'
|
||||
import { Button, Form, Input } from 'ui'
|
||||
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
|
||||
import { LastSignInWrapper } from './LastSignInWrapper'
|
||||
|
||||
const signInSchema = object({
|
||||
@@ -22,11 +24,13 @@ const signInSchema = object({
|
||||
const SignInForm = () => {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [lastSignIn, setLastSignIn] = useLastSignIn()
|
||||
const [_, setLastSignIn] = useLastSignIn()
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const onSignIn = async ({ email, password }: { email: string; password: string }) => {
|
||||
const toastId = toast.loading('Signing in...')
|
||||
|
||||
@@ -56,6 +60,7 @@ const SignInForm = () => {
|
||||
}
|
||||
|
||||
toast.success(`Signed in successfully!`, { id: toastId })
|
||||
sendEvent({ action: TelemetryActions.SIGN_IN, properties: { category: 'account' } })
|
||||
await queryClient.resetQueries()
|
||||
const returnTo = getReturnToPath()
|
||||
// since we're already on the /sign-in page, prevent redirect loops
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { TelemetryActions } from 'lib/constants/telemetry'
|
||||
import { auth, buildPathWithParams } from 'lib/gotrue'
|
||||
import { Button, Form, Input } from 'ui'
|
||||
import { useLastSignIn } from 'hooks/misc/useLastSignIn'
|
||||
|
||||
const WHITELIST_ERRORS = ['No SSO provider assigned for this domain']
|
||||
|
||||
@@ -16,10 +18,14 @@ const SignInSSOForm = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const captchaRef = useRef<HCaptcha>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [lastSignInUsed, setLastSignInUsed] = useLastSignIn()
|
||||
const [_, setLastSignInUsed] = useLastSignIn()
|
||||
|
||||
const signInSchema = object({
|
||||
email: string().email('Must be a valid email').required('Email is required'),
|
||||
})
|
||||
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const onSignIn = async ({ email }: { email: string }) => {
|
||||
const toastId = toast.loading('Signing in...')
|
||||
|
||||
@@ -47,6 +53,7 @@ const SignInSSOForm = () => {
|
||||
})
|
||||
|
||||
if (!error) {
|
||||
sendEvent({ action: TelemetryActions.SIGN_IN, properties: { category: 'account' } })
|
||||
await queryClient.resetQueries()
|
||||
setLastSignInUsed('sso')
|
||||
if (data) {
|
||||
|
||||
@@ -89,12 +89,12 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
|
||||
</h1>
|
||||
</Link>
|
||||
{name && (
|
||||
<div className="mt-1.5 flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-foreground-light">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
@@ -105,7 +105,7 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h5 className="text-lg text-foreground">{name}</h5>
|
||||
<h1 className="text-2xl text-foreground">{name}</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
{selectedOrganization?.name ?? 'Organization'} settings
|
||||
</ScaffoldTitle>
|
||||
<NavMenu
|
||||
className="border-none max-w-full overflow-hidden overflow-x-scroll"
|
||||
className="border-none max-w-full overflow-y-hidden overflow-x-auto"
|
||||
aria-label="Organization menu navigation"
|
||||
>
|
||||
{filteredNavMenuItems.map((item) => (
|
||||
|
||||
@@ -102,7 +102,7 @@ const LayoutHeader = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between py-2 pl-1 pr-3 md:px-3 flex-nowrap overflow-x-scroll">
|
||||
<div className="flex w-full items-center justify-between py-2 pl-1 pr-3 md:px-3 flex-nowrap overflow-x-auto">
|
||||
<div className="flex items-center text-sm">
|
||||
{projectRef && (
|
||||
<>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLE
|
||||
import { useContentCountQuery } from 'data/content/content-count-query'
|
||||
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
|
||||
import { getContentById } from 'data/content/content-id-query'
|
||||
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
|
||||
import { useSQLSnippetFoldersDeleteMutation } from 'data/content/sql-folders-delete-mutation'
|
||||
import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from 'data/content/sql-folders-query'
|
||||
import { useSqlSnippetsQuery } from 'data/content/sql-snippets-query'
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
useSnippetFolders,
|
||||
useSqlEditorV2StateSnapshot,
|
||||
} from 'state/sql-editor-v2'
|
||||
import { SqlSnippets } from 'types'
|
||||
import { cn, Separator, TreeView } from 'ui'
|
||||
import {
|
||||
InnerSideBarEmptyPanel,
|
||||
@@ -34,10 +36,8 @@ import {
|
||||
} from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets'
|
||||
import { ROOT_NODE, formatFolderResponseForTreeView, getLastItemIds } from './SQLEditorNav.utils'
|
||||
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
|
||||
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
|
||||
import { useContentUpsertV2Mutation } from 'data/content/content-upsert-v2-mutation'
|
||||
import { SqlSnippets } from 'types'
|
||||
|
||||
interface SQLEditorNavProps {
|
||||
searchText: string
|
||||
@@ -331,7 +331,7 @@ export const SQLEditorNav = ({
|
||||
// Snippet mutations from RQ
|
||||
// ==========================
|
||||
|
||||
const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertV2Mutation({
|
||||
const { mutate: upsertContent, isLoading: isUpserting } = useContentUpsertMutation({
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update query: ${error.message}`)
|
||||
},
|
||||
|
||||
@@ -267,196 +267,198 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorTablePageLink
|
||||
title={entity.name}
|
||||
id={String(entity.id)}
|
||||
href={`/project/${projectRef}/editor/${entity.id}?schema=${selectedSchema}`}
|
||||
role="button"
|
||||
aria-label={`View ${entity.name}`}
|
||||
className={cn(
|
||||
'w-full',
|
||||
'flex items-center gap-2',
|
||||
'py-1 px-2',
|
||||
'text-light',
|
||||
'rounded-md',
|
||||
isActive ? 'bg-selection' : 'hover:bg-surface-200 focus:bg-surface-200',
|
||||
'group',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
<Tooltip.Root delayDuration={0} disableHoverableContent={true}>
|
||||
<Tooltip.Trigger className="min-w-4" asChild>
|
||||
{entity.type === ENTITY_TYPE.TABLE ? (
|
||||
<Table2
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter',
|
||||
isActive && 'text-foreground-lighter',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
) : entity.type === ENTITY_TYPE.VIEW ? (
|
||||
<Eye
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter',
|
||||
isActive && 'text-foreground-lighter',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
|
||||
entity.type === ENTITY_TYPE.FOREIGN_TABLE && 'text-yellow-900 bg-yellow-500',
|
||||
entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && 'text-purple-1000 bg-purple-500',
|
||||
entity.type === ENTITY_TYPE.PARTITIONED_TABLE &&
|
||||
'text-foreground-light bg-border-stronger'
|
||||
)}
|
||||
>
|
||||
{Object.entries(ENTITY_TYPE)
|
||||
.find(([, value]) => value === entity.type)?.[0]?.[0]
|
||||
?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
'text-xs text-foreground capitalize',
|
||||
].join(' ')}
|
||||
>
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
{formatTooltipText(entity.type)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<div
|
||||
<div className="px-2">
|
||||
<EditorTablePageLink
|
||||
title={entity.name}
|
||||
id={String(entity.id)}
|
||||
href={`/project/${projectRef}/editor/${entity.id}?schema=${selectedSchema}`}
|
||||
role="button"
|
||||
aria-label={`View ${entity.name}`}
|
||||
className={cn(
|
||||
'truncate',
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
|
||||
isActive && 'text-foreground'
|
||||
'w-full',
|
||||
'flex items-center gap-2',
|
||||
'py-1 px-2',
|
||||
'text-light',
|
||||
'rounded-md',
|
||||
isActive ? 'bg-selection' : 'hover:bg-surface-200 focus:bg-surface-200',
|
||||
'group',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
<Tooltip.Root delayDuration={0} disableHoverableContent={true}>
|
||||
<Tooltip.Trigger className="min-w-4" asChild>
|
||||
{entity.type === ENTITY_TYPE.TABLE ? (
|
||||
<Table2
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter',
|
||||
isActive && 'text-foreground-lighter',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
) : entity.type === ENTITY_TYPE.VIEW ? (
|
||||
<Eye
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'text-foreground-muted group-hover:text-foreground-lighter',
|
||||
isActive && 'text-foreground-lighter',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
|
||||
entity.type === ENTITY_TYPE.FOREIGN_TABLE && 'text-yellow-900 bg-yellow-500',
|
||||
entity.type === ENTITY_TYPE.MATERIALIZED_VIEW && 'text-purple-1000 bg-purple-500',
|
||||
entity.type === ENTITY_TYPE.PARTITIONED_TABLE &&
|
||||
'text-foreground-light bg-border-stronger'
|
||||
)}
|
||||
>
|
||||
{Object.entries(ENTITY_TYPE)
|
||||
.find(([, value]) => value === entity.type)?.[0]?.[0]
|
||||
?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
'text-xs text-foreground capitalize',
|
||||
].join(' ')}
|
||||
>
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
{formatTooltipText(entity.type)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<div
|
||||
className={cn(
|
||||
isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
|
||||
'text-sm',
|
||||
'transition',
|
||||
'truncate'
|
||||
'truncate',
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
|
||||
isActive && 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{entity.name}
|
||||
</span>
|
||||
<EntityTooltipTrigger entity={entity} />
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-foreground-lighter transition-all hover:text-foreground data-[state=open]:text-foreground">
|
||||
<MoreHorizontal size={14} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
key="copy-name"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(entity.name)
|
||||
}}
|
||||
>
|
||||
<Clipboard size={12} />
|
||||
<span>Copy name</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{entity.type === ENTITY_TYPE.TABLE && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
key="edit-table"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onEditTable()
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<span>Edit table</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="duplicate-table"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onDuplicateTable()
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<span>Duplicate table</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem key="view-policies" className="space-x-2" asChild>
|
||||
<Link
|
||||
key="view-policies"
|
||||
href={`/project/${projectRef}/auth/policies?schema=${selectedSchema}&search=${entity.id}`}
|
||||
>
|
||||
<Lock size={12} />
|
||||
<span>View policies</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-x-2">
|
||||
<Download size={12} />
|
||||
Export data
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
key="download-table-csv"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsCSV()
|
||||
}}
|
||||
>
|
||||
<span>Export table as CSV</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="download-table-sql"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsSQL()
|
||||
}}
|
||||
>
|
||||
<span>Export table as SQL</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
key="delete-table"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onDeleteTable()
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<span>Delete table</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<span
|
||||
className={cn(
|
||||
isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
|
||||
'text-sm',
|
||||
'transition',
|
||||
'truncate'
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</EditorTablePageLink>
|
||||
>
|
||||
{entity.name}
|
||||
</span>
|
||||
<EntityTooltipTrigger entity={entity} />
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-foreground-lighter transition-all hover:text-foreground data-[state=open]:text-foreground">
|
||||
<MoreHorizontal size={14} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
key="copy-name"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(entity.name)
|
||||
}}
|
||||
>
|
||||
<Clipboard size={12} />
|
||||
<span>Copy name</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{entity.type === ENTITY_TYPE.TABLE && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
key="edit-table"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onEditTable()
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<span>Edit table</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="duplicate-table"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onDuplicateTable()
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<span>Duplicate table</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem key="view-policies" className="space-x-2" asChild>
|
||||
<Link
|
||||
key="view-policies"
|
||||
href={`/project/${projectRef}/auth/policies?schema=${selectedSchema}&search=${entity.id}`}
|
||||
>
|
||||
<Lock size={12} />
|
||||
<span>View policies</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-x-2">
|
||||
<Download size={12} />
|
||||
Export data
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
key="download-table-csv"
|
||||
className="space-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsCSV()
|
||||
}}
|
||||
>
|
||||
<span>Export table as CSV</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key="download-table-sql"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
exportTableAsSQL()
|
||||
}}
|
||||
>
|
||||
<span>Export table as SQL</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
key="delete-table"
|
||||
className="gap-x-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
snap.onDeleteTable()
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<span>Delete table</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</EditorTablePageLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ const TableEditorMenu = () => {
|
||||
/>
|
||||
)}
|
||||
{(entityTypes?.length ?? 0) > 0 && (
|
||||
<div className="flex flex-1" data-testid="tables-list">
|
||||
<div className="flex flex-1 -mx-2" data-testid="tables-list">
|
||||
<InfiniteList
|
||||
items={entityTypes}
|
||||
ItemComponent={EntityListItem}
|
||||
|
||||
+14
-10
@@ -27,6 +27,8 @@ import {
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
|
||||
interface CreateCredentialModalProps {
|
||||
visible: boolean
|
||||
@@ -40,6 +42,10 @@ export const CreateCredentialModal = ({ visible, onOpenChange }: CreateCredentia
|
||||
|
||||
const canCreateCredentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
|
||||
|
||||
const { data: config } = useProjectStorageConfigQuery({ projectRef })
|
||||
const isS3ConnectionEnabled = config?.features.s3Protocol.enabled
|
||||
const disableCreation = !isProjectActive || !canCreateCredentials || !isS3ConnectionEnabled
|
||||
|
||||
const FormSchema = z.object({
|
||||
description: z.string().min(3, {
|
||||
message: 'Description must be at least 3 characters long',
|
||||
@@ -78,22 +84,20 @@ export const CreateCredentialModal = ({ visible, onOpenChange }: CreateCredentia
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!isProjectActive || !canCreateCredentials}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<Button type="default" disabled={disableCreation} className="pointer-events-auto">
|
||||
New access key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
{(!isProjectActive || !canCreateCredentials) && (
|
||||
{disableCreation && (
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
{!isProjectActive
|
||||
? 'Restore your project to create new access keys'
|
||||
: !canCreateCredentials
|
||||
? 'You need additional permissions to create new access keys'
|
||||
: ''}
|
||||
: !isS3ConnectionEnabled
|
||||
? 'Connection via S3 protocol is currently disabled'
|
||||
: !canCreateCredentials
|
||||
? 'You need additional permissions to create new access keys'
|
||||
: ''}
|
||||
</TooltipContent_Shadcn_>
|
||||
)}
|
||||
</Tooltip_Shadcn_>
|
||||
@@ -159,7 +163,7 @@ export const CreateCredentialModal = ({ visible, onOpenChange }: CreateCredentia
|
||||
<DialogSection>
|
||||
<FormField
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
render={() => (
|
||||
<FormItemLayout label="Description">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { AlertTitle } from '@ui/components/shadcn/ui/alert'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
@@ -9,13 +13,26 @@ import {
|
||||
useProjectContext,
|
||||
} from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
|
||||
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
|
||||
import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation'
|
||||
import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, WarningIcon } from 'ui'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Separator,
|
||||
Switch,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
@@ -34,54 +51,169 @@ export const S3Connection = () => {
|
||||
const [deleteCred, setDeleteCred] = useState<{ id: string; description: string }>()
|
||||
|
||||
const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*')
|
||||
const canUpdateStorageSettings = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery(
|
||||
const {
|
||||
data: config,
|
||||
error: configError,
|
||||
isSuccess: isSuccessStorageConfig,
|
||||
isError: isErrorStorageConfig,
|
||||
} = useProjectStorageConfigQuery({ projectRef })
|
||||
const { data: storageCreds, isLoading: isLoadingStorageCreds } = useStorageCredentialsQuery(
|
||||
{ projectRef },
|
||||
{ enabled: canReadS3Credentials }
|
||||
)
|
||||
|
||||
const { mutate: updateStorageConfig, isLoading: isUpdating } =
|
||||
useProjectStorageConfigUpdateUpdateMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
if (vars.features) form.reset({ s3ConnectionEnabled: vars.features.s3Protocol.enabled })
|
||||
toast.success('Successfully updated storage settings')
|
||||
},
|
||||
})
|
||||
|
||||
const FormSchema = z.object({ s3ConnectionEnabled: z.boolean() })
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { s3ConnectionEnabled: false },
|
||||
})
|
||||
|
||||
const protocol = settings?.app_config?.protocol ?? 'https'
|
||||
const endpoint = settings?.app_config?.endpoint
|
||||
const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0
|
||||
const s3connectionUrl = getConnectionURL(projectRef ?? '', protocol, endpoint)
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!config) return console.error('Storage config is required')
|
||||
updateStorageConfig({
|
||||
projectRef,
|
||||
features: {
|
||||
...config?.features,
|
||||
s3Protocol: { enabled: data.s3ConnectionEnabled },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ s3ConnectionEnabled: config?.features.s3Protocol.enabled })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccessStorageConfig])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<FormHeader
|
||||
title="S3 Connection"
|
||||
description="Connect to your bucket via the S3 protocol."
|
||||
docsUrl="https://supabase.com/docs/guides/storage/s3/authentication"
|
||||
/>
|
||||
{projectIsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : isProjectActive ? (
|
||||
<Panel className="grid gap-4 p-4 !mb-0">
|
||||
<FormItemLayout layout="horizontal" label="Endpoint" isReactForm={false}>
|
||||
<Input readOnly copy disabled value={s3connectionUrl} />
|
||||
</FormItemLayout>
|
||||
{!projectIsLoading && (
|
||||
<FormItemLayout layout="horizontal" label="Region" isReactForm={false}>
|
||||
<Input className="input-mono" copy disabled value={project?.region} />
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</Panel>
|
||||
) : (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle>Project is paused</AlertTitle>
|
||||
<AlertDescription_Shadcn_>
|
||||
To connect to your S3 bucket, you need to restore your project.
|
||||
</AlertDescription_Shadcn_>
|
||||
<div className="mt-3 flex items-center space-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}`}>Restore project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</div>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id="s3-connection-form" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormHeader
|
||||
title="S3 Connection"
|
||||
description="Connect to your bucket using any S3-compatible service via the S3 protocol"
|
||||
docsUrl="https://supabase.com/docs/guides/storage/s3/authentication"
|
||||
/>
|
||||
{projectIsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : isProjectActive ? (
|
||||
<Panel className="!mb-0">
|
||||
<FormField_Shadcn_
|
||||
name="s3ConnectionEnabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
className="px-8 py-8 [&>*>label]:text-foreground"
|
||||
label="Enable connection via S3 protocol"
|
||||
description="Allow clients to connect to Supabase Storage via the S3 protocol"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
size="large"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!isSuccessStorageConfig || field.disabled}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isErrorStorageConfig && (
|
||||
<div className="px-8 pb-8">
|
||||
<AlertError
|
||||
subject="Failed to retrieve storage configuration"
|
||||
error={configError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<div className="flex flex-col gap-y-4 py-8">
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
className="px-8"
|
||||
label="Endpoint"
|
||||
isReactForm={false}
|
||||
>
|
||||
<Input readOnly copy disabled value={s3connectionUrl} />
|
||||
</FormItemLayout>
|
||||
{!projectIsLoading && (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
className="px-8"
|
||||
label="Region"
|
||||
isReactForm={false}
|
||||
>
|
||||
<Input className="input-mono" copy disabled value={project?.region} />
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-2 px-8 py-4">
|
||||
{!canUpdateStorageSettings ? (
|
||||
<p className="text-sm text-foreground-light">
|
||||
You need additional permissions to update storage settings
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
htmlType="reset"
|
||||
onClick={() => form.reset()}
|
||||
disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdating}
|
||||
disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
) : (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle>Project is paused</AlertTitle>
|
||||
<AlertDescription_Shadcn_>
|
||||
To connect to your S3 bucket, you need to restore your project.
|
||||
</AlertDescription_Shadcn_>
|
||||
<div className="mt-3 flex items-center space-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${projectRef}`}>Restore project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
|
||||
<div>
|
||||
<FormHeader
|
||||
@@ -111,7 +243,7 @@ export const S3Connection = () => {
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<>
|
||||
{storageCredsQuery.isLoading ? (
|
||||
{isLoadingStorageCreds ? (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
} from 'ui'
|
||||
import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES, StorageSizeUnits } from './StorageSettings.constants'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings.utils'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
interface StorageSettingsState {
|
||||
fileSizeLimit: number
|
||||
@@ -54,7 +54,7 @@ const StorageSettings = () => {
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM })
|
||||
} = useProjectStorageConfigQuery({ projectRef })
|
||||
|
||||
const organization = useSelectedOrganization()
|
||||
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
|
||||
@@ -116,7 +116,7 @@ const StorageSettings = () => {
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: initialValues,
|
||||
})
|
||||
const { fileSizeLimit: limit, unit: storageUnit, imageTransformationEnabled } = form.watch()
|
||||
const { fileSizeLimit: limit, unit: storageUnit } = form.watch()
|
||||
|
||||
const { mutate: updateStorageConfig, isLoading: isUpdating } =
|
||||
useProjectStorageConfigUpdateUpdateMutation({
|
||||
@@ -127,13 +127,14 @@ const StorageSettings = () => {
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!config) return console.error('Storage config is required')
|
||||
|
||||
updateStorageConfig({
|
||||
projectRef,
|
||||
fileSizeLimit: convertToBytes(data.fileSizeLimit, data.unit),
|
||||
features: {
|
||||
imageTransformation: {
|
||||
enabled: data.imageTransformationEnabled,
|
||||
},
|
||||
imageTransformation: { enabled: data.imageTransformationEnabled },
|
||||
s3Protocol: config.features.s3Protocol,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -153,7 +154,7 @@ const StorageSettings = () => {
|
||||
{isSuccess && (
|
||||
<form id={formId} className="" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="bg-surface-100 overflow-hidden border-muted rounded-md border shadow">
|
||||
<div className="flex flex-col gap-0 divide-y divide-border-muted">
|
||||
<div className="flex flex-col gap-0 divide-y divide-border">
|
||||
<div className="grid grid-cols-12 gap-6 px-8 py-8 lg:gap-12">
|
||||
<div className="relative flex flex-col col-span-12 gap-6 lg:col-span-4">
|
||||
<p className="text-sm">Upload file size limit</p>
|
||||
@@ -251,7 +252,13 @@ const StorageSettings = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Markdown content="Optimize and resize images on the fly. [Learn more](https://supabase.com/docs/guides/storage/serving/image-transformations)." />
|
||||
<p className="text-sm text-foreground-light">
|
||||
Optimize and resize images on the fly.{' '}
|
||||
<InlineLink href="https://supabase.com/docs/guides/storage/serving/image-transformations">
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,7 +511,9 @@ export const AIAssistant = ({
|
||||
title="OpenAI API key not set"
|
||||
description={
|
||||
<Markdown
|
||||
content={'Add your `OPENAI_API_KEY` to `./docker/.env` to use the AI Assistant.'}
|
||||
content={
|
||||
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -213,7 +213,7 @@ export const SqlCard = ({
|
||||
})
|
||||
|
||||
sendEvent({
|
||||
action: TelemetryActions.ASSISTANT_SUGGESTION_RAN,
|
||||
action: TelemetryActions.ASSISTANT_SUGGESTION_RUN_QUERY_CLICKED,
|
||||
properties: {
|
||||
type: 'mutation',
|
||||
category: identifyQueryType(sql) ?? 'unknown',
|
||||
@@ -261,7 +261,13 @@ export const SqlCard = ({
|
||||
icon={<Edit size={14} />}
|
||||
onClick={() => {
|
||||
handleEditInSQLEditor()
|
||||
sendEvent({ action: TelemetryActions.ASSISTANT_EDIT_SQL_CLICKED })
|
||||
sendEvent({
|
||||
action: TelemetryActions.ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED,
|
||||
properties: {
|
||||
isInSQLEditor,
|
||||
isInNewSnippet,
|
||||
},
|
||||
})
|
||||
}}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Edit in SQL Editor' } }}
|
||||
/>
|
||||
@@ -306,7 +312,7 @@ export const SqlCard = ({
|
||||
handleExecute()
|
||||
if (isReadOnlySelect(sql)) {
|
||||
sendEvent({
|
||||
action: TelemetryActions.ASSISTANT_SUGGESTION_RAN,
|
||||
action: TelemetryActions.ASSISTANT_SUGGESTION_RUN_QUERY_CLICKED,
|
||||
properties: { type: 'select' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,11 +111,7 @@ const BarChart = ({
|
||||
title={title}
|
||||
format={format}
|
||||
customDateFormat={customDateFormat}
|
||||
highlightedValue={
|
||||
typeof resolvedHighlightedValue === 'number'
|
||||
? numberFormatter(resolvedHighlightedValue, valuePrecision)
|
||||
: resolvedHighlightedValue
|
||||
}
|
||||
highlightedValue={resolvedHighlightedValue}
|
||||
highlightedLabel={resolvedHighlightedLabel}
|
||||
minimalHeader={minimalHeader}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface InlineLinkProps {
|
||||
|
||||
export const InlineLink = ({ href, children }: PropsWithChildren<InlineLinkProps>) => {
|
||||
const className =
|
||||
'underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground'
|
||||
'underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground'
|
||||
if (href.startsWith('http')) {
|
||||
return (
|
||||
<a className={className} href={href} target="_blank" rel="noreferrer noopener">
|
||||
|
||||
@@ -132,7 +132,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => {
|
||||
)}
|
||||
<Panel.Notice
|
||||
className="border-t"
|
||||
title="New API keys coming Q4 2024"
|
||||
title="New API keys coming 2025"
|
||||
description={`
|
||||
\`anon\` and \`service_role\` API keys will be changing to \`publishable\` and \`secret\` API keys.
|
||||
`}
|
||||
|
||||
@@ -10,9 +10,8 @@ const EmptyListState = ({ title, description }: { title: string; description: st
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative flex h-4 w-32 items-center rounded border border-dashed px-2"></div>
|
||||
<div className="relative flex h-4 w-32 items-center rounded border border-dashed px-2">
|
||||
<div className="absolute right-1 -bottom-4">
|
||||
<div className="relative flex h-4 w-32 items-center justify-center rounded border border-dashed px-2">
|
||||
<div className="absolute -bottom-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { authKeys } from './keys'
|
||||
export type UserDeleteVariables = {
|
||||
projectRef: string
|
||||
userId: string
|
||||
skipInvalidation?: boolean
|
||||
}
|
||||
|
||||
export async function deleteUser({ projectRef, userId }: UserDeleteVariables) {
|
||||
@@ -34,12 +35,14 @@ export const useUserDeleteMutation = ({
|
||||
(vars) => deleteUser(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
const { projectRef, skipInvalidation = false } = variables
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
|
||||
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
|
||||
])
|
||||
if (!skipInvalidation) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)),
|
||||
queryClient.invalidateQueries(authKeys.usersCount(projectRef)),
|
||||
])
|
||||
}
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { components } from 'data/api'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { configKeys } from './keys'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
|
||||
export type ProjectStorageConfigVariables = {
|
||||
projectRef?: string
|
||||
@@ -39,5 +40,5 @@ export const useProjectStorageConfigQuery = <TData = ProjectStorageConfigData>(
|
||||
useQuery<ProjectStorageConfigData, ProjectStorageConfigError, TData>(
|
||||
configKeys.storage(projectRef),
|
||||
({ signal }) => getProjectStorageConfig({ projectRef }, signal),
|
||||
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
|
||||
{ enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined', ...options }
|
||||
)
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { patch } from 'lib/common/fetch'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { handleError, patch } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { configKeys } from './keys'
|
||||
import { components } from 'api-types'
|
||||
|
||||
export type ProjectStorageConfigUpdateUpdateVariables = {
|
||||
type StorageConfigUpdatePayload = components['schemas']['UpdateStorageConfigBody']
|
||||
|
||||
export type ProjectStorageConfigUpdateUpdateVariables = StorageConfigUpdatePayload & {
|
||||
projectRef: string
|
||||
fileSizeLimit: number
|
||||
features?: {
|
||||
imageTransformation: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProjectStorageConfigUpdate({
|
||||
@@ -21,12 +17,12 @@ export async function updateProjectStorageConfigUpdate({
|
||||
fileSizeLimit,
|
||||
features,
|
||||
}: ProjectStorageConfigUpdateUpdateVariables) {
|
||||
const response = await patch(`${API_URL}/projects/${projectRef}/config/storage`, {
|
||||
fileSizeLimit,
|
||||
features,
|
||||
const { data, error } = await patch('/platform/projects/{ref}/config/storage', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: { fileSizeLimit, features },
|
||||
})
|
||||
if (response.error) throw response.error
|
||||
return response
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type ProjectStorageConfigUpdateUpdateData = Awaited<
|
||||
|
||||
@@ -25,17 +25,8 @@ export async function upsertContent(
|
||||
) {
|
||||
const { data, error } = await put('/platform/projects/{ref}/content', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
project_id: payload.project_id,
|
||||
owner_id: payload.owner_id,
|
||||
type: payload.type,
|
||||
visibility: payload.visibility,
|
||||
content: payload.content as any,
|
||||
folder_id: payload.folder_id,
|
||||
},
|
||||
body: payload,
|
||||
headers: { Version: '2' },
|
||||
signal,
|
||||
})
|
||||
if (error) handleError(error)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { components } from 'data/api'
|
||||
import { handleError, put } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import type { Content } from './content-query'
|
||||
import { contentKeys } from './keys'
|
||||
|
||||
export type UpsertContentPayloadV2 = Omit<
|
||||
components['schemas']['UpsertContentBodyDto'],
|
||||
'content'
|
||||
> & {
|
||||
content: Content['content']
|
||||
}
|
||||
|
||||
export type UpsertContentVariables = {
|
||||
projectRef: string
|
||||
payload: UpsertContentPayloadV2
|
||||
}
|
||||
|
||||
export async function upsertContent(
|
||||
{ projectRef, payload }: UpsertContentVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const { data, error } = await put('/platform/projects/{ref}/content', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: payload as any,
|
||||
headers: { Version: '2' },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type UpsertContentData = Awaited<ReturnType<typeof upsertContent>>
|
||||
|
||||
export const useContentUpsertV2Mutation = ({
|
||||
onError,
|
||||
onSuccess,
|
||||
invalidateQueriesOnSuccess = true,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<UpsertContentData, ResponseError, UpsertContentVariables>,
|
||||
'mutationFn'
|
||||
> & {
|
||||
invalidateQueriesOnSuccess?: boolean
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<UpsertContentData, ResponseError, UpsertContentVariables>(
|
||||
(args) => upsertContent(args),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
if (invalidateQueriesOnSuccess) {
|
||||
await queryClient.invalidateQueries(contentKeys.allContentLists(projectRef))
|
||||
}
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to upsert content: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { ResponseError } from 'types'
|
||||
import { CronJobRun } from './database-cron-jobs-runs-infinite-query'
|
||||
import { databaseCronJobsKeys } from './keys'
|
||||
|
||||
export type DatabaseCronJobRunVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
jobId: number
|
||||
}
|
||||
|
||||
export async function getDatabaseCronJobRun({
|
||||
projectRef,
|
||||
connectionString,
|
||||
jobId,
|
||||
}: DatabaseCronJobRunVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const query = `
|
||||
SELECT * FROM cron.job_run_details
|
||||
WHERE jobid = '${jobId}'
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 1`
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: query,
|
||||
})
|
||||
|
||||
return result[0]
|
||||
}
|
||||
|
||||
export type DatabaseCronJobRunData = CronJobRun
|
||||
export type DatabaseCronJobRunError = ResponseError
|
||||
|
||||
export const useCronJobRunQuery = <TData = DatabaseCronJobRunData>(
|
||||
{ projectRef, connectionString, jobId }: DatabaseCronJobRunVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<DatabaseCronJobRunData, DatabaseCronJobRunError, TData> = {}
|
||||
) =>
|
||||
useQuery<DatabaseCronJobRunData, DatabaseCronJobRunError, TData>(
|
||||
databaseCronJobsKeys.run(projectRef, jobId),
|
||||
() => getDatabaseCronJobRun({ projectRef, connectionString, jobId }),
|
||||
{
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
}
|
||||
)
|
||||
@@ -3,6 +3,13 @@ export const databaseCronJobsKeys = {
|
||||
delete: () => ['cron-jobs', 'delete'] as const,
|
||||
alter: () => ['cronjobs', 'alter'] as const,
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'cron-jobs'] as const,
|
||||
run: (projectRef: string | undefined, jobId: number) => [
|
||||
'projects',
|
||||
projectRef,
|
||||
'cron-jobs',
|
||||
jobId,
|
||||
'run',
|
||||
],
|
||||
runsInfinite: (projectRef: string | undefined, jobId: number, options?: object) => [
|
||||
'projects',
|
||||
projectRef,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { ResponseError } from 'types'
|
||||
import { storageCredentialsKeys } from './s3-access-key-keys'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
|
||||
type StorageCredentialsVariables = { projectRef?: string }
|
||||
|
||||
@@ -39,5 +40,5 @@ export const useStorageCredentialsQuery = <TData = StorageCredentialsData>(
|
||||
useQuery<StorageCredentialsData, ResponseError, TData>(
|
||||
storageCredentialsKeys.credentials(projectRef),
|
||||
({ signal }) => fetchStorageCredentials({ projectRef }, signal),
|
||||
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
|
||||
{ enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined', ...options }
|
||||
)
|
||||
@@ -16,12 +16,32 @@ import {
|
||||
FeaturePreviewsClickedEvent,
|
||||
FeaturePreviewEnabledEvent,
|
||||
FeaturePreviewDisabledEvent,
|
||||
SqlEditorQuickstartClickedEvent,
|
||||
SqlEditorTemplateClickedEvent,
|
||||
SqlEditorResultDownloadCsvClickedEvent,
|
||||
SqlEditorResultCopyMarkdownClickedEvent,
|
||||
SqlEditorResultCopyJsonClickedEvent,
|
||||
TelemetryActions,
|
||||
SignUpEvent,
|
||||
SignInEvent,
|
||||
RealtimeInspectorListenChannelClickedEvent,
|
||||
RealtimeInspectorBroadcastSentEvent,
|
||||
RealtimeInspectorMessageClickedEvent,
|
||||
RealtimeInspectorCopyMessageClickedEvent,
|
||||
RealtimeInspectorFiltersAppliedEvent,
|
||||
RealtimeInspectorDatabaseRoleUpdatedEvent,
|
||||
AssistantPromptSubmittedEvent,
|
||||
AssistantDebugSubmittedEvent,
|
||||
AssistantSuggestionRunQueryClickedEvent,
|
||||
AssistantSqlDiffHandlerEvaluatedEvent,
|
||||
AssistantEditInSqlEditorClickedEvent,
|
||||
} from 'lib/constants/telemetry'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
export type SendEventVariables =
|
||||
| SignUpEvent
|
||||
| SignInEvent
|
||||
| ConnectionStringCopiedEvent
|
||||
| CronJobCreatedEvent
|
||||
| CronJobUpdatedEvent
|
||||
@@ -33,6 +53,22 @@ export type SendEventVariables =
|
||||
| FeaturePreviewsClickedEvent
|
||||
| FeaturePreviewEnabledEvent
|
||||
| FeaturePreviewDisabledEvent
|
||||
| RealtimeInspectorListenChannelClickedEvent
|
||||
| RealtimeInspectorBroadcastSentEvent
|
||||
| RealtimeInspectorMessageClickedEvent
|
||||
| RealtimeInspectorCopyMessageClickedEvent
|
||||
| RealtimeInspectorFiltersAppliedEvent
|
||||
| RealtimeInspectorDatabaseRoleUpdatedEvent
|
||||
| SqlEditorQuickstartClickedEvent
|
||||
| SqlEditorTemplateClickedEvent
|
||||
| SqlEditorResultDownloadCsvClickedEvent
|
||||
| SqlEditorResultCopyMarkdownClickedEvent
|
||||
| SqlEditorResultCopyJsonClickedEvent
|
||||
| AssistantPromptSubmittedEvent
|
||||
| AssistantDebugSubmittedEvent
|
||||
| AssistantSuggestionRunQueryClickedEvent
|
||||
| AssistantSqlDiffHandlerEvaluatedEvent
|
||||
| AssistantEditInSqlEditorClickedEvent
|
||||
|
||||
// TODO remove this once all events are documented
|
||||
| {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AWS_REGIONS, FLY_REGIONS } from 'shared-data'
|
||||
import type { components } from 'data/api'
|
||||
|
||||
export const AWS_REGIONS_DEFAULT =
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.EAST_US
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.WEST_US
|
||||
|
||||
// TO DO, change default to US region for prod
|
||||
export const FLY_REGIONS_DEFAULT = FLY_REGIONS.SOUTHEAST_ASIA
|
||||
|
||||
@@ -6,11 +6,9 @@ export enum TelemetryActions {
|
||||
|
||||
ASSISTANT_PROMPT_SUBMITTED = 'assistant_prompt_submitted',
|
||||
ASSISTANT_DEBUG_SUBMITTED = 'assistant_debug_submitted',
|
||||
ASSISTANT_SUGGESTION_RAN = 'assistant_suggestion_ran',
|
||||
ASSISTANT_SUGGESTION_ACCEPTED = 'assistant_suggestion_accepted',
|
||||
ASSISTANT_SUGGESTION_REJECTED = 'assistant_suggestion_rejected',
|
||||
ASSISTANT_SUGGESTION_COPIED = 'assistant_suggestion_copied',
|
||||
ASSISTANT_EDIT_SQL_CLICKED = 'assistant_edit_sql_clicked',
|
||||
ASSISTANT_SUGGESTION_RUN_QUERY_CLICKED = 'assistant_suggestion_run_query_clicked',
|
||||
ASSISTANT_SQL_DIFF_HANDLER_EVALUATED = 'assistant_sql_diff_handler_evaluated',
|
||||
ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED = 'assistant_edit_in_sql_editor_clicked',
|
||||
|
||||
CONNECTION_STRING_COPIED = 'connection_string_copied',
|
||||
|
||||
@@ -40,6 +38,37 @@ export enum TelemetryActions {
|
||||
SQL_EDITOR_RESULT_COPY_JSON_CLICKED = 'sql_editor_result_copy_markdown_clicked',
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a user signs up. When signing up with Email and Password, this is only triggered once user confirms their email.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /sign-up
|
||||
*/
|
||||
export interface SignUpEvent {
|
||||
action: TelemetryActions.SIGN_UP
|
||||
properties: {
|
||||
category: 'conversion'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when a user signs in with Github, Email and Password or SSO.
|
||||
*
|
||||
* Some unintuitive behavior:
|
||||
* - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /sign-in-mfa
|
||||
*/
|
||||
export interface SignInEvent {
|
||||
action: TelemetryActions.SIGN_IN
|
||||
properties: {
|
||||
category: 'account'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User copied the database connection string.
|
||||
*
|
||||
@@ -208,3 +237,207 @@ export interface FeaturePreviewDisabledEvent {
|
||||
feature: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After selecting channel, either "Listening to channel" or "Start listening" button was clicked.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorListenChannelClickedEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_LISTEN_CHANNEL_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* A broadcast message was sent from the SendMessageModal.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorBroadcastSentEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_BROADCAST_SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* A message was clicked in the RealtimeInspector, which opens a sidebar that shows the messsage details including metadata.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorMessageClickedEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_MESSAGE_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* A message was copied from the RealtimeInspector.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorCopyMessageClickedEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_COPY_MESSAGE_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters were applied in the RealtimeInspector.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorFiltersAppliedEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_FILTERS_APPLIED
|
||||
}
|
||||
|
||||
/**
|
||||
* Database role was updated in the RealtimeInspector.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/realtime/inspector
|
||||
*/
|
||||
export interface RealtimeInspectorDatabaseRoleUpdatedEvent {
|
||||
action: TelemetryActions.REALTIME_INSPECTOR_DATABASE_ROLE_UPDATED
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickstart card clicked in the SQL editor.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface SqlEditorQuickstartClickedEvent {
|
||||
action: TelemetryActions.SQL_EDITOR_QUICKSTART_CLICKED
|
||||
properties: {
|
||||
/**
|
||||
* The title of the quickstart card clicked.
|
||||
*/
|
||||
quickstartName: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template card clicked in the SQL editor.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface SqlEditorTemplateClickedEvent {
|
||||
action: TelemetryActions.SQL_EDITOR_TEMPLATE_CLICKED
|
||||
properties: {
|
||||
/**
|
||||
* The name of the template card clicked.
|
||||
*/
|
||||
templateName: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result download CSV button clicked in the SQL editor.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface SqlEditorResultDownloadCsvClickedEvent {
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_DOWNLOAD_CSV_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Result copy markdown button clicked in the SQL editor.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface SqlEditorResultCopyMarkdownClickedEvent {
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_MARKDOWN_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Result copy JSON button clicked in the SQL editor.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface SqlEditorResultCopyJsonClickedEvent {
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_JSON_CLICKED
|
||||
}
|
||||
|
||||
/**
|
||||
* User submitted a prompt to the assistant sidebar.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
*/
|
||||
export interface AssistantPromptSubmittedEvent {
|
||||
action: TelemetryActions.ASSISTANT_PROMPT_SUBMITTED
|
||||
}
|
||||
|
||||
/**
|
||||
* User submitted a debug request to the assistant sidebar or prompt submitted has Help me to debug.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
*/
|
||||
export interface AssistantDebugSubmittedEvent {
|
||||
action: TelemetryActions.ASSISTANT_DEBUG_SUBMITTED
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked the run query button in the suggestion provided in the assistant sidebar.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
*/
|
||||
export interface AssistantSuggestionRunQueryClickedEvent {
|
||||
action: TelemetryActions.ASSISTANT_SUGGESTION_RUN_QUERY_CLICKED
|
||||
properties: {
|
||||
/**
|
||||
* The type of suggestion that was run by the user. Mutate or Select query types only.
|
||||
*/
|
||||
queryType: string
|
||||
category?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User accepted or rejected changes in sql ai diff handler. They can accept change by clicking accept button or typing shortcut (CMD+Enter) or reject by clicking reject button or typing shortcut (Esc). Handler only appears after clicking any dropdown option in Edit in Sql Editor in suggestion provided by the assistant. The dropdown options only appear in any page with 'sql' in url.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
* @page /dashboard/project/{ref}/sql
|
||||
*/
|
||||
export interface AssistantSqlDiffHandlerEvaluatedEvent {
|
||||
action: TelemetryActions.ASSISTANT_SQL_DIFF_HANDLER_EVALUATED
|
||||
properties: {
|
||||
/**
|
||||
* Whether the user accepted or rejected the changes.
|
||||
*/
|
||||
handlerAccepted: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User clicked Edit in SQL Editor button in the assistant sidebar when user is in any page that does not have 'sql' in url or is in a new snippet.
|
||||
*
|
||||
* @group Events
|
||||
* @source studio
|
||||
*/
|
||||
export interface AssistantEditInSqlEditorClickedEvent {
|
||||
action: TelemetryActions.ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED
|
||||
properties: {
|
||||
/**
|
||||
* Whether the user is in the SQL editor page or in a new snippet.
|
||||
*/
|
||||
isInSQLEditor: boolean
|
||||
isInNewSnippet: boolean
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
"mobx": "^6.10.2",
|
||||
"mobx-react-lite": "^4.0.5",
|
||||
"monaco-editor": "0.33.0",
|
||||
"next": "^14.2.10",
|
||||
"next": "catalog:",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "^1.17.8",
|
||||
"openai": "^4.20.1",
|
||||
@@ -147,6 +147,7 @@
|
||||
"@types/lodash": "^4.14.172",
|
||||
"@types/markdown-table": "^3.0.0",
|
||||
"@types/mime-db": "^1.43.5",
|
||||
"@types/node": "catalog:",
|
||||
"@types/papaparse": "^5.3.1",
|
||||
"@types/randombytes": "^2.0.3",
|
||||
"@types/react": "^18.2.24",
|
||||
@@ -178,7 +179,6 @@
|
||||
"next-router-mock": "^0.9.13",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "3.2.4",
|
||||
"supports-color": "5.5.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.5.0"
|
||||
}
|
||||
|
||||
@@ -461,13 +461,7 @@ const Wizard: NextPageWithLayout = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Project name" layout="horizontal">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
placeholder="Project name"
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.replace(/\./g, ''))
|
||||
}}
|
||||
/>
|
||||
<Input_Shadcn_ {...field} placeholder="Project name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
|
||||
@@ -83,10 +83,9 @@ const RestoreToNewProject = () => {
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
const PITR_ENABLED = cloneBackups?.pitr_enabled
|
||||
|
||||
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
|
||||
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
|
||||
const IS_PG15_OR_ABOVE = dbVersion >= 15
|
||||
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
|
||||
|
||||
const {
|
||||
data: cloneStatus,
|
||||
@@ -260,7 +259,7 @@ const RestoreToNewProject = () => {
|
||||
<Markdown
|
||||
className="max-w-full [&>p]:!leading-normal"
|
||||
content={`This is a temporary limitation whereby projects that were originally restored from another project cannot be restored to yet another project.
|
||||
If you need to restore a project to multiple other projects, please reach out via [support](/support/new?ref=${project?.ref}).`}
|
||||
If you need to restore from a restored project, please reach out via [support](/support/new?ref=${project?.ref}).`}
|
||||
/>
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''}`}>
|
||||
|
||||
@@ -151,7 +151,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
return <NoPermission isFullPage resourceText="access this edge function" />
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="space-y-6 py-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
{CHART_INTERVALS.map((item, i) => {
|
||||
|
||||
@@ -19,9 +19,11 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
|
||||
return (
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType={'fn_edge'}
|
||||
filterOverride={{ function_id: selectedFunction.id }}
|
||||
filterPanelClassName="px-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user