RestoreToNewProject.tsx314 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { Loader2 } from 'lucide-react' |
| 3 | import Link from 'next/link' |
| 4 | import { useEffect, useState } from 'react' |
| 5 | import { Alert, AlertDescription, AlertTitle, Button } from 'ui' |
| 6 | import { Admonition } from 'ui-patterns/admonition' |
| 7 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 8 | |
| 9 | import { PreviousRestoreItem } from './PreviousRestoreItem' |
| 10 | import { PITRForm } from '@/components/interfaces/Database/Backups/PITR/PITRForm' |
| 11 | import { BackupsList } from '@/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList' |
| 12 | import { ConfirmRestoreDialog } from '@/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog' |
| 13 | import { CreateNewProjectDialog } from '@/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog' |
| 14 | import { projectSpecToMonthlyPrice } from '@/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils' |
| 15 | import { DiskType } from '@/components/interfaces/DiskManagement/ui/DiskManagement.constants' |
| 16 | import { Markdown } from '@/components/interfaces/Markdown' |
| 17 | import AlertError from '@/components/ui/AlertError' |
| 18 | import { InlineLink } from '@/components/ui/InlineLink' |
| 19 | import NoPermission from '@/components/ui/NoPermission' |
| 20 | import Panel from '@/components/ui/Panel' |
| 21 | import { UpgradeToPro } from '@/components/ui/UpgradeToPro' |
| 22 | import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' |
| 23 | import { useCloneBackupsQuery } from '@/data/projects/clone-query' |
| 24 | import { useCloneStatusQuery } from '@/data/projects/clone-status-query' |
| 25 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 26 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 27 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 28 | import { |
| 29 | useIsAwsK8sCloudProvider, |
| 30 | useIsOrioleDb, |
| 31 | useSelectedProjectQuery, |
| 32 | } from '@/hooks/misc/useSelectedProject' |
| 33 | import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants' |
| 34 | import { getDatabaseMajorVersion } from '@/lib/helpers' |
| 35 | |
| 36 | export const RestoreToNewProject = () => { |
| 37 | const { data: project } = useSelectedProjectQuery() |
| 38 | const { data: organization } = useSelectedOrganizationQuery() |
| 39 | const { hasAccess: hasAccessToRestoreToNewProject, isLoading: isLoadingEntitlement } = |
| 40 | useCheckEntitlements('backup.restore_to_new_project') |
| 41 | const isOrioleDb = useIsOrioleDb() |
| 42 | const isAwsK8s = useIsAwsK8sCloudProvider() |
| 43 | |
| 44 | const [refetchInterval, setRefetchInterval] = useState<number | false>(false) |
| 45 | const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null) |
| 46 | const [showConfirmationDialog, setShowConfirmationDialog] = useState(false) |
| 47 | const [showNewProjectDialog, setShowNewProjectDialog] = useState(false) |
| 48 | const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null) |
| 49 | |
| 50 | const { |
| 51 | data: cloneBackups, |
| 52 | error, |
| 53 | isPending: cloneBackupsLoading, |
| 54 | isError, |
| 55 | } = useCloneBackupsQuery( |
| 56 | { projectRef: project?.ref }, |
| 57 | { enabled: hasAccessToRestoreToNewProject } |
| 58 | ) |
| 59 | |
| 60 | const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY |
| 61 | |
| 62 | const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( |
| 63 | PermissionAction.READ, |
| 64 | 'physical_backups' |
| 65 | ) |
| 66 | const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions( |
| 67 | PermissionAction.INFRA_EXECUTE, |
| 68 | 'queue_job.restore.prepare' |
| 69 | ) |
| 70 | const PITR_ENABLED = cloneBackups?.pitr_enabled |
| 71 | const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled |
| 72 | const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '') |
| 73 | const IS_PG15_OR_ABOVE = dbVersion >= 15 |
| 74 | const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb |
| 75 | const targetComputeSize = cloneBackups?.target_compute_size |
| 76 | const planId = organization?.plan?.id ?? 'free' |
| 77 | const { data } = useDiskAttributesQuery({ projectRef: project?.ref }) |
| 78 | const storageType = data?.attributes?.type ?? 'gp3' |
| 79 | |
| 80 | const { |
| 81 | data: cloneStatus, |
| 82 | refetch: refetchCloneStatus, |
| 83 | isPending: cloneStatusLoading, |
| 84 | isSuccess: isCloneStatusSuccess, |
| 85 | } = useCloneStatusQuery( |
| 86 | { |
| 87 | projectRef: project?.ref, |
| 88 | }, |
| 89 | { |
| 90 | refetchInterval, |
| 91 | refetchOnWindowFocus: false, |
| 92 | enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED, |
| 93 | } |
| 94 | ) |
| 95 | const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading |
| 96 | |
| 97 | useEffect(() => { |
| 98 | if (!isCloneStatusSuccess) return |
| 99 | const hasTransientState = cloneStatus.clones.some((c) => c.status === 'IN_PROGRESS') |
| 100 | if (!hasTransientState) { |
| 101 | setRefetchInterval(false) |
| 102 | } |
| 103 | }, [cloneStatus?.clones, isCloneStatusSuccess]) |
| 104 | |
| 105 | const previousClones = cloneStatus?.clones |
| 106 | const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS') |
| 107 | const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS') |
| 108 | |
| 109 | if (isLoadingEntitlement) { |
| 110 | return <GenericSkeletonLoader /> |
| 111 | } |
| 112 | |
| 113 | if (!hasAccessToRestoreToNewProject) { |
| 114 | return ( |
| 115 | <UpgradeToPro |
| 116 | buttonText="Upgrade" |
| 117 | source="backupsRestoreToNewProject" |
| 118 | featureProposition="enable restoring to new project" |
| 119 | primaryText="Restore to a new project requires Pro Plan and above" |
| 120 | secondaryText="To restore to a new project, you need to upgrade to a Pro Plan and have physical backups enabled." |
| 121 | /> |
| 122 | ) |
| 123 | } |
| 124 | |
| 125 | if (isOrioleDb) { |
| 126 | return ( |
| 127 | <Admonition |
| 128 | type="default" |
| 129 | title="Restoring to new projects are not available for OrioleDB" |
| 130 | description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups" |
| 131 | /> |
| 132 | ) |
| 133 | } |
| 134 | |
| 135 | if (isAwsK8s) { |
| 136 | return ( |
| 137 | <Admonition |
| 138 | type="default" |
| 139 | description="Restoring to new projects is temporarily not available for AWS (Revamped) projects." |
| 140 | /> |
| 141 | ) |
| 142 | } |
| 143 | |
| 144 | if (!canReadPhysicalBackups) { |
| 145 | return <NoPermission resourceText="view backups" /> |
| 146 | } |
| 147 | |
| 148 | if (!canTriggerPhysicalBackups) { |
| 149 | return <NoPermission resourceText="restore backups" /> |
| 150 | } |
| 151 | |
| 152 | if (!IS_PG15_OR_ABOVE) { |
| 153 | return ( |
| 154 | <Admonition |
| 155 | type="default" |
| 156 | title="Restore to new project is not available for this database version" |
| 157 | > |
| 158 | <Markdown |
| 159 | className="max-w-full" |
| 160 | content={`Restore to new project is only available for Postgres 15 and above. |
| 161 | Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure) |
| 162 | to upgrade your database version. |
| 163 | `} |
| 164 | /> |
| 165 | </Admonition> |
| 166 | ) |
| 167 | } |
| 168 | |
| 169 | if (!PHYSICAL_BACKUPS_ENABLED) { |
| 170 | return ( |
| 171 | <Admonition |
| 172 | type="default" |
| 173 | title="Physical backups are required" |
| 174 | description={ |
| 175 | <> |
| 176 | Physical backups must be enabled to restore your database to a new project.{' '} |
| 177 | <InlineLink href={`${DOCS_URL}/guides/platform/backups`}>Learn more</InlineLink> |
| 178 | </> |
| 179 | } |
| 180 | /> |
| 181 | ) |
| 182 | } |
| 183 | |
| 184 | if (isLoading) { |
| 185 | return <GenericSkeletonLoader /> |
| 186 | } |
| 187 | |
| 188 | if (isError) { |
| 189 | return <AlertError error={error} subject="Failed to retrieve backups" /> |
| 190 | } |
| 191 | |
| 192 | if (!isActiveHealthy) { |
| 193 | return ( |
| 194 | <Admonition |
| 195 | type="default" |
| 196 | title="Restore to new project is not available while project is offline" |
| 197 | description="Your project needs to be online to restore your database to a new project" |
| 198 | /> |
| 199 | ) |
| 200 | } |
| 201 | |
| 202 | if ( |
| 203 | !isLoading && |
| 204 | PITR_ENABLED && |
| 205 | !cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix |
| 206 | ) { |
| 207 | return ( |
| 208 | <Admonition |
| 209 | type="default" |
| 210 | title="No backups found" |
| 211 | description="PITR is enabled, but no backups were found. Check again in a few minutes." |
| 212 | /> |
| 213 | ) |
| 214 | } |
| 215 | |
| 216 | if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) { |
| 217 | return ( |
| 218 | <> |
| 219 | <Admonition |
| 220 | type="default" |
| 221 | title="No backups found" |
| 222 | description="Backups are enabled, but no backups were found. Check again tomorrow." |
| 223 | /> |
| 224 | </> |
| 225 | ) |
| 226 | } |
| 227 | |
| 228 | const additionalMonthlySpend = projectSpecToMonthlyPrice({ |
| 229 | targetVolumeSizeGb: targetVolumeSizeGb ?? 0, |
| 230 | targetComputeSize: targetComputeSize ?? 'nano', |
| 231 | planId: planId ?? 'free', |
| 232 | storageType: storageType as DiskType, |
| 233 | }) |
| 234 | |
| 235 | return ( |
| 236 | <div className="flex flex-col gap-4"> |
| 237 | <ConfirmRestoreDialog |
| 238 | open={showConfirmationDialog} |
| 239 | onOpenChange={setShowConfirmationDialog} |
| 240 | onSelectContinue={() => { |
| 241 | setShowConfirmationDialog(false) |
| 242 | setShowNewProjectDialog(true) |
| 243 | }} |
| 244 | additionalMonthlySpend={additionalMonthlySpend} |
| 245 | /> |
| 246 | <CreateNewProjectDialog |
| 247 | open={showNewProjectDialog} |
| 248 | selectedBackupId={selectedBackupId} |
| 249 | recoveryTimeTarget={recoveryTimeTarget} |
| 250 | additionalMonthlySpend={additionalMonthlySpend} |
| 251 | hasAccess={hasAccessToRestoreToNewProject} |
| 252 | onOpenChange={setShowNewProjectDialog} |
| 253 | onCloneSuccess={() => { |
| 254 | refetchCloneStatus() |
| 255 | setRefetchInterval(5000) |
| 256 | setShowNewProjectDialog(false) |
| 257 | }} |
| 258 | /> |
| 259 | {isRestoring ? ( |
| 260 | <Alert className="[&>svg]:bg-none! [&>svg]:text-foreground-light mb-6"> |
| 261 | <Loader2 className="animate-spin" /> |
| 262 | <AlertTitle>Restoration in progress</AlertTitle> |
| 263 | <AlertDescription> |
| 264 | <p> |
| 265 | The new project {(restoringClone?.target_project as any)?.name || ''} is currently |
| 266 | being created. You'll be able to restore again once the project is ready. |
| 267 | </p> |
| 268 | <Button asChild type="default" className="mt-2"> |
| 269 | <Link href={`/project/${restoringClone?.target_project?.ref ?? '_'}`}> |
| 270 | Go to new project |
| 271 | </Link> |
| 272 | </Button> |
| 273 | </AlertDescription> |
| 274 | </Alert> |
| 275 | ) : null} |
| 276 | {previousClones?.length ? ( |
| 277 | <div className="flex flex-col gap-2"> |
| 278 | <h3 className="text-sm font-medium">Previous restorations</h3> |
| 279 | <Panel className="flex flex-col divide-y divide-border"> |
| 280 | {previousClones?.map((c) => ( |
| 281 | <PreviousRestoreItem key={c.inserted_at} clone={c} /> |
| 282 | ))} |
| 283 | </Panel> |
| 284 | </div> |
| 285 | ) : null} |
| 286 | {PITR_ENABLED ? ( |
| 287 | <> |
| 288 | <PITRForm |
| 289 | disabled={isRestoring} |
| 290 | onSubmit={(v) => { |
| 291 | setShowConfirmationDialog(true) |
| 292 | setRecoveryTimeTarget(v.recoveryTimeTargetUnix) |
| 293 | }} |
| 294 | earliestAvailableBackupUnix={ |
| 295 | cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0 |
| 296 | } |
| 297 | latestAvailableBackupUnix={ |
| 298 | cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0 |
| 299 | } |
| 300 | /> |
| 301 | </> |
| 302 | ) : ( |
| 303 | <BackupsList |
| 304 | disabled={isRestoring} |
| 305 | hasAccess={hasAccessToRestoreToNewProject} |
| 306 | onSelectRestore={(id) => { |
| 307 | setSelectedBackupId(id) |
| 308 | setShowConfirmationDialog(true) |
| 309 | }} |
| 310 | /> |
| 311 | )} |
| 312 | </div> |
| 313 | ) |
| 314 | } |