RestoreToNewProject.tsx314 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { Loader2 } from 'lucide-react'
3import Link from 'next/link'
4import { useEffect, useState } from 'react'
5import { Alert, AlertDescription, AlertTitle, Button } from 'ui'
6import { Admonition } from 'ui-patterns/admonition'
7import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
8
9import { PreviousRestoreItem } from './PreviousRestoreItem'
10import { PITRForm } from '@/components/interfaces/Database/Backups/PITR/PITRForm'
11import { BackupsList } from '@/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
12import { ConfirmRestoreDialog } from '@/components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
13import { CreateNewProjectDialog } from '@/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
14import { projectSpecToMonthlyPrice } from '@/components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils'
15import { DiskType } from '@/components/interfaces/DiskManagement/ui/DiskManagement.constants'
16import { Markdown } from '@/components/interfaces/Markdown'
17import AlertError from '@/components/ui/AlertError'
18import { InlineLink } from '@/components/ui/InlineLink'
19import NoPermission from '@/components/ui/NoPermission'
20import Panel from '@/components/ui/Panel'
21import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
22import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query'
23import { useCloneBackupsQuery } from '@/data/projects/clone-query'
24import { useCloneStatusQuery } from '@/data/projects/clone-status-query'
25import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
26import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
27import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
28import {
29 useIsAwsK8sCloudProvider,
30 useIsOrioleDb,
31 useSelectedProjectQuery,
32} from '@/hooks/misc/useSelectedProject'
33import { DOCS_URL, PROJECT_STATUS } from '@/lib/constants'
34import { getDatabaseMajorVersion } from '@/lib/helpers'
35
36export 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}