From ecf001ab905538cab8670f0d9ca9c13d3462672a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 22 Nov 2024 16:42:12 +0800 Subject: [PATCH] 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 --- .../Database/Backups/DatabaseBackupsNav.tsx | 2 +- .../AdditionalMonthlySpend.tsx | 45 ++ .../RestoreToNewProject/BackupsList.tsx | 59 +++ .../ConfirmRestoreDialog.tsx | 84 ++++ .../CreateNewProjectDialog.tsx | 205 ++++++++ .../DatabaseSettings/ResetDbPassword.tsx | 16 +- apps/studio/lib/project.tsx | 10 + .../[slug]/deploy-button/new-project.tsx | 13 +- apps/studio/pages/new/[slug].tsx | 20 +- .../backups/restore-to-new-project.tsx | 460 ++++-------------- apps/studio/pages/vercel/setupProject.tsx | 15 +- 11 files changed, 525 insertions(+), 404 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx create mode 100644 apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx create mode 100644 apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx create mode 100644 apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx create mode 100644 apps/studio/lib/project.tsx diff --git a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx index 70a5894d1ff..cd0ef0f72a7 100644 --- a/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx +++ b/apps/studio/components/interfaces/Database/Backups/DatabaseBackupsNav.tsx @@ -32,7 +32,7 @@ function DatabaseBackupsNav({ active }: Props) { label: (
Restore to new project{' '} - + New
diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx new file mode 100644 index 00000000000..d78b34b0846 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/AdditionalMonthlySpend.tsx @@ -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 ( +
+

+ 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{' '} + + Project Settings > Compute and Disk + +

+
+

Additional Monthly Compute + Disk Cost

+

${additionalMonthlySpend.priceMonthly}

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx new file mode 100644 index 00000000000..ad330001843 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx @@ -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 ( + + {cloneBackups?.backups.length === 0 ? ( + <> + + + ) : ( +
+ {/*
{JSON.stringify({ cloneStatus }, null, 2)}
*/} + {cloneBackups?.backups.map((backup) => { + if (!backup.isPhysicalBackup) return null + return ( +
+
+ +
+ {JSON.stringify(backup.status).replaceAll('"', '')} + {(backup.status as any) === 'COMPLETED' && ( + + )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx new file mode 100644 index 00000000000..64a63c5b5b5 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog.tsx @@ -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 ( + + + + Confirm restore to a new project + + This process will create a new project and restore your database to it. + + + +
    +
  • + Project organization will stay the same: {organization?.name} +
  • +
  • + Project region will stay the same: {project?.region || ''} +
  • +
  • + A project can only be restored to another project once.
    + + This is a temporary limitation. Please contact us if you need to restore a project + to multiple other projects. + +
  • +
+
    +
  • What will be transferred?
  • +
      +
    • Database schema (tables, views, procedures)
    • +
    • All data and indexes
    • +
    • Database roles, permissions and users
    • +
    +
+
    +
  • What needs manual reconfiguration?
  • +
      +
    • Storage objects & settings
    • +
    • Edge Functions
    • +
    • Auth settings & API keys
    • +
    • Realtime settings
    • +
    • Database extensions and settings
    • +
    • Read replicas
    • +
    +
+
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx new file mode 100644 index 00000000000..e795f55814b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -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>({ + 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 ( + + + + Create new project + + This process will create a new project and restore your database to it. + + + +
{ + 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 + } + })} + > + + ( + + + + + + )} + /> + ( + + + 0} + onChange={(e) => { + const value = e.target.value + field.onChange(value) + if (value == '') { + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + } else delayedCheckPasswordStrength(value) + }} + descriptionText={ + + } + /> + + + )} + /> + + + + + + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 8fa10f4f70a..29493fe9edc 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -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} /> } /> diff --git a/apps/studio/lib/project.tsx b/apps/studio/lib/project.tsx new file mode 100644 index 00000000000..09f8fba7f30 --- /dev/null +++ b/apps/studio/lib/project.tsx @@ -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 +} diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index aa0903d6141..f5ecb3174d4 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -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} /> } /> diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 8c2724a60e6..85f50808195 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -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} /> } > diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index 632ac45122a..5c9d1c349c0 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -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(false) + const [selectedBackupId, setSelectedBackupId] = useState(null) + const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) + const [showNewProjectDialog, setShowNewProjectDialog] = useState(false) + const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(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(null) - const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) - const [showNewProjectDialog, setShowNewProjectDialog] = useState(false) - const [recoveryTimeTarget, setRecoveryTimeTarget] = useState(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>({ - 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 ( -
-

- 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{' '} - - Project Settings > Compute and Disk - -

-
-

Additional Monthly Compute + Disk Cost

-

${additionalMonthlySpend.priceMonthly}

-
-
- ) - } + const clonedProject = projects?.find( + (p) => p.ref === cloneStatus?.clones?.[0]?.target_project.ref + ) if (isLoading) { return @@ -237,6 +132,7 @@ const RestoreToNewProject = () => { title="Restore to new project is not available for this database version" > { ) } if (IS_CLONED_PROJECT) { return ( - + + ) } @@ -309,10 +211,16 @@ const RestoreToNewProject = () => { if (lastClone?.status === 'FAILED') { return ( - + + ) } @@ -321,10 +229,12 @@ const RestoreToNewProject = () => { return ( + ) } @@ -335,11 +245,13 @@ const RestoreToNewProject = () => { Restoration in progress - The new project is being created. -
- - Go to new project - +

+ The new project{!!clonedProject ? ` ${clonedProject.name}` : ''} is currently being + created +

+
) @@ -354,7 +266,7 @@ const RestoreToNewProject = () => { ) } @@ -365,169 +277,25 @@ const RestoreToNewProject = () => { return ( <> - - - - Confirm restore to a new project - - This process will create a new project and restore your database to it. - - - -
    -
  • - Project organization will stay the same: {organization?.name} -
  • -
  • - Project region will stay the same: {project?.region || ''} -
  • -
  • - A project can only be restored to another project once.
    - - This is a temporary limitation. Please contact us if you need to restore a project - to multiple other projects. - -
  • -
-
    -
  • What will be transferred?
  • -
      -
    • Database schema (tables, views, procedures)
    • -
    • All data and indexes
    • -
    • Database roles, permissions and users
    • -
    -
-
    -
  • What needs manual reconfiguration?
  • -
      -
    • Storage objects & settings
    • -
    • Edge Functions
    • -
    • Auth settings & API keys
    • -
    • Realtime settings
    • -
    • Database extensions and settings
    • -
    • Read replicas
    • -
    -
-
- - - - - -
-
- - - - Create new project - - This process will create a new project and restore your database to it. - - - -
{ - 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 - } - })} - > - - ( - - - - - - )} - /> - ( - - - 0} - onChange={(e) => { - const value = e.target.value - field.onChange(value) - if (value == '') { - setPasswordStrengthScore(-1) - setPasswordStrengthMessage('') - } else delayedCheckPasswordStrength(value) - }} - descriptionText={ - - } - /> - - - )} - /> - - - - - - - -
-
-
+ { + setShowConfirmationDialog(false) + setShowNewProjectDialog(true) + }} + /> + { + refetchCloneStatus() + setRefetchInterval(5000) + setShowNewProjectDialog(false) + }} + /> {hasPITREnabled ? ( <> { /> ) : ( - <> - - {cloneBackups?.backups.length === 0 ? ( - <> - - - ) : ( -
- {/*
{JSON.stringify({ cloneStatus }, null, 2)}
*/} - {cloneBackups?.backups.map((backup) => { - if (!backup.isPhysicalBackup) return null - return ( -
-
- -
- {JSON.stringify(backup.status).replaceAll('"', '')} - {(backup.status as any) === 'COMPLETED' && ( - - )} -
- ) - })} -
- )} -
- + { + setSelectedBackupId(id) + setShowConfirmationDialog(true) + }} + /> )} ) diff --git a/apps/studio/pages/vercel/setupProject.tsx b/apps/studio/pages/vercel/setupProject.tsx index a9d24ba6070..34e1bab9de7 100644 --- a/apps/studio/pages/vercel/setupProject.tsx +++ b/apps/studio/pages/vercel/setupProject.tsx @@ -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} /> } />