RestoreToNewProject.tsx 11 KB

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