Merge branch 'master' into feat/connection-string-revamp

This commit is contained in:
Jonathan Summers-Muir committed 2024-11-22 11:51:10 +08:00
commit da4296f459
32 files changed
+2438 -495

No files matched your search

+10 -5
View File
@@ -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'
+384 -1
View File
@@ -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,
},
})
@@ -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,
}
)
}
+29
View File
@@ -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 }
)
}
+6
View File
@@ -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],
},
}
+5 -3
View File
@@ -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) => {
+1 -93
View File
@@ -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 &gt; 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
+1 -1
View File
@@ -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 {
+22 -13
View File
@@ -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}
+647 -51
View File
File diff suppressed because it is too large. Load diff
+14 -4
View File
@@ -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>
)
}