| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- import { SupportCategories } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { ChevronRight, CpuIcon, Lock, Microchip } from 'lucide-react'
- import { useEffect, useMemo, useState } from 'react'
- import { UseFormReturn } from 'react-hook-form'
- import {
- Button,
- cn,
- FormField,
- RadioGroupCard,
- RadioGroupCardItem,
- Skeleton,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { ComputeBadge } from 'ui-patterns'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { DiskStorageSchemaType } from '../DiskManagement.schema'
- import { ComputeInstanceAddonVariantId, InfraInstanceSize } from '../DiskManagement.types'
- import {
- calculateComputeSizePrice,
- ComputeAddonVariant,
- getAvailableComputeOptions,
- } from '../DiskManagement.utils'
- import { BillingChangeBadge } from '../ui/BillingChangeBadge'
- import FormMessage from '../ui/FormMessage'
- import { NoticeBar } from '../ui/NoticeBar'
- import { SupportLink } from '@/components/interfaces/Support/SupportLink'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { InlineLink } from '@/components/ui/InlineLink'
- import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils'
- import { DOCS_URL } from '@/lib/constants'
- const INITIALLY_VISIBLE_COUNT = 6
- /**
- * to do: this could be a type from api-types
- */
- type ComputeSizeFieldProps = {
- form: UseFormReturn<DiskStorageSchemaType>
- disabled?: boolean
- }
- export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
- const { ref } = useParams()
- const { data: org } = useSelectedOrganizationQuery()
- const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery()
- const { hasAccess: entitledUpdateCompute, isLoading: isEntitlementLoading } =
- useCheckEntitlements('instances.compute_update_available_sizes')
- const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price')
- const { computeSize } = form.watch()
- const {
- data: addons,
- isPending: isAddonsLoading,
- error: addonsError,
- } = useProjectAddonsQuery({ projectRef: ref })
- const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading
- const { control, formState, setValue, trigger } = form
- const availableAddons = useMemo(() => {
- return addons?.available_addons ?? []
- }, [addons])
- const availableOptions = useMemo(() => {
- /**
- * Returns the available compute options for the project
- * Also handles backwards compatibility for older API versions
- * Also handles a case in which Nano is not available from the API
- */
- return getAvailableComputeOptions(availableAddons, project?.cloud_provider)
- }, [availableAddons, project?.cloud_provider])
- // Expand by default if the project's current compute size is beyond the initial visible set
- const [showAllSizes, setShowAllSizes] = useState(() => {
- const idx = availableOptions.findIndex((o) => o.identifier === computeSize)
- return idx >= INITIALLY_VISIBLE_COUNT
- })
- // Expand whenever the selected size falls outside the visible set — covers both initial data
- // load (availableOptions starts empty) and computeSize changes after mount (e.g. form reset)
- useEffect(() => {
- const idx = availableOptions.findIndex((o) => o.identifier === computeSize)
- if (idx >= INITIALLY_VISIBLE_COUNT) {
- setShowAllSizes(true)
- }
- }, [computeSize, availableOptions])
- const subscriptionPitr = addons?.selected_addons.find((addon) => addon.type === 'pitr')
- const computeSizePrice = calculateComputeSizePrice({
- availableOptions: availableOptions,
- oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro',
- newComputeSize: form.getValues('computeSize'),
- plan: org?.plan.id ?? 'free',
- })
- const projectComputeSize = project?.infra_compute_size ?? 'nano'
- const showUpgradeBadge = entitledUpdateCompute && projectComputeSize === 'nano'
- const selectedOptionIndex = availableOptions.findIndex((o) => o.identifier === computeSize)
- const selectedOptionIsHidden = selectedOptionIndex >= INITIALLY_VISIBLE_COUNT
- // Always show all options if the selected one would be outside the visible slice,
- // so the active card is never hidden from the user.
- const visibleOptions =
- showAllSizes || selectedOptionIsHidden
- ? availableOptions
- : availableOptions.slice(0, INITIALLY_VISIBLE_COUNT)
- const hasHiddenOptions = availableOptions.length > INITIALLY_VISIBLE_COUNT
- return (
- <FormField
- name="computeSize"
- control={control}
- render={({ field }) => (
- <RadioGroupCard
- {...field}
- onValueChange={(value: ComputeInstanceAddonVariantId) => {
- setValue('computeSize', value, {
- shouldDirty: true,
- shouldValidate: true,
- })
- trigger('provisionedIOPS')
- trigger('throughput')
- }}
- defaultValue={field.value}
- disabled={disabled}
- >
- <FormItemLayout
- layout="horizontal"
- label="Compute size"
- id={field.name}
- className="gap-5"
- labelOptional={
- <>
- <BillingChangeBadge
- className="mb-2"
- show={
- formState.isDirty &&
- formState.dirtyFields.computeSize &&
- !formState.errors.computeSize
- }
- beforePrice={Number(computeSizePrice.oldPrice)}
- afterPrice={Number(computeSizePrice.newPrice)}
- free={showUpgradeBadge && computeSize === 'ci_micro' ? true : false}
- />
- <p className="text-foreground-lighter">
- Hardware resources allocated to your Postgres database
- </p>
- <div className="mt-3">
- <DocsButton
- abbrev={false}
- href={`${DOCS_URL}/guides/platform/compute-and-disk`}
- />
- </div>
- <NoticeBar
- showIcon={false}
- type="default"
- className="mt-3 border-violet-900 bg-violet-200 [&_h5]:text-violet-1100"
- visible={showUpgradeBadge && form.watch('computeSize') === 'ci_nano'}
- title={'Upgrade to Micro Compute'}
- description="This Project is already paying for Micro Compute. You can upgrade to Micro Compute at any time when convenient."
- />
- </>
- }
- >
- <div
- className={
- !addonsError
- ? 'grid gap-4 grid-cols-[repeat(auto-fit,minmax(min(100%,13em),1fr))]'
- : ''
- }
- >
- {isLoading ? (
- Array(INITIALLY_VISIBLE_COUNT)
- .fill(0)
- .map((_, i) => <Skeleton key={i} className="w-full h-[110px] rounded-md" />)
- ) : addonsError ? (
- <FormMessage message={'Failed to load Compute size options'} type="error">
- <p>{addonsError?.message}</p>
- </FormMessage>
- ) : (
- <>
- {visibleOptions.map((compute) => {
- const cpuArchitecture = getCloudProviderArchitecture(project?.cloud_provider)
- const lockedMicroDueToPITR =
- compute.identifier === 'ci_micro' && !!subscriptionPitr
- const lockedNanoDueToPlan =
- org?.plan.id !== 'free' &&
- project?.infra_compute_size !== 'nano' &&
- compute.identifier === 'ci_nano'
- const lockedOption = lockedNanoDueToPlan || lockedMicroDueToPITR
- const price =
- org?.plan.id !== 'free' &&
- project?.infra_compute_size === 'nano' &&
- compute.identifier === 'ci_nano'
- ? availableOptions.find(
- (option: ComputeAddonVariant) => option.identifier === 'ci_micro'
- )?.price
- : compute.price
- const cpuLabel = (() => {
- const cpuCores = compute.meta?.cpu_cores
- if (typeof cpuCores === 'number') {
- return `${cpuCores}-core ${cpuArchitecture} CPU`
- }
- if (cpuCores) {
- return `${cpuCores} CPU`
- }
- return 'CPU'
- })()
- return (
- <RadioGroupCardItem
- showIndicator={false}
- id={compute.identifier}
- key={compute.identifier}
- value={compute.identifier}
- className={cn(
- 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]',
- lockedOption && 'opacity-50'
- )}
- disabled={disabled || lockedOption}
- label={
- <Tooltip>
- <TooltipTrigger asChild>
- <div>
- {showUpgradeBadge && compute.identifier === 'ci_micro' && (
- <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">
- <span>No additional charge</span>
- </div>
- )}
- <div className="w-full flex flex-col gap-3 justify-between">
- <div className="relative px-3 opacity-50 group-data-checked:opacity-100 flex justify-between">
- <ComputeBadge
- className="inline-flex font-semibold"
- infraComputeSize={compute.name as InfraInstanceSize}
- />
- <div className="flex items-center space-x-1">
- {lockedOption ? (
- <div className="bg border rounded-lg h-7 w-7 flex items-center justify-center">
- <Lock size={14} />
- </div>
- ) : (
- showComputePrice && (
- <>
- <span
- className="text-foreground text-sm font-semibold"
- translate="no"
- >
- ${price}
- </span>
- <span className="text-foreground-light translate-y-px">
- {' '}
- /{' '}
- {compute.price_interval === 'monthly'
- ? 'month'
- : 'hour'}
- </span>
- </>
- )
- )}
- </div>
- </div>
- <div className="w-full">
- <div className="px-3 text-sm flex flex-col gap-1">
- <div className="text-foreground-light flex gap-2 items-center">
- <Microchip
- strokeWidth={1}
- size={14}
- className="text-foreground-lighter"
- />
- <span>
- {compute.identifier === 'ci_nano' && 'Up to '}
- {compute.meta?.memory_gb ?? 0} GB memory
- </span>
- </div>
- <div className="text-foreground-light flex gap-2 items-center">
- <CpuIcon
- strokeWidth={1}
- size={14}
- className="text-foreground-lighter"
- />
- <span>{cpuLabel}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </TooltipTrigger>
- {lockedMicroDueToPITR && (
- <TooltipContent side="bottom" className="w-64 text-center">
- Project has PITR enabled which requires a minimum of Small compute.
- Please{' '}
- <InlineLink href="/project/_/settings/addons?panel=pitr">
- disable PITR
- </InlineLink>{' '}
- first before selecting Micro
- </TooltipContent>
- )}
- </Tooltip>
- }
- />
- )
- })}
- {showAllSizes && (
- <RadioGroupCardItem
- id="larger-compute"
- key="larger-compute"
- showIndicator={false}
- value="larger-compute"
- onClick={(e) => e.preventDefault()}
- className={cn(
- 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group w-full h-[110px]'
- )}
- label={
- <SupportLink
- queryParams={{
- projectRef: ref,
- category: SupportCategories.SALES_ENQUIRY,
- subject: 'Enquiry about larger instance sizes',
- }}
- >
- <div className="w-full flex flex-col gap-3 justify-between">
- <div className="relative px-3 flex justify-between">
- <ComputeBadge infraComputeSize=">16XL" />
- <div className="flex items-center space-x-1 opacity-50 ">
- <span className="text-foreground-light text-sm">Contact Us</span>
- </div>
- </div>
- <div className="w-full">
- <div className="px-3 text-sm flex flex-col gap-1">
- <div className="text-foreground-light flex gap-2 items-center">
- <Microchip
- strokeWidth={1}
- size={14}
- className="text-foreground-lighter"
- />
- <span>Custom memory</span>
- </div>
- <div className="text-foreground-light flex gap-2 items-center">
- <CpuIcon
- strokeWidth={1}
- size={14}
- className="text-foreground-lighter"
- />
- <span>Custom CPU</span>
- </div>
- </div>
- </div>
- </div>
- </SupportLink>
- }
- />
- )}
- </>
- )}
- </div>
- {!isLoading && !addonsError && hasHiddenOptions && (
- <Button
- type="default"
- size="tiny"
- className="mt-4"
- aria-expanded={showAllSizes}
- // Prevent collapsing when the selected size would become hidden
- disabled={showAllSizes && selectedOptionIsHidden}
- onClick={() => setShowAllSizes((prev) => !prev)}
- icon={
- <ChevronRight
- size={14}
- strokeWidth={1.5}
- className={cn('transition-transform', showAllSizes && '-rotate-90')}
- />
- }
- >
- {showAllSizes ? 'Show fewer sizes' : 'Show all sizes'}
- </Button>
- )}
- </FormItemLayout>
- </RadioGroupCard>
- )}
- />
- )
- }
|