Refactor restore to new project by breaking into smaller components (#30604)

* Refactor restore to new project by breaking into smaller components

* Shift generateStrongPassword into a utils

* Update each notice component in restore to new project to be more consistent

* Add long polling in restore to new project

* Call refetch
This commit is contained in:
Joshen Lim authored and GitHub committed 2024-11-22 16:42:12 +08:00
1 parent 8c72360707
commit ecf001ab90
11 files changed
+525 -404

No files matched your search

@@ -32,7 +32,7 @@ function DatabaseBackupsNav({ active }: Props) {
label: (
<div className="flex items-center gap-1">
Restore to new project{' '}
<Badge size="small" className="!text-[10px] px-1 py-0">
<Badge size="small" className="!text-[10px] px-1.5 py-0">
New
</Badge>
</div>
@@ -0,0 +1,45 @@
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
export const AdditionalMonthlySpend = () => {
const { project } = useProjectContext()
/**
* 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 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>
)
}
@@ -0,0 +1,59 @@
import Panel from 'components/ui/Panel'
import BackupsEmpty from '../BackupsEmpty'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Badge, Button } from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface BackupsListProps {
onSelectRestore: (id: number) => void
}
export const BackupsList = ({ onSelectRestore }: BackupsListProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
return (
<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={() => onSelectRestore(backup.id)}
>
Restore
</Button>
)}
</div>
)
})}
</div>
)}
</Panel>
)
}
@@ -0,0 +1,84 @@
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
} from 'ui'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface ConfirmRestoreDialogProps {
open: boolean
onOpenChange: (value: boolean) => void
onSelectContinue: () => void
}
export const ConfirmRestoreDialog = ({
open,
onOpenChange,
onSelectContinue,
}: ConfirmRestoreDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onSelectContinue()}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,205 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { debounce } from 'lodash'
import { useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useProjectCloneMutation } from 'data/projects/clone-mutation'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { passwordStrength } from 'lib/helpers'
import { generateStrongPassword } from 'lib/project'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogTitle,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { AdditionalMonthlySpend } from './AdditionalMonthlySpend'
interface CreateNewProjectDialogProps {
open: boolean
selectedBackupId: number | null
recoveryTimeTarget: number | null
onOpenChange: (value: boolean) => void
onCloneSuccess: () => void
}
export const CreateNewProjectDialog = ({
open,
selectedBackupId,
recoveryTimeTarget,
onOpenChange,
onCloneSuccess,
}: CreateNewProjectDialogProps) => {
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
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 { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const { data: cloneBackups } = useCloneBackupsQuery(
{ projectRef: project?.ref },
{ enabled: !isFreePlan }
)
const hasPITREnabled = cloneBackups?.pitr_enabled
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')
onCloneSuccess()
},
})
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)
}
const generatePassword = () => {
const password = generateStrongPassword()
form.setValue('password', password)
delayedCheckPasswordStrength(password)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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={generatePassword}
/>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
<AdditionalMonthlySpend />
<DialogFooter>
<Button htmlType="reset" type="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={cloneMutationLoading}>
Restore to new project
</Button>
</DialogFooter>
</form>
</Form_Shadcn_>
</DialogContent>
</Dialog>
)
}
@@ -1,11 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Input, Modal } from 'ui'
import { useParams } from 'common'
import {
useIsProjectActive,
useProjectContext,
@@ -17,6 +15,8 @@ import { useDatabasePasswordResetMutation } from 'data/database/database-passwor
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { Button, Input, Modal } from 'ui'
const ResetDbPassword = ({ disabled = false }) => {
const { ref } = useParams()
@@ -80,12 +80,8 @@ const ResetDbPassword = ({ disabled = false }) => {
}
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setPassword(password)
delayedCheckPasswordStrength(password)
}
@@ -148,7 +144,7 @@ const ResetDbPassword = ({ disabled = false }) => {
passwordStrengthScore={passwordStrengthScore}
passwordStrengthMessage={passwordStrengthMessage}
password={password}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>
+10
View File
@@ -0,0 +1,10 @@
import generator from 'generate-password-browser'
export const generateStrongPassword = () => {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
return password
}
@@ -1,5 +1,4 @@
import { useParams } from 'common'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { useRouter } from 'next/router'
import { ChangeEvent, useRef, useState } from 'react'
@@ -21,6 +20,7 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PROVIDERS } from 'lib/constants'
import { getInitialMigrationSQLFromGitHubRepo } from 'lib/integration-utils'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { AWS_REGIONS } from 'shared-data'
import { useIntegrationInstallationSnapshot } from 'state/integration-installation'
import type { NextPageWithLayout } from 'types'
@@ -117,13 +117,8 @@ const CreateProject = () => {
setPasswordStrengthMessage(message)
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setDbPass(password)
delayedCheckPasswordStrength(password)
}
@@ -245,7 +240,7 @@ const CreateProject = () => {
passwordStrengthScore={passwordStrengthScore}
password={dbPass}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>
+7 -13
View File
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { ChevronRight, ExternalLink } from 'lucide-react'
import Link from 'next/link'
@@ -27,9 +26,11 @@ import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToInci
import Panel from 'components/ui/Panel'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
import { useDefaultRegionQuery } from 'data/misc/get-default-region-query'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { instanceSizeSpecs } from 'data/projects/new-project.constants'
import {
DbInstanceSize,
ProjectCreateVariables,
@@ -50,6 +51,7 @@ import {
PROVIDERS,
} from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import type { CloudProvider } from 'shared-data'
import type { NextPageWithLayout } from 'types'
import {
@@ -83,8 +85,6 @@ import { Admonition } from 'ui-patterns/admonition'
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']
@@ -258,15 +258,9 @@ const Wizard: NextPageWithLayout = () => {
// compute credits
10
// [Joshen] Refactor: DB Password could be a common component
// used in multiple pages with repeated logic
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
// [Refactor] DB Password could be a common component used in multiple pages with repeated logic
function generatePassword() {
const password = generateStrongPassword()
form.setValue('dbPass', password)
delayedCheckPasswordStrength(password)
}
@@ -759,7 +753,7 @@ const Wizard: NextPageWithLayout = () => {
passwordStrengthScore={form.getValues('dbPassStrength')}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
>
@@ -1,59 +1,34 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import BackupsEmpty from 'components/interfaces/Database/Backups/BackupsEmpty'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
import { Markdown } from 'components/interfaces/Markdown'
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 { getDatabaseMajorVersion } from 'lib/helpers'
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'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { useProjectsQuery } from 'data/projects/projects-query'
const RestoreToNewProjectPage: NextPageWithLayout = () => {
return (
@@ -83,19 +58,20 @@ const RestoreToNewProject = () => {
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const isFreePlan = subscription?.plan?.id === 'free'
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
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 { data: projects } = useProjectsQuery()
const {
data: cloneBackups,
error,
isLoading: cloneBackupsLoading,
isError,
} = useCloneBackupsQuery(
{
projectRef: project?.ref,
},
{
enabled: !isFreePlan,
}
)
} = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
const plan = subscription?.plan?.id
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
@@ -116,107 +92,26 @@ const RestoreToNewProject = () => {
data: cloneStatus,
refetch: refetchCloneStatus,
isLoading: cloneStatusLoading,
} = useCloneStatusQuery({
projectRef: project?.ref,
})
} = useCloneStatusQuery(
{
projectRef: project?.ref,
},
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
const hasTransientState = data.clones.some((c) => c.status === 'IN_PROGRESS')
if (!hasTransientState) setRefetchInterval(false)
},
}
)
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>
)
}
const clonedProject = projects?.find(
(p) => p.ref === cloneStatus?.clones?.[0]?.target_project.ref
)
if (isLoading) {
return <GenericSkeletonLoader />
@@ -237,6 +132,7 @@ const RestoreToNewProject = () => {
title="Restore to new project is not available for this database version"
>
<Markdown
className="max-w-full"
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.
@@ -274,20 +170,26 @@ const RestoreToNewProject = () => {
<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.'
}
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`}>
<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 || ''})`}
className="max-w-full [&>p]:!leading-normal"
content={`This is a temporary limitation whereby projects that were originally restored from another project cannot be restored to yet another project.
If you need to restore a project to multiple other projects, please reach out via [support](/support/new?ref=${project?.ref}).`}
/>
<Button asChild type="default">
<Link
href={`/dashboard/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''}`}
>
Go to original project
</Link>
</Button>
</Admonition>
)
}
@@ -309,10 +211,16 @@ const RestoreToNewProject = () => {
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)"
/>
<Markdown content="Sorry! The new project failed to be created, please reach out to support for assistance." />
<Button asChild type="default">
<Link
target="_blank"
rel="noreferrer noopener"
href={`/support/new?category=dashboard_bug&subject=Failed%20to%20restore%20to%20new%20project&message=Target%20project%20reference:%20${clonedProject?.ref ?? 'unknown'}`}
>
Contact support
</Link>
</Button>
</Admonition>
)
}
@@ -321,10 +229,12 @@ const RestoreToNewProject = () => {
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})
`}
className="max-w-full"
content={`The new project${!!clonedProject ? ` ${clonedProject.name}` : ''} has been created. A project can only be restored to another project once.`}
/>
<Button asChild type="default">
<Link href={`/project/${lastClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</Admonition>
)
}
@@ -335,11 +245,13 @@ const RestoreToNewProject = () => {
<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>
<p>
The new project{!!clonedProject ? ` ${clonedProject.name}` : ''} is currently being
created
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${lastClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
@@ -354,7 +266,7 @@ const RestoreToNewProject = () => {
<Admonition
type="default"
title="No backups found"
description={'PITR is enabled, but no backups were found. Check again in a few minutes.'}
description="PITR is enabled, but no backups were found. Check again in a few minutes."
/>
)
}
@@ -365,169 +277,25 @@ const RestoreToNewProject = () => {
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>
<ConfirmRestoreDialog
open={showConfirmationDialog}
onOpenChange={setShowConfirmationDialog}
onSelectContinue={() => {
setShowConfirmationDialog(false)
setShowNewProjectDialog(true)
}}
/>
<CreateNewProjectDialog
open={showNewProjectDialog}
selectedBackupId={selectedBackupId}
recoveryTimeTarget={recoveryTimeTarget}
onOpenChange={setShowNewProjectDialog}
onCloneSuccess={() => {
refetchCloneStatus()
setRefetchInterval(5000)
setShowNewProjectDialog(false)
}}
/>
{hasPITREnabled ? (
<>
<PITRForm
@@ -544,42 +312,12 @@ const RestoreToNewProject = () => {
/>
</>
) : (
<>
<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>
</>
<BackupsList
onSelectRestore={(id) => {
setSelectedBackupId(id)
setShowConfirmationDialog(true)
}}
/>
)}
</>
)
+5 -10
View File
@@ -1,11 +1,9 @@
import generator from 'generate-password-browser'
import { debounce } from 'lodash'
import { makeAutoObservable } from 'mobx'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { ChangeEvent, createContext, useContext, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, Input, Listbox } from 'ui'
import VercelIntegrationLayout from 'components/layouts/VercelIntegrationLayout'
import {
@@ -22,9 +20,11 @@ import {
PROVIDERS,
} from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { VERCEL_INTEGRATION_CONFIGS } from 'lib/vercelConfigs'
import { AWS_REGIONS } from 'shared-data'
import type { Dictionary } from 'types'
import { Button, Input, Listbox } from 'ui'
interface ISetupProjectStore {
token: string
@@ -211,13 +211,8 @@ const CreateProject = observer(() => {
setPasswordStrengthMessage(message)
}
function generateStrongPassword() {
const password = generator.generate({
length: 16,
numbers: true,
uppercase: true,
})
function generatePassword() {
const password = generateStrongPassword()
setDbPass(password)
delayedCheckPasswordStrength(password)
}
@@ -271,7 +266,7 @@ const CreateProject = observer(() => {
passwordStrengthScore={passwordStrengthScore}
password={dbPass}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generateStrongPassword}
generateStrongPassword={generatePassword}
/>
}
/>