merge master into feat/auth-webauthn-fe

This commit is contained in:
Francesco Sansalvadore committed 2025-10-07 15:27:31 +02:00
commit efdbb9fc9a
247 files changed
+171712 -166359

No files matched your search

+1
View File
@@ -0,0 +1 @@
!.env.example
+15 -11
View File
@@ -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'
+7 -7
View File
@@ -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.
+1 -1
View File
@@ -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.
+118 -26
View File
@@ -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
+38 -21
View File
@@ -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>
)}
</>
)
}
+2
View File
@@ -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
+6 -6
View File
@@ -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
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -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.
+30
View File
@@ -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"
}
]
},
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
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
+9 -1
View File
@@ -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"]
}
}
}
}
+12 -12
View File
@@ -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'
+2
View File
@@ -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>
)
}
@@ -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>
))}
</>
)
}
@@ -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',
}
@@ -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,
@@ -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>
@@ -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'
@@ -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']
@@ -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) => {
@@ -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 && (
<>
@@ -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