ComputeSizeField.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import { SupportCategories } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { ChevronRight, CpuIcon, Lock, Microchip } from 'lucide-react'
  4. import { useEffect, useMemo, useState } from 'react'
  5. import { UseFormReturn } from 'react-hook-form'
  6. import {
  7. Button,
  8. cn,
  9. FormField,
  10. RadioGroupCard,
  11. RadioGroupCardItem,
  12. Skeleton,
  13. Tooltip,
  14. TooltipContent,
  15. TooltipTrigger,
  16. } from 'ui'
  17. import { ComputeBadge } from 'ui-patterns'
  18. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  19. import { DiskStorageSchemaType } from '../DiskManagement.schema'
  20. import { ComputeInstanceAddonVariantId, InfraInstanceSize } from '../DiskManagement.types'
  21. import {
  22. calculateComputeSizePrice,
  23. ComputeAddonVariant,
  24. getAvailableComputeOptions,
  25. } from '../DiskManagement.utils'
  26. import { BillingChangeBadge } from '../ui/BillingChangeBadge'
  27. import FormMessage from '../ui/FormMessage'
  28. import { NoticeBar } from '../ui/NoticeBar'
  29. import { SupportLink } from '@/components/interfaces/Support/SupportLink'
  30. import { DocsButton } from '@/components/ui/DocsButton'
  31. import { InlineLink } from '@/components/ui/InlineLink'
  32. import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
  33. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  34. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  35. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  36. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  37. import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils'
  38. import { DOCS_URL } from '@/lib/constants'
  39. const INITIALLY_VISIBLE_COUNT = 6
  40. /**
  41. * to do: this could be a type from api-types
  42. */
  43. type ComputeSizeFieldProps = {
  44. form: UseFormReturn<DiskStorageSchemaType>
  45. disabled?: boolean
  46. }
  47. export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
  48. const { ref } = useParams()
  49. const { data: org } = useSelectedOrganizationQuery()
  50. const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery()
  51. const { hasAccess: entitledUpdateCompute, isLoading: isEntitlementLoading } =
  52. useCheckEntitlements('instances.compute_update_available_sizes')
  53. const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price')
  54. const { computeSize } = form.watch()
  55. const {
  56. data: addons,
  57. isPending: isAddonsLoading,
  58. error: addonsError,
  59. } = useProjectAddonsQuery({ projectRef: ref })
  60. const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading
  61. const { control, formState, setValue, trigger } = form
  62. const availableAddons = useMemo(() => {
  63. return addons?.available_addons ?? []
  64. }, [addons])
  65. const availableOptions = useMemo(() => {
  66. /**
  67. * Returns the available compute options for the project
  68. * Also handles backwards compatibility for older API versions
  69. * Also handles a case in which Nano is not available from the API
  70. */
  71. return getAvailableComputeOptions(availableAddons, project?.cloud_provider)
  72. }, [availableAddons, project?.cloud_provider])
  73. // Expand by default if the project's current compute size is beyond the initial visible set
  74. const [showAllSizes, setShowAllSizes] = useState(() => {
  75. const idx = availableOptions.findIndex((o) => o.identifier === computeSize)
  76. return idx >= INITIALLY_VISIBLE_COUNT
  77. })
  78. // Expand whenever the selected size falls outside the visible set — covers both initial data
  79. // load (availableOptions starts empty) and computeSize changes after mount (e.g. form reset)
  80. useEffect(() => {
  81. const idx = availableOptions.findIndex((o) => o.identifier === computeSize)
  82. if (idx >= INITIALLY_VISIBLE_COUNT) {
  83. setShowAllSizes(true)
  84. }
  85. }, [computeSize, availableOptions])
  86. const subscriptionPitr = addons?.selected_addons.find((addon) => addon.type === 'pitr')
  87. const computeSizePrice = calculateComputeSizePrice({
  88. availableOptions: availableOptions,
  89. oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro',
  90. newComputeSize: form.getValues('computeSize'),
  91. plan: org?.plan.id ?? 'free',
  92. })
  93. const projectComputeSize = project?.infra_compute_size ?? 'nano'
  94. const showUpgradeBadge = entitledUpdateCompute && projectComputeSize === 'nano'
  95. const selectedOptionIndex = availableOptions.findIndex((o) => o.identifier === computeSize)
  96. const selectedOptionIsHidden = selectedOptionIndex >= INITIALLY_VISIBLE_COUNT
  97. // Always show all options if the selected one would be outside the visible slice,
  98. // so the active card is never hidden from the user.
  99. const visibleOptions =
  100. showAllSizes || selectedOptionIsHidden
  101. ? availableOptions
  102. : availableOptions.slice(0, INITIALLY_VISIBLE_COUNT)
  103. const hasHiddenOptions = availableOptions.length > INITIALLY_VISIBLE_COUNT
  104. return (
  105. <FormField
  106. name="computeSize"
  107. control={control}
  108. render={({ field }) => (
  109. <RadioGroupCard
  110. {...field}
  111. onValueChange={(value: ComputeInstanceAddonVariantId) => {
  112. setValue('computeSize', value, {
  113. shouldDirty: true,
  114. shouldValidate: true,
  115. })
  116. trigger('provisionedIOPS')
  117. trigger('throughput')
  118. }}
  119. defaultValue={field.value}
  120. disabled={disabled}
  121. >
  122. <FormItemLayout
  123. layout="horizontal"
  124. label="Compute size"
  125. id={field.name}
  126. className="gap-5"
  127. labelOptional={
  128. <>
  129. <BillingChangeBadge
  130. className="mb-2"
  131. show={
  132. formState.isDirty &&
  133. formState.dirtyFields.computeSize &&
  134. !formState.errors.computeSize
  135. }
  136. beforePrice={Number(computeSizePrice.oldPrice)}
  137. afterPrice={Number(computeSizePrice.newPrice)}
  138. free={showUpgradeBadge && computeSize === 'ci_micro' ? true : false}
  139. />
  140. <p className="text-foreground-lighter">
  141. Hardware resources allocated to your Postgres database
  142. </p>
  143. <div className="mt-3">
  144. <DocsButton
  145. abbrev={false}
  146. href={`${DOCS_URL}/guides/platform/compute-and-disk`}
  147. />
  148. </div>
  149. <NoticeBar
  150. showIcon={false}
  151. type="default"
  152. className="mt-3 border-violet-900 bg-violet-200 [&_h5]:text-violet-1100"
  153. visible={showUpgradeBadge && form.watch('computeSize') === 'ci_nano'}
  154. title={'Upgrade to Micro Compute'}
  155. description="This Project is already paying for Micro Compute. You can upgrade to Micro Compute at any time when convenient."
  156. />
  157. </>
  158. }
  159. >
  160. <div
  161. className={
  162. !addonsError
  163. ? 'grid gap-4 grid-cols-[repeat(auto-fit,minmax(min(100%,13em),1fr))]'
  164. : ''
  165. }
  166. >
  167. {isLoading ? (
  168. Array(INITIALLY_VISIBLE_COUNT)
  169. .fill(0)
  170. .map((_, i) => <Skeleton key={i} className="w-full h-[110px] rounded-md" />)
  171. ) : addonsError ? (
  172. <FormMessage message={'Failed to load Compute size options'} type="error">
  173. <p>{addonsError?.message}</p>
  174. </FormMessage>
  175. ) : (
  176. <>
  177. {visibleOptions.map((compute) => {
  178. const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider)
  179. const lockedMicroDueToPITR =
  180. compute.identifier === 'ci_micro' && !!subscriptionPitr
  181. const lockedNanoDueToPlan =
  182. org?.plan.id !== 'free' &&
  183. project?.infra_compute_size !== 'nano' &&
  184. compute.identifier === 'ci_nano'
  185. const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR
  186. const price =
  187. org?.plan.id !== 'free' &&
  188. project?.infra_compute_size === 'nano' &&
  189. compute.identifier === 'ci_nano'
  190. ? availableOptions.find(
  191. (option: ComputeAddonVariant) => option.identifier === 'ci_micro'
  192. )?.price
  193. : compute.price
  194. const cpuLabel = (() => {
  195. const cpuCores = compute.meta?.cpu_cores
  196. if (typeof cpuCores === 'number') {
  197. return `${cpuCores}-core ${cpuArchitecture} CPU`
  198. }
  199. if (cpuCores) {
  200. return `${cpuCores} CPU`
  201. }
  202. return 'CPU'
  203. })()
  204. return (
  205. <RadioGroupCardItem
  206. showIndicator={false}
  207. id={compute.identifier}
  208. key={compute.identifier}
  209. value={compute.identifier}
  210. className={cn(
  211. 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]',
  212. lockedOption && 'opacity-50'
  213. )}
  214. disabled={disabled || lockedOption}
  215. label={
  216. <Tooltip>
  217. <TooltipTrigger asChild>
  218. <div>
  219. {showUpgradeBadge && compute.identifier === 'ci_micro' && (
  220. <div className="absolute -top-4 -right-3 text-violet-1100 flex items-center gap-1 bg-surface-75 py-0.5 px-2 rounded-full border border-violet-900">
  221. <span>No additional charge</span>
  222. </div>
  223. )}
  224. <div className="w-full flex flex-col gap-3 justify-between">
  225. <div className="relative px-3 opacity-50 group-data-checked:opacity-100 flex justify-between">
  226. <ComputeBadge
  227. className="inline-flex font-semibold"
  228. infraComputeSize={compute.name as InfraInstanceSize}
  229. />
  230. <div className="flex items-center space-x-1">
  231. {lockedOption ? (
  232. <div className="bg border rounded-lg h-7 w-7 flex items-center justify-center">
  233. <Lock size={14} />
  234. </div>
  235. ) : (
  236. showComputePrice && (
  237. <>
  238. <span
  239. className="text-foreground text-sm font-semibold"
  240. translate="no"
  241. >
  242. ${price}
  243. </span>
  244. <span className="text-foreground-light translate-y-px">
  245. {' '}
  246. /{' '}
  247. {compute.price_interval === 'monthly'
  248. ? 'month'
  249. : 'hour'}
  250. </span>
  251. </>
  252. )
  253. )}
  254. </div>
  255. </div>
  256. <div className="w-full">
  257. <div className="px-3 text-sm flex flex-col gap-1">
  258. <div className="text-foreground-light flex gap-2 items-center">
  259. <Microchip
  260. strokeWidth={1}
  261. size={14}
  262. className="text-foreground-lighter"
  263. />
  264. <span>
  265. {compute.identifier === 'ci_nano' && 'Up to '}
  266. {compute.meta?.memory_gb ?? 0} GB memory
  267. </span>
  268. </div>
  269. <div className="text-foreground-light flex gap-2 items-center">
  270. <CpuIcon
  271. strokeWidth={1}
  272. size={14}
  273. className="text-foreground-lighter"
  274. />
  275. <span>{cpuLabel}</span>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. </div>
  281. </TooltipTrigger>
  282. {lockedMicroDueToPITR && (
  283. <TooltipContent side="bottom" className="w-64 text-center">
  284. Project has PITR enabled which requires a minimum of Small compute.
  285. Please{' '}
  286. <InlineLink href="/project/_/settings/addons?panel=pitr">
  287. disable PITR
  288. </InlineLink>{' '}
  289. first before selecting Micro
  290. </TooltipContent>
  291. )}
  292. </Tooltip>
  293. }
  294. />
  295. )
  296. })}
  297. {showAllSizes && (
  298. <RadioGroupCardItem
  299. id="larger-compute"
  300. key="larger-compute"
  301. showIndicator={false}
  302. value="larger-compute"
  303. onClick={(e) => e.preventDefault()}
  304. className={cn(
  305. 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]'
  306. )}
  307. label={
  308. <SupportLink
  309. queryParams={{
  310. projectRef: ref,
  311. category: SupportCategories.SALES_ENQUIRY,
  312. subject: 'Enquiry about larger instance sizes',
  313. }}
  314. >
  315. <div className="w-full flex flex-col gap-3 justify-between">
  316. <div className="relative px-3 flex justify-between">
  317. <ComputeBadge infraComputeSize=">16XL" />
  318. <div className="flex items-center space-x-1 opacity-50 ">
  319. <span className="text-foreground-light text-sm">Contact Us</span>
  320. </div>
  321. </div>
  322. <div className="w-full">
  323. <div className="px-3 text-sm flex flex-col gap-1">
  324. <div className="text-foreground-light flex gap-2 items-center">
  325. <Microchip
  326. strokeWidth={1}
  327. size={14}
  328. className="text-foreground-lighter"
  329. />
  330. <span>Custom memory</span>
  331. </div>
  332. <div className="text-foreground-light flex gap-2 items-center">
  333. <CpuIcon
  334. strokeWidth={1}
  335. size={14}
  336. className="text-foreground-lighter"
  337. />
  338. <span>Custom CPU</span>
  339. </div>
  340. </div>
  341. </div>
  342. </div>
  343. </SupportLink>
  344. }
  345. />
  346. )}
  347. </>
  348. )}
  349. </div>
  350. {!isLoading && !addonsError && hasHiddenOptions && (
  351. <Button
  352. type="default"
  353. size="tiny"
  354. className="mt-4"
  355. aria-expanded={showAllSizes}
  356. // Prevent collapsing when the selected size would become hidden
  357. disabled={showAllSizes && selectedOptionIsHidden}
  358. onClick={() => setShowAllSizes((prev) => !prev)}
  359. icon={
  360. <ChevronRight
  361. size={14}
  362. strokeWidth={1.5}
  363. className={cn('transition-transform', showAllSizes && '-rotate-90')}
  364. />
  365. }
  366. >
  367. {showAllSizes ? 'Show fewer sizes' : 'Show all sizes'}
  368. </Button>
  369. )}
  370. </FormItemLayout>
  371. </RadioGroupCard>
  372. )}
  373. />
  374. )
  375. }