mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
merge master into feat/auth-webauthn-fe
This commit is contained in:
247 files changed
+171712
-166359
No files matched your search
@@ -0,0 +1 @@
|
||||
!.env.example
|
||||
@@ -4,14 +4,14 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version that was released (e.g., patch, minor, major, or v2.1.0)"
|
||||
description: 'Version that was released (e.g., patch, minor, major, or v2.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
source:
|
||||
description: "Source of the documentation update"
|
||||
description: 'Source of the documentation update'
|
||||
required: false
|
||||
type: string
|
||||
default: "manual"
|
||||
default: 'manual'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -25,8 +25,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -36,8 +34,8 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
@@ -50,12 +48,18 @@ jobs:
|
||||
echo "Version: ${{ github.event.inputs.version }}"
|
||||
make
|
||||
|
||||
- name: Generate new typespec snapshot
|
||||
working-directory: apps/docs
|
||||
run: |
|
||||
echo "Generating new typespec snapshot for review..."
|
||||
npx vitest run --update ./features/docs/Reference.typeSpec.test.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "docs: update js client libraries (${{ github.event.inputs.version }})"
|
||||
title: "docs: update js client libraries (${{ github.event.inputs.version }})"
|
||||
commit-message: 'docs: update js client libraries (${{ github.event.inputs.version }})'
|
||||
title: 'docs: update js client libraries (${{ github.event.inputs.version }})'
|
||||
body: |
|
||||
Updates JS client libraries documentation following stable release.
|
||||
Ran `make` in apps/docs/spec to regenerate tsdoc files.
|
||||
@@ -66,5 +70,5 @@ jobs:
|
||||
- **Changes:** Regenerated tsdoc files from latest spec files
|
||||
|
||||
🤖 Auto-generated from supabase-js-libs stable release.
|
||||
branch: "gha/update-js-libs-docs-${{ github.run_number }}"
|
||||
base: "master"
|
||||
branch: 'gha/update-js-libs-docs-${{ github.run_number }}'
|
||||
base: 'master'
|
||||
@@ -40,19 +40,19 @@ jobs:
|
||||
- name: Update @supabase/*-js packages in pnpm-workspace.yaml
|
||||
run: |
|
||||
# Update @supabase/supabase-js
|
||||
sed -i "s/'@supabase\/supabase-js': .*/'@supabase\/supabase-js': ^${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s/'@supabase\/supabase-js': .*/'@supabase\/supabase-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
|
||||
# Update @supabase/auth-js
|
||||
sed -i "s/'@supabase\/auth-js': .*/'@supabase\/auth-js': ^${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s/'@supabase\/auth-js': .*/'@supabase\/auth-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
|
||||
# Update @supabase/realtime-js
|
||||
sed -i "s/'@supabase\/realtime-js': .*/'@supabase\/realtime-js': ^${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
sed -i "s/'@supabase\/realtime-js': .*/'@supabase\/realtime-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml
|
||||
|
||||
echo "Updated pnpm-workspace.yaml:"
|
||||
cat pnpm-workspace.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
run: pnpm i --no-frozen-lockfile
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
@@ -66,9 +66,9 @@ jobs:
|
||||
**Source**: ${{ github.event.inputs.source }}
|
||||
|
||||
**Changes**:
|
||||
- Updated @supabase/supabase-js to ^${{ github.event.inputs.version }}
|
||||
- Updated @supabase/auth-js to ^${{ github.event.inputs.version }}
|
||||
- Updated @supabase/realtime-js to ^${{ github.event.inputs.version }}
|
||||
- Updated @supabase/supabase-js to ${{ github.event.inputs.version }}
|
||||
- Updated @supabase/auth-js to ${{ github.event.inputs.version }}
|
||||
- Updated @supabase/realtime-js to ${{ github.event.inputs.version }}
|
||||
- Refreshed pnpm-lock.yaml
|
||||
|
||||
This PR was created automatically.
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"sharp": "0.32.6",
|
||||
"sharp": "~0.34.0",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -1390,7 +1390,13 @@ export const queues: NavMenuConstant = {
|
||||
{
|
||||
name: 'Getting Started',
|
||||
url: undefined,
|
||||
items: [{ name: 'Quickstart', url: '/guides/queues/quickstart' }],
|
||||
items: [
|
||||
{ name: 'Quickstart', url: '/guides/queues/quickstart' },
|
||||
{
|
||||
name: 'Consuming Messages with Edge Functions',
|
||||
url: '/guides/queues/consuming-messages-with-edge-functions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'References',
|
||||
@@ -2450,8 +2456,6 @@ export const platform: NavMenuConstant = {
|
||||
name: 'Manage your usage',
|
||||
url: '/guides/platform/manage-your-usage' as `/${string}`,
|
||||
items: [
|
||||
{ name: 'Usage limits', url: '/guides/platform/usage-limits' as `/${string}` },
|
||||
{ name: 'Overages', url: '/guides/platform/overages' as `/${string}` },
|
||||
{
|
||||
name: 'Compute',
|
||||
url: '/guides/platform/manage-your-usage/compute' as `/${string}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ Postgres comes with built-in support for replication via publications and replic
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
If you want to set up a read replica, see [Read Replicas](/docs/guides/platform/read-replicas) instead. If you want to sync your data in real time to a client such as a browser or mobile app, see [Realtime](/docs/guides/realtime) instead.
|
||||
If you want to set up a read replica, see [Read Replicas](/docs/guides/platform/read-replicas) instead. If you want to sync your data in real time to a client such as a browser or mobile app, see [Realtime](/docs/guides/realtime) instead. For configuring replication to an ETL destination, use the [Dashboard](/dashboard/project/_/database/replication).
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -20,14 +20,6 @@ Choose your Supabase platform, project, and MCP client and follow the installati
|
||||
|
||||
<McpConfigPanel />
|
||||
|
||||
<Admonition type="note" title="Authentication" className="mt-3">
|
||||
|
||||
Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions.
|
||||
|
||||
Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Next steps
|
||||
|
||||
Your AI tool is now connected to your Supabase project or account using remote MCP. Try asking the AI tool to query your database using natural language commands.
|
||||
@@ -63,33 +55,3 @@ We recommend the following best practices to mitigate security risks when using
|
||||
- **Project scoping**: Scope your MCP server to a [specific project](https://github.com/supabase-community/supabase-mcp#project-scoped-mode), limiting access to only that project's resources. This prevents LLMs from accessing data from other projects in your Supabase account.
|
||||
- **Branching**: Use Supabase's [branching feature](/docs/guides/deployment/branching) to create a development branch for your database. This allows you to test changes in a safe environment before merging them to production.
|
||||
- **Feature groups**: The server allows you to enable or disable specific [tool groups](https://github.com/supabase-community/supabase-mcp#feature-groups), so you can control which tools are available to the LLM. This helps reduce the attack surface and limits the actions that LLMs can perform to only those that you need.
|
||||
|
||||
## MCP for local Supabase instances
|
||||
|
||||
The Supabase MCP server connects directly to the cloud platform to access your database. If you are running a local instance of Supabase, you can instead use the [Postgres MCP server](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres) to connect to your local database. This MCP server runs all queries as read-only transactions.
|
||||
|
||||
### Step 1: Find your database connection string
|
||||
|
||||
To connect to your local Supabase instance, you need to get the connection string for your local database. You can find your connection string by running:
|
||||
|
||||
```shell
|
||||
supabase status
|
||||
```
|
||||
|
||||
or if you are using `npx`:
|
||||
|
||||
```shell
|
||||
npx supabase status
|
||||
```
|
||||
|
||||
This will output a list of details about your local Supabase instance. Copy the `DB URL` field in the output.
|
||||
|
||||
### Step 2: Configure the MCP server
|
||||
|
||||
Configure your client with the following:
|
||||
|
||||
<$Partial path="mcp_postgres_config.mdx" variables={{ "app": "your MCP client" }} />
|
||||
|
||||
### Next steps
|
||||
|
||||
Your AI tool is now connected to your local Supabase instance using MCP. Try asking the AI tool to query your database using natural language commands.
|
||||
@@ -21,21 +21,19 @@ Once a project is deleted all associated data will be permanently removed, inclu
|
||||
|
||||
Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [here](/blog/postgresql-physical-logical-backups).
|
||||
|
||||
As a general rule of thumb, projects will either have logical or physical backups based on plan, database size, and add-ons:
|
||||
<Admonition type="note" label="Physical backups are not enabled by default">
|
||||
|
||||
| Plan | Database Size (0-15GB) | [Database Size (>15GB)](#backup-process-for-large-databases) | [PITR](#point-in-time-recovery) | [Read Replicas](./read-replicas#prerequisites) |
|
||||
| ---------- | ---------------------- | ------------------------------------------------------------ | ------------------------------- | ---------------------------------------------- |
|
||||
| Pro | logical | physical | physical | physical |
|
||||
| Team | logical | physical | physical | physical |
|
||||
| Enterprise | physical | physical | physical | physical |
|
||||
To enable physical backups, you have three options:
|
||||
|
||||
<Admonition type="note">
|
||||
- Enable [Point-in-Time Recovery (PITR)](#point-in-time-recovery)
|
||||
- [Increase your disk size](/docs/guides/platform/database-size) to greater than 15GB
|
||||
- [Create a read replica](/docs/guides/platform/read-replicas)
|
||||
|
||||
Once a project satisfies at least one of the requirements for physical backups then logical backups will no longer be taken. However, your project may revert back to logical backups if add-ons are removed.
|
||||
Once a project satisfies at least one of the requirements for physical backups then logical backups are no longer made. However, your project may revert back to logical backups if you remove add-ons.
|
||||
|
||||
</Admonition>
|
||||
|
||||
You can confirm your project's backup type by navigating to [Database Backups > Scheduled backups](/dashboard/project/_/database/backups/scheduled) and if you can download a backup then it is logical, otherwise it is physical.
|
||||
You can confirm your project's backup type by navigating to [**Database Backups > Scheduled backups**](/dashboard/project/_/database/backups/scheduled) and if you can download a backup then it is logical, otherwise it is physical.
|
||||
|
||||
However, if your project has the Point-in-Time Recovery (PITR) add-on then the backups are physical and you can view them in [Database Backups > Point in time](/dashboard/project/_/database/backups/pitr).
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Consuming Supabase Queue Messages with Edge Functions
|
||||
subtitle: 'Learn how to consume Supabase Queue messages server-side with a Supabase Edge Function'
|
||||
---
|
||||
|
||||
This guide helps you read & process queue messages server-side with a Supabase Edge Function. Read [Queues API Reference](/docs/guides/queues/api) for more details on our API.
|
||||
|
||||
## Concepts
|
||||
|
||||
Supabase Queues is a pull-based Message Queue consisting of three main components: Queues, Messages, and Queue Types. You should already be familiar with the [Queues Quickstart](/docs/guides/queues/quickstart).
|
||||
|
||||
### Consuming messages in an Edge Function
|
||||
|
||||
This is a Supabase Edge Function that reads 5 messages off the queue, processes each of them, and deletes each message when it is done.
|
||||
|
||||
```tsx
|
||||
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const supabaseUrl = 'supabaseURL'
|
||||
const supabaseKey = 'supabaseKey'
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
const queueName = 'your_queue_name'
|
||||
|
||||
// Type definition for queue messages
|
||||
interface QueueMessage {
|
||||
msg_id: bigint
|
||||
read_ct: number
|
||||
vt: string
|
||||
enqueued_at: string
|
||||
message: any
|
||||
}
|
||||
|
||||
async function processMessage(message: QueueMessage) {
|
||||
//
|
||||
// Do whatever logic you need to with the message content
|
||||
//
|
||||
// Delete the message from the queue
|
||||
const { error: deleteError } = await supabase.schema('pgmq_public').rpc('delete', {
|
||||
queue_name: queueName,
|
||||
msg_id: message.msg_id,
|
||||
})
|
||||
|
||||
if (deleteError) {
|
||||
console.error(`Failed to delete message ${message.msg_id}:`, deleteError)
|
||||
} else {
|
||||
console.log(`Message ${message.msg_id} deleted from queue`)
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { data: messages, error } = await supabase.schema('pgmq_public').rpc('read', {
|
||||
queue_name: queueName,
|
||||
sleep_seconds: 0, // Don't wait if queue is empty
|
||||
n: 5, // Read 5 messages off the queue
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(`Error reading from ${queueName} queue:`, error)
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
console.log('No messages in workflow_messages queue')
|
||||
return new Response(JSON.stringify({ message: 'No messages in queue' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Found ${messages.length} messages to process`)
|
||||
|
||||
// Process each message that was read off the queue
|
||||
for (const message of messages) {
|
||||
try {
|
||||
await processMessage(message as QueueMessage)
|
||||
} catch (error) {
|
||||
console.error(`Error processing message ${message.msg_id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately while background processing continues
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `Processing ${messages.length} messages in background`,
|
||||
count: messages.length,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Every time this Edge Function is run it:
|
||||
|
||||
1. Read 5 messages off the queue
|
||||
2. Call the `processMessage` function
|
||||
3. At the end of `processMessage`, the message is deleted from the queue
|
||||
4. If `processMessage` throws an error, the error is logged. In this case, the message is still in the queue, so the next time this Edge Function runs it reads the message again.
|
||||
|
||||
You might find this kind of setup handy to run with [Supabase Cron](/docs/guides/cron). You can set up Cron so that every N number of minutes or seconds, the Edge Function will run and process a number of messages off the queue.
|
||||
|
||||
Similarly, you can invoke the Edge Function on command at any given time with [`supabase.functions.invoke`](/docs/guides/functions/quickstart-dashboard#usage).
|
||||
@@ -220,6 +220,7 @@ Update the `./docker/.env` file with your own secrets. In particular, these are
|
||||
- `SITE_URL`: the base URL of your site.
|
||||
- `SMTP_*`: mail server credentials. You can use any SMTP server.
|
||||
- `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string
|
||||
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta
|
||||
|
||||
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ const KIND_MODULE = 2
|
||||
const KIND_CLASS = 128
|
||||
const KIND_INTERFACE = 256
|
||||
const KIND_CONSTRUCTOR = 512
|
||||
const KIND_PROPERTY = 1024
|
||||
const KIND_METHOD = 2048
|
||||
const KIND_TYPE_LITERAL = 65536
|
||||
|
||||
@@ -303,6 +304,15 @@ function parseModInternal(
|
||||
parseConstructor(node, map, currentPath, res)
|
||||
} else if (node.kind === KIND_METHOD) {
|
||||
return parseMethod(node, map, currentPath, res)
|
||||
} else if (node.kind === KIND_PROPERTY) {
|
||||
if (node.type?.type === 'reference') {
|
||||
const referent = map.get(node.type.target)
|
||||
if (referent?.variant === 'declaration' && referent?.kind === KIND_INTERFACE) {
|
||||
const children = referent?.children ?? []
|
||||
updatedPath = [...currentPath, node.name]
|
||||
children.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
case 'property':
|
||||
@@ -439,24 +449,26 @@ function parseSignature(
|
||||
//
|
||||
// with additional properties depending on the type.
|
||||
|
||||
function parseType(type: any, map: Map<number, any>) {
|
||||
function parseType(type: any, map: Map<number, any>, typeArguments?: any, debug = false) {
|
||||
switch (type.type) {
|
||||
case 'literal':
|
||||
return type
|
||||
case 'intrinsic':
|
||||
return type
|
||||
case 'reference':
|
||||
return parseReferenceType(type, map)
|
||||
return parseReferenceType(type, map, typeArguments, debug)
|
||||
case 'array':
|
||||
return parseArrayType(type, map)
|
||||
return parseArrayType(type, map, typeArguments, debug)
|
||||
case 'union':
|
||||
return parseUnionType(type, map)
|
||||
return parseUnionType(type, map, typeArguments, debug)
|
||||
case 'reflection':
|
||||
return parseReflectionType(type, map)
|
||||
return parseReflectionType(type, map, typeArguments, debug)
|
||||
case 'indexedAccess':
|
||||
return parseIndexedAccessType(type, map)
|
||||
return parseIndexedAccessType(type, map, typeArguments, debug)
|
||||
case 'typeOperator':
|
||||
return parseTypeOperatorType(type, map)
|
||||
return parseTypeOperatorType(type, map, typeArguments, debug)
|
||||
case 'conditional':
|
||||
return parseConditionalType(type, map, typeArguments, debug)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -481,8 +493,13 @@ function parseType(type: any, map: Map<number, any>) {
|
||||
* parsing to the referenced node, but use the name and comment from the
|
||||
* original type.
|
||||
*/
|
||||
function delegateParsing(original: any, referenced: any, map: Map<number, any>) {
|
||||
const dereferencedType = parseType(referenced, map)
|
||||
function delegateParsing(
|
||||
original: any,
|
||||
referenced: any,
|
||||
map: Map<number, any>,
|
||||
typeArguments?: any
|
||||
) {
|
||||
const dereferencedType = parseType(referenced, map, typeArguments)
|
||||
|
||||
if (dereferencedType) {
|
||||
dereferencedType.name = nameOrAnonymous([original, dereferencedType])
|
||||
@@ -498,7 +515,44 @@ function delegateParsing(original: any, referenced: any, map: Map<number, any>)
|
||||
return dereferencedType
|
||||
}
|
||||
|
||||
function parseReferenceType(type: any, map: Map<number, any>) {
|
||||
function parseConditionalType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
typeArguments?: any,
|
||||
_debug = false
|
||||
) {
|
||||
if (type.extendsType?.type === 'intrinsic' && type.extendsType?.name === 'object') {
|
||||
let properties = []
|
||||
|
||||
if (
|
||||
type.trueType?.type === 'mapped' &&
|
||||
type.trueType?.parameterType?.target?.refersToTypeParameter &&
|
||||
typeArguments?.[0]
|
||||
) {
|
||||
const propertyNames = parseType(typeArguments?.[0], map).properties?.map((p) => p.name)
|
||||
properties = (propertyNames ?? [])
|
||||
.map((p: string) => {
|
||||
if (!type.trueType?.templateType) return undefined
|
||||
|
||||
const mappedType = parseType(type.trueType.templateType, map)
|
||||
if (mappedType) {
|
||||
return {
|
||||
name: p,
|
||||
type: mappedType,
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseReferenceType(type: any, map: Map<number, any>, typeArguments?: any, debug = false) {
|
||||
if (type.dereferenced?.type) {
|
||||
return delegateParsing(type, type.dereferenced.type, map)
|
||||
}
|
||||
@@ -507,6 +561,10 @@ function parseReferenceType(type: any, map: Map<number, any>) {
|
||||
return delegateParsing(type, type.dereferenced, map)
|
||||
}
|
||||
|
||||
if (type.refersToTypeParameter === true && typeArguments?.[0]) {
|
||||
return delegateParsing(type, typeArguments?.[0], map)
|
||||
}
|
||||
|
||||
if (
|
||||
type.package === 'typescript' &&
|
||||
(type.name === 'Record' || type.qualifiedName === 'Record')
|
||||
@@ -537,8 +595,13 @@ function parseReferenceType(type: any, map: Map<number, any>) {
|
||||
if (referenced) {
|
||||
const maybeType =
|
||||
typeof referenced.type === 'object' && 'type' in referenced.type
|
||||
? /* need to go down a level */ delegateParsing(type, referenced.type, map)
|
||||
: delegateParsing(type, referenced, map)
|
||||
? /* need to go down a level */ delegateParsing(
|
||||
type,
|
||||
referenced.type,
|
||||
map,
|
||||
type.typeArguments
|
||||
)
|
||||
: delegateParsing(type, referenced, map, type.typeArguments)
|
||||
|
||||
if (maybeType) {
|
||||
return maybeType
|
||||
@@ -580,7 +643,12 @@ function parseReferenceType(type: any, map: Map<number, any>) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseArrayType(type: any, map: Map<number, any>): ArrayType {
|
||||
function parseArrayType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
_typeArguments?: any,
|
||||
debug = false
|
||||
): ArrayType {
|
||||
const elemType = parseType(type.elementType, map)
|
||||
|
||||
return {
|
||||
@@ -590,9 +658,14 @@ function parseArrayType(type: any, map: Map<number, any>): ArrayType {
|
||||
}
|
||||
}
|
||||
|
||||
function parseUnionType(type: any, map: Map<number, any>): CustomUnionType {
|
||||
function parseUnionType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
typeArguments?: any,
|
||||
_debug = false
|
||||
): CustomUnionType {
|
||||
// Need the Boolean filter because there are nulls in some of the nodes
|
||||
const subTypes = type.types.filter(Boolean).map((type) => parseType(type, map))
|
||||
const subTypes = type.types.filter(Boolean).map((type) => parseType(type, map, typeArguments))
|
||||
|
||||
return {
|
||||
type: 'union',
|
||||
@@ -654,17 +727,22 @@ function parsePickType(type: any, map: Map<number, any>) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseReflectionType(type: any, map: Map<number, any>): TypeDetails | undefined {
|
||||
function parseReflectionType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
typeArguments?: any,
|
||||
_debug = false
|
||||
): TypeDetails | undefined {
|
||||
if (!type.declaration) return undefined
|
||||
|
||||
let res: TypeDetails | undefined
|
||||
switch ((type.declaration.kindString ?? type.declaration.variant).toLowerCase()) {
|
||||
case 'type literal':
|
||||
res = parseTypeLiteral(type, map)
|
||||
res = parseTypeLiteral(type, map, typeArguments)
|
||||
break
|
||||
case 'declaration':
|
||||
if (type.declaration.kind === KIND_TYPE_LITERAL) {
|
||||
res = parseTypeLiteral(type, map)
|
||||
res = parseTypeLiteral(type, map, typeArguments)
|
||||
}
|
||||
default:
|
||||
break
|
||||
@@ -673,12 +751,16 @@ function parseReflectionType(type: any, map: Map<number, any>): TypeDetails | un
|
||||
return res
|
||||
}
|
||||
|
||||
function parseTypeLiteral(type: any, map: Map<number, any>): TypeDetails | undefined {
|
||||
function parseTypeLiteral(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
typeArguments?: any
|
||||
): TypeDetails | undefined {
|
||||
const name = nameOrAnonymous(type)
|
||||
|
||||
if ('children' in type.declaration) {
|
||||
const properties = type.declaration.children
|
||||
.map((child: any) => parseTypeInternals(child, map))
|
||||
.map((child: any) => parseTypeInternals(child, map, typeArguments))
|
||||
.filter(Boolean)
|
||||
return {
|
||||
name,
|
||||
@@ -714,14 +796,24 @@ function parseTypeLiteral(type: any, map: Map<number, any>): TypeDetails | undef
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseIndexedAccessType(type: any, map: Map<number, any>) {
|
||||
function parseIndexedAccessType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
_typeArguments?: any,
|
||||
_debug = false
|
||||
) {
|
||||
return {
|
||||
type: 'nameOnly',
|
||||
name: `${type.objectType?.name ?? ''}['${type.indexType.value ?? type.indexType.name ?? ''}']`,
|
||||
}
|
||||
}
|
||||
|
||||
function parseTypeOperatorType(type: any, map: Map<number, any>) {
|
||||
function parseTypeOperatorType(
|
||||
type: any,
|
||||
map: Map<number, any>,
|
||||
_typeArguments?: any,
|
||||
_debug = false
|
||||
) {
|
||||
switch (type.operator) {
|
||||
case 'readonly':
|
||||
return parseType(type.target, map)
|
||||
@@ -745,11 +837,11 @@ function parseInterface(type: any, map: Map<number, any>): CustomObjectType {
|
||||
// This layer is for the sub-types that define a custom type, for example, the
|
||||
// properties of an interface.
|
||||
|
||||
function parseTypeInternals(elem: any, map: Map<number, any>) {
|
||||
function parseTypeInternals(elem: any, map: Map<number, any>, typeArguments?: any) {
|
||||
switch ((elem.kindString || elem.variant).toLowerCase()) {
|
||||
case 'property':
|
||||
case 'declaration':
|
||||
return parseInternalProperty(elem, map)
|
||||
return parseInternalProperty(elem, map, typeArguments)
|
||||
case 'method':
|
||||
if (elem.signatures?.[0]) {
|
||||
const { params, ret, comment } = parseSignature(elem.signatures?.[0], map)
|
||||
@@ -772,13 +864,13 @@ function parseTypeInternals(elem: any, map: Map<number, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseInternalProperty(elem: any, map: Map<number, any>) {
|
||||
function parseInternalProperty(elem: any, map: Map<number, any>, typeArguments?: any) {
|
||||
const name = nameOrAnonymous(elem)
|
||||
if (!elem.type) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const type = parseType(elem.type, map)
|
||||
const type = parseType(elem.type, map, typeArguments)
|
||||
|
||||
const res = {
|
||||
name,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,7 @@ import {
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder'
|
||||
import { useProjectsQuery } from '~/lib/fetch/projects'
|
||||
|
||||
@@ -193,28 +194,44 @@ export function McpConfigPanel() {
|
||||
const project = isPlatform ? selectedProject : null
|
||||
|
||||
return (
|
||||
<div className="not-prose">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<PlatformSelector
|
||||
selectedPlatform={selectedPlatform}
|
||||
onPlatformSelect={setSelectedPlatform}
|
||||
<>
|
||||
<div className="not-prose">
|
||||
<div className="flex flex-wrap gap-3 mb-3">
|
||||
<PlatformSelector
|
||||
selectedPlatform={selectedPlatform}
|
||||
onPlatformSelect={setSelectedPlatform}
|
||||
/>
|
||||
{isPlatform && (
|
||||
<ProjectSelector selectedProject={project} onProjectSelect={setSelectedProject} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{isPlatform
|
||||
? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.'
|
||||
: 'Project selection is only available for the hosted platform.'}
|
||||
</p>
|
||||
<McpConfigPanelBase
|
||||
basePath="/docs"
|
||||
className="mt-6"
|
||||
projectRef={project?.ref}
|
||||
theme={theme as 'light' | 'dark'}
|
||||
isPlatform={isPlatform}
|
||||
/>
|
||||
{isPlatform && (
|
||||
<ProjectSelector selectedProject={project} onProjectSelect={setSelectedProject} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{isPlatform
|
||||
? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.'
|
||||
: 'Project selection is only available for the hosted platform.'}
|
||||
</p>
|
||||
<McpConfigPanelBase
|
||||
basePath="/docs"
|
||||
className="mt-6"
|
||||
projectRef={project?.ref}
|
||||
theme={theme as 'light' | 'dark'}
|
||||
isPlatform={isPlatform}
|
||||
/>
|
||||
</div>
|
||||
{isPlatform && (
|
||||
<Admonition type="note" title="Authentication" className="mt-3">
|
||||
<p>
|
||||
{
|
||||
"Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions."
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
'Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.'
|
||||
}
|
||||
</p>
|
||||
</Admonition>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ TEAM
|
||||
Supabase is 100% remote.
|
||||
|
||||
Aaron Byrne
|
||||
Adam Mokan
|
||||
Alaister Young
|
||||
Alan De Los Santos
|
||||
Aleksi Immonen
|
||||
@@ -44,6 +45,7 @@ Deji I
|
||||
Div Arora
|
||||
Divit D
|
||||
Douglas Hunley
|
||||
Dustin Keib
|
||||
Eduardo Gurgel
|
||||
Egor Romanov
|
||||
Eleftheria Trivyzaki
|
||||
|
||||
@@ -37,12 +37,12 @@ download.storage.v1:
|
||||
# curl -sS https://supabase.github.io/functions-js/v1/spec.json > $(REPO_DIR)/enrichments/tsdoc_v1/functions.json
|
||||
|
||||
download.tsdoc.v2:
|
||||
curl -sS https://supabase.github.io/supabase-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/supabase.json
|
||||
curl -sS https://supabase.github.io/auth-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/gotrue.json
|
||||
curl -sS https://supabase.github.io/postgrest-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/postgrest.json
|
||||
curl -sS https://supabase.github.io/realtime-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/realtime.json
|
||||
curl -sS https://supabase.github.io/storage-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/storage.json
|
||||
curl -sS https://supabase.github.io/functions-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/functions.json
|
||||
curl -sS https://supabase.github.io/supabase-js/supabase-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/supabase.json
|
||||
curl -sS https://supabase.github.io/supabase-js/auth-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/gotrue.json
|
||||
curl -sS https://supabase.github.io/supabase-js/postgrest-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/postgrest.json
|
||||
curl -sS https://supabase.github.io/supabase-js/realtime-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/realtime.json
|
||||
curl -sS https://supabase.github.io/supabase-js/storage-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/storage.json
|
||||
curl -sS https://supabase.github.io/supabase-js/functions-js/v2/spec.json > $(REPO_DIR)/enrichments/tsdoc_v2/functions.json
|
||||
|
||||
download.analytics.v0:
|
||||
curl -sS https://logflare.app/api/openapi > $(REPO_DIR)/analytics_v0_openapi.json
|
||||
|
||||
+774
-126
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
clispec: '001'
|
||||
info:
|
||||
id: cli
|
||||
version: 2.45.5
|
||||
version: 2.48.3
|
||||
title: Supabase CLI
|
||||
language: sh
|
||||
source: https://github.com/supabase/cli
|
||||
@@ -78,7 +78,7 @@ flags:
|
||||
name: --workdir <string>
|
||||
description: path to a Supabase project directory
|
||||
default_value: ''
|
||||
- id: "yes"
|
||||
- id: 'yes'
|
||||
name: --yes
|
||||
description: answer yes to all prompts
|
||||
default_value: 'false'
|
||||
@@ -2359,6 +2359,10 @@ commands:
|
||||
name: --exp <time>
|
||||
description: Expiry timestamp for this token.
|
||||
default_value: ''
|
||||
- id: payload
|
||||
name: --payload <string>
|
||||
description: Custom claims in JSON format.
|
||||
default_value: '{}'
|
||||
- id: role
|
||||
name: --role <string>
|
||||
description: Postgres role to use.
|
||||
|
||||
@@ -484,6 +484,12 @@
|
||||
"type": "category",
|
||||
"title": "Environments",
|
||||
"items": [
|
||||
{
|
||||
"id": "v1-count-action-runs",
|
||||
"title": "Count action runs",
|
||||
"slug": "v1-count-action-runs",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-create-a-branch",
|
||||
"title": "Create a branch",
|
||||
@@ -520,6 +526,24 @@
|
||||
"slug": "v1-get-a-branch-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-action-run",
|
||||
"title": "Get action run",
|
||||
"slug": "v1-get-action-run",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-get-action-run-logs",
|
||||
"title": "Get action run logs",
|
||||
"slug": "v1-get-action-run-logs",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-action-runs",
|
||||
"title": "List action runs",
|
||||
"slug": "v1-list-action-runs",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-list-all-branches",
|
||||
"title": "List all branches",
|
||||
@@ -549,6 +573,12 @@
|
||||
"title": "Update a branch config",
|
||||
"slug": "v1-update-a-branch-config",
|
||||
"type": "operation"
|
||||
},
|
||||
{
|
||||
"id": "v1-update-action-run-status",
|
||||
"title": "Update action run status",
|
||||
"slug": "v1-update-action-run-status",
|
||||
"type": "operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+49779
-61389
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+18678
-12243
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+14692
-10822
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1278,7 +1278,15 @@
|
||||
"prefix": { "type": "string", "examples": ["folder/subfolder"] },
|
||||
"limit": { "type": "integer", "minimum": 1, "examples": [10] },
|
||||
"cursor": { "type": "string" },
|
||||
"with_delimiter": { "type": "boolean" }
|
||||
"with_delimiter": { "type": "boolean" },
|
||||
"sortBy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"column": { "type": "string", "enum": ["name", "updated_at", "created_at"] },
|
||||
"order": { "type": "string", "enum": ["asc", "desc"] }
|
||||
},
|
||||
"required": ["column"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ info:
|
||||
functions:
|
||||
- id: initializing
|
||||
title: 'Initializing'
|
||||
$ref: '@supabase/supabase-js.index.SupabaseClient.constructor'
|
||||
$ref: '@supabase/supabase-js.SupabaseClient.constructor'
|
||||
description: |
|
||||
You can initialize a new Supabase client using the `createClient()` method.
|
||||
|
||||
@@ -1197,14 +1197,14 @@ functions:
|
||||
isSpotlight: false
|
||||
code: |
|
||||
```js
|
||||
const { error } = await supabase.auth.signOut('local')
|
||||
const { error } = await supabase.auth.signOut({ scope: 'local' })
|
||||
```
|
||||
- id: sign-out-other-sessions
|
||||
name: Sign out (other sessions)
|
||||
isSpotlight: false
|
||||
code: |
|
||||
```js
|
||||
const { error } = await supabase.auth.signOut('others')
|
||||
const { error } = await supabase.auth.signOut({ scope: 'others' })
|
||||
```
|
||||
- id: verify-otp
|
||||
title: 'verifyOtp()'
|
||||
@@ -2250,7 +2250,7 @@ functions:
|
||||
Learn more about implementing MFA in your application [in the MFA guide](https://supabase.com/docs/guides/auth/auth-mfa#overview).
|
||||
- id: mfa-enroll
|
||||
title: 'mfa.enroll()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.enroll'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.enroll'
|
||||
notes: |
|
||||
- Use `totp` or `phone` as the `factorType` and use the returned `id` to create a challenge.
|
||||
- To create a challenge, see [`mfa.challenge()`](/docs/reference/javascript/auth-mfa-challenge).
|
||||
@@ -2324,7 +2324,7 @@ functions:
|
||||
```
|
||||
- id: mfa-challenge
|
||||
title: 'mfa.challenge()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.challenge'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.challenge'
|
||||
notes: |
|
||||
- An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before creating a challenge.
|
||||
- To verify a challenge, see [`mfa.verify()`](/docs/reference/javascript/auth-mfa-verify).
|
||||
@@ -2392,7 +2392,7 @@ functions:
|
||||
```
|
||||
- id: mfa-verify
|
||||
title: 'mfa.verify()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.verify'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.verify'
|
||||
notes: |
|
||||
- To verify a challenge, please [create a challenge](/docs/reference/javascript/auth-mfa-challenge) first.
|
||||
examples:
|
||||
@@ -2468,7 +2468,7 @@ functions:
|
||||
```
|
||||
- id: mfa-challenge-and-verify
|
||||
title: 'mfa.challengeAndVerify()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.challengeAndVerify'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.challengeAndVerify'
|
||||
notes: |
|
||||
- Intended for use with only TOTP factors.
|
||||
- An [enrolled factor](/docs/reference/javascript/auth-mfa-enroll) is required before invoking `challengeAndVerify()`.
|
||||
@@ -2545,7 +2545,7 @@ functions:
|
||||
```
|
||||
- id: mfa-unenroll
|
||||
title: 'mfa.unenroll()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.unenroll'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.unenroll'
|
||||
examples:
|
||||
- id: unenroll-a-factor
|
||||
name: Unenroll a factor
|
||||
@@ -2567,7 +2567,7 @@ functions:
|
||||
```
|
||||
- id: mfa-get-authenticator-assurance-level
|
||||
title: 'mfa.getAuthenticatorAssuranceLevel()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.getAuthenticatorAssuranceLevel'
|
||||
$ref: '@supabase/auth-js.GoTrueClient.mfa.getAuthenticatorAssuranceLevel'
|
||||
notes: |
|
||||
- Authenticator Assurance Level (AAL) is the measure of the strength of an authentication mechanism.
|
||||
- In Supabase, having an AAL of `aal1` refers to having the 1st factor of authentication such as an email and password or OAuth sign-in while `aal2` refers to the 2nd factor of authentication such as a time-based, one-time-password (TOTP) or Phone factor.
|
||||
@@ -7384,7 +7384,7 @@ functions:
|
||||
```
|
||||
- id: get-channels
|
||||
title: getChannels()
|
||||
$ref: '@supabase/supabase-js.index.SupabaseClient.getChannels'
|
||||
$ref: '@supabase/supabase-js.SupabaseClient.getChannels'
|
||||
examples:
|
||||
- id: get-all-channels
|
||||
name: Get all channels
|
||||
@@ -7396,7 +7396,7 @@ functions:
|
||||
|
||||
- id: remove-channel
|
||||
title: removeChannel()
|
||||
$ref: '@supabase/supabase-js.index.SupabaseClient.removeChannel'
|
||||
$ref: '@supabase/supabase-js.SupabaseClient.removeChannel'
|
||||
notes: |
|
||||
- Removing a channel is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed.
|
||||
examples:
|
||||
@@ -7410,7 +7410,7 @@ functions:
|
||||
|
||||
- id: remove-all-channels
|
||||
title: removeAllChannels()
|
||||
$ref: '@supabase/supabase-js.index.SupabaseClient.removeAllChannels'
|
||||
$ref: '@supabase/supabase-js.SupabaseClient.removeAllChannels'
|
||||
notes: |
|
||||
- Removing channels is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed.
|
||||
examples:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1973,6 +1973,20 @@
|
||||
},
|
||||
"with_delimiter": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sortBy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"column": {
|
||||
"type": "string",
|
||||
"enum": ["name", "updated_at", "created_at"]
|
||||
},
|
||||
"order": {
|
||||
"type": "string",
|
||||
"enum": ["asc", "desc"]
|
||||
}
|
||||
},
|
||||
"required": ["column"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { compact, filter } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { compact } from 'lodash'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid'
|
||||
|
||||
import type { Filter, SavedState } from 'components/grid/types'
|
||||
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
|
||||
import { useUrlState } from 'hooks/ui/useUrlState'
|
||||
import { copyToClipboard } from 'ui'
|
||||
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
|
||||
import { STORAGE_KEY_PREFIX } from './constants'
|
||||
import type { Sort, SupaColumn, SupaTable } from './types'
|
||||
import { formatClipboardValue } from './utils/common'
|
||||
import { parseAsArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { parseAsNativeArrayOf, parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export const LOAD_TAB_FROM_CACHE_PARAM = 'loadFromCache'
|
||||
|
||||
@@ -197,16 +197,25 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({
|
||||
projectRef: string | undefined
|
||||
table: Entity | undefined
|
||||
}) {
|
||||
const [urlParams, updateUrlParams] = useQueryStates(
|
||||
// Warning: nuxt url state often fails to update to changes to URL
|
||||
const [, updateUrlParams] = useQueryStates(
|
||||
{
|
||||
sort: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
filter: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
sort: parseAsNativeArrayOf(parseAsString),
|
||||
filter: parseAsNativeArrayOf(parseAsString),
|
||||
[LOAD_TAB_FROM_CACHE_PARAM]: parseAsBoolean.withDefault(false),
|
||||
},
|
||||
{
|
||||
history: 'replace',
|
||||
}
|
||||
)
|
||||
// Use nextjs useSearchParams to get the latest URL params
|
||||
const searchParams = useSearchParams()
|
||||
const urlParams = useMemo(() => {
|
||||
const sort = searchParams.getAll('sort')
|
||||
const filter = searchParams.getAll('filter')
|
||||
const loadFromCache = !!searchParams.get(LOAD_TAB_FROM_CACHE_PARAM)
|
||||
return { sort, filter, loadFromCache }
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectRef || !table) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Key } from 'react'
|
||||
import { TriggerEvent, useContextMenu } from 'react-contexify'
|
||||
import { RenderRowProps, Row } from 'react-data-grid'
|
||||
|
||||
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
|
||||
import { SupaRow } from 'components/grid/types'
|
||||
import { ROW_CONTEXT_MENU_ID } from '../menu'
|
||||
|
||||
export default function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
|
||||
const { show: showContextMenu } = useContextMenu()
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useCallback } from 'react'
|
||||
import { Item, ItemParams, Menu } from 'react-contexify'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { copyToClipboard, DialogSectionSeparator } from 'ui'
|
||||
import { ROW_CONTEXT_MENU_ID } from '.'
|
||||
import { formatClipboardValue } from '../../utils/common'
|
||||
|
||||
export type RowContextMenuProps = {
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export { default as ColumnMenu } from './ColumnMenu'
|
||||
export { default as RowContextMenu } from './RowContextMenu'
|
||||
|
||||
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id'
|
||||
@@ -12,3 +12,5 @@ const RLS_ACKNOWLEDGED_KEY = 'supabase-acknowledge-rls-warning'
|
||||
|
||||
export const rlsAcknowledgedKey = (tableID?: string | number) =>
|
||||
`${RLS_ACKNOWLEDGED_KEY}-${String(tableID)}`
|
||||
|
||||
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id'
|
||||
@@ -1,7 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowDown, ArrowUp, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
@@ -11,13 +12,17 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import type { AuditLog } from 'data/organizations/organization-audit-logs-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProfileAuditLogsQuery } from 'data/profile/profile-audit-logs-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
|
||||
import { Button } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
|
||||
|
||||
const AuditLogs = () => {
|
||||
const currentTime = dayjs().utc().set('millisecond', 0)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const [dateSortDesc, setDateSortDesc] = useState(true)
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: currentTime.subtract(1, 'day').toISOString(),
|
||||
@@ -29,8 +34,20 @@ const AuditLogs = () => {
|
||||
projects: [], // project_ref[]
|
||||
})
|
||||
|
||||
const { data: projectsData } = useProjectsQuery()
|
||||
const projects = projectsData?.projects ?? []
|
||||
const {
|
||||
data: projectsData,
|
||||
isLoading: isLoadingProjects,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useProjectsInfiniteQuery(
|
||||
{ search: search.length === 0 ? search : debouncedSearch },
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
const projects =
|
||||
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
||||
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } =
|
||||
useProfileAuditLogsQuery(
|
||||
@@ -88,6 +105,13 @@ const AuditLogs = () => {
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoadingProjects}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
|
||||
import { useDashboardHistory } from 'hooks/misc/useDashboardHistory'
|
||||
import useLatest from 'hooks/misc/useLatest'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
@@ -12,7 +12,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
|
||||
// Ideally these could all be within a _middleware when we use Next 12
|
||||
const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const router = useRouter()
|
||||
const { ref, slug, id } = useParams()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
@@ -21,12 +21,16 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
const isUserMFAEnabled = useIsMFAEnabled()
|
||||
|
||||
const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory()
|
||||
const [__, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
const [lastVisitedOrganization, setLastVisitedOrganization] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
||||
''
|
||||
)
|
||||
|
||||
const DEFAULT_HOME = IS_PLATFORM ? '/organizations' : '/project/default'
|
||||
const DEFAULT_HOME = IS_PLATFORM
|
||||
? !!lastVisitedOrganization
|
||||
? `/org/${lastVisitedOrganization}`
|
||||
: '/organizations'
|
||||
: '/project/default'
|
||||
|
||||
/**
|
||||
* Array of urls/routes that should be ignored
|
||||
@@ -49,6 +53,8 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
return excemptUrls.includes(router?.pathname)
|
||||
}
|
||||
|
||||
const { isError: isErrorProject } = useProjectDetailQuery({ ref })
|
||||
|
||||
const { data: organizations, isSuccess: orgsInitialized } = useOrganizationsQuery({
|
||||
enabled: isLoggedIn,
|
||||
})
|
||||
@@ -71,31 +77,17 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
}
|
||||
}, [orgsInitialized])
|
||||
|
||||
const { data, isSuccess: projectsInitialized } = useProjectsQuery({
|
||||
enabled: isLoggedIn,
|
||||
})
|
||||
const projects = data?.projects ?? []
|
||||
const projectsRef = useLatest(projects)
|
||||
|
||||
useEffect(() => {
|
||||
// check if current route is excempted from route validation check
|
||||
if (isExceptUrl() || !isLoggedIn) return
|
||||
|
||||
if (projectsInitialized && ref) {
|
||||
// Check validity of project that the user is trying to access
|
||||
const projects = projectsRef.current ?? []
|
||||
const isValidProject = projects.some((project) => project.ref === ref)
|
||||
const isValidBranch = IS_PLATFORM
|
||||
? projects.some((project) => project.preview_branch_refs.includes(ref))
|
||||
: true
|
||||
|
||||
if (!isValidProject && !isValidBranch) {
|
||||
toast.error('This project does not exist')
|
||||
router.push(DEFAULT_HOME)
|
||||
return
|
||||
}
|
||||
// A successful request to project details will validate access to both project and branches
|
||||
if (!!ref && isErrorProject) {
|
||||
toast.error('This project does not exist')
|
||||
router.push(DEFAULT_HOME)
|
||||
return
|
||||
}
|
||||
}, [projectsInitialized])
|
||||
}, [isErrorProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (ref !== undefined && id !== undefined) {
|
||||
@@ -125,5 +117,3 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default RouteValidationWrapper
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as AppBannerWrapper } from './AppBannerWrapper'
|
||||
export { default as RouteValidationWrapper } from './RouteValidationWrapper'
|
||||
export { RouteValidationWrapper } from './RouteValidationWrapper'
|
||||
@@ -448,6 +448,12 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_APPLE_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_APPLE_ENABLED: boolean().required(),
|
||||
@@ -525,6 +531,7 @@ const EXTERNAL_PROVIDER_APPLE = {
|
||||
then: (schema) =>
|
||||
schema.required('At least one Client ID is required when Apple sign-in is enabled.'),
|
||||
}),
|
||||
EXTERNAL_APPLE_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'apple-icon',
|
||||
@@ -566,6 +573,12 @@ const EXTERNAL_PROVIDER_AZURE = {
|
||||
descriptionOptional: 'Optional',
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_AZURE_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_AZURE_ENABLED: boolean().required(),
|
||||
@@ -580,6 +593,7 @@ const EXTERNAL_PROVIDER_AZURE = {
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_AZURE_URL: string().matches(urlRegex(), 'Must be a valid URL').optional(),
|
||||
EXTERNAL_AZURE_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'microsoft-icon',
|
||||
@@ -606,6 +620,12 @@ const EXTERNAL_PROVIDER_BITBUCKET = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_BITBUCKET_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_BITBUCKET_ENABLED: boolean().required(),
|
||||
@@ -645,6 +665,12 @@ const EXTERNAL_PROVIDER_DISCORD = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_DISCORD_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_DISCORD_ENABLED: boolean().required(),
|
||||
@@ -658,6 +684,7 @@ const EXTERNAL_PROVIDER_DISCORD = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_DISCORD_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'discord-icon',
|
||||
@@ -684,6 +711,12 @@ const EXTERNAL_PROVIDER_FACEBOOK = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_FACEBOOK_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_FACEBOOK_ENABLED: boolean().required(),
|
||||
@@ -697,6 +730,7 @@ const EXTERNAL_PROVIDER_FACEBOOK = {
|
||||
then: (schema) => schema.required('"Facebook secret" is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_FACEBOOK_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'facebook-icon',
|
||||
@@ -723,6 +757,12 @@ const EXTERNAL_PROVIDER_FIGMA = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_FIGMA_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_FIGMA_ENABLED: boolean().required(),
|
||||
@@ -736,6 +776,7 @@ const EXTERNAL_PROVIDER_FIGMA = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_FIGMA_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'figma-icon',
|
||||
@@ -762,6 +803,12 @@ const EXTERNAL_PROVIDER_GITHUB = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_GITHUB_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_GITHUB_ENABLED: boolean().required(),
|
||||
@@ -775,6 +822,7 @@ const EXTERNAL_PROVIDER_GITHUB = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_GITHUB_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'github-icon',
|
||||
@@ -808,6 +856,12 @@ const EXTERNAL_PROVIDER_GITLAB = {
|
||||
descriptionOptional: 'Optional',
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_GITLAB_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_GITLAB_ENABLED: boolean().required(),
|
||||
@@ -822,6 +876,7 @@ const EXTERNAL_PROVIDER_GITLAB = {
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_GITLAB_URL: string().matches(urlRegex(), 'Must be a valid URL').optional(),
|
||||
EXTERNAL_GITLAB_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'gitlab-icon',
|
||||
@@ -859,6 +914,12 @@ const EXTERNAL_PROVIDER_GOOGLE = {
|
||||
"Allows ID tokens with any nonce to be accepted, which is less secure. Useful in situations where you don't have access to the nonce used to issue the ID token, such as with iOS.",
|
||||
type: 'boolean',
|
||||
},
|
||||
EXTERNAL_GOOGLE_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_GOOGLE_ENABLED: boolean().required(),
|
||||
@@ -882,6 +943,7 @@ const EXTERNAL_PROVIDER_GOOGLE = {
|
||||
),
|
||||
}),
|
||||
EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: boolean().required(),
|
||||
EXTERNAL_GOOGLE_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'google-icon',
|
||||
@@ -912,6 +974,12 @@ const EXTERNAL_PROVIDER_KAKAO = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_KAKAO_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_KAKAO_ENABLED: boolean().required(),
|
||||
@@ -925,6 +993,7 @@ const EXTERNAL_PROVIDER_KAKAO = {
|
||||
then: (schema) => schema.required('Client Secret Code is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_KAKAO_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'kakao-icon',
|
||||
@@ -957,6 +1026,12 @@ const EXTERNAL_PROVIDER_KEYCLOAK = {
|
||||
description: '',
|
||||
type: 'string',
|
||||
},
|
||||
EXTERNAL_KEYCLOAK_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_KEYCLOAK_ENABLED: boolean().required(),
|
||||
@@ -976,6 +1051,7 @@ const EXTERNAL_PROVIDER_KEYCLOAK = {
|
||||
schema.matches(urlRegex(), 'Must be a valid URL').required('Realm URL is required'),
|
||||
otherwise: (schema) => schema.matches(urlRegex(), 'Must be a valid URL'),
|
||||
}),
|
||||
EXTERNAL_KEYCLOAK_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'keycloak-icon',
|
||||
@@ -1003,6 +1079,12 @@ const EXTERNAL_PROVIDER_LINKEDIN_OIDC = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_LINKEDIN_OIDC_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_LINKEDIN_OIDC_ENABLED: boolean().required(),
|
||||
@@ -1016,6 +1098,7 @@ const EXTERNAL_PROVIDER_LINKEDIN_OIDC = {
|
||||
then: (schema) => schema.required('API Secret Key is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_LINKEDIN_OIDC_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'linkedin-icon',
|
||||
@@ -1042,6 +1125,12 @@ const EXTERNAL_PROVIDER_NOTION = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_NOTION_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_NOTION_ENABLED: boolean().required(),
|
||||
@@ -1055,6 +1144,7 @@ const EXTERNAL_PROVIDER_NOTION = {
|
||||
then: (schema) => schema.required('OAuth client secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_NOTION_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'notion-icon',
|
||||
@@ -1081,6 +1171,12 @@ const EXTERNAL_PROVIDER_TWITCH = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_TWITCH_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_TWITCH_ENABLED: boolean().required(),
|
||||
@@ -1094,6 +1190,7 @@ const EXTERNAL_PROVIDER_TWITCH = {
|
||||
then: (schema) => schema.required('Client secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_TWITCH_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'twitch-icon',
|
||||
@@ -1120,6 +1217,12 @@ const EXTERNAL_PROVIDER_TWITTER = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_TWITTER_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_TWITTER_ENABLED: boolean().required(),
|
||||
@@ -1133,6 +1236,7 @@ const EXTERNAL_PROVIDER_TWITTER = {
|
||||
then: (schema) => schema.required('API Secret Key is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_TWITTER_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'twitter-icon',
|
||||
@@ -1159,6 +1263,12 @@ const EXTERNAL_PROVIDER_SLACK = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_SLACK_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_SLACK_ENABLED: boolean().required(),
|
||||
@@ -1172,6 +1282,7 @@ const EXTERNAL_PROVIDER_SLACK = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_SLACK_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'slack-icon',
|
||||
@@ -1199,6 +1310,12 @@ const EXTERNAL_PROVIDER_SLACK_OIDC = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_SLACK_OIDC_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_SLACK_OIDC_ENABLED: boolean().required(),
|
||||
@@ -1212,6 +1329,7 @@ const EXTERNAL_PROVIDER_SLACK_OIDC = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_SLACK_OIDC_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'slack-icon',
|
||||
@@ -1238,6 +1356,12 @@ const EXTERNAL_PROVIDER_SPOTIFY = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_SPOTIFY_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_SPOTIFY_ENABLED: boolean().required(),
|
||||
@@ -1251,6 +1375,7 @@ const EXTERNAL_PROVIDER_SPOTIFY = {
|
||||
then: (schema) => schema.required('Client Secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_SPOTIFY_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'spotify-icon',
|
||||
@@ -1327,6 +1452,12 @@ const EXTERNAL_PROVIDER_ZOOM = {
|
||||
type: 'string',
|
||||
isSecret: true,
|
||||
},
|
||||
EXTERNAL_ZOOM_EMAIL_OPTIONAL: {
|
||||
title: 'Allow users without an email',
|
||||
description:
|
||||
'Allows the user to successfully authenticate when the provider does not return an email address.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
validationSchema: object().shape({
|
||||
EXTERNAL_ZOOM_ENABLED: boolean().required(),
|
||||
@@ -1340,6 +1471,7 @@ const EXTERNAL_PROVIDER_ZOOM = {
|
||||
then: (schema) => schema.required('Client secret is required'),
|
||||
otherwise: (schema) => schema,
|
||||
}),
|
||||
EXTERNAL_ZOOM_EMAIL_OPTIONAL: boolean().optional(),
|
||||
}),
|
||||
misc: {
|
||||
iconKey: 'zoom-icon',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, AiIconAnimation, Image } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { Logs } from 'icons'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
|
||||
export const OverviewLearnMore = () => {
|
||||
const { ref } = useParams()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
|
||||
const LearnMoreCards = [
|
||||
{
|
||||
label: 'Docs',
|
||||
title: 'Authentication docs',
|
||||
description: 'Read more on authentication and benefits of using Supabase policies.',
|
||||
image: `${BASE_PATH}/img/auth-overview/auth-overview-docs.jpg`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Docs',
|
||||
href: 'https://supabase.com/docs/guides/auth',
|
||||
icon: <BookOpen />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assistant',
|
||||
title: 'Explain authentication errors',
|
||||
description: 'Our Assistant can help you debug and fix authentication errors.',
|
||||
image: `${BASE_PATH}/img/auth-overview/auth-overview-assistant.jpg`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Ask Assistant',
|
||||
onClick: () => {
|
||||
aiSnap.newChat({
|
||||
name: 'Authentication Help',
|
||||
open: true,
|
||||
initialInput: 'Help me debug and fix authentication errors in my Supabase project',
|
||||
suggestions: {
|
||||
title: 'I can help you with authentication issues. Here are some common problems:',
|
||||
prompts: [
|
||||
{
|
||||
label: 'Login Issues',
|
||||
description: 'Why are users unable to log in to my app?',
|
||||
},
|
||||
{
|
||||
label: 'JWT Problems',
|
||||
description: 'Help me understand and fix JWT token issues',
|
||||
},
|
||||
{
|
||||
label: 'RLS Policies',
|
||||
description: 'Explain my Row Level Security policies and fix issues',
|
||||
},
|
||||
{
|
||||
label: 'Provider Setup',
|
||||
description: 'Help me configure OAuth providers correctly',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
},
|
||||
icon: <AiIconAnimation size={14} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
title: 'Dive into the logs',
|
||||
description:
|
||||
'Our authentication logs provide a deeper view into your auth requests and errors.',
|
||||
image: `${BASE_PATH}/img/auth-overview/auth-overview-logs.jpg`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Go to logs',
|
||||
href: `/project/${ref}/logs/auth-logs`,
|
||||
icon: <Logs />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Learn more</ScaffoldSectionTitle>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
{LearnMoreCards.map((card) => (
|
||||
<Card key={card.label} className="relative">
|
||||
<CardHeader className="absolute top-0 left-0 right-0 border-b-0">
|
||||
<CardTitle className="text-foreground-lighter">{card.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="bg-black/20 flex w-full">
|
||||
<Image
|
||||
src={card.image && card?.image}
|
||||
alt={card.title}
|
||||
width={620}
|
||||
height={324}
|
||||
className="object-fit"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-1 mb-4">
|
||||
<h4>{card.title}</h4>
|
||||
<p className="text-sm text-foreground-lighter">{card.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{card.actions.map((action) => {
|
||||
if ('href' in action) {
|
||||
return (
|
||||
<Button
|
||||
key={action.label}
|
||||
className="inline-flex"
|
||||
type="default"
|
||||
icon={action.icon}
|
||||
asChild
|
||||
>
|
||||
<Link href={action.href} className="inline-flex">
|
||||
{action.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="default"
|
||||
className="inline-flex"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ScaffoldSectionTitle, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { Card } from 'ui'
|
||||
|
||||
export const OverviewMonitoring = () => {
|
||||
return (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Monitoring</ScaffoldSectionTitle>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-30 pointer-events-none">
|
||||
<Card className="h-36" />
|
||||
<Card className="h-36" />
|
||||
<Card className="aspect-video" />
|
||||
<Card className="aspect-video" />
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { Card } from 'ui'
|
||||
|
||||
export const OverviewUsage = () => {
|
||||
return (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Usage</ScaffoldSectionTitle>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-30 pointer-events-none">
|
||||
<Card className="aspect-video" />
|
||||
<Card className="aspect-video" />
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -96,7 +96,7 @@ export const LockedRenameQuerySection = ({
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-mono tracking-tighter">
|
||||
<span className="text-[#569cd6]">alter</span> policy {oldName}
|
||||
<span className="text-[#569cd6]">alter</span> policy "{oldName}"
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ fontSize: '14px' }}>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
import { ActionName, ActionStatus, type ActionRunStep } from 'data/actions/action-runs-query'
|
||||
import { Badge, StatusIcon, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
export interface ActionStatusBadgeProps {
|
||||
name: ActionName
|
||||
status: ActionStatus
|
||||
}
|
||||
|
||||
const UNHEALTHY_STATUES: ActionStatus[] = ['DEAD', 'REMOVING']
|
||||
const WAITING_STATUSES: ActionStatus[] = ['CREATED', 'RESTARTING', 'RUNNING']
|
||||
|
||||
export const STATUS_TO_LABEL: Record<ActionStatus, string> = {
|
||||
CREATED: 'pending',
|
||||
DEAD: 'failed',
|
||||
EXITED: 'succeeded',
|
||||
PAUSED: 'skipped',
|
||||
REMOVING: 'failed',
|
||||
RESTARTING: 'restarting',
|
||||
RUNNING: 'running',
|
||||
}
|
||||
|
||||
const NAME_TO_LABEL: Record<ActionName, string> = {
|
||||
clone: 'Cloning repo',
|
||||
pull: 'Pulling data',
|
||||
health: 'Health check',
|
||||
configure: 'Configurations',
|
||||
migrate: 'Migrations',
|
||||
seed: 'Data seeding',
|
||||
deploy: 'Functions deployment',
|
||||
}
|
||||
|
||||
export const ActionStatusBadgeCondensed = ({
|
||||
children,
|
||||
status,
|
||||
details,
|
||||
}: PropsWithChildren<{
|
||||
status: ActionStatus
|
||||
details: Array<ActionRunStep>
|
||||
}>) => {
|
||||
if (status === 'EXITED') {
|
||||
return null
|
||||
}
|
||||
|
||||
const isUnhealthy = UNHEALTHY_STATUES.includes(status)
|
||||
const isWaiting = WAITING_STATUSES.includes(status)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={isUnhealthy ? 'destructive' : 'default'} className="gap-1.5">
|
||||
{(isUnhealthy || isWaiting) && (
|
||||
<StatusIcon variant={isUnhealthy ? 'destructive' : 'default'} hideBackground />
|
||||
)}
|
||||
{children}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Additional {STATUS_TO_LABEL[status]} steps:
|
||||
<ul>
|
||||
{details.map((step) => (
|
||||
<li key={step.name} className="before:content-['-'] before:mr-0.5">
|
||||
{NAME_TO_LABEL[step.name]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActionStatusBadge = ({ name, status }: ActionStatusBadgeProps) => {
|
||||
if (status === 'EXITED') {
|
||||
return null
|
||||
}
|
||||
|
||||
const isUnhealthy = UNHEALTHY_STATUES.includes(status)
|
||||
const isWaiting = WAITING_STATUSES.includes(status)
|
||||
|
||||
return (
|
||||
<Badge variant={isUnhealthy ? 'destructive' : 'default'} className="gap-1.5">
|
||||
{(isUnhealthy || isWaiting) && (
|
||||
<StatusIcon variant={isUnhealthy ? 'destructive' : 'default'} hideBackground />
|
||||
)}
|
||||
{NAME_TO_LABEL[name]}: {STATUS_TO_LABEL[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { Branch } from 'data/branches/branches-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { groupBy } from 'lodash'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { StatusIcon } from 'ui'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { useWorkflowRunLogsQuery } from 'data/workflow-runs/workflow-run-logs-query'
|
||||
import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query'
|
||||
import { ActionRunData } from 'data/actions/action-detail-query'
|
||||
import { useActionRunLogsQuery } from 'data/actions/action-logs-query'
|
||||
import {
|
||||
useActionsQuery,
|
||||
type ActionRunStep,
|
||||
type ActionStatus,
|
||||
} from 'data/actions/action-runs-query'
|
||||
import type { Branch } from 'data/branches/branches-query'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
@@ -18,24 +23,21 @@ import {
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
StatusIcon,
|
||||
} from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns'
|
||||
import { ActionStatusBadge, ActionStatusBadgeCondensed, STATUS_TO_LABEL } from './ActionStatusBadge'
|
||||
import BranchStatusBadge from './BranchStatusBadge'
|
||||
|
||||
interface WorkflowLogsProps {
|
||||
projectRef: string
|
||||
status?: Branch['status'] | string
|
||||
status: Branch['status']
|
||||
}
|
||||
|
||||
type StatusType = Branch['status'] | string
|
||||
type StatusType = Branch['status']
|
||||
|
||||
const UNHEALTHY_STATUSES: StatusType[] = [
|
||||
'ACTIVE_UNHEALTHY',
|
||||
'INIT_FAILED',
|
||||
'UNKNOWN',
|
||||
'MIGRATIONS_FAILED',
|
||||
'FUNCTIONS_FAILED',
|
||||
]
|
||||
const HEALTHY_STATUSES: StatusType[] = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_PASSED']
|
||||
const UNHEALTHY_STATUSES: StatusType[] = ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED']
|
||||
|
||||
export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -46,9 +48,11 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
isLoading: isWorkflowRunsLoading,
|
||||
isError: isWorkflowRunsError,
|
||||
error: workflowRunsError,
|
||||
} = useWorkflowRunsQuery({ projectRef }, { enabled: isOpen })
|
||||
} = useActionsQuery({ ref: projectRef }, { enabled: isOpen })
|
||||
|
||||
const [selectedWorkflowRunId, setSelectedWorkflowRunId] = useState<string | undefined>(undefined)
|
||||
const [selectedWorkflowRun, setSelectedWorkflowRun] = useState<ActionRunData | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const {
|
||||
data: workflowRunLogs,
|
||||
@@ -56,17 +60,13 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
isLoading: isWorkflowRunLogsLoading,
|
||||
isError: isWorkflowRunLogsError,
|
||||
error: workflowRunLogsError,
|
||||
} = useWorkflowRunLogsQuery(
|
||||
{ workflowRunId: selectedWorkflowRunId },
|
||||
{ enabled: isOpen && selectedWorkflowRunId !== undefined }
|
||||
} = useActionRunLogsQuery(
|
||||
{ ref: projectRef, run_id: selectedWorkflowRun?.id ?? '' },
|
||||
{ enabled: isOpen && Boolean(selectedWorkflowRun) }
|
||||
)
|
||||
|
||||
const showStatusIcon =
|
||||
status !== undefined &&
|
||||
status !== 'ACTIVE_HEALTHY' &&
|
||||
status !== 'FUNCTIONS_DEPLOYED' &&
|
||||
status !== 'MIGRATIONS_PASSED'
|
||||
const isUnhealthy = status !== undefined && UNHEALTHY_STATUSES.includes(status)
|
||||
const showStatusIcon = !HEALTHY_STATUSES.includes(status)
|
||||
const isUnhealthy = UNHEALTHY_STATUSES.includes(status)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -93,7 +93,7 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSection className={cn('px-0', isWorkflowRunLogsSuccess ? 'py-0 pt-2' : '!py-0')}>
|
||||
{selectedWorkflowRunId === undefined ? (
|
||||
{!selectedWorkflowRun ? (
|
||||
<>
|
||||
{isWorkflowRunsLoading && <GenericSkeletonLoader className="py-4" />}
|
||||
{isWorkflowRunsError && (
|
||||
@@ -108,11 +108,15 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
<li key={workflowRun.id} className="py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedWorkflowRunId(workflowRun.id)}
|
||||
onClick={() => setSelectedWorkflowRun(workflowRun)}
|
||||
className="flex items-center gap-2 w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<BranchStatusBadge status={workflowRun.status} />
|
||||
{workflowRun.run_steps.length > 0 ? (
|
||||
<RunSteps steps={workflowRun.run_steps} />
|
||||
) : (
|
||||
<BranchStatusBadge status={status} />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{dayjs(workflowRun.created_at).format('DD MMM, YYYY HH:mm')}
|
||||
</span>
|
||||
@@ -131,7 +135,7 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<Button
|
||||
onClick={() => setSelectedWorkflowRunId(undefined)}
|
||||
onClick={() => setSelectedWorkflowRun(undefined)}
|
||||
type="text"
|
||||
icon={<ArrowLeft />}
|
||||
className="self-start"
|
||||
@@ -161,3 +165,29 @@ export const WorkflowLogs = ({ projectRef, status }: WorkflowLogsProps) => {
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RunSteps({ steps }: { steps: Array<ActionRunStep> }) {
|
||||
const stepsByStatus = groupBy(steps, 'status') as Record<ActionStatus, Array<ActionRunStep>>
|
||||
const firstFailedStep = stepsByStatus.DEAD?.[0]
|
||||
const numberFailedSteps = stepsByStatus.DEAD?.length ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{firstFailedStep && (
|
||||
<ActionStatusBadge name={firstFailedStep.name} status={firstFailedStep.status} />
|
||||
)}
|
||||
{numberFailedSteps > 1 && (
|
||||
<ActionStatusBadgeCondensed status={'DEAD'} details={stepsByStatus.DEAD.slice(1)}>
|
||||
{numberFailedSteps - 1} more
|
||||
</ActionStatusBadgeCondensed>
|
||||
)}
|
||||
{(Object.keys(stepsByStatus) as Array<ActionStatus>)
|
||||
.filter((status) => status !== 'DEAD')
|
||||
.map((status) => (
|
||||
<ActionStatusBadgeCondensed key={status} status={status} details={stepsByStatus[status]}>
|
||||
{stepsByStatus[status].length} {STATUS_TO_LABEL[status]}
|
||||
</ActionStatusBadgeCondensed>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+5
-4
@@ -115,10 +115,11 @@ const FunctionsList = ({
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={(schema) => {
|
||||
const url = new URL(document.URL)
|
||||
url.searchParams.delete('search')
|
||||
router.push(url)
|
||||
setSelectedSchema(schema)
|
||||
setFilterString('')
|
||||
// Wait for the filter to be cleared from the URL
|
||||
setTimeout(() => {
|
||||
setSelectedSchema(schema)
|
||||
}, 50)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
|
||||
@@ -22,8 +22,8 @@ import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { DeleteDestination } from './DeleteDestination'
|
||||
import { DestinationPanel } from './DestinationPanel'
|
||||
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { RowMenu } from './RowMenu'
|
||||
import { UpdateVersionModal } from './UpdateVersionModal'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
|
||||
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
|
||||
export const PIPELINE_ERROR_MESSAGES = {
|
||||
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
|
||||
|
||||
@@ -8,14 +8,7 @@ import { ResponseError } from 'types'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger, WarningIcon } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { getPipelineStateMessages } from './Pipeline.utils'
|
||||
|
||||
export enum PipelineStatusName {
|
||||
FAILED = 'failed',
|
||||
STARTING = 'starting',
|
||||
STARTED = 'started',
|
||||
STOPPED = 'stopped',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
|
||||
interface PipelineStatusProps {
|
||||
pipelineStatus: ReplicationPipelineStatusData['status'] | undefined
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
export const STATUS_REFRESH_FREQUENCY_MS: number = 5000
|
||||
|
||||
export enum PipelineStatusName {
|
||||
FAILED = 'failed',
|
||||
STARTING = 'starting',
|
||||
STARTED = 'started',
|
||||
STOPPED = 'stopped',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
+2
-2
@@ -39,8 +39,8 @@ import {
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
getStatusName,
|
||||
} from '../Pipeline.utils'
|
||||
import { PipelineStatus, PipelineStatusName } from '../PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
import { PipelineStatus } from '../PipelineStatus'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
import { UpdateVersionModal } from '../UpdateVersionModal'
|
||||
import { SlotLagMetrics, TableState } from './ReplicationPipelineStatus.types'
|
||||
import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Edit, MoreVertical, Pause, Play, RotateCcw, Trash, ArrowUpCircle } from 'lucide-react'
|
||||
import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
getStatusName,
|
||||
} from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
|
||||
interface RowMenuProps {
|
||||
pipeline: Pipeline | undefined
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
} from 'state/replication-pipeline-request-status'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { getStatusName } from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './PipelineStatus'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
|
||||
interface UpdateVersionModalProps {
|
||||
visible: boolean
|
||||
@@ -26,8 +25,6 @@ interface UpdateVersionModalProps {
|
||||
export const UpdateVersionModal = ({
|
||||
visible,
|
||||
pipeline,
|
||||
// currentVersionName,
|
||||
// newVersionName,
|
||||
confirmLabel = 'Update and restart',
|
||||
confirmLabelLoading = 'Updating',
|
||||
onClose,
|
||||
|
||||
+4
-8
@@ -5,15 +5,11 @@ import { CloneStatus } from 'data/projects/clone-status-query'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
|
||||
export const PreviousRestoreItem = ({
|
||||
clone,
|
||||
}: {
|
||||
clone: NonNullable<CloneStatus['clones']>[number]
|
||||
}) => {
|
||||
export const PreviousRestoreItem = ({ clone }: { clone: CloneStatus['clones'][number] }) => {
|
||||
if (clone.status === 'REMOVED') {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 text-sm p-4 group">
|
||||
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
|
||||
<div className="min-w-24 truncate">{clone.target_project?.name ?? 'Unknown project'}</div>
|
||||
<div>
|
||||
<StatusBadge status={clone.status} />
|
||||
</div>
|
||||
@@ -28,10 +24,10 @@ export const PreviousRestoreItem = ({
|
||||
} else {
|
||||
return (
|
||||
<Link
|
||||
href={`/project/${clone.target_project.ref}`}
|
||||
href={`/project/${clone.target_project?.ref ?? '_'}`}
|
||||
className="grid grid-cols-4 gap-2 text-sm p-4 group"
|
||||
>
|
||||
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
|
||||
<div className="min-w-24 truncate">{clone.target_project?.name ?? 'Unknown project'}</div>
|
||||
<div>
|
||||
<StatusBadge status={clone.status} />
|
||||
</div>
|
||||
|
||||
+149
-132
@@ -1,7 +1,7 @@
|
||||
import { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { noop } from 'lodash'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { DatabaseZap, FunctionSquare, Plus, Search, Shield } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
|
||||
@@ -19,7 +19,10 @@ import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSch
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
} from 'ui'
|
||||
import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
|
||||
import TriggerList from './TriggerList'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface TriggersListProps {
|
||||
createTrigger: () => void
|
||||
@@ -79,146 +83,159 @@ const TriggersList = ({
|
||||
return <AlertError error={error} subject="Failed to retrieve database triggers" />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(triggers ?? []).length === 0 ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ProductEmptyState
|
||||
title="Triggers"
|
||||
ctaButtonLabel="Create a new trigger"
|
||||
onClickCta={() => createTrigger()}
|
||||
>
|
||||
<AlphaPreview />
|
||||
<p className="text-sm text-foreground-light">
|
||||
A PostgreSQL trigger is a function invoked automatically whenever an event associated
|
||||
with a table occurs.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
An event could be any of the following: INSERT, UPDATE, DELETE. A trigger is a special
|
||||
user-defined function associated with a table.
|
||||
</p>
|
||||
</ProductEmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search for a trigger"
|
||||
size="tiny"
|
||||
icon={<Search size="14" />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isSchemaLocked && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
disabled={!hasTables || !canCreateTriggers}
|
||||
icon={<Plus />}
|
||||
onClick={() => createTrigger()}
|
||||
className="flex-grow"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !hasTables
|
||||
? 'Create a table first before creating triggers'
|
||||
: !canCreateTriggers
|
||||
? 'You need additional permissions to create triggers'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New trigger
|
||||
</ButtonTooltip>
|
||||
const schemaTriggers = triggers.filter((x) => x.schema == selectedSchema)
|
||||
|
||||
{hasTables && (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!hasTables || !canCreateTriggers}
|
||||
className="px-1 pointer-events-auto"
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
onClick={() =>
|
||||
aiSnap.newChat({
|
||||
name: 'Create new trigger',
|
||||
open: true,
|
||||
initialInput: `Create a new trigger for the schema ${selectedSchema} that does ...`,
|
||||
suggestions: {
|
||||
title:
|
||||
'I can help you create a new trigger, here are a few example prompts to get you started:',
|
||||
prompts: [
|
||||
{
|
||||
label: 'Log Changes',
|
||||
description: 'Create a trigger that logs changes to the users table',
|
||||
},
|
||||
{
|
||||
label: 'Update Timestamp',
|
||||
description: 'Create a trigger that updates updated_at timestamp',
|
||||
},
|
||||
{
|
||||
label: 'Validate Email',
|
||||
description:
|
||||
'Create a trigger that validates email format before insert',
|
||||
},
|
||||
],
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<SchemaSelector
|
||||
className="w-full lg:w-[180px]"
|
||||
size="tiny"
|
||||
showError={false}
|
||||
selectedSchemaName={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search for a trigger"
|
||||
size="tiny"
|
||||
icon={<Search size="14" />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!isSchemaLocked && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
disabled={!hasTables || !canCreateTriggers}
|
||||
icon={<Plus />}
|
||||
onClick={() => createTrigger()}
|
||||
className="flex-grow"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !hasTables
|
||||
? 'Create a table first before creating triggers'
|
||||
: !canCreateTriggers
|
||||
? 'You need additional permissions to create triggers'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New trigger
|
||||
</ButtonTooltip>
|
||||
|
||||
{hasTables && (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!hasTables || !canCreateTriggers}
|
||||
className="px-1 pointer-events-auto"
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
onClick={() =>
|
||||
aiSnap.newChat({
|
||||
name: 'Create new trigger',
|
||||
open: true,
|
||||
initialInput: `Create a new trigger for the schema ${selectedSchema} that does ...`,
|
||||
suggestions: {
|
||||
title:
|
||||
'I can help you create a new trigger, here are a few example prompts to get you started:',
|
||||
prompts: [
|
||||
{
|
||||
label: 'Log Changes',
|
||||
description: 'Create a trigger that logs changes to the users table',
|
||||
},
|
||||
})
|
||||
}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateTriggers
|
||||
? 'You need additional permissions to create triggers'
|
||||
: 'Create with Supabase Assistant',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
label: 'Update Timestamp',
|
||||
description: 'Create a trigger that updates updated_at timestamp',
|
||||
},
|
||||
{
|
||||
label: 'Validate Email',
|
||||
description: 'Create a trigger that validates email format before insert',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateTriggers
|
||||
? 'You need additional permissions to create triggers'
|
||||
: 'Create with Supabase Assistant',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
|
||||
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="table">Table</TableHead>
|
||||
<TableHead key="function">Function</TableHead>
|
||||
<TableHead key="events">Events</TableHead>
|
||||
<TableHead key="orientation">Orientation</TableHead>
|
||||
<TableHead key="enabled" className="w-20">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead key="buttons" className="w-1/12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TriggerList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editTrigger={editTrigger}
|
||||
deleteTrigger={deleteTrigger}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
{!isSchemaLocked && (schemaTriggers ?? []).length === 0 ? (
|
||||
<Card className="grid grid-cols-1 @xl:grid-cols-3 bg divide-x @container">
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<DatabaseZap strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
<h3 className="heading-default">Create realtime experiences</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm flex-1">
|
||||
Keep your application in sync by automatically updating when data changes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FunctionSquare strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
<h3 className="heading-default">Trigger an edge function</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm flex-1">
|
||||
Automatically invoke edge functions when database events occur
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Shield strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
<h3 className="heading-default">Validate data</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm flex-1">
|
||||
Ensure data meets your requirements before it is inserted into the database
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="table">Table</TableHead>
|
||||
<TableHead key="function">Function</TableHead>
|
||||
<TableHead key="events">Events</TableHead>
|
||||
<TableHead key="orientation">Orientation</TableHead>
|
||||
<TableHead key="enabled" className="w-20">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead key="buttons" className="w-1/12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TriggerList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editTrigger={editTrigger}
|
||||
deleteTrigger={deleteTrigger}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export function DiskSizeField({
|
||||
const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
|
||||
|
||||
return (
|
||||
<div className="grid @xl:grid-cols-12 gap-5">
|
||||
<div id="disk-size" className="grid @xl:grid-cols-12 gap-5">
|
||||
<div className="col-span-4">
|
||||
<FormField_Shadcn_
|
||||
name="totalSize"
|
||||
|
||||
@@ -83,7 +83,7 @@ export const TerminalInstructions = forwardRef<
|
||||
return (
|
||||
<>
|
||||
<span className="text-brand-600">curl</span> -L -X POST '{functionsEndpoint}
|
||||
/hello-world' -H 'Authorization: Bearer [YOUR ANON KEY]' s
|
||||
/hello-world' -H 'Authorization: Bearer [YOUR ANON KEY]'
|
||||
{anonKey?.type === 'publishable' ? " -H 'apikey: [YOUR ANON KEY]' " : ''}
|
||||
{`--data '{"name":"Functions"}'`}
|
||||
</>
|
||||
|
||||
@@ -5,7 +5,7 @@ import CardButton from 'components/ui/CardButton'
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
|
||||
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
|
||||
import { OrgProject } from 'data/projects/projects-infinite-query'
|
||||
import { OrgProject } from 'data/projects/org-projects-infinite-query'
|
||||
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
|
||||
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
|
||||
@@ -7,7 +7,7 @@ import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
|
||||
import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only'
|
||||
import { usePermissionsQuery } from 'data/permissions/permissions-query'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
|
||||
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
|
||||
@@ -4,7 +4,7 @@ import InlineSVG from 'react-inlinesvg'
|
||||
|
||||
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
|
||||
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
|
||||
import { OrgProject } from 'data/projects/projects-infinite-query'
|
||||
import { OrgProject } from 'data/projects/org-projects-infinite-query'
|
||||
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Organization } from 'types'
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/projects-infinite-query'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import z from 'zod'
|
||||
|
||||
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
|
||||
import { cronPattern, secondsPattern } from '../CronJobs.constants'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const edgeFunctionSchema = z.object({
|
||||
type: z.literal('edge_function'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Input must be valid JSON'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const httpRequestSchema = z.object({
|
||||
type: z.literal('http_request'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
endpoint: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a URL')
|
||||
.regex(urlRegex(), 'Please provide a valid URL')
|
||||
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Input must be valid JSON'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const sqlFunctionSchema = z.object({
|
||||
type: z.literal('sql_function'),
|
||||
schema: z.string().trim().min(1, 'Please select one of the listed database schemas'),
|
||||
functionName: z.string().trim().min(1, 'Please select one of the listed database functions'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const sqlSnippetSchema = z.object({
|
||||
type: z.literal('sql_snippet'),
|
||||
snippet: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export const FormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
|
||||
supportsSeconds: z.boolean(),
|
||||
schedule: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
convertCronToString(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, 'Invalid Cron format'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
sqlFunctionSchema,
|
||||
sqlSnippetSchema,
|
||||
]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!cronPattern.test(data.schedule)) {
|
||||
if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.',
|
||||
path: ['schedule'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateCronJobForm = z.infer<typeof FormSchema>
|
||||
export type CronJobType = CreateCronJobForm['values']
|
||||
+13
-124
@@ -1,15 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
|
||||
import { useWatch } from '@ui/components/shadcn/ui/form'
|
||||
import { useParams } from 'common'
|
||||
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { getDatabaseCronJob } from 'data/database-cron-jobs/database-cron-job-query'
|
||||
@@ -38,23 +35,22 @@ import {
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
|
||||
import { CRONJOB_DEFINITIONS } from '../CronJobs.constants'
|
||||
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils'
|
||||
import { EdgeFunctionSection } from '../EdgeFunctionSection'
|
||||
import { HttpBodyFieldSection } from '../HttpBodyFieldSection'
|
||||
import { HTTPHeaderFieldsSection } from '../HttpHeaderFieldsSection'
|
||||
import { HttpRequestSection } from '../HttpRequestSection'
|
||||
import { SqlFunctionSection } from '../SqlFunctionSection'
|
||||
import { SqlSnippetSection } from '../SqlSnippetSection'
|
||||
import {
|
||||
buildCronQuery,
|
||||
buildHttpRequestCommand,
|
||||
cronPattern,
|
||||
parseCronJobCommand,
|
||||
secondsPattern,
|
||||
} from './CronJobs.utils'
|
||||
FormSchema,
|
||||
type CreateCronJobForm,
|
||||
type CronJobType,
|
||||
} from './CreateCronJobSheet.constants'
|
||||
import { CronJobScheduleSection } from './CronJobScheduleSection'
|
||||
import { EdgeFunctionSection } from './EdgeFunctionSection'
|
||||
import { HttpBodyFieldSection } from './HttpBodyFieldSection'
|
||||
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
|
||||
import { HttpRequestSection } from './HttpRequestSection'
|
||||
import { SqlFunctionSection } from './SqlFunctionSection'
|
||||
import { SqlSnippetSection } from './SqlSnippetSection'
|
||||
|
||||
export interface CreateCronJobSheetProps {
|
||||
interface CreateCronJobSheetProps {
|
||||
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
|
||||
supportsSeconds: boolean
|
||||
isClosing: boolean
|
||||
@@ -62,113 +58,6 @@ export interface CreateCronJobSheetProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const edgeFunctionSchema = z.object({
|
||||
type: z.literal('edge_function'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Input must be valid JSON'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const httpRequestSchema = z.object({
|
||||
type: z.literal('http_request'),
|
||||
method: z.enum(['GET', 'POST']),
|
||||
endpoint: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please provide a URL')
|
||||
.regex(urlRegex(), 'Please provide a valid URL')
|
||||
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
|
||||
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
|
||||
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
httpBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Input must be valid JSON'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const sqlFunctionSchema = z.object({
|
||||
type: z.literal('sql_function'),
|
||||
schema: z.string().trim().min(1, 'Please select one of the listed database schemas'),
|
||||
functionName: z.string().trim().min(1, 'Please select one of the listed database functions'),
|
||||
// When editing a cron job, we want to keep the original command as a snippet in case the user wants to manually edit it
|
||||
snippet: z.string().trim(),
|
||||
})
|
||||
|
||||
const sqlSnippetSchema = z.object({
|
||||
type: z.literal('sql_snippet'),
|
||||
snippet: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
|
||||
supportsSeconds: z.boolean(),
|
||||
schedule: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
CronToString(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, 'Invalid Cron format'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
sqlFunctionSchema,
|
||||
sqlSnippetSchema,
|
||||
]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!cronPattern.test(data.schedule)) {
|
||||
if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.',
|
||||
path: ['schedule'],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateCronJobForm = z.infer<typeof FormSchema>
|
||||
export type CronJobType = CreateCronJobForm['values']
|
||||
|
||||
const FORM_ID = 'create-cron-job-sidepanel'
|
||||
|
||||
const buildCommand = (values: CronJobType) => {
|
||||
+3
-3
@@ -23,9 +23,9 @@ import {
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { formatScheduleString, getScheduleMessage } from './CronJobs.utils'
|
||||
import CronSyntaxChart from './CronSyntaxChart'
|
||||
import { formatScheduleString, getScheduleMessage } from '../CronJobs.utils'
|
||||
import CronSyntaxChart from '../CronSyntaxChart'
|
||||
import { type CreateCronJobForm } from './CreateCronJobSheet.constants'
|
||||
|
||||
interface CronJobScheduleSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { CreateCronJobSheet } from './CreateCronJobSheet'
|
||||
import { CreateCronJobSheet } from './CreateCronJobSheet/CreateCronJobSheet'
|
||||
import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils'
|
||||
import { PreviousRunsTab } from './PreviousRunsTab'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import parser from 'cron-parser'
|
||||
import dayjs from 'dayjs'
|
||||
import { Clipboard, Edit, MoreVertical, Play, Trash } from 'lucide-react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
@@ -41,7 +42,34 @@ import {
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { getNextRun } from './CronJobs.utils'
|
||||
|
||||
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, { tz: 'UTC' })
|
||||
return interval.next().getTime()
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
|
||||
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
|
||||
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
|
||||
if (
|
||||
['second', 'seconds'].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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CronJobTableCellProps {
|
||||
col: any
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { EdgeFunctions, RESTApi, SqlEditor } from 'icons'
|
||||
import { ScrollText } from 'lucide-react'
|
||||
|
||||
export const cronPattern =
|
||||
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds*$/
|
||||
|
||||
export const CRONJOB_TYPES = [
|
||||
'http_request',
|
||||
'edge_function',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cronPattern, parseCronJobCommand, secondsPattern } from './CronJobs.utils'
|
||||
import { cronPattern, secondsPattern } from './CronJobs.constants'
|
||||
import { parseCronJobCommand } from './CronJobs.utils'
|
||||
|
||||
describe('parseCronJobCommand', () => {
|
||||
it('should return a default object when the command is null', () => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import parser from 'cron-parser'
|
||||
import { toString as CronToString } from 'cronstrue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Column } from 'react-data-grid'
|
||||
|
||||
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
import { cn } from 'ui'
|
||||
import { CronJobType } from './CreateCronJobSheet'
|
||||
import { CRON_TABLE_COLUMNS, HTTPHeader } from './CronJobs.constants'
|
||||
import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants'
|
||||
import { CronJobTableCell } from './CronJobTableCell'
|
||||
|
||||
export function buildCronQuery(name: string, schedule: string, command: string) {
|
||||
@@ -186,12 +184,6 @@ export function formatDate(dateString: string): string {
|
||||
return date.toLocaleString(undefined, options)
|
||||
}
|
||||
|
||||
export const cronPattern =
|
||||
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds*$/
|
||||
|
||||
export function isSecondsFormat(schedule: string): boolean {
|
||||
return secondsPattern.test(schedule.trim().toLocaleLowerCase())
|
||||
}
|
||||
@@ -230,44 +222,6 @@ export const formatScheduleString = (value: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
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, { tz: 'UTC' })
|
||||
return interval.next().getTime()
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
// [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
|
||||
// Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
|
||||
const [value, unit] = schedule.toLocaleLowerCase().split(' ')
|
||||
if (
|
||||
['second', 'seconds'].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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const formatCronJobColumns = ({
|
||||
onSelectEdit,
|
||||
onSelectDelete,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UIEvent, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { DataGridHandle, Row } from 'react-data-grid'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
|
||||
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useCronJobsCountQuery } from 'data/database-cron-jobs/database-cron-jobs-count-query'
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
SheetSection,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface HTTPRequestFieldsProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { UseFormReturn } from 'react-hook-form'
|
||||
|
||||
import {
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
SheetSection,
|
||||
TextArea_Shadcn_,
|
||||
} from 'ui'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface HttpBodyFieldSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Input_Shadcn_,
|
||||
SheetSection,
|
||||
} from 'ui'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface HTTPHeaderFieldsSectionProps {
|
||||
variant: 'edge_function' | 'http_request'
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SheetSection,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface HttpRequestSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
|
||||
@@ -4,7 +4,7 @@ import FunctionSelector from 'components/ui/FunctionSelector'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
import { FormField_Shadcn_, SheetSection } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface SqlFunctionSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UseFormReturn } from 'react-hook-form'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import { FormField_Shadcn_, SheetSection } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
|
||||
|
||||
interface SqlSnippetSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-rea
|
||||
import type { VaultSecret } from 'types'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import EditSecretModal from './EditSecretModal'
|
||||
import type { SecretTableColumn } from './Secrets.utils'
|
||||
import { SecretTableColumn } from './Secrets.types'
|
||||
|
||||
interface SecretRowProps {
|
||||
row: VaultSecret
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface SecretTableColumn {
|
||||
id: 'secret' | 'id' | 'secret_value' | 'updated_at' | 'actions'
|
||||
name: string
|
||||
minWidth?: number
|
||||
width?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
@@ -3,16 +3,7 @@ import type { Column } from 'react-data-grid'
|
||||
import type { VaultSecret } from 'types'
|
||||
import { cn } from 'ui'
|
||||
import SecretRow from './SecretRow'
|
||||
|
||||
export type SecretColumnId = 'secret' | 'id' | 'secret_value' | 'updated_at' | 'actions'
|
||||
|
||||
export interface SecretTableColumn {
|
||||
id: SecretColumnId
|
||||
name: string
|
||||
minWidth?: number
|
||||
width?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
import { SecretTableColumn } from './Secrets.types'
|
||||
|
||||
export const SECRET_TABLE_COLUMNS: SecretTableColumn[] = [
|
||||
{ id: 'secret', name: 'Secret', minWidth: 300, width: 360 },
|
||||
|
||||
@@ -30,6 +30,8 @@ import { WrapperMeta } from './Wrappers.types'
|
||||
import { makeValidateRequired } from './Wrappers.utils'
|
||||
import WrapperTableEditor from './WrapperTableEditor'
|
||||
|
||||
const FORM_ID = 'create-wrapper-form'
|
||||
|
||||
export interface CreateWrapperSheetProps {
|
||||
isClosing: boolean
|
||||
wrapperMeta: WrapperMeta
|
||||
@@ -37,8 +39,6 @@ export interface CreateWrapperSheetProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FORM_ID = 'create-wrapper-form'
|
||||
|
||||
export const CreateWrapperSheet = ({
|
||||
wrapperMeta,
|
||||
isClosing,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
|
||||
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
|
||||
import { CreateWrapperSheet } from './CreateWrapperSheet'
|
||||
import { WRAPPERS } from './Wrappers.constants'
|
||||
import { WrapperTable } from './WrapperTable'
|
||||
@@ -53,7 +54,13 @@ export const WrapperOverviewTab = () => {
|
||||
const databaseNeedsUpgrading =
|
||||
wrappersExtension?.installed_version === wrappersExtension?.default_version
|
||||
|
||||
const CreateWrapperSheetComponent = wrapperMeta.createComponent || CreateWrapperSheet
|
||||
// [Joshen] Opting to declare custom wrapper sheets here instead of within Wrappers.constants.ts
|
||||
// as we'll easily run into circular dependencies doing so unfortunately
|
||||
const CreateWrapperSheetComponent = wrapperMeta.customComponent
|
||||
? wrapperMeta.name === 'iceberg_wrapper'
|
||||
? CreateIcebergWrapperSheet
|
||||
: ({}) => null
|
||||
: CreateWrapperSheet
|
||||
|
||||
return (
|
||||
<IntegrationOverviewTab
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BASE_PATH, DOCS_URL } from 'lib/constants'
|
||||
import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet'
|
||||
import type { ServerOption, WrapperMeta } from './Wrappers.types'
|
||||
|
||||
export const WRAPPER_HANDLERS = {
|
||||
@@ -2175,7 +2174,7 @@ export const WRAPPERS: WrapperMeta[] = [
|
||||
label: 'Iceberg',
|
||||
docsUrl: `${DOCS_URL}/guides/database/extensions/wrappers/iceberg`,
|
||||
minimumExtensionVersion: '0.5.3',
|
||||
createComponent: CreateIcebergWrapperSheet,
|
||||
customComponent: true,
|
||||
server: {
|
||||
// The fields are intentionally not required. The required flag is enforced in the create iceberg wrapper sheet.
|
||||
// In the edit wrapper sheet, all fields are shown and not required.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { CreateWrapperSheetProps } from './CreateWrapperSheet'
|
||||
|
||||
export type WrapperMeta = {
|
||||
name: string
|
||||
handlerName: string
|
||||
@@ -12,7 +10,7 @@ export type WrapperMeta = {
|
||||
server: Server
|
||||
docsUrl: string
|
||||
minimumExtensionVersion?: string
|
||||
createComponent?: React.ComponentType<CreateWrapperSheetProps>
|
||||
customComponent?: boolean
|
||||
// If true, the wrapper can target a schema which will be populated with tables specified by the wrapper..
|
||||
canTargetSchema?: boolean
|
||||
sourceSchemaOption?: ServerOption
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowDown, ArrowUp, RefreshCw, User } from 'lucide-react'
|
||||
import Image from 'next/legacy/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
|
||||
import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from 'data/organizations/organization-audit-logs-query'
|
||||
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
@@ -43,6 +44,7 @@ const logsUpgradeError = 'upgrade to Team or Enterprise Plan to access audit log
|
||||
export const AuditLogs = () => {
|
||||
const { slug } = useParams()
|
||||
const currentTime = dayjs().utc().set('millisecond', 0)
|
||||
|
||||
const [dateSortDesc, setDateSortDesc] = useState(true)
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: currentTime.subtract(1, 'day').toISOString(),
|
||||
@@ -54,16 +56,14 @@ export const AuditLogs = () => {
|
||||
projects: [], // project_ref[]
|
||||
})
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const { can: canReadAuditLogs, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'notifications'
|
||||
)
|
||||
|
||||
const { data: projectsData } = useProjectsQuery()
|
||||
const projects = projectsData?.projects ?? []
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
const { data: members } = useOrganizationMembersQuery({ slug })
|
||||
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
|
||||
const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } =
|
||||
useOrganizationAuditLogsQuery(
|
||||
{
|
||||
@@ -79,9 +79,31 @@ export const AuditLogs = () => {
|
||||
},
|
||||
}
|
||||
)
|
||||
const isLogsNotAvailableBasedOnPlan = isError && error.message.endsWith(logsUpgradeError)
|
||||
const isRangeExceededError = isError && error.message.includes('range exceeded')
|
||||
const showFilters = !isLoading && !isLogsNotAvailableBasedOnPlan
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
isLoading: isLoadingProjects,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useOrgProjectsInfiniteQuery(
|
||||
{ slug, search: search.length === 0 ? search : debouncedSearch },
|
||||
{ keepPreviousData: true, enabled: showFilters }
|
||||
)
|
||||
const { data: organizations } = useOrganizationsQuery({
|
||||
enabled: showFilters,
|
||||
})
|
||||
const { data: members } = useOrganizationMembersQuery({ slug }, { enabled: showFilters })
|
||||
const { data: rolesData } = useOrganizationRolesV2Query({ slug }, { enabled: showFilters })
|
||||
|
||||
const activeMembers = (members ?? []).filter((x) => !x.invited_at)
|
||||
const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])]
|
||||
const projects =
|
||||
useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
|
||||
|
||||
const logs = data?.result ?? []
|
||||
const sortedLogs = logs
|
||||
@@ -105,8 +127,6 @@ export const AuditLogs = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const currentOrganization = organizations?.find((o) => o.slug === slug)
|
||||
|
||||
// This feature depends on the subscription tier of the user. Free user can view logs up to 1 day
|
||||
// in the past. The API limits the logs to maximum of 1 day and 5 minutes so when the page is
|
||||
// viewed for more than 5 minutes, the call parameters needs to be updated. This also works with
|
||||
@@ -124,81 +144,122 @@ export const AuditLogs = () => {
|
||||
return () => clearInterval(interval)
|
||||
}, [dateRange.from, dateRange.to])
|
||||
|
||||
if (isLogsNotAvailableBasedOnPlan) {
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Alert_Shadcn_
|
||||
variant="default"
|
||||
title="Organization Audit Logs are not available on Free or Pro plans"
|
||||
>
|
||||
<WarningIcon />
|
||||
<div className="flex flex-col md:flex-row pt-1 gap-4">
|
||||
<div className="grow">
|
||||
<AlertTitle_Shadcn_>
|
||||
Organization Audit Logs are not available on Free or Pro plans
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-row justify-between gap-3">
|
||||
<p>
|
||||
Upgrade to Team or Enterprise to view up to 28 days of Audit Logs for your
|
||||
organization.
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<UpgradePlanButton source="auditLogs" plan="Team" type="primary">
|
||||
Upgrade subscription
|
||||
</UpgradePlanButton>
|
||||
</div>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="space-y-4 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Users"
|
||||
options={activeMembers}
|
||||
labelKey="username"
|
||||
valueKey="gotrue_id"
|
||||
activeOptions={filters.users}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, users: values })}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Projects"
|
||||
options={
|
||||
projects?.filter((p) => p.organization_id === currentOrganization?.id) ?? []
|
||||
}
|
||||
labelKey="name"
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
/>
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
value={dateRange}
|
||||
onSubmit={(value) => setDateRange(value)}
|
||||
helpers={[
|
||||
{
|
||||
text: 'Last 1 hour',
|
||||
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 3 hours',
|
||||
calcFrom: () => dayjs().subtract(3, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{showFilters && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Users"
|
||||
options={activeMembers}
|
||||
labelKey="username"
|
||||
valueKey="gotrue_id"
|
||||
activeOptions={filters.users}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, users: values })}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Projects"
|
||||
options={projects}
|
||||
labelKey="name"
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoadingProjects}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
value={dateRange}
|
||||
onSubmit={(value) => setDateRange(value)}
|
||||
helpers={[
|
||||
{
|
||||
text: 'Last 1 hour',
|
||||
calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 3 hours',
|
||||
calcFrom: () => dayjs().subtract(3, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
|
||||
{
|
||||
text: 'Last 6 hours',
|
||||
calcFrom: () => dayjs().subtract(6, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 12 hours',
|
||||
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 24 hours',
|
||||
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{isSuccess && (
|
||||
<>
|
||||
<div className="h-[20px] border-r border-strong !ml-4 !mr-2" />
|
||||
<p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
text: 'Last 6 hours',
|
||||
calcFrom: () => dayjs().subtract(6, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 12 hours',
|
||||
calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
{
|
||||
text: 'Last 24 hours',
|
||||
calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{isSuccess && (
|
||||
<>
|
||||
<div className="h-[20px] border-r border-strong !ml-4 !mr-2" />
|
||||
<p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={<RefreshCw className={isRefetching ? 'animate-spin' : ''} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{isRefetching ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={<RefreshCw className={isRefetching ? 'animate-spin' : ''} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{isRefetching ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || isLoadingPermissions ? (
|
||||
<div className="space-y-2">
|
||||
@@ -210,34 +271,8 @@ export const AuditLogs = () => {
|
||||
<NoPermission resourceText="view organization audit logs" />
|
||||
) : null}
|
||||
|
||||
{isError ? (
|
||||
error.message.endsWith(logsUpgradeError) ? (
|
||||
<Alert_Shadcn_
|
||||
variant="default"
|
||||
title="Organization Audit Logs are not available on Free or Pro plans"
|
||||
>
|
||||
<WarningIcon />
|
||||
<div className="flex flex-col md:flex-row pt-1 gap-4">
|
||||
<div className="grow">
|
||||
<AlertTitle_Shadcn_>
|
||||
Organization Audit Logs are not available on Free or Pro plans
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-row justify-between gap-3">
|
||||
<p>
|
||||
Upgrade to Team or Enterprise to view up to 28 days of Audit Logs for your
|
||||
organization.
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<UpgradePlanButton source="auditLogs" plan="Team" type="primary">
|
||||
Upgrade subscription
|
||||
</UpgradePlanButton>
|
||||
</div>
|
||||
</div>
|
||||
</Alert_Shadcn_>
|
||||
) : error.message.includes('range exceeded') ? (
|
||||
{isError &&
|
||||
(isRangeExceededError ? (
|
||||
<Alert_Shadcn_ variant="destructive" title="Date range too large">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Date range too large</AlertTitle_Shadcn_>
|
||||
@@ -248,8 +283,7 @@ export const AuditLogs = () => {
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AlertError error={error} subject="Failed to retrieve audit logs" />
|
||||
)
|
||||
) : null}
|
||||
))}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
|
||||
+12
-10
@@ -71,22 +71,24 @@ const BillingBreakdown = () => {
|
||||
<p className="prose text-sm">
|
||||
{selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ? (
|
||||
<>
|
||||
You'll receive two invoices from AWS Marketplace: one on the 3rd of{' '}
|
||||
{billingCycleEnd.format('MMMM')} for your usage in{' '}
|
||||
{billingCycleStart.format('MMMM')} and one on {billingCycleEnd.format('MMMM DD')}{' '}
|
||||
for the fixed subscription fee.
|
||||
<p>
|
||||
AWS Marketplace sends two invoices each month: one for your fixed subscription
|
||||
fee, billed on the day you subscribed, and one for your usage charges from the
|
||||
previous month, billed by the 3rd.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For a more detailed breakdown, visit the{' '}
|
||||
<Link href={`/org/${orgSlug}/usage`}>usage page.</Link>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Your upcoming invoice (excluding credits) will continue to update until the end of
|
||||
your billing cycle on {billingCycleEnd.format('MMMM DD')}.
|
||||
your billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed
|
||||
breakdown, visit the <Link href={`/org/${orgSlug}/usage`}>usage page.</Link>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
{' '}
|
||||
For a more detailed breakdown, visit the{' '}
|
||||
<Link href={`/org/${orgSlug}/usage`}>usage page.</Link>
|
||||
</>
|
||||
</p>
|
||||
<br />
|
||||
<p className="text-sm text-foreground-light mt-4">
|
||||
|
||||
@@ -57,7 +57,7 @@ export const BillingSettings = () => {
|
||||
|
||||
<ScaffoldDivider />
|
||||
|
||||
{org?.plan.id !== 'free' && (
|
||||
{org && org.plan.id !== 'free' && (
|
||||
<ScaffoldContainer id="breakdown">
|
||||
<BillingBreakdown />
|
||||
</ScaffoldContainer>
|
||||
|
||||
@@ -55,7 +55,9 @@ const CreditBalance = () => {
|
||||
{isPermissionsLoaded && !canReadSubscriptions ? (
|
||||
<NoPermission resourceText="view this organization's credits" />
|
||||
) : (
|
||||
<FormPanel footer={<CreditTopUp slug={slug} />}>
|
||||
<FormPanel
|
||||
footer={subscription?.billing_via_partner ? undefined : <CreditTopUp slug={slug} />}
|
||||
>
|
||||
<FormSection>
|
||||
<FormSectionContent fullWidth loading={isLoading}>
|
||||
{isError && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link'
|
||||
|
||||
import { useIsMFAEnabled } from 'common'
|
||||
import { ActionCard } from 'components/ui/ActionCard'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
|
||||
import { Organization } from 'types'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
@@ -15,10 +15,8 @@ export const OrganizationCard = ({
|
||||
href?: string
|
||||
}) => {
|
||||
const isUserMFAEnabled = useIsMFAEnabled()
|
||||
const { data } = useProjectsQuery()
|
||||
const allProjects = data?.projects ?? []
|
||||
|
||||
const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length
|
||||
const { data } = useOrgProjectsInfiniteQuery({ slug: organization.slug })
|
||||
const numProjects = data?.pages[0].pagination.count ?? 0
|
||||
const isMfaRequired = organization.organization_requires_mfa
|
||||
|
||||
return (
|
||||
|
||||
@@ -157,7 +157,7 @@ export const SSOConfig = () => {
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth className="!pt-8">
|
||||
<ScaffoldSection isFullWidth>
|
||||
{!!plan && !canSetupSSOConfig ? (
|
||||
<Alert_Shadcn_
|
||||
variant="default"
|
||||
|
||||
@@ -126,7 +126,7 @@ export const Usage = () => {
|
||||
<ScaffoldTitle>Usage</ScaffoldTitle>
|
||||
</ScaffoldHeader>
|
||||
</ScaffoldContainer>
|
||||
<div className="sticky top-0 border-b bg-sidebar z-[1] overflow-hidden">
|
||||
<div className="sticky top-0 border-b bg-sidebar z-[1]">
|
||||
<ScaffoldContainer>
|
||||
<div className="py-4 flex items-center space-x-4">
|
||||
{isLoadingSubscription || isLoadingPermissions ? (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MousePointer2 } from 'lucide-react'
|
||||
|
||||
export const AnimatedCursors = () => {
|
||||
const [cursor1Position, setCursor1Position] = useState({ x: 20, y: 20 })
|
||||
const [cursor2Position, setCursor2Position] = useState({ x: 180, y: 80 })
|
||||
|
||||
useEffect(() => {
|
||||
const animateCursors = () => {
|
||||
const newCursor1Position = {
|
||||
x: Math.random() * 160 + 20,
|
||||
y: Math.random() * 80 + 20,
|
||||
}
|
||||
const newCursor2Position = {
|
||||
x: Math.random() * 160 + 20,
|
||||
y: Math.random() * 80 + 20,
|
||||
}
|
||||
|
||||
setCursor1Position(newCursor1Position)
|
||||
setCursor2Position(newCursor2Position)
|
||||
}
|
||||
|
||||
const initialTimer = setTimeout(animateCursors, 1000)
|
||||
|
||||
const interval = setInterval(animateCursors, 3000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimer)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative w-48 h-32 mx-auto mb-8">
|
||||
<motion.div
|
||||
className="absolute text-warning"
|
||||
animate={{ x: cursor1Position.x, y: cursor1Position.y }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<MousePointer2 size={20} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="absolute text-brand"
|
||||
animate={{ x: cursor2Position.x, y: cursor2Position.y }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2,
|
||||
}}
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<MousePointer2 size={20} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AiIconAnimation, Button, Card, cn } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import { AnimatedCursors } from './AnimatedCursors'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
|
||||
/**
|
||||
* Acts as a container component for the entire log display
|
||||
*/
|
||||
export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => {
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
|
||||
const handleCreateTriggerWithAssistant = () => {
|
||||
aiSnap.newChat({
|
||||
name: `Realtime`,
|
||||
open: true,
|
||||
initialInput: `Help me set up a realtime experience for my project`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow items-center justify-center p-12 border-t @container">
|
||||
<div className="w-full max-w-4xl flex flex-col items-center gap-0">
|
||||
<div className="text-center mb-12">
|
||||
<AnimatedCursors />
|
||||
<h2 className="heading-section mb-1">Create realtime experiences</h2>
|
||||
<p className="text-foreground-light mb-6">
|
||||
Send your first realtime message from your database, application code or edge function
|
||||
</p>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<AiIconAnimation />}
|
||||
onClick={handleCreateTriggerWithAssistant}
|
||||
>
|
||||
Set up realtime for me
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="grid grid-cols-1 @xl:grid-cols-3 bg divide-x mb-8">
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs shrink-0 font-mono text-foreground-light w-7 h-7 bg border flex items-center justify-center rounded-md'
|
||||
)}
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<h3 className="heading-default">Broadcast messages</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm mb-4 flex-1">
|
||||
Send messages to a channel from your client application or database via triggers.
|
||||
</p>
|
||||
<Button type="default" className="w-full">
|
||||
<Link href={`/project/${projectRef}/database/triggers`}>Create a trigger</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs shrink-0 font-mono text-foreground-light w-7 h-7 bg border flex items-center justify-center rounded-md'
|
||||
)}
|
||||
>
|
||||
2
|
||||
</span>
|
||||
<h3 className="heading-default">Write policies</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm mb-4 flex-1">
|
||||
Set up Row Level Security policies to control who can see messages within a channel
|
||||
</p>
|
||||
<Button type="default">
|
||||
<Link href={`/project/${projectRef}/realtime/policies`}>Write a policy</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs shrink-0 font-mono text-foreground-light w-7 h-7 bg border flex items-center justify-center rounded-md'
|
||||
)}
|
||||
>
|
||||
3
|
||||
</span>
|
||||
<h3 className="heading-default">Subscribe to a channel</h3>
|
||||
</div>
|
||||
<p className="text-foreground-light text-sm mb-4 flex-1">
|
||||
Receive realtime messages in your application by listening to a channel
|
||||
</p>
|
||||
<Button type="default" asChild>
|
||||
<Link href="https://supabase.com/docs/guides/realtime/subscribing-to-database-changes#listening-on-client-side">
|
||||
Read the guide
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MousePointer2 } from 'lucide-react'
|
||||
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
@@ -7,6 +9,7 @@ import { Header } from './Header'
|
||||
import MessagesTable from './MessagesTable'
|
||||
import { SendMessageModal } from './SendMessageModal'
|
||||
import { RealtimeConfig, useRealtimeMessages } from './useRealtimeMessages'
|
||||
import { EmptyRealtime } from './EmptyRealtime'
|
||||
|
||||
/**
|
||||
* Acts as a container component for the entire log display
|
||||
@@ -40,12 +43,16 @@ export const RealtimeInspector = () => {
|
||||
<Header config={realtimeConfig} onChangeConfig={setRealtimeConfig} />
|
||||
<div className="relative flex flex-col grow">
|
||||
<div className="flex grow">
|
||||
<MessagesTable
|
||||
hasChannelSet={realtimeConfig.channelName.length > 0}
|
||||
enabled={realtimeConfig.enabled}
|
||||
data={logData}
|
||||
showSendMessage={() => setSendMessageShown(true)}
|
||||
/>
|
||||
{(logData ?? []).length > 0 ? (
|
||||
<MessagesTable
|
||||
hasChannelSet={realtimeConfig.channelName.length > 0}
|
||||
enabled={realtimeConfig.enabled}
|
||||
data={logData}
|
||||
showSendMessage={() => setSendMessageShown(true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyRealtime projectRef={ref!} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SendMessageModal
|
||||
|
||||
@@ -74,10 +74,8 @@ export const ChartBlock = ({
|
||||
{
|
||||
projectRef: ref as string,
|
||||
attribute: attribute as ProjectDailyStatsAttribute,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: interval as AnalyticsInterval,
|
||||
databaseIdentifier,
|
||||
startDate: dayjs(startDate).format('YYYY-MM-DD'),
|
||||
endDate: dayjs(endDate).format('YYYY-MM-DD'),
|
||||
},
|
||||
{ enabled: provider === 'daily-stats' }
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ResponseError } from 'types'
|
||||
import { DEFAULT_QUERY_PARAMS } from './Reports.constants'
|
||||
|
||||
export enum Presets {
|
||||
API = 'api',
|
||||
@@ -11,7 +10,9 @@ export enum Presets {
|
||||
|
||||
export type MetaQueryResponse = any & { error: ResponseError }
|
||||
|
||||
export type BaseReportParams = typeof DEFAULT_QUERY_PARAMS & { sql?: string } & unknown
|
||||
export type BaseReportParams = { iso_timestamp_start: string; iso_timestamp_end: string } & {
|
||||
sql?: string
|
||||
} & unknown
|
||||
export interface PresetConfig {
|
||||
title: string
|
||||
queries: BaseQueries<string>
|
||||
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user