DiskSizeConfigurationModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { SupportCategories } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import dayjs from 'dayjs'
  5. import { ExternalLink, Info } from 'lucide-react'
  6. import Link from 'next/link'
  7. import { SetStateAction, useEffect, useMemo } from 'react'
  8. import { SubmitHandler, useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import {
  11. Alert,
  12. AlertDescription,
  13. AlertTitle,
  14. Button,
  15. Form,
  16. FormControl,
  17. FormField,
  18. FormInputGroupInput,
  19. InfoIcon,
  20. InputGroup,
  21. InputGroupAddon,
  22. InputGroupText,
  23. Modal,
  24. WarningIcon,
  25. } from 'ui'
  26. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  27. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  28. import * as z from 'zod'
  29. import { SupportLink } from '@/components/interfaces/Support/SupportLink'
  30. import { useProjectDiskResizeMutation } from '@/data/config/project-disk-resize-mutation'
  31. import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
  32. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  33. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  34. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  35. import { DOCS_URL } from '@/lib/constants'
  36. export interface DiskSizeConfigurationProps {
  37. visible: boolean
  38. hideModal: (value: SetStateAction<boolean>) => void
  39. loading: boolean
  40. }
  41. const formId = 'disk-size-form'
  42. const maxDiskSize = 200
  43. const DiskSizeConfigurationModal = ({
  44. visible,
  45. loading,
  46. hideModal,
  47. }: DiskSizeConfigurationProps) => {
  48. const { ref: projectRef } = useParams()
  49. const { data: organization } = useSelectedOrganizationQuery()
  50. const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery()
  51. const { lastDatabaseResizeAt } = project ?? {}
  52. const { data: projectSubscriptionData, isPending: isLoadingSubscription } =
  53. useOrgSubscriptionQuery({ orgSlug: organization?.slug }, { enabled: visible })
  54. const { hasAccess: hasAccessToDiskModifications, isLoading: isLoadingDiskEntitlement } =
  55. useCheckEntitlements('instances.disk_modifications')
  56. const isLoading = isLoadingProject || isLoadingSubscription || isLoadingDiskEntitlement
  57. const timeTillNextAvailableDatabaseResize =
  58. lastDatabaseResizeAt === null ? 0 : 6 * 60 - dayjs().diff(lastDatabaseResizeAt, 'minutes')
  59. const isAbleToResizeDatabase = timeTillNextAvailableDatabaseResize <= 0
  60. const formattedTimeTillNextAvailableResize =
  61. timeTillNextAvailableDatabaseResize < 60
  62. ? `${timeTillNextAvailableDatabaseResize} minute(s)`
  63. : `${Math.floor(timeTillNextAvailableDatabaseResize / 60)} hours and ${
  64. timeTillNextAvailableDatabaseResize % 60
  65. } minute(s)`
  66. const { mutate: updateProjectUsage, isPending: isUpdatingDiskSize } =
  67. useProjectDiskResizeMutation({
  68. onSuccess: (_res, variables) => {
  69. toast.success(`Successfully updated disk size to ${variables.volumeSize} GB`)
  70. hideModal(false)
  71. },
  72. })
  73. const currentDiskSize = project?.volumeSizeGb ?? 0
  74. const INITIAL_VALUES = useMemo(
  75. () => ({
  76. 'new-disk-size': currentDiskSize,
  77. }),
  78. [currentDiskSize]
  79. )
  80. const diskSizeValidationSchema = useMemo(
  81. () =>
  82. z.object({
  83. 'new-disk-size': z.coerce
  84. .number({ required_error: 'Please enter a GB amount you want to resize the disk up to.' })
  85. .min(Number(currentDiskSize ?? 0), `Must be at least ${currentDiskSize} GB`)
  86. // to do, update with max_disk_volume_size_gb
  87. .max(Number(maxDiskSize), `Must not be more than ${maxDiskSize} GB`),
  88. }),
  89. [currentDiskSize]
  90. )
  91. const handleSubmit: SubmitHandler<z.infer<typeof diskSizeValidationSchema>> = async (values) => {
  92. if (!projectRef) return console.error('Project ref is required')
  93. const volumeSize = values['new-disk-size']
  94. updateProjectUsage({ projectRef, volumeSize })
  95. }
  96. const form = useForm<z.infer<typeof diskSizeValidationSchema>>({
  97. resolver: zodResolver(diskSizeValidationSchema as any),
  98. defaultValues: INITIAL_VALUES,
  99. })
  100. const { reset, formState } = form
  101. const { isDirty } = formState
  102. useEffect(() => {
  103. if (isDirty) return
  104. reset(INITIAL_VALUES)
  105. }, [INITIAL_VALUES, isDirty, reset])
  106. return (
  107. <Modal
  108. header="Increase Disk Storage Size"
  109. size="medium"
  110. visible={visible}
  111. loading={loading}
  112. onCancel={() => hideModal(false)}
  113. hideFooter
  114. >
  115. {isLoading ? (
  116. <div className="flex flex-col gap-4 p-4">
  117. <ShimmeringLoader />
  118. <ShimmeringLoader />
  119. </div>
  120. ) : projectSubscriptionData?.usage_billing_enabled === true &&
  121. hasAccessToDiskModifications ? (
  122. <>
  123. {currentDiskSize >= maxDiskSize ? (
  124. <Alert variant="warning" className="rounded-t-none border-0">
  125. <WarningIcon />
  126. <AlertTitle>Maximum manual disk size increase reached</AlertTitle>
  127. <AlertDescription>
  128. <p>
  129. You cannot manually expand the disk size any more than {maxDiskSize}GB. If you
  130. need more than this, contact us via support for help.
  131. </p>
  132. <Button asChild type="default" className="mt-3">
  133. <SupportLink
  134. queryParams={{
  135. projectRef,
  136. category: SupportCategories.PERFORMANCE_ISSUES,
  137. subject: 'Increase disk size beyond 200GB',
  138. }}
  139. >
  140. Contact support
  141. </SupportLink>
  142. </Button>
  143. </AlertDescription>
  144. </Alert>
  145. ) : (
  146. <>
  147. <Modal.Content className="w-full space-y-4">
  148. <Alert variant={isAbleToResizeDatabase ? 'default' : 'warning'}>
  149. <Info size={16} />
  150. <AlertTitle>This operation is only possible every 4 hours</AlertTitle>
  151. <AlertDescription>
  152. <div className="mb-4">
  153. {isAbleToResizeDatabase
  154. ? `Upon updating your disk size, the next disk size update will only be available from ${dayjs().format(
  155. 'DD MMM YYYY, HH:mm (ZZ)'
  156. )}`
  157. : `Your database was last resized at ${dayjs(lastDatabaseResizeAt).format(
  158. 'DD MMM YYYY, HH:mm (ZZ)'
  159. )}. You can resize your database again in approximately ${formattedTimeTillNextAvailableResize}`}
  160. </div>
  161. <Button asChild type="default" iconRight={<ExternalLink size={14} />}>
  162. <Link href={`${DOCS_URL}/guides/platform/database-size#disk-management`}>
  163. Read more about disk management
  164. </Link>
  165. </Button>
  166. </AlertDescription>
  167. </Alert>
  168. <Form {...form}>
  169. <form id={formId} onSubmit={form.handleSubmit(handleSubmit)} noValidate>
  170. <FormField
  171. control={form.control}
  172. name="new-disk-size"
  173. disabled={!isAbleToResizeDatabase}
  174. render={({ field }) => (
  175. <FormItemLayout
  176. name="new-disk-size"
  177. layout="vertical"
  178. label="New disk size"
  179. >
  180. <FormControl>
  181. <InputGroup>
  182. <FormInputGroupInput
  183. {...field}
  184. id="new-disk-size"
  185. type="number"
  186. onChange={(e) => field.onChange(Number(e.target.value))}
  187. />
  188. <InputGroupAddon align="inline-end">
  189. <InputGroupText>GB</InputGroupText>
  190. </InputGroupAddon>
  191. </InputGroup>
  192. </FormControl>
  193. </FormItemLayout>
  194. )}
  195. />
  196. </form>
  197. </Form>
  198. </Modal.Content>
  199. <Modal.Separator />
  200. <Modal.Content className="flex space-x-2 justify-end">
  201. <Button type="default" onClick={() => hideModal(false)}>
  202. Cancel
  203. </Button>
  204. <Button
  205. form={formId}
  206. htmlType="submit"
  207. type="primary"
  208. disabled={!isAbleToResizeDatabase || isUpdatingDiskSize || !isDirty}
  209. loading={isUpdatingDiskSize}
  210. >
  211. Update disk size
  212. </Button>
  213. </Modal.Content>
  214. </>
  215. )}
  216. </>
  217. ) : (
  218. <Alert className="border-none">
  219. <InfoIcon />
  220. <AlertTitle>
  221. {hasAccessToDiskModifications === false
  222. ? 'Disk size configuration is not available for projects on the Free Plan'
  223. : 'Disk size configuration is only available when the spend cap has been disabled'}
  224. </AlertTitle>
  225. <AlertDescription>
  226. {hasAccessToDiskModifications === false ? (
  227. <p>
  228. If you are intending to use more than 500MB of disk space, then you will need to
  229. upgrade to at least the Pro Plan.
  230. </p>
  231. ) : (
  232. <p>
  233. If you are intending to use more than 8GB of disk space, then you will need to
  234. disable your spend cap.
  235. </p>
  236. )}
  237. <Button asChild type="default" className="mt-3">
  238. <Link
  239. href={`/org/${organization?.slug}/billing?panel=${
  240. hasAccessToDiskModifications === false ? 'subscriptionPlan' : 'costControl'
  241. }`}
  242. target="_blank"
  243. >
  244. {hasAccessToDiskModifications === false
  245. ? 'Upgrade subscription'
  246. : 'Disable spend cap'}
  247. </Link>
  248. </Button>
  249. </AlertDescription>
  250. </Alert>
  251. )}
  252. </Modal>
  253. )
  254. }
  255. export default DiskSizeConfigurationModal