ComputeSizeField.tsx405 lines · main
1import { SupportCategories } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { ChevronRight, CpuIcon, Lock, Microchip } from 'lucide-react'
4import { useEffect, useMemo, useState } from 'react'
5import { UseFormReturn } from 'react-hook-form'
6import {
7 Button,
8 cn,
9 FormField,
10 RadioGroupCard,
11 RadioGroupCardItem,
12 Skeleton,
13 Tooltip,
14 TooltipContent,
15 TooltipTrigger,
16} from 'ui'
17import { ComputeBadge } from 'ui-patterns'
18import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
19
20import { DiskStorageSchemaType } from '../DiskManagement.schema'
21import { ComputeInstanceAddonVariantId, InfraInstanceSize } from '../DiskManagement.types'
22import {
23 calculateComputeSizePrice,
24 ComputeAddonVariant,
25 getAvailableComputeOptions,
26} from '../DiskManagement.utils'
27import { BillingChangeBadge } from '../ui/BillingChangeBadge'
28import FormMessage from '../ui/FormMessage'
29import { NoticeBar } from '../ui/NoticeBar'
30import { SupportLink } from '@/components/interfaces/Support/SupportLink'
31import { DocsButton } from '@/components/ui/DocsButton'
32import { InlineLink } from '@/components/ui/InlineLink'
33import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
34import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
35import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
36import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
37import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
38import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils'
39import { DOCS_URL } from '@/lib/constants'
40
41const INITIALLY_VISIBLE_COUNT = 6
42
43/**
44 * to do: this could be a type from api-types
45 */
46type ComputeSizeFieldProps = {
47 form: UseFormReturn<DiskStorageSchemaType>
48 disabled?: boolean
49}
50
51export 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}