ResumeProjectButton.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useFlag, useParams } from 'common'
  4. import { useRouter } from 'next/router'
  5. import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
  6. import { useForm } from 'react-hook-form'
  7. import { AWS_REGIONS, CloudProvider } from 'shared-data'
  8. import { toast } from 'sonner'
  9. import {
  10. Button,
  11. cn,
  12. Dialog,
  13. DialogContent,
  14. DialogFooter,
  15. DialogHeader,
  16. DialogSection,
  17. DialogTitle,
  18. Form,
  19. FormField,
  20. } from 'ui'
  21. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  22. import { z } from 'zod'
  23. import {
  24. extractPostgresVersionDetails,
  25. PostgresVersionSelector,
  26. } from '@/components/interfaces/ProjectCreation/PostgresVersionSelector'
  27. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  28. import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
  29. import { useSetProjectStatus } from '@/data/projects/project-detail-query'
  30. import { useProjectPauseStatusQuery } from '@/data/projects/project-pause-status-query'
  31. import { useProjectRestoreMutation } from '@/data/projects/project-restore-mutation'
  32. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  33. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  34. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  35. import { PROJECT_STATUS } from '@/lib/constants'
  36. const FormSchema = z.object({
  37. postgresVersionSelection: z.string(),
  38. })
  39. type ResumeProjectButtonProps = Pick<
  40. ComponentPropsWithoutRef<typeof ButtonTooltip>,
  41. 'className' | 'size' | 'type'
  42. > & {
  43. label?: string
  44. }
  45. export const ResumeProjectButton = ({
  46. className,
  47. label = 'Resume project',
  48. size,
  49. type = 'default',
  50. }: ResumeProjectButtonProps) => {
  51. const router = useRouter()
  52. const { ref } = useParams()
  53. const { data: project } = useSelectedProjectQuery()
  54. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  55. const { setProjectStatus } = useSetProjectStatus()
  56. const newProjectInternalOnlyConfiguration = useFlag('newProjectInternalOnlyConfiguration')
  57. const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region)
  58. const orgSlug = selectedOrganization?.slug
  59. const isFreePlan = selectedOrganization?.plan?.id === 'free'
  60. const {
  61. data: pauseStatus,
  62. isPending: isPauseStatusPending,
  63. isSuccess: isPauseStatusSuccess,
  64. } = useProjectPauseStatusQuery({ ref }, { enabled: project?.status === PROJECT_STATUS.INACTIVE })
  65. const isRestoreDisabled = isPauseStatusSuccess && !pauseStatus.can_restore
  66. const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
  67. { slug: orgSlug },
  68. { enabled: isFreePlan }
  69. )
  70. const hasMembersExceedingFreeTierLimit = (membersExceededLimit ?? []).length > 0
  71. const [showConfirmRestore, setShowConfirmRestore] = useState(false)
  72. const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false)
  73. const { can: canResumeProject } = useAsyncCheckPermissions(
  74. PermissionAction.INFRA_EXECUTE,
  75. 'queue_jobs.projects.initialize_or_resume'
  76. )
  77. const { mutate: restoreProject, isPending: isRestoring } = useProjectRestoreMutation({
  78. onSuccess: async (_, variables) => {
  79. setProjectStatus({ ref: variables.ref, status: PROJECT_STATUS.RESTORING })
  80. toast.success('Restoring project, project will be ready in a few minutes')
  81. await router.push(`/project/${variables.ref}`)
  82. },
  83. })
  84. const form = useForm<z.infer<typeof FormSchema>>({
  85. resolver: zodResolver(FormSchema as any),
  86. mode: 'onChange',
  87. defaultValues: { postgresVersionSelection: '' },
  88. })
  89. const onSelectRestore = () => {
  90. if (project?.status !== PROJECT_STATUS.INACTIVE) {
  91. return toast.error('Unable to resume: project is not paused')
  92. }
  93. if (isRestoreDisabled) {
  94. return toast.error('This project can no longer be resumed from the dashboard')
  95. }
  96. if (!canResumeProject) {
  97. return toast.error('You do not have the required permissions to restore this project')
  98. }
  99. if (hasMembersExceedingFreeTierLimit) {
  100. return setShowFreeProjectLimitWarning(true)
  101. }
  102. setShowConfirmRestore(true)
  103. }
  104. const onConfirmRestore = async (values: z.infer<typeof FormSchema>) => {
  105. if (!project) {
  106. return toast.error('Unable to restore: project is required')
  107. }
  108. if (!newProjectInternalOnlyConfiguration) {
  109. return restoreProject({ ref: project.ref })
  110. }
  111. const postgresVersionDetails = extractPostgresVersionDetails(values.postgresVersionSelection)
  112. restoreProject({
  113. ref: project.ref,
  114. releaseChannel: postgresVersionDetails.releaseChannel,
  115. postgresEngine: postgresVersionDetails.postgresEngine,
  116. })
  117. }
  118. const buttonDisabled =
  119. project?.status !== PROJECT_STATUS.INACTIVE ||
  120. project === undefined ||
  121. isPauseStatusPending ||
  122. isRestoring ||
  123. isRestoreDisabled ||
  124. !canResumeProject
  125. const tooltipText = useMemo(() => {
  126. if (isPauseStatusPending) return 'Checking whether this project can be resumed'
  127. if (project?.status !== PROJECT_STATUS.INACTIVE) {
  128. return 'Project must be paused before it can be resumed'
  129. }
  130. if (isRestoreDisabled) return 'This project can no longer be resumed from the dashboard'
  131. if (!canResumeProject) return 'You need additional permissions to resume this project'
  132. return undefined
  133. }, [canResumeProject, isPauseStatusPending, isRestoreDisabled, project?.status])
  134. return (
  135. <>
  136. <ButtonTooltip
  137. className={className}
  138. size={size}
  139. type={type}
  140. disabled={buttonDisabled}
  141. loading={isRestoring}
  142. onClick={onSelectRestore}
  143. tooltip={{
  144. content: {
  145. side: 'bottom',
  146. text: tooltipText,
  147. },
  148. }}
  149. >
  150. {label}
  151. </ButtonTooltip>
  152. <ConfirmationModal
  153. visible={showConfirmRestore}
  154. size="small"
  155. title="Resume this project"
  156. onCancel={() => setShowConfirmRestore(false)}
  157. onConfirm={() => form.handleSubmit(onConfirmRestore)()}
  158. loading={isRestoring}
  159. confirmLabel="Resume"
  160. confirmLabelLoading="Resuming"
  161. cancelLabel="Cancel"
  162. >
  163. <div className={cn(newProjectInternalOnlyConfiguration && 'flex flex-col gap-y-4')}>
  164. <p className="text-sm">
  165. {isFreePlan
  166. ? 'Your project’s data will be restored to when it was initially paused.'
  167. : 'Your project’s data will be restored and billing will resume based on compute size and hours active.'}
  168. </p>
  169. <Form {...form}>
  170. <form onSubmit={form.handleSubmit(onConfirmRestore)}>
  171. {newProjectInternalOnlyConfiguration && (
  172. <div className="space-y-2">
  173. <FormField
  174. control={form.control}
  175. name="postgresVersionSelection"
  176. render={({ field }) => (
  177. <PostgresVersionSelector
  178. field={field}
  179. form={form}
  180. type="unpause"
  181. label="Postgres version"
  182. layout="vertical"
  183. dbRegion={region?.displayName ?? ''}
  184. cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider}
  185. organizationSlug={selectedOrganization?.slug}
  186. />
  187. )}
  188. />
  189. </div>
  190. )}
  191. </form>
  192. </Form>
  193. </div>
  194. </ConfirmationModal>
  195. <Dialog
  196. open={showFreeProjectLimitWarning}
  197. onOpenChange={() => setShowFreeProjectLimitWarning(false)}
  198. >
  199. <DialogContent size="medium" className="gap-0 pb-0">
  200. <DialogHeader className="border-b">
  201. <DialogTitle className="leading-normal">
  202. Your organization has members who have exceeded their free project limits
  203. </DialogTitle>
  204. </DialogHeader>
  205. <DialogSection className="text-sm">
  206. <p className="text-foreground-light">
  207. The following members have reached their maximum limits for the number of active free
  208. plan projects within organizations where they are an administrator or owner:
  209. </p>
  210. <ul className="my-4 list-disc list-inside">
  211. {(membersExceededLimit ?? []).map((member, idx: number) => (
  212. <li key={`member-${idx}`}>
  213. {member.username || member.primary_email} (Limit: {member.free_project_limit} free
  214. projects)
  215. </li>
  216. ))}
  217. </ul>
  218. <p className="text-foreground-light">
  219. These members will need to either delete, pause, or upgrade one or more of these
  220. projects before you're able to resume this project.
  221. </p>
  222. </DialogSection>
  223. <DialogFooter>
  224. <Button
  225. htmlType="button"
  226. type="default"
  227. onClick={() => setShowFreeProjectLimitWarning(false)}
  228. >
  229. Understood
  230. </Button>
  231. </DialogFooter>
  232. </DialogContent>
  233. </Dialog>
  234. </>
  235. )
  236. }