mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 15:19:08 +03:00
Merge branch 'master' into feat/connection-string-revamp
This commit is contained in:
commit
da4296f459
32 files changed
+2438
-495
No files matched your search
@@ -11,15 +11,20 @@ jobs:
|
||||
name: runner / vale
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.vale.ini
|
||||
vale
|
||||
apps/docs/content
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
git fetch origin $BASE_REF
|
||||
echo "CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT origin/$BASE_REF $SHA | grep '^apps/docs/content/' | tr '\n' ',' | sed 's/,$//')" >> $GITHUB_OUTPUT
|
||||
- uses: errata-ai/vale-action@reviewdog
|
||||
with:
|
||||
files: apps/docs/content
|
||||
# [Charis] Temporary because we still have to go through giant backlog of existing errors
|
||||
# Don't want people to worry about this (just want them looking at the specific PR comments)
|
||||
# fail_on_error: true
|
||||
files: ${{ steps.changed-files.outputs.CHANGED_FILES }}
|
||||
@@ -219,7 +219,6 @@ hideToc: true
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import Auth from './components/Auth'
|
||||
import Account from './components/Account'
|
||||
import { View, Text } from 'react-native'
|
||||
import { Session } from '@supabase/supabase-js'
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ info:
|
||||
- id: analytics
|
||||
title: Analytics
|
||||
description: Analytics settings.
|
||||
- id: experimental
|
||||
title: Experimental
|
||||
description: Settings in alpha testing, subject to changes or removal at any time.
|
||||
- id: local
|
||||
title: Local Development
|
||||
description: Local Development settings.
|
||||
@@ -190,6 +193,237 @@ parameters:
|
||||
- name: 'PgBouncer Configuration'
|
||||
link: https://www.pgbouncer.org/config.html#default_pool_size
|
||||
|
||||
- id: 'db.settings.effective_cache_size'
|
||||
title: 'db.settings.effective_cache_size'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the planner's assumption about the effective size of the disk cache.
|
||||
This is a query planner parameter that doesn't affect actual memory allocation.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE'
|
||||
|
||||
- id: 'db.settings.logical_decoding_work_mem'
|
||||
title: 'db.settings.logical_decoding_work_mem'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the amount of memory to be used by logical decoding, before writing data to local disk.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-LOGICAL-DECODING-WORK-MEM'
|
||||
|
||||
- id: 'db.settings.maintenance_work_mem'
|
||||
title: 'db.settings.maintenance_work_mem'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, and ALTER TABLE ADD FOREIGN KEY.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM'
|
||||
|
||||
- id: 'db.settings.max_connections'
|
||||
title: 'db.settings.max_connections'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Determines the maximum number of concurrent connections to the database server.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS'
|
||||
|
||||
- id: 'db.settings.max_locks_per_transaction'
|
||||
title: 'db.settings.max_locks_per_transaction'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Controls the average number of object locks allocated for each transaction.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-locks.html#GUC-MAX-LOCKS-PER-TRANSACTION'
|
||||
|
||||
- id: 'db.settings.max_parallel_maintenance_workers'
|
||||
title: 'db.settings.max_parallel_maintenance_workers'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum number of parallel workers that can be started by a single utility command.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-MAINTENANCE-WORKERS'
|
||||
|
||||
- id: 'db.settings.max_parallel_workers'
|
||||
title: 'db.settings.max_parallel_workers'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum number of parallel workers that the system can support.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS'
|
||||
|
||||
- id: 'db.settings.max_parallel_workers_per_gather'
|
||||
title: 'db.settings.max_parallel_workers_per_gather'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum number of parallel workers that can be started by a single Gather or Gather Merge node.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS-PER-GATHER'
|
||||
|
||||
- id: 'db.settings.max_replication_slots'
|
||||
title: 'db.settings.max_replication_slots'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the maximum number of replication slots that the server can support.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-REPLICATION-SLOTS'
|
||||
|
||||
- id: 'db.settings.max_slot_wal_keep_size'
|
||||
title: 'db.settings.max_slot_wal_keep_size'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the maximum size of WAL files that replication slots are allowed to retain in the pg_wal directory.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE'
|
||||
|
||||
- id: 'db.settings.max_standby_archive_delay'
|
||||
title: 'db.settings.max_standby_archive_delay'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum delay before canceling queries when a hot standby server is processing archived WAL data.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-ARCHIVE-DELAY'
|
||||
|
||||
- id: 'db.settings.max_standby_streaming_delay'
|
||||
title: 'db.settings.max_standby_streaming_delay'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum delay before canceling queries when a hot standby server is processing streamed WAL data.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY'
|
||||
|
||||
- id: 'db.settings.max_wal_size'
|
||||
title: 'db.settings.max_wal_size'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the maximum size of WAL files that the system will keep in the pg_wal directory.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-MAX-WAL-SIZE'
|
||||
|
||||
- id: 'db.settings.max_wal_senders'
|
||||
title: 'db.settings.max_wal_senders'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the maximum number of concurrent connections from standby servers or streaming base backup clients.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-WAL-SENDERS'
|
||||
|
||||
- id: 'db.settings.max_worker_processes'
|
||||
title: 'db.settings.max_worker_processes'
|
||||
tags: ['database']
|
||||
required: false
|
||||
type: 'uint'
|
||||
description: |
|
||||
Sets the maximum number of background processes that the system can support.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAX-WORKER-PROCESSES'
|
||||
|
||||
- id: 'db.settings.session_replication_role'
|
||||
title: 'db.settings.session_replication_role'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Controls whether triggers and rewrite rules are enabled. Valid values are: "origin", "replica", or "local".
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE'
|
||||
|
||||
- id: 'db.settings.shared_buffers'
|
||||
title: 'db.settings.shared_buffers'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Sets the amount of memory the database server uses for shared memory buffers.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS'
|
||||
|
||||
- id: 'db.settings.statement_timeout'
|
||||
title: 'db.settings.statement_timeout'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Abort any statement that takes more than the specified amount of time.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT'
|
||||
|
||||
- id: 'db.settings.track_commit_timestamp'
|
||||
title: 'db.settings.track_commit_timestamp'
|
||||
tags: ['database']
|
||||
required: false
|
||||
type: 'bool'
|
||||
description: |
|
||||
Record commit time of transactions.
|
||||
Note: Changing this parameter requires a database restart.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-TRACK-COMMIT-TIMESTAMP'
|
||||
|
||||
- id: 'db.settings.wal_keep_size'
|
||||
title: 'db.settings.wal_keep_size'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the minimum size of past log file segments kept in the pg_wal directory.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-KEEP-SIZE'
|
||||
|
||||
- id: 'db.settings.wal_sender_timeout'
|
||||
title: 'db.settings.wal_sender_timeout'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Terminate replication connections that are inactive for longer than this amount of time.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-WAL-SENDER-TIMEOUT'
|
||||
|
||||
- id: 'db.settings.work_mem'
|
||||
title: 'db.settings.work_mem'
|
||||
tags: ['database']
|
||||
required: false
|
||||
description: |
|
||||
Specifies the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files.
|
||||
links:
|
||||
- name: 'PostgreSQL configuration'
|
||||
link: 'https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM'
|
||||
|
||||
- id: 'db.pooler.max_client_conn'
|
||||
title: 'db.pooler.max_client_conn'
|
||||
tags: ['database']
|
||||
@@ -463,6 +697,56 @@ parameters:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.email.secure_password_change'
|
||||
title: 'auth.email.secure_password_change'
|
||||
tags: ['auth']
|
||||
required: false
|
||||
type: 'bool'
|
||||
default: false
|
||||
description: |
|
||||
If enabled, requires the user's current password to be provided when changing to a new password.
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.email.max_frequency'
|
||||
title: 'auth.email.max_frequency'
|
||||
tags: ['auth']
|
||||
required: false
|
||||
type: 'duration'
|
||||
default: '1m'
|
||||
description: |
|
||||
The minimum amount of time that must pass between email requests.
|
||||
Helps prevent email spam by limiting how frequently emails can be sent.
|
||||
Example values: "1m", "1h", "24h"
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.email.otp_length'
|
||||
title: 'auth.email.otp_length'
|
||||
tags: ['auth']
|
||||
required: false
|
||||
default: '6'
|
||||
description: |
|
||||
The length of the OTP code to be sent in emails.
|
||||
Must be between 6 and 10 digits.
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.email.otp_exp'
|
||||
title: 'auth.email.otp_exp'
|
||||
tags: ['auth']
|
||||
required: false
|
||||
default: '300'
|
||||
description: |
|
||||
The expiry time for an OTP code in seconds.
|
||||
Default is 300 seconds (5 minutes).
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.email.smtp.host'
|
||||
title: 'auth.email.smtp.host'
|
||||
tags: ['auth']
|
||||
@@ -779,7 +1063,7 @@ parameters:
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
|
||||
- id: 'auth.external.provider.skip_nonce_check'
|
||||
title: 'auth.external.<provider>.skip_nonce_check'
|
||||
tags: ['auth']
|
||||
@@ -790,6 +1074,28 @@ parameters:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.mfa.web_authn.enroll_enabled'
|
||||
title: 'auth.mfa.web_authn.enroll_enabled'
|
||||
tags: ['auth', 'local']
|
||||
required: false
|
||||
default: 'false'
|
||||
description: |
|
||||
Enable WebAuthn enrollment for multi-factor authentication.
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'auth.mfa.web_authn.verify_enabled'
|
||||
title: 'auth.mfa.web_authn.verify_enabled'
|
||||
tags: ['auth', 'local']
|
||||
required: false
|
||||
default: 'false'
|
||||
description: |
|
||||
Enable WebAuthn verification for multi-factor authentication.
|
||||
links:
|
||||
- name: 'Auth Server configuration'
|
||||
link: 'https://supabase.com/docs/reference/auth'
|
||||
|
||||
- id: 'functions.function_name.enabled'
|
||||
title: 'functions.<function_name>.enabled'
|
||||
tags: ['edge-functions']
|
||||
@@ -831,6 +1137,20 @@ parameters:
|
||||
- name: '`supabase functions` CLI subcommands'
|
||||
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
|
||||
|
||||
- id: 'functions.function_name.entrypoint'
|
||||
title: 'functions.<function_name>.entrypoint'
|
||||
tags: ['edge-functions']
|
||||
required: false
|
||||
description: |
|
||||
Specify a custom entrypoint path for the function relative to the project root.
|
||||
When not specified, defaults to `supabase/functions/<function_name>/index.ts`.
|
||||
links:
|
||||
- name: '`supabase functions` CLI subcommands'
|
||||
link: 'https://supabase.com/docs/reference/cli/supabase-functions'
|
||||
usage: |
|
||||
[functions.my_function]
|
||||
entrypoint = "path/to/custom/function.ts"
|
||||
|
||||
- id: 'analytics.enabled'
|
||||
title: 'analytics.enabled'
|
||||
tags: ['analytics']
|
||||
@@ -873,3 +1193,66 @@ parameters:
|
||||
links:
|
||||
- name: Self-hosted Logflare Configuration
|
||||
link: https://supabase.com/docs/reference/self-hosting-analytics/list-endpoints#getting-started
|
||||
|
||||
- id: 'experimental.webhooks.enabled'
|
||||
title: 'experimental.webhooks.enabled'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
default: 'false'
|
||||
description: |
|
||||
Automatically enable webhook features on each new created branch
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'experimental.orioledb_version'
|
||||
title: 'experimental.orioledb_version'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
description: |
|
||||
Configures Postgres storage engine to use OrioleDB with S3 support.
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'experimental.s3_host'
|
||||
title: 'experimental.s3_host'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
default: 'env(S3_HOST)'
|
||||
description: |
|
||||
Configures S3 bucket URL for OrioleDB storage.
|
||||
Format example: <bucket_name>.s3-<region>.amazonaws.com
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'experimental.s3_region'
|
||||
title: 'experimental.s3_region'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
default: 'env(S3_REGION)'
|
||||
description: |
|
||||
Configures S3 bucket region for OrioleDB storage.
|
||||
Example: us-east-1
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'experimental.s3_access_key'
|
||||
title: 'experimental.s3_access_key'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
default: 'env(S3_ACCESS_KEY)'
|
||||
description: |
|
||||
Configures AWS_ACCESS_KEY_ID for S3 bucket access.
|
||||
DO NOT commit your AWS access key to git. Use environment variable substitution instead.
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
|
||||
- id: 'experimental.s3_secret_key'
|
||||
title: 'experimental.s3_secret_key'
|
||||
tags: ['experimental']
|
||||
required: false
|
||||
default: 'env(S3_SECRET_KEY)'
|
||||
description: |
|
||||
Configures AWS_SECRET_ACCESS_KEY for S3 bucket access.
|
||||
DO NOT commit your AWS secret key to git. Use environment variable substitution instead.
|
||||
Note: This is an experimental feature and may change in future releases.
|
||||
links: []
|
||||
@@ -3,7 +3,12 @@ import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidatio
|
||||
|
||||
export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({
|
||||
name: provider.title,
|
||||
value: provider.title.toLowerCase(),
|
||||
value:
|
||||
provider.title === 'Slack (OIDC)'
|
||||
? 'slack_oidc'
|
||||
: provider.title === 'LinkedIn (OIDC)'
|
||||
? 'linkedin_oidc'
|
||||
: provider.title.toLowerCase(),
|
||||
icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`,
|
||||
iconClass: provider.title === 'GitHub' ? 'dark:invert' : '',
|
||||
})).concat(
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { NavMenu, NavMenuItem } from 'ui'
|
||||
import { Badge, NavMenu, NavMenuItem } from 'ui'
|
||||
|
||||
type Props = {
|
||||
active: 'scheduled' | 'pitr'
|
||||
active: 'pitr' | 'scheduled' | 'rtnp'
|
||||
}
|
||||
|
||||
function DatabaseBackupsNav({ active }: Props) {
|
||||
const { ref } = useParams()
|
||||
const isCloneToNewProjectEnabled = useFlag('clonetonewproject')
|
||||
const ref = useProjectContext()?.project?.ref
|
||||
|
||||
const navMenuItems = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 'scheduled',
|
||||
label: 'Scheduled backups',
|
||||
href: `/project/${ref}/database/backups/scheduled`,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: 'pitr',
|
||||
label: 'Point in time',
|
||||
href: `/project/${ref}/database/backups/pitr`,
|
||||
},
|
||||
]
|
||||
{
|
||||
enabled: isCloneToNewProjectEnabled,
|
||||
id: 'rtnp',
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
Restore to new project{' '}
|
||||
<Badge size="small" className="!text-[10px] px-1 py-0">
|
||||
New
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
href: `/project/${ref}/database/backups/restore-to-new-project`,
|
||||
},
|
||||
] as const
|
||||
|
||||
return (
|
||||
<NavMenu>
|
||||
{navMenuItems.map((item) => (
|
||||
<NavMenuItem key={item.label} active={item.id === active}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
))}
|
||||
{navMenuItems.map(
|
||||
(item) =>
|
||||
item.enabled && (
|
||||
<NavMenuItem key={item.id} active={item.id === active}>
|
||||
<Link href={item.href}>{item.label}</Link>
|
||||
</NavMenuItem>
|
||||
)
|
||||
)}
|
||||
</NavMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import DatePicker from 'react-datepicker'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { useBackupsQuery } from 'data/database/backups-query'
|
||||
import { usePitrRestoreMutation } from 'data/database/pitr-restore-mutation'
|
||||
import { setProjectStatus } from 'data/projects/projects-query'
|
||||
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import {
|
||||
Alert,
|
||||
@@ -31,15 +22,9 @@ import {
|
||||
import BackupsEmpty from '../BackupsEmpty'
|
||||
import BackupsStorageAlert from '../BackupsStorageAlert'
|
||||
import type { Timezone } from './PITR.types'
|
||||
import {
|
||||
constrainDateToRange,
|
||||
formatNumberToTwoDigits,
|
||||
getClientTimezone,
|
||||
getDatesBetweenRange,
|
||||
} from './PITR.utils'
|
||||
import { getClientTimezone } from './PITR.utils'
|
||||
import PITRStatus from './PITRStatus'
|
||||
import TimeInput from './TimeInput'
|
||||
import { TimezoneSelection } from './TimezoneSelection'
|
||||
import { PITRForm } from './pitr-form'
|
||||
|
||||
const PITRSelection = () => {
|
||||
const router = useRouter()
|
||||
@@ -51,11 +36,11 @@ const PITRSelection = () => {
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
||||
|
||||
const canTriggerPhysicalBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
const [selectedRecoveryPoint, setSelectedRecoveryPoint] = useState<{
|
||||
recoveryTimeTargetUnix: number
|
||||
recoveryTimeString: string
|
||||
recoveryTimeStringUtc: string
|
||||
}>()
|
||||
|
||||
const hasReadReplicas = (databases ?? []).length > 1
|
||||
|
||||
@@ -76,67 +61,16 @@ const PITRSelection = () => {
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
backups?.physicalBackupData ?? {}
|
||||
const hasNoBackupsAvailable = !earliestPhysicalBackupDateUnix || !latestPhysicalBackupDateUnix
|
||||
const earliestAvailableBackup = dayjs
|
||||
.unix(earliestPhysicalBackupDateUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
const latestAvailableBackup = dayjs
|
||||
.unix(latestPhysicalBackupDateUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
|
||||
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
|
||||
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) // true to keep local time and just change +whatever
|
||||
|
||||
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
|
||||
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
|
||||
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
|
||||
|
||||
const selectedTime = {
|
||||
h: selectedDate.hour(),
|
||||
m: selectedDate.minute(),
|
||||
s: selectedDate.second(),
|
||||
}
|
||||
|
||||
const earliestAvailableBackupTime = {
|
||||
h: earliestAvailableBackup.hour(),
|
||||
m: earliestAvailableBackup.minute(),
|
||||
s: earliestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
const latestAvailableBackupTime = {
|
||||
h: latestAvailableBackup.hour(),
|
||||
m: latestAvailableBackup.minute(),
|
||||
s: latestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
// This will be the actual unix timestamp for the backup
|
||||
const recoveryTimeTargetUnix = selectedDate.unix()
|
||||
// Formatting from the unix again just to double check correctness
|
||||
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
|
||||
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const isSelectedOutOfRange =
|
||||
selectedDate &&
|
||||
(selectedDate.isBefore(earliestAvailableBackup) || selectedDate.isAfter(latestAvailableBackup))
|
||||
|
||||
const onUpdateDate = (date: Date) => {
|
||||
setSelectedDateRaw(
|
||||
constrainDateToRange(
|
||||
dayjs(date).tz(selectedTimezone.utc[0], true),
|
||||
earliestAvailableBackup,
|
||||
latestAvailableBackup
|
||||
).toDate()
|
||||
)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setShowConfiguration(false)
|
||||
setSelectedTimezone(getClientTimezone())
|
||||
}
|
||||
|
||||
const onConfirmRestore = async () => {
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
if (!recoveryTimeTargetUnix) return console.error('Recovery time target unix is required')
|
||||
restoreFromPitr({ ref, recovery_time_target_unix: recoveryTimeTargetUnix })
|
||||
if (!selectedRecoveryPoint?.recoveryTimeTargetUnix)
|
||||
return console.error('Recovery time target unix is required')
|
||||
|
||||
restoreFromPitr({
|
||||
ref,
|
||||
recovery_time_target_unix: selectedRecoveryPoint.recoveryTimeTargetUnix,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -177,173 +111,14 @@ const PITRSelection = () => {
|
||||
onSetConfiguration={() => setShowConfiguration(true)}
|
||||
/>
|
||||
) : (
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<Button type="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<ButtonTooltip
|
||||
type="warning"
|
||||
disabled={isSelectedOutOfRange || !selectedDate || !canTriggerPhysicalBackups}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: isSelectedOutOfRange
|
||||
? 'Selected date is out of range where backups are available'
|
||||
: !canTriggerPhysicalBackups
|
||||
? 'You need additional permissions to trigger a restore'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Review restore details
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex justify-between px-10 py-6 space-x-10">
|
||||
<div className="w-1/3 space-y-2">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDateRaw}
|
||||
onChange={onUpdateDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
minDate={earliestAvailableBackup.toDate()}
|
||||
maxDate={latestAvailableBackup.toDate()}
|
||||
highlightDates={availableDates.map((date) => date.toDate())}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-foreground-light text-sm">
|
||||
{format(date, 'MMMM yyyy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
|
||||
<p className="text-xs text-foreground-light">Point in time back up available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-2/3">
|
||||
{!selectedDate ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="mx-2">
|
||||
<InformationBox
|
||||
defaultVisibility
|
||||
hideCollapse
|
||||
icon={<HelpCircle size={14} strokeWidth={2} />}
|
||||
title="Select a date which you'd like to restore your database to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8 py-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Restore database to</p>
|
||||
<p className="text-3xl">
|
||||
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
|
||||
<span>
|
||||
, {formatNumberToTwoDigits(selectedTime.h)}:
|
||||
{formatNumberToTwoDigits(selectedTime.m)}:
|
||||
{formatNumberToTwoDigits(selectedTime.s)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Time zone</p>
|
||||
<div className="w-[350px]">
|
||||
<TimezoneSelection
|
||||
selectedTimezone={selectedTimezone}
|
||||
onSelectTimezone={setSelectedTimezone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Recovery time</p>
|
||||
<TimeInput
|
||||
defaultTime={selectedTime}
|
||||
minimumTime={
|
||||
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
|
||||
}
|
||||
maximumTime={
|
||||
isSelectedOnLatestDay ? latestAvailableBackupTime : undefined
|
||||
}
|
||||
onChange={({ h, m, s }) => {
|
||||
const newDate = dayjs(selectedDateRaw)
|
||||
.set('hour', h)
|
||||
.set('minute', m)
|
||||
.set('second', s)
|
||||
|
||||
setSelectedDateRaw(newDate.toDate())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground-light mt-8">
|
||||
Enter a time within the available range to restore from. <br /> Backups
|
||||
are captured every 2 minutes, allowing you to enter a time and restore
|
||||
your database to the closest backup point. We'll match the time you
|
||||
enter to the closest backup within the 2-minute window
|
||||
</p>
|
||||
</div>
|
||||
<div className="!mt-4 space-y-1">
|
||||
<h3 className="text-sm text-foreground-light"></h3>
|
||||
{isSelectedOnEarliestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Earliest backup available for this date</strong>:{' '}
|
||||
{earliestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
{isSelectedOnLatestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Latest backup available for this date</strong>:{' '}
|
||||
{latestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
<PITRForm
|
||||
earliestAvailableBackupUnix={earliestPhysicalBackupDateUnix}
|
||||
latestAvailableBackupUnix={latestPhysicalBackupDateUnix}
|
||||
onSubmit={(recoveryPoint) => {
|
||||
setSelectedRecoveryPoint(recoveryPoint)
|
||||
setShowConfirmation(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -379,12 +154,12 @@ const PITRSelection = () => {
|
||||
</div>
|
||||
<div className="py-2 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-mono text-foreground-lighter">{selectedTimezone?.text}</p>
|
||||
<p className="text-2xl">{recoveryTimeString}</p>
|
||||
<p className="text-sm font-mono text-foreground-lighter">Local Time</p>
|
||||
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeString}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono text-foreground-lighter">(UTC+00:00)</p>
|
||||
<p className="text-2xl">{recoveryTimeStringUtc}</p>
|
||||
<p className="text-2xl">{selectedRecoveryPoint?.recoveryTimeStringUtc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const TimezoneSelection = ({
|
||||
value={option}
|
||||
onSelect={(text) => {
|
||||
const selectedTimezone = ALL_TIMEZONES.find(
|
||||
(option) => option.text.toLocaleLowerCase() === text
|
||||
(option) => option.text === text
|
||||
)
|
||||
if (selectedTimezone) {
|
||||
onSelectTimezone(selectedTimezone)
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import DatePicker from 'react-datepicker'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { format } from 'date-fns'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronLeft, ChevronRight, HelpCircle } from 'lucide-react'
|
||||
import {
|
||||
constrainDateToRange,
|
||||
formatNumberToTwoDigits,
|
||||
getClientTimezone,
|
||||
getDatesBetweenRange,
|
||||
} from './PITR.utils'
|
||||
import { TimezoneSelection } from './TimezoneSelection'
|
||||
import TimeInput from './TimeInput'
|
||||
import { Timezone } from './PITR.types'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: {
|
||||
recoveryTimeTargetUnix: number
|
||||
recoveryTimeString: string
|
||||
recoveryTimeStringUtc: string
|
||||
}) => void
|
||||
earliestAvailableBackupUnix: number
|
||||
latestAvailableBackupUnix: number
|
||||
}
|
||||
|
||||
export function PITRForm({
|
||||
onSubmit,
|
||||
earliestAvailableBackupUnix,
|
||||
latestAvailableBackupUnix,
|
||||
}: Props) {
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
||||
const earliestAvailableBackup = dayjs
|
||||
.unix(earliestAvailableBackupUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
const latestAvailableBackup = dayjs
|
||||
.unix(latestAvailableBackupUnix ?? 0)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
|
||||
const [selectedDateRaw, setSelectedDateRaw] = useState<Date>(latestAvailableBackup.toDate())
|
||||
|
||||
const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true)
|
||||
const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day')
|
||||
const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day')
|
||||
const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup)
|
||||
|
||||
const selectedTime = {
|
||||
h: selectedDate.hour(),
|
||||
m: selectedDate.minute(),
|
||||
s: selectedDate.second(),
|
||||
}
|
||||
|
||||
const earliestAvailableBackupTime = {
|
||||
h: earliestAvailableBackup.hour(),
|
||||
m: earliestAvailableBackup.minute(),
|
||||
s: earliestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
const latestAvailableBackupTime = {
|
||||
h: latestAvailableBackup.hour(),
|
||||
m: latestAvailableBackup.minute(),
|
||||
s: latestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
const recoveryTimeTargetUnix = selectedDate.unix()
|
||||
const recoveryTimeString = selectedDate.format('DD MMM YYYY HH:mm:ss')
|
||||
const recoveryTimeStringUtc = selectedDate.utc().format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const isWithinRange = useMemo(
|
||||
() =>
|
||||
(!!selectedDate && selectedDate.isSame(latestAvailableBackup)) ||
|
||||
selectedDate.isSame(earliestAvailableBackup) ||
|
||||
(selectedDate.isBefore(latestAvailableBackup) &&
|
||||
selectedDate.isAfter(earliestAvailableBackup)),
|
||||
[selectedDate, latestAvailableBackup, earliestAvailableBackup]
|
||||
)
|
||||
|
||||
const onUpdateDate = (date: Date) => {
|
||||
setSelectedDateRaw(
|
||||
constrainDateToRange(
|
||||
dayjs(date).tz(selectedTimezone.utc[0], true),
|
||||
earliestAvailableBackup,
|
||||
latestAvailableBackup
|
||||
).toDate()
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
recoveryTimeTargetUnix,
|
||||
recoveryTimeString,
|
||||
recoveryTimeStringUtc,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={!selectedDate || !isWithinRange}
|
||||
onClick={handleSubmit}
|
||||
tooltip={{
|
||||
content: {
|
||||
hidden: !isWithinRange,
|
||||
side: 'bottom',
|
||||
text: !isWithinRange
|
||||
? 'Selected date is out of range where backups are available'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex justify-between px-10 py-6 space-x-10">
|
||||
<div className="w-1/3 space-y-2">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDateRaw}
|
||||
onChange={onUpdateDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
minDate={earliestAvailableBackup.toDate()}
|
||||
maxDate={latestAvailableBackup.toDate()}
|
||||
highlightDates={availableDates.map((date) => date.toDate())}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-foreground-light text-sm">
|
||||
{format(date, 'MMMM yyyy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-foreground-light hover:text-foreground focus:outline-none
|
||||
`}
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{availableDates.length > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border w-4 h-4 border-stronger bg-overlay-hover" />
|
||||
<p className="text-xs text-foreground-light">Point in time back up available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-2/3">
|
||||
{!selectedDate ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="mx-2">
|
||||
<InformationBox
|
||||
defaultVisibility
|
||||
hideCollapse
|
||||
icon={<HelpCircle size={14} strokeWidth={2} />}
|
||||
title="Select a date which you'd like to restore your database to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8 py-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Date to restore to</p>
|
||||
<p className="text-3xl">
|
||||
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
|
||||
<span>
|
||||
, {formatNumberToTwoDigits(selectedTime.h)}:
|
||||
{formatNumberToTwoDigits(selectedTime.m)}:
|
||||
{formatNumberToTwoDigits(selectedTime.s)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Time zone</p>
|
||||
<div className="w-[350px]">
|
||||
<TimezoneSelection
|
||||
selectedTimezone={selectedTimezone}
|
||||
onSelectTimezone={setSelectedTimezone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground-light">Recovery time</p>
|
||||
<TimeInput
|
||||
defaultTime={selectedTime}
|
||||
minimumTime={
|
||||
isSelectedOnEarliestDay ? earliestAvailableBackupTime : undefined
|
||||
}
|
||||
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
|
||||
onChange={({ h, m, s }) => {
|
||||
const newDate = dayjs(selectedDateRaw)
|
||||
.set('hour', h)
|
||||
.set('minute', m)
|
||||
.set('second', s)
|
||||
|
||||
setSelectedDateRaw(newDate.toDate())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground-light mt-8">
|
||||
Enter a time within the available range to restore from. <br /> Backups are
|
||||
captured every 2 minutes, allowing you to enter a time and restore your
|
||||
database to the closest backup point. We'll match the time you enter to the
|
||||
closest backup within the 2-minute window
|
||||
</p>
|
||||
</div>
|
||||
<div className="!mt-4 space-y-1">
|
||||
<h3 className="text-sm text-foreground-light"></h3>
|
||||
{isSelectedOnEarliestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Earliest backup available for this date</strong>:{' '}
|
||||
{earliestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
{isSelectedOnLatestDay && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
<strong>Latest backup available for this date</strong>:{' '}
|
||||
{latestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Badge, Button, Checkbox, Input, Listbox, SidePanel } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import FormEmptyBox from 'components/ui/FormBoxEmpty'
|
||||
import NoTableState from 'components/ui/States/NoTableState'
|
||||
import {
|
||||
@@ -19,9 +20,9 @@ import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/databas
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
|
||||
import type { Dictionary } from 'types'
|
||||
import ChooseFunctionForm from './ChooseFunctionForm'
|
||||
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
|
||||
|
||||
class CreateTriggerFormState {
|
||||
id: number | undefined
|
||||
@@ -46,6 +47,8 @@ class CreateTriggerFormState {
|
||||
table: { value: string }
|
||||
// @ts-ignore
|
||||
tableId: { value: number; error?: string }
|
||||
// @ts-ignore
|
||||
condition: { value: string }
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
@@ -80,6 +83,7 @@ class CreateTriggerFormState {
|
||||
this.schema = { value: trigger?.schema ?? '' }
|
||||
this.table = { value: trigger?.table ?? '' }
|
||||
this.tableId = { value: trigger?.table_id ?? '' }
|
||||
this.condition = { value: trigger?.condition ?? '' }
|
||||
}
|
||||
|
||||
update(state: Dictionary<any>) {
|
||||
@@ -342,6 +346,7 @@ const CreateTrigger = ({ trigger, visible, setVisible }: CreateTriggerProps) =>
|
||||
<div className="space-y-6 px-6">
|
||||
<InputName />
|
||||
<SelectEnabledMode />
|
||||
{_localState.formState.condition.value && <DisplayCondition />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -492,6 +497,34 @@ const SelectEnabledMode = observer(({}) => {
|
||||
)
|
||||
})
|
||||
|
||||
const DisplayCondition = observer(() => {
|
||||
const _localState = useContext(CreateTriggerContext)
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-4 grid gap-2 md:grid md:grid-cols-12">
|
||||
<div className="flex flex-col space-y-2 col-span-4">
|
||||
<label className="block text-foreground-light text-sm leading-4 break-all">Condition</label>
|
||||
</div>
|
||||
<div className="col-span-8 h-[100px]">
|
||||
<CodeEditor
|
||||
isReadOnly
|
||||
autofocus={false}
|
||||
id={`trigger-condition-${_localState!.formState.id}`}
|
||||
language="pgsql"
|
||||
defaultValue={_localState!.formState.condition.value}
|
||||
/>
|
||||
<div className="mt-2 text-foreground-lighter leading-normal text-sm">
|
||||
This condition must be met for the trigger to fire.
|
||||
<br />
|
||||
<span className="text-foreground">
|
||||
To update the condition, you must drop and recreate this trigger.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SelectOrientation = observer(({}) => {
|
||||
const _localState = useContext(CreateTriggerContext)
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
@@ -35,7 +34,13 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
|
||||
import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from './CronJobs.utils'
|
||||
import {
|
||||
buildCronQuery,
|
||||
buildHttpRequestCommand,
|
||||
cronPattern,
|
||||
secondsPattern,
|
||||
parseCronJobCommand,
|
||||
} from './CronJobs.utils'
|
||||
import { CronJobScheduleSection } from './CronJobScheduleSection'
|
||||
import { EdgeFunctionSection } from './EdgeFunctionSection'
|
||||
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
|
||||
@@ -44,8 +49,6 @@ import { HttpRequestSection } from './HttpRequestSection'
|
||||
import { SqlFunctionSection } from './SqlFunctionSection'
|
||||
import { SqlSnippetSection } from './SqlSnippetSection'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { checkDomainOfScale } from 'recharts/types/util/ChartUtils'
|
||||
import { InfoIcon } from 'lucide-react'
|
||||
|
||||
export interface CreateCronJobSheetProps {
|
||||
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
|
||||
@@ -94,13 +97,18 @@ const FormSchema = z.object({
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
try {
|
||||
CronToString(value)
|
||||
} catch {
|
||||
return false
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
CronToString(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}, 'The schedule needs to be in a Cron format.'),
|
||||
return false
|
||||
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
@@ -135,7 +143,7 @@ export const CreateCronJobSheet = ({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: selectedCronJob?.jobname || '',
|
||||
schedule: selectedCronJob?.schedule || '* * * * * *',
|
||||
schedule: selectedCronJob?.schedule || '*/5 * * * *',
|
||||
values: cronJobValues,
|
||||
},
|
||||
})
|
||||
|
||||
+71
-37
@@ -26,17 +26,19 @@ import {
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import CronSyntaxChart from './CronSyntaxChart'
|
||||
import { secondsPattern } from './CronJobs.utils'
|
||||
|
||||
interface CronJobScheduleSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ name: 'Every minute', expression: '* * * * * *' },
|
||||
const PRESETS = [
|
||||
{ name: 'Every minute', expression: '* * * * *' },
|
||||
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
|
||||
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
|
||||
{ name: 'Every Monday at midnight', expression: '0 0 * * 1' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 0 * * *' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 * * *' },
|
||||
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
|
||||
{ name: 'Every 30 seconds', expression: '30 seconds' },
|
||||
] as const
|
||||
|
||||
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
|
||||
@@ -46,7 +48,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
|
||||
const [presetValue, setPresetValue] = useState<string>(initialValue)
|
||||
const [inputValue, setInputValue] = useState(initialValue)
|
||||
const [debouncedValue] = useDebounce(inputValue, 500)
|
||||
const [debouncedValue] = useDebounce(inputValue, 750)
|
||||
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
|
||||
const [scheduleString, setScheduleString] = useState('')
|
||||
|
||||
@@ -78,19 +80,31 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
}, [debouncedValue, useNaturalLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputValue) return
|
||||
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
|
||||
|
||||
// update the cronstrue string when the input value changes
|
||||
try {
|
||||
setScheduleString(CronToString(inputValue))
|
||||
form.setValue('schedule', inputValue)
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
}, [form, inputValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!useNaturalLanguage) {
|
||||
setPresetValue(schedule)
|
||||
if (useNaturalLanguage) return
|
||||
|
||||
setPresetValue(schedule)
|
||||
|
||||
if (!schedule) {
|
||||
setScheduleString('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setScheduleString(CronToString(schedule))
|
||||
} catch (error) {
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
}, [schedule])
|
||||
|
||||
@@ -113,6 +127,11 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
value={inputValue}
|
||||
placeholder="E.g. every 5 minutes"
|
||||
className={cn(!useNaturalLanguage && 'hidden')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -136,6 +155,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
setInputValue('')
|
||||
setPresetValue('')
|
||||
setScheduleString('')
|
||||
form.setValue('schedule', '')
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">Use natural language</p>
|
||||
@@ -143,7 +163,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
|
||||
<div className="mt-2">
|
||||
<ul className="flex gap-2 flex-wrap mt-2">
|
||||
{presets.map((preset) => (
|
||||
{PRESETS.map((preset) => (
|
||||
<li key={preset.name}>
|
||||
<Button
|
||||
type="outline"
|
||||
@@ -183,46 +203,38 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xl font-mono text-foreground-lighter">
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * * *'}
|
||||
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
|
||||
</span>
|
||||
)}
|
||||
{!inputValue && !isGeneratingCron && !scheduleString ? (
|
||||
<span className="text-sm text-foreground-light">
|
||||
Describe your schedule above
|
||||
</span>
|
||||
) : scheduleString ? (
|
||||
) : (
|
||||
<span className="text-sm text-foreground-light flex items-center gap-2">
|
||||
The cron will be run {/* lowercase the first letter */}
|
||||
{isGeneratingCron ? (
|
||||
<span className="inline-flex items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
<LoadingDots />
|
||||
) : scheduleString === '' ? ( // set a min length before showing invalid message
|
||||
'Enter a valid cron expression above'
|
||||
) : scheduleString.includes('Invalid cron expression') ? (
|
||||
'Invalid cron expression'
|
||||
) : (
|
||||
scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'
|
||||
<>
|
||||
The cron will be run{' '}
|
||||
{secondsPattern.test(schedule)
|
||||
? 'every ' + schedule
|
||||
: scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)
|
||||
@@ -235,7 +247,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
const CronSyntaxLoader = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{['*', '*', '*', '*', '*', '*'].map((char, i) => (
|
||||
{['*', '*', '*', '*', '*'].map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0.3 }}
|
||||
@@ -253,3 +265,25 @@ const CronSyntaxLoader = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadingDots = () => {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
>
|
||||
.
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { it } from 'vitest'
|
||||
import { parseCronJobCommand } from './CronJobs.utils'
|
||||
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
|
||||
|
||||
describe('parseCronJobCommand', () => {
|
||||
it('should return a default object when the command is null', () => {
|
||||
@@ -34,4 +34,38 @@ describe('parseCronJobCommand', () => {
|
||||
type: 'edge_function',
|
||||
})
|
||||
})
|
||||
|
||||
// Array of test cases for secondsPattern
|
||||
const secondsPatternTests = [
|
||||
{ description: '10 seconds', command: '10 seconds' },
|
||||
{ description: '30 seconds', command: '30 seconds' },
|
||||
]
|
||||
|
||||
// Run tests for secondsPattern
|
||||
secondsPatternTests.forEach(({ description, command }) => {
|
||||
it(`should match the regex for a secondsPattern with "${description}"`, () => {
|
||||
expect(command).toMatch(secondsPattern)
|
||||
})
|
||||
})
|
||||
|
||||
// Array of test cases for cronPattern
|
||||
const cronPatternTests = [
|
||||
{ description: 'every hour', command: '0 * * * *' },
|
||||
{ description: 'every day at midnight', command: '0 0 * * *' },
|
||||
{ description: 'every Monday at 9 AM', command: '0 9 * * 1' },
|
||||
{ description: 'every 15th of the month at noon', command: '0 12 15 * *' },
|
||||
{ description: 'every January 1st at 3 AM', command: '0 3 1 1 *' },
|
||||
{ description: 'every 30 minutes', command: '30 * * * *' },
|
||||
{ description: 'every weekday at 6 PM', command: '0 18 * * 1-5' },
|
||||
{ description: 'every weekend at 10 AM', command: '0 10 * * 0,6' },
|
||||
{ description: 'every quarter hour', command: '*/15 * * * *' },
|
||||
{ description: 'twice daily at 8 AM and 8 PM', command: '0 8,20 * * *' },
|
||||
]
|
||||
|
||||
// Replace the single cronPattern test with forEach
|
||||
cronPatternTests.forEach(({ description, command }) => {
|
||||
it(`should match the regex for a cronPattern with "${description}"`, () => {
|
||||
expect(command).toMatch(cronPattern)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -110,3 +110,8 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
|
||||
|
||||
return DEFAULT_CRONJOB_COMMAND
|
||||
}
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds$/
|
||||
export const cronPattern =
|
||||
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
|
||||
@@ -4,14 +4,13 @@ export default function CronSyntaxChart() {
|
||||
<pre className="text-xs font-mono text-foreground-light">
|
||||
{/* prettier-ignore */}
|
||||
{`
|
||||
┌───────────── second (0 - 59)
|
||||
│ ┌───────────── minute (0 - 59)
|
||||
│ │ ┌───────────── hour (0 - 23)
|
||||
│ │ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ │ ┌───────────── day of week (0 - 7)
|
||||
│ │ │ │ │ │
|
||||
* * * * * * `}
|
||||
┌───────────── minute (0 - 59)
|
||||
│ ┌───────────── hour (0 - 23)
|
||||
│ │ ┌───────────── day of month (1 - 31)
|
||||
│ │ │ ┌───────────── month (1 - 12)
|
||||
│ │ │ │ ┌───────────── day of week (0 - 7)
|
||||
│ │ │ │ │
|
||||
* * * * * `}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -363,6 +363,7 @@ export enum LogsTableName {
|
||||
POSTGREST = 'postgrest_logs',
|
||||
SUPAVISOR = 'supavisor_logs',
|
||||
WAREHOUSE = 'warehouse_logs',
|
||||
CRON_JOBS = 'cron_job_run_details',
|
||||
}
|
||||
|
||||
export const LOGS_TABLES = {
|
||||
@@ -376,6 +377,7 @@ export const LOGS_TABLES = {
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
supavisor: LogsTableName.SUPAVISOR,
|
||||
warehouse: LogsTableName.WAREHOUSE,
|
||||
cron: LogsTableName.CRON_JOBS,
|
||||
}
|
||||
|
||||
export const LOGS_SOURCE_DESCRIPTION = {
|
||||
@@ -389,6 +391,7 @@ export const LOGS_SOURCE_DESCRIPTION = {
|
||||
[LogsTableName.POSTGREST]: 'RESTful API web server logs',
|
||||
[LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs',
|
||||
[LogsTableName.WAREHOUSE]: 'Logs obtained from a data warehouse collection',
|
||||
[LogsTableName.CRON_JOBS]: 'Logs obtained from cron job runs',
|
||||
}
|
||||
|
||||
export const genQueryParams = (params: { [k: string]: string }) => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export type QueryType =
|
||||
| 'supavisor'
|
||||
| 'postgrest'
|
||||
| 'warehouse'
|
||||
| 'cron'
|
||||
|
||||
export type Mode = 'simple' | 'custom'
|
||||
|
||||
|
||||
@@ -211,6 +211,9 @@ limit ${limit}
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`
|
||||
|
||||
case 'cron_job_run_details':
|
||||
return `select status, start_time, end_time, jobid from ${table} ${where} ${orderBy} limit ${limit}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,12 @@ export function LogsSidebarMenuV2() {
|
||||
url: `/project/${ref}/logs/edge-functions-logs`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Cron Jobs',
|
||||
key: 'cron-logs',
|
||||
url: `/project/${ref}/logs/cron-logs`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
|
||||
const filteredLogs = BASE_COLLECTIONS.filter((collection) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
|
||||
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import type { Project } from 'data/projects/project-detail-query'
|
||||
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
|
||||
import type { Organization } from 'types'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
|
||||
export const generateSettingsMenu = (
|
||||
ref?: string,
|
||||
@@ -27,6 +27,7 @@ export const generateSettingsMenu = (
|
||||
const storageEnabled = features?.storage ?? true
|
||||
const warehouseEnabled = features?.warehouse ?? false
|
||||
const logDrainsEnabled = features?.logDrains ?? false
|
||||
const newDiskComputeEnabled = features?.diskAndCompute ?? false
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -38,7 +39,7 @@ export const generateSettingsMenu = (
|
||||
url: `/project/${ref}/settings/general`,
|
||||
items: [],
|
||||
},
|
||||
...(IS_PLATFORM && features?.diskAndCompute
|
||||
...(IS_PLATFORM && newDiskComputeEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Compute and Disk',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { projectKeys } from './keys'
|
||||
|
||||
export type ProjectCloneVariables = {
|
||||
projectRef: string
|
||||
newProjectName: string
|
||||
newDbPass: string
|
||||
cloneBackupId?: number
|
||||
recoveryTimeTarget?: number
|
||||
}
|
||||
|
||||
export async function triggerClone({
|
||||
projectRef,
|
||||
newProjectName,
|
||||
cloneBackupId,
|
||||
newDbPass,
|
||||
recoveryTimeTarget,
|
||||
}: ProjectCloneVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
const { data, error } = await post('/platform/database/{ref}/clone', {
|
||||
params: { path: { ref: projectRef } },
|
||||
body: { cloneBackupId, newProjectName, newDbPass, recoveryTimeTarget },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type ProjectCloneData = Awaited<ReturnType<typeof triggerClone>>
|
||||
|
||||
export const useProjectCloneMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<ProjectCloneData, ResponseError, ProjectCloneVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<ProjectCloneData, ResponseError, ProjectCloneVariables>(
|
||||
(vars) => triggerClone(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.listCloneBackups(variables.projectRef),
|
||||
})
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
console.error(data)
|
||||
toast.error(`Failed to trigger clone: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { projectKeys } from './keys'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export async function getCloneBackups(projectRef?: string) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { data, error } = await get(`/platform/database/{ref}/clone`, {
|
||||
params: { path: { ref: projectRef } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type CloneBackupsData = Awaited<ReturnType<typeof getCloneBackups>>
|
||||
export type CloneBackupsError = ResponseError
|
||||
|
||||
export const useCloneBackupsQuery = <TData = CloneBackupsData>(
|
||||
{ projectRef }: { projectRef?: string },
|
||||
options: UseQueryOptions<CloneBackupsData, CloneBackupsError, TData> = {}
|
||||
) => {
|
||||
return useQuery<CloneBackupsData, CloneBackupsError, TData>(
|
||||
projectKeys.listCloneBackups(projectRef),
|
||||
() => getCloneBackups(projectRef),
|
||||
{ enabled: !!projectRef, ...options }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { projectKeys } from './keys'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export async function getCloneStatus(projectRef?: string) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
const { data, error } = await get(`/platform/database/{ref}/clone/status`, {
|
||||
params: { path: { ref: projectRef } },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export type CloneStatusData = Awaited<ReturnType<typeof getCloneStatus>>
|
||||
export type CloneStatusError = ResponseError
|
||||
|
||||
export const useCloneStatusQuery = <TData = CloneStatusData>(
|
||||
{ projectRef }: { projectRef?: string },
|
||||
options: UseQueryOptions<CloneStatusData, CloneStatusError, TData> = {}
|
||||
) => {
|
||||
return useQuery<CloneStatusData, CloneStatusError, TData>(
|
||||
projectKeys.listCloneStatus(projectRef),
|
||||
() => getCloneStatus(projectRef),
|
||||
{ enabled: !!projectRef, ...options }
|
||||
)
|
||||
}
|
||||
@@ -18,4 +18,10 @@ export const projectKeys = {
|
||||
['projects', projectRef, 'pause-status'] as const,
|
||||
|
||||
orgProjects: (slug: string | undefined) => ['projects', 'org', slug] as const,
|
||||
|
||||
// Clone to new project
|
||||
listCloneBackups: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'clone-backups'] as const,
|
||||
listCloneStatus: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'clone-status'] as const,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { components } from 'api-types'
|
||||
import { PROVIDERS } from 'lib/constants'
|
||||
|
||||
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
|
||||
|
||||
export const instanceSizeSpecs: Record<
|
||||
DesiredInstanceSize,
|
||||
{
|
||||
label: string
|
||||
ram: string
|
||||
cpu: string
|
||||
priceHourly: number
|
||||
priceMonthly: number
|
||||
cloud_providers: string[]
|
||||
}
|
||||
> = {
|
||||
micro: {
|
||||
label: 'Micro',
|
||||
ram: '1 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.01344,
|
||||
priceMonthly: 10,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
ram: '2 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0206,
|
||||
priceMonthly: 15,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
ram: '4 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0822,
|
||||
priceMonthly: 60,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
ram: '8 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.1517,
|
||||
priceMonthly: 110,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
xlarge: {
|
||||
label: 'XL',
|
||||
ram: '16 GB',
|
||||
cpu: '4-core',
|
||||
priceHourly: 0.2877,
|
||||
priceMonthly: 210,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'2xlarge': {
|
||||
label: '2XL',
|
||||
ram: '32 GB',
|
||||
cpu: '8-core',
|
||||
priceHourly: 0.562,
|
||||
priceMonthly: 410,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'4xlarge': {
|
||||
label: '4XL',
|
||||
ram: '64 GB',
|
||||
cpu: '16-core',
|
||||
priceHourly: 1.32,
|
||||
priceMonthly: 960,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'8xlarge': {
|
||||
label: '8XL',
|
||||
ram: '128 GB',
|
||||
cpu: '32-core',
|
||||
priceHourly: 2.562,
|
||||
priceMonthly: 1870,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'12xlarge': {
|
||||
label: '12XL',
|
||||
ram: '192 GB',
|
||||
cpu: '48-core',
|
||||
priceHourly: 3.836,
|
||||
priceMonthly: 2800,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'16xlarge': {
|
||||
label: '16XL',
|
||||
ram: '256 GB',
|
||||
cpu: '64-core',
|
||||
priceHourly: 5.12,
|
||||
priceMonthly: 3730,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import FlagContext from 'components/ui/Flag/FlagContext'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
|
||||
export function useFlag<T = boolean>(name: string) {
|
||||
const project = useSelectedProject()
|
||||
const store: any = useContext(FlagContext)
|
||||
|
||||
if (name === 'connectDialogUpdate') {
|
||||
return true
|
||||
// Temporary override as Fly projects are cant seem to upgrade their compute with the new disk UI
|
||||
if (name === 'diskAndComputeForm' && project?.cloud_provider === 'FLY') {
|
||||
return false
|
||||
}
|
||||
|
||||
const isObjectEmpty = (objectName: Object) => {
|
||||
|
||||
@@ -84,6 +84,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
|
||||
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
|
||||
|
||||
type DesiredInstanceSize = components['schemas']['DesiredInstanceSize']
|
||||
|
||||
@@ -100,99 +101,6 @@ const sizes: DesiredInstanceSize[] = [
|
||||
'16xlarge',
|
||||
]
|
||||
|
||||
const instanceSizeSpecs: Record<
|
||||
DesiredInstanceSize,
|
||||
{
|
||||
label: string
|
||||
ram: string
|
||||
cpu: string
|
||||
priceHourly: number
|
||||
priceMonthly: number
|
||||
cloud_providers: string[]
|
||||
}
|
||||
> = {
|
||||
micro: {
|
||||
label: 'Micro',
|
||||
ram: '1 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.01344,
|
||||
priceMonthly: 10,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
ram: '2 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0206,
|
||||
priceMonthly: 15,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
ram: '4 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0822,
|
||||
priceMonthly: 60,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
ram: '8 GB',
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.1517,
|
||||
priceMonthly: 110,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
xlarge: {
|
||||
label: 'XL',
|
||||
ram: '16 GB',
|
||||
cpu: '4-core',
|
||||
priceHourly: 0.2877,
|
||||
priceMonthly: 210,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'2xlarge': {
|
||||
label: '2XL',
|
||||
ram: '32 GB',
|
||||
cpu: '8-core',
|
||||
priceHourly: 0.562,
|
||||
priceMonthly: 410,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'4xlarge': {
|
||||
label: '4XL',
|
||||
ram: '64 GB',
|
||||
cpu: '16-core',
|
||||
priceHourly: 1.32,
|
||||
priceMonthly: 960,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'8xlarge': {
|
||||
label: '8XL',
|
||||
ram: '128 GB',
|
||||
cpu: '32-core',
|
||||
priceHourly: 2.562,
|
||||
priceMonthly: 1870,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'12xlarge': {
|
||||
label: '12XL',
|
||||
ram: '192 GB',
|
||||
cpu: '48-core',
|
||||
priceHourly: 3.836,
|
||||
priceMonthly: 2800,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
'16xlarge': {
|
||||
label: '16XL',
|
||||
ram: '256 GB',
|
||||
cpu: '64-core',
|
||||
priceHourly: 5.12,
|
||||
priceMonthly: 3730,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
},
|
||||
}
|
||||
|
||||
const Wizard: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { slug, projectName } = useParams()
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty'
|
||||
|
||||
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import UpgradeToPro from 'components/ui/UpgradeToPro'
|
||||
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
|
||||
import { useCloneBackupsQuery } from 'data/projects/clone-query'
|
||||
import { useCloneStatusQuery } from 'data/projects/clone-status-query'
|
||||
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { getDatabaseMajorVersion, passwordStrength } from 'lib/helpers'
|
||||
import Link from 'next/link'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogSection,
|
||||
Input,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition, TimestampInfo } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { z } from 'zod'
|
||||
import { debounce } from 'lodash'
|
||||
import generator from 'generate-password-browser'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
|
||||
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
|
||||
const RestoreToNewProjectPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<div className="space-y-6">
|
||||
<FormHeader className="!mb-0" title="Database Backups" />
|
||||
<DatabaseBackupsNav active="rtnp" />
|
||||
<div className="space-y-8">
|
||||
<RestoreToNewProject />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
RestoreToNewProjectPage.getLayout = (page) => (
|
||||
<DatabaseLayout title="Database">{page}</DatabaseLayout>
|
||||
)
|
||||
|
||||
const RestoreToNewProject = () => {
|
||||
const { project } = useProjectContext()
|
||||
const organization = useSelectedOrganization()
|
||||
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
|
||||
const isFreePlan = subscription?.plan?.id === 'free'
|
||||
|
||||
const {
|
||||
data: cloneBackups,
|
||||
error,
|
||||
isLoading: cloneBackupsLoading,
|
||||
isError,
|
||||
} = useCloneBackupsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
},
|
||||
{
|
||||
enabled: !isFreePlan,
|
||||
}
|
||||
)
|
||||
|
||||
const plan = subscription?.plan?.id
|
||||
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
|
||||
|
||||
const isPermissionsLoaded = usePermissionsLoaded()
|
||||
const canReadPhysicalBackups = useCheckPermissions(PermissionAction.READ, 'physical_backups')
|
||||
const canTriggerPhysicalBackups = useCheckPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
const hasPITREnabled = cloneBackups?.pitr_enabled
|
||||
|
||||
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
|
||||
const IS_PG15_OR_ABOVE = dbVersion >= 15
|
||||
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
|
||||
|
||||
const {
|
||||
data: cloneStatus,
|
||||
refetch: refetchCloneStatus,
|
||||
isLoading: cloneStatusLoading,
|
||||
} = useCloneStatusQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
const lastClone = cloneStatus?.clones?.[cloneStatus?.clones.length - 1]
|
||||
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
|
||||
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
|
||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
|
||||
const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null)
|
||||
const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
|
||||
|
||||
const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
|
||||
|
||||
/**
|
||||
* New project will have the same compute size and disk size as the original project
|
||||
*/
|
||||
function getAdditionalMonthlySpend() {
|
||||
const currentProjectComputeSize = project?.infra_compute_size
|
||||
if (!currentProjectComputeSize) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentProjectComputeSize === 'nano') {
|
||||
return null
|
||||
}
|
||||
|
||||
const additionalMonthlySpend = instanceSizeSpecs[currentProjectComputeSize]
|
||||
|
||||
return additionalMonthlySpend
|
||||
}
|
||||
|
||||
const { mutate: triggerClone, isLoading: cloneMutationLoading } = useProjectCloneMutation({
|
||||
onError: (error) => {
|
||||
console.error('error', error)
|
||||
toast.error('Failed to restore to new project')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Restoration process started')
|
||||
refetchCloneStatus()
|
||||
setShowNewProjectDialog(false)
|
||||
},
|
||||
})
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
|
||||
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
|
||||
|
||||
const delayedCheckPasswordStrength = useRef(
|
||||
debounce((value: string) => checkPasswordStrength(value), 300)
|
||||
).current
|
||||
|
||||
async function checkPasswordStrength(value: string) {
|
||||
const { message, strength } = await passwordStrength(value)
|
||||
setPasswordStrengthScore(strength)
|
||||
setPasswordStrengthMessage(message)
|
||||
}
|
||||
|
||||
function generateStrongPassword() {
|
||||
const password = generator.generate({
|
||||
length: 16,
|
||||
numbers: true,
|
||||
uppercase: true,
|
||||
})
|
||||
|
||||
form.setValue('password', password)
|
||||
delayedCheckPasswordStrength(password)
|
||||
}
|
||||
|
||||
function AdditionalMonthlySpend() {
|
||||
const additionalMonthlySpend = getAdditionalMonthlySpend()
|
||||
if (!additionalMonthlySpend) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground-lighter border-t p-5">
|
||||
<p>
|
||||
The new project will have the same compute size and disk size as this project. You will be
|
||||
able to update the compute size and disk size after the new project is created in{' '}
|
||||
<span className="font-mono text-xs tracking-tighter text-foreground-light">
|
||||
Project Settings > Compute and Disk
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-between text-foreground mt-2">
|
||||
<p>Additional Monthly Compute + Disk Cost</p>
|
||||
<p className="font-mono text-right text-brand">${additionalMonthlySpend.priceMonthly}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
if (!canReadPhysicalBackups) {
|
||||
return <NoPermission resourceText="view backups" />
|
||||
}
|
||||
|
||||
if (!canTriggerPhysicalBackups) {
|
||||
return <NoPermission resourceText="restore backups" />
|
||||
}
|
||||
|
||||
if (!IS_PG15_OR_ABOVE) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project is not available for this database version"
|
||||
>
|
||||
<Markdown
|
||||
content={`Restore to new project is only available for Postgres 15 and above.
|
||||
Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure)
|
||||
to upgrade your database version.
|
||||
`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (!PHYSICAL_BACKUPS_ENABLED) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project requires physical backups"
|
||||
description={
|
||||
<>
|
||||
Physical backups must be enabled to restore your database to a new project.
|
||||
<br /> Find out more about how backups work at supabase{' '}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="underline"
|
||||
href="https://supabase.com/docs/guides/platform/backups"
|
||||
>
|
||||
in our docs
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (plan === 'free') {
|
||||
return (
|
||||
<UpgradeToPro
|
||||
buttonText="Upgrade"
|
||||
primaryText="Restore to a new project requires a pro plan or above."
|
||||
secondaryText={
|
||||
'To restore to a new project, you need to upgrade to a Pro plan and have physical backups enabled.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (IS_CLONED_PROJECT) {
|
||||
return (
|
||||
<Admonition type="default" title={`This project cannot be restored to a new project`}>
|
||||
<Markdown
|
||||
content={`This project was originally restored from another project. This is a temporary limitation. Please [contact us](/support/new?ref=${project?.ref}) if you need to restore a project to multiple other projects.
|
||||
[Go to original project](/dashboard/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''})`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <AlertError error={error} subject="Failed to retrieve backups" />
|
||||
}
|
||||
|
||||
if (!isActiveHealthy) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="Restore to new project is not available while project is offline"
|
||||
description="Your project needs to be online to restore your database to a new project"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'FAILED') {
|
||||
return (
|
||||
<Admonition type="destructive" title="Failed to restore to new project">
|
||||
<Markdown
|
||||
content="The new project failed to be created.
|
||||
[Contact support](/support/new?category=dashboard_bug)"
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'COMPLETED') {
|
||||
return (
|
||||
<Admonition type="default" title="Restoration completed">
|
||||
<Markdown
|
||||
content={`The new project has been created. A project can only be restored to another project once.
|
||||
[Go to new project](/project/${lastClone?.target_project.ref})
|
||||
`}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
if (lastClone?.status === 'IN_PROGRESS') {
|
||||
return (
|
||||
<Alert_Shadcn_ className="[&>svg]:bg-none! [&>svg]:text-foreground-light">
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle_Shadcn_>Restoration in progress</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
The new project is being created.
|
||||
<br />
|
||||
<Link className="underline" href={`/project/${lastClone?.target_project.ref}`}>
|
||||
Go to new project
|
||||
</Link>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
!isLoading &&
|
||||
hasPITREnabled &&
|
||||
!cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
|
||||
) {
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="No backups found"
|
||||
description={'PITR is enabled, but no backups were found. Check again in a few minutes.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && !hasPITREnabled && cloneBackups?.backups.length === 0) {
|
||||
return <BackupsEmpty />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={showConfirmationDialog} onOpenChange={setShowConfirmationDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Confirm restore to a new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
This process will create a new project and restore your database to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSection className="prose pb-6 space-y-4 text-sm">
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
Project organization will stay the same: <code>{organization?.name}</code>
|
||||
</li>
|
||||
<li>
|
||||
Project region will stay the same: <code>{project?.region || ''}</code>
|
||||
</li>
|
||||
<li>
|
||||
A project can only be restored to another project once. <br />
|
||||
<span className="text-foreground-lighter text-xs">
|
||||
This is a temporary limitation. Please contact us if you need to restore a project
|
||||
to multiple other projects.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>What will be transferred?</li>
|
||||
<ul className="ml-4">
|
||||
<li>Database schema (tables, views, procedures)</li>
|
||||
<li>All data and indexes</li>
|
||||
<li>Database roles, permissions and users</li>
|
||||
</ul>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>What needs manual reconfiguration?</li>
|
||||
<ul className="ml-4">
|
||||
<li>Storage objects & settings</li>
|
||||
<li>Edge Functions</li>
|
||||
<li>Auth settings & API keys</li>
|
||||
<li>Realtime settings</li>
|
||||
<li>Database extensions and settings</li>
|
||||
<li>Read replicas</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</DialogSection>
|
||||
<AdditionalMonthlySpend />
|
||||
<DialogFooter>
|
||||
<Button type="outline" onClick={() => setShowConfirmationDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowConfirmationDialog(false)
|
||||
setShowNewProjectDialog(true)
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="border-b">
|
||||
<DialogTitle>Create new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
This process will create a new project and restore your database to it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={'create-new-project-form'}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (!project?.ref) {
|
||||
toast.error('Project ref is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (hasPITREnabled && recoveryTimeTarget) {
|
||||
triggerClone({
|
||||
projectRef: project?.ref,
|
||||
newProjectName: data.name,
|
||||
newDbPass: data.password,
|
||||
recoveryTimeTarget: recoveryTimeTarget,
|
||||
cloneBackupId: undefined,
|
||||
})
|
||||
} else if (selectedBackupId) {
|
||||
triggerClone({
|
||||
projectRef: project?.ref,
|
||||
cloneBackupId: selectedBackupId,
|
||||
newProjectName: data.name,
|
||||
newDbPass: data.password,
|
||||
recoveryTimeTarget: undefined,
|
||||
})
|
||||
} else {
|
||||
toast.error('No backup or point in time selected')
|
||||
return
|
||||
}
|
||||
})}
|
||||
>
|
||||
<DialogSection className="pb-6 space-y-4 text-sm">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="New Project Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ placeholder="Enter a name" type="text" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout>
|
||||
<FormControl_Shadcn_>
|
||||
<Input
|
||||
id="db-password"
|
||||
label="Database Password"
|
||||
type="password"
|
||||
placeholder="Type in a strong password"
|
||||
value={field.value}
|
||||
copy={field.value?.length > 0}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
field.onChange(value)
|
||||
if (value == '') {
|
||||
setPasswordStrengthScore(-1)
|
||||
setPasswordStrengthMessage('')
|
||||
} else delayedCheckPasswordStrength(value)
|
||||
}}
|
||||
descriptionText={
|
||||
<PasswordStrengthBar
|
||||
passwordStrengthScore={passwordStrengthScore}
|
||||
password={field.value}
|
||||
passwordStrengthMessage={passwordStrengthMessage}
|
||||
generateStrongPassword={generateStrongPassword}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</DialogSection>
|
||||
<AdditionalMonthlySpend />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
htmlType="reset"
|
||||
type="outline"
|
||||
onClick={() => setShowNewProjectDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={cloneMutationLoading}>
|
||||
Restore to new project
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{hasPITREnabled ? (
|
||||
<>
|
||||
<PITRForm
|
||||
onSubmit={(v) => {
|
||||
setShowConfirmationDialog(true)
|
||||
setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
|
||||
}}
|
||||
earliestAvailableBackupUnix={
|
||||
cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
|
||||
}
|
||||
latestAvailableBackupUnix={
|
||||
cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Panel>
|
||||
{cloneBackups?.backups.length === 0 ? (
|
||||
<>
|
||||
<BackupsEmpty />
|
||||
</>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{/* <pre>{JSON.stringify({ cloneStatus }, null, 2)}</pre> */}
|
||||
{cloneBackups?.backups.map((backup) => {
|
||||
if (!backup.isPhysicalBackup) return null
|
||||
return (
|
||||
<div className="flex p-4 gap-4" key={backup.id}>
|
||||
<div>
|
||||
<TimestampInfo value={backup.inserted_at} />
|
||||
</div>
|
||||
<Badge>{JSON.stringify(backup.status).replaceAll('"', '')}</Badge>
|
||||
{(backup.status as any) === 'COMPLETED' && (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setSelectedBackupId(backup.id)
|
||||
setShowConfirmationDialog(true)
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreToNewProjectPage
|
||||
@@ -0,0 +1,26 @@
|
||||
// select * from cron.job_run_details where jobid = '1' order by start_time desc limit 10
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
|
||||
export const LogPage: NextPageWithLayout = () => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
return (
|
||||
<LogsPreviewer
|
||||
projectRef={ref as string}
|
||||
condensedLayout={true}
|
||||
tableName={LogsTableName.CRON_JOBS}
|
||||
queryType={'cron'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => <LogsLayout title="Cron Logs">{page}</LogsLayout>
|
||||
|
||||
export default LogPage
|
||||
@@ -96,7 +96,7 @@
|
||||
}
|
||||
|
||||
.react-datepicker__day--highlighted {
|
||||
@apply bg-brand-600 text-foreground-light;
|
||||
@apply bg-overlay-hover text-foreground-light;
|
||||
}
|
||||
|
||||
.react-datepicker__aria-live {
|
||||
|
||||
@@ -12,28 +12,37 @@ export async function generateCron(openai: OpenAI, prompt: string) {
|
||||
You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron.
|
||||
|
||||
Rules for responses:
|
||||
- Output cron expressions in the 6-field format supported by pg_cron (seconds minute hour day month weekday)
|
||||
- The seconds field is optional - if not specified, it defaults to 0
|
||||
- Provide a brief explanation of what the cron expression does
|
||||
- Output cron expressions in the 5-field format supported by pg_cron
|
||||
- Do not provide any explanation of what the cron expression does
|
||||
- Format output as markdown with the cron expression in a code block
|
||||
- If the request is unclear or impossible, ask for clarification
|
||||
- If the request isn't related to cron scheduling, explain that you can only help with cron expressions
|
||||
- Do not ask for clarification if you need it. Just output the cron expression.
|
||||
|
||||
Example input: "Every Monday at 3am"
|
||||
Example output:
|
||||
This cron expression runs every Monday at 3:00:00 AM:
|
||||
\`\`\`
|
||||
0 0 3 * * 1
|
||||
0 3 * * 1
|
||||
\`\`\`
|
||||
|
||||
Common patterns to remember:
|
||||
- * means "every" for that field
|
||||
- */n means "every n intervals"
|
||||
- n means "exactly at n"
|
||||
- n,m means "at n and m"
|
||||
- n-m means "every value from n to m"
|
||||
|
||||
Field order: seconds (0-59), minute (0-59), hour (0-23), day (1-31), month (1-12), weekday (0-6, Sunday=0)
|
||||
This cron expression runs every minute:
|
||||
\`\`\`
|
||||
* * * * *
|
||||
\`\`\`
|
||||
|
||||
Additional examples:
|
||||
- Every minute: \`* * * * *\`
|
||||
- Every 5 minutes: \`*/5 * * * *\`
|
||||
- Every first of the month, at 00:00: \`0 0 1 * *\`
|
||||
- Every night at midnight: \`0 0 * * *\`
|
||||
- Every Monday at 2am: \`0 2 * * 1\`
|
||||
|
||||
Field order:
|
||||
- minute (0-59)
|
||||
- hour (0-23)
|
||||
- day (1-31)
|
||||
- month (1-12)
|
||||
- weekday (0-6, Sunday=0)
|
||||
|
||||
Here is the user's prompt:
|
||||
${prompt}
|
||||
|
||||
Vendored
+647
-51
File diff suppressed because it is too large.
Load diff
@@ -84,12 +84,22 @@ export const TimestampInfo = ({
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
}}
|
||||
className={cn('grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1', {
|
||||
'bg-surface-100': copied,
|
||||
})}
|
||||
className={cn(
|
||||
'relative cursor-default grid grid-cols-2 gap-2 hover:bg-surface-100 px-2 py-1',
|
||||
{
|
||||
'bg-surface-100': copied,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="text-right truncate">{label}:</span>
|
||||
<span className={copied ? 'text-brand-600' : ''}>{copied ? 'Copied!' : value}</span>
|
||||
<div className="relative">
|
||||
{copied && (
|
||||
<span className="absolute inset-0 flex items-center text-brand-600 bg-surface-100">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user