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