Add new ui for restore to new project (#30325)

* add new ui for restore to new project

* rm false

* fix typeerr badly

* add form with name and new project pass

* fix flash in loading > results state change

* restore api types

* update api types, add pitr picker

* add additional monthly spend section

* fix loading state jump

* update copy

* update api types

* update to new clone status api res

* fix mutation missing vars

* dont render logical backups in list

* fix state issue on submit

* better pitr empty state

* Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx

Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com>

* Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>

* Apply suggestions from code review

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com>

* update api types to accept recoveryTimeTarget for PITR restores

* simplify pitr code a bit cleanup state bugs

* cleanup

* add PITR submit

* dont show restore btn unless status is complete

* update api types

* fix backups nav

* markdown descs and fix type err in nav

* check if project is cloned

* fix: make source project data optional

* fix datepicker highlighted day style

* fix tz picker

* check if its same or before/after in isWithinRange

* update api types fix issue with clones

* unsus

* fix markdown rendering issues

* add badge

* Update database roles and permissions description

* Update restore message text in PITR form component

* Update key attribute to item.id in DatabaseBackupsNav component

* Add conditional rendering for multiple available dates

* Refactor dialog section styles in restore-to-new-project.tsx

* Update null to undefined in conditional text assignment

* reuse component in pitr backups

* Update apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx

Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com>

* Update link to original project in RestoreToNewProject

* fix prettier

---------

Co-authored-by: Wen Bo Xie <5532241+w3b6x9@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Crispy1975 <12525875+Crispy1975@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-11-21 17:52:10 +01:00
1 parent 5cd436a16f
commit 37f77ae482
19 files changed
+1836 -410

No files matched your search

@@ -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>
)
}
@@ -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) => {
@@ -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],
},
}
+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 {
+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>
)
}