DiskManagementForm.tsx640 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { useParams } from 'common'
5import { AnimatePresence, motion } from 'framer-motion'
6import { ChevronRight } from 'lucide-react'
7import { useEffect, useRef, useState } from 'react'
8import { useForm } from 'react-hook-form'
9import { CloudProvider } from 'shared-data'
10import { toast } from 'sonner'
11import {
12 Button,
13 cn,
14 Collapsible,
15 CollapsibleContent,
16 CollapsibleTrigger,
17 DialogSectionSeparator,
18 Form,
19 Separator,
20} from 'ui'
21import { Admonition } from 'ui-patterns'
22
23import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
24import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
25import { DiskManagementMessage } from './DiskManagement.types'
26import {
27 calculateDiskSizeRequiredForIopsWithGp3,
28 mapComputeSizeNameToAddonVariantId,
29} from './DiskManagement.utils'
30import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection'
31import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog'
32import { AutoScaleFields } from './fields/AutoScaleFields'
33import { ComputeSizeField } from './fields/ComputeSizeField'
34import { DiskSizeField } from './fields/DiskSizeField'
35import { IOPSField } from './fields/IOPSField'
36import { StorageTypeField } from './fields/StorageTypeField'
37import { ThroughputField } from './fields/ThroughputField'
38import { DiskCountdownRadial } from './ui/DiskCountdownRadial'
39import {
40 DISK_LIMITS,
41 DiskType,
42 PLAN_DETAILS,
43 RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3,
44} from './ui/DiskManagement.constants'
45import { NoticeBar } from './ui/NoticeBar'
46import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
47import {
48 MAX_WIDTH_CLASSES,
49 PADDING_CLASSES,
50 ScaffoldContainer,
51} from '@/components/layouts/Scaffold'
52import { DocsButton } from '@/components/ui/DocsButton'
53import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners'
54import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
55import {
56 useDiskAttributesQuery,
57 useRemainingDurationForDiskAttributeUpdate,
58} from '@/data/config/disk-attributes-query'
59import { useUpdateDiskAttributesMutation } from '@/data/config/disk-attributes-update-mutation'
60import { useDiskAutoscaleCustomConfigQuery } from '@/data/config/disk-autoscale-config-query'
61import { useUpdateDiskAutoscaleConfigMutation } from '@/data/config/disk-autoscale-config-update-mutation'
62import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query'
63import { useSetProjectStatus } from '@/data/projects/project-detail-query'
64import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
65import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
66import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
67import { AddonVariantId } from '@/data/subscriptions/types'
68import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
69import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
70import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
71import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
72import {
73 useIsAwsCloudProvider,
74 useIsAwsK8sCloudProvider,
75 useIsAwsNimbusCloudProvider,
76 useSelectedProjectQuery,
77} from '@/hooks/misc/useSelectedProject'
78import { DOCS_URL, GB, PROJECT_STATUS } from '@/lib/constants'
79
80export function DiskManagementForm() {
81 const { ref: projectRef } = useParams()
82 const { data: project, isPending: isProjectPending } = useSelectedProjectQuery()
83 const { data: org } = useSelectedOrganizationQuery()
84 const { setProjectStatus } = useSetProjectStatus()
85
86 const advancedSettingsRef = useRef<HTMLDivElement>(null)
87
88 const isSpendCapEnabled =
89 org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY'
90
91 const { data: resourceWarnings } = useResourceWarningsQuery({ ref: projectRef })
92 // [Joshen Cleanup] JFYI this client side filtering can be cleaned up once BE changes are live which will only return the warnings based on the provided ref
93 const projectResourceWarnings = (resourceWarnings ?? [])?.find(
94 (warning) => warning.project === project?.ref
95 )
96 const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
97 const isAws = useIsAwsCloudProvider()
98 const isAwsK8s = useIsAwsK8sCloudProvider()
99 const isAwsNimbus = useIsAwsNimbusCloudProvider()
100
101 const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } =
102 useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
103 resource: {
104 project_id: project?.id,
105 },
106 })
107
108 const { hasAccess, isSuccess: isEntitlementsLoaded } = useCheckEntitlements(
109 'instances.compute_update_available_sizes'
110 )
111
112 const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false)
113 const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
114 const [message, setMessageState] = useState<DiskManagementMessage | null>(null)
115 const [advancedSettingsOpen, setAdvancedSettingsOpenState] = useState(false)
116
117 const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef })
118 const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery(
119 { projectRef },
120 {
121 refetchInterval,
122 refetchOnWindowFocus: false,
123 enabled: project != null && isAws,
124 }
125 )
126
127 const { isSuccess: isAddonsSuccess } = useProjectAddonsQuery({ projectRef })
128 const { isWithinCooldownWindow, isSuccess: isCooldownSuccess } =
129 useRemainingDurationForDiskAttributeUpdate({
130 projectRef,
131 enabled: project != null && isAws,
132 })
133 const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery(
134 {
135 projectRef,
136 },
137 { enabled: project != null && isAws }
138 )
139
140 const { data: diskAutoscaleConfig, isSuccess: isDiskAutoscaleConfigSuccess } =
141 useDiskAutoscaleCustomConfigQuery({ projectRef }, { enabled: project != null && isAws })
142
143 const computeSize = project?.infra_compute_size
144 ? mapComputeSizeNameToAddonVariantId(project?.infra_compute_size)
145 : undefined
146
147 // @ts-ignore
148 const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0, iops: 0 }
149 const { growth_percent, max_size_gb, min_increment_gb } = diskAutoscaleConfig ?? {}
150 const defaultValues = {
151 storageType: type ?? DiskType.GP3,
152 provisionedIOPS: iops,
153 throughput: throughput_mbps,
154 totalSize: size_gb,
155 computeSize: computeSize ?? 'ci_micro',
156 growthPercent: growth_percent,
157 minIncrementGb: min_increment_gb,
158 maxSizeGb: max_size_gb,
159 }
160
161 const form = useForm<DiskStorageSchemaType>({
162 resolver: zodResolver(
163 CreateDiskStorageSchema({
164 defaultTotalSize: defaultValues.totalSize,
165 cloudProvider: project?.cloud_provider as CloudProvider,
166 isSpendCapEnabled,
167 } as any)
168 ),
169 defaultValues,
170 mode: 'onBlur',
171 reValidateMode: 'onChange',
172 })
173
174 const { computeSize: modifiedComputeSize } = form.watch()
175
176 const isSuccess =
177 isAddonsSuccess &&
178 isDiskAttributesSuccess &&
179 isDiskUtilizationSuccess &&
180 isReadReplicasSuccess &&
181 isDiskAutoscaleConfigSuccess &&
182 isCooldownSuccess
183
184 const isRequestingChanges = data?.requested_modification !== undefined
185 const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef)
186 const isPlanUpgradeRequired = !hasAccess
187
188 const { formState } = form
189 const errors = formState.errors
190 const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
191 const totalSize = formState.defaultValues?.totalSize || 0
192 const usedPercentage = (usedSize / totalSize) * 100
193
194 const disableIopsThroughputConfig =
195 modifiedComputeSize &&
196 !isSpendCapEnabled &&
197 RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)
198
199 const watchedTotalSize = form.watch('totalSize') ?? 0
200 const watchedStorageType = form.watch('storageType')
201 // Minimum disk size where the platform API will accept an IOPS payload (500 IOPS/GB rule).
202 const minDiskSizeForCustomIops = calculateDiskSizeRequiredForIopsWithGp3(
203 DISK_LIMITS[DiskType.GP3].minIops
204 )
205 // Suggested target when prompting a resize, sits above the floor so users
206 // aren't pinned at the minimum during the 4-hour disk-config cooldown.
207 const suggestedDiskSizeForCustomIops = PLAN_DETAILS.pro.includedDiskGB.gp3
208 const isDiskTooSmallForCustomIops =
209 watchedStorageType === 'gp3' && watchedTotalSize < minDiskSizeForCustomIops
210
211 const isBranch = project?.parent_project_ref !== undefined
212
213 const disableDiskSizeInput =
214 isRequestingChanges ||
215 isPlanUpgradeRequired ||
216 isWithinCooldownWindow ||
217 !canUpdateDiskConfiguration ||
218 !isAws
219
220 const disableDiskInputs = disableDiskSizeInput || isSpendCapEnabled
221
222 const disableComputeInputs = isPlanUpgradeRequired
223 const isDirty = !!Object.keys(form.formState.dirtyFields).length
224 const isProjectResizing = project?.status === PROJECT_STATUS.RESIZING
225 const isProjectRequestingDiskChanges = isRequestingChanges && !isProjectResizing
226 const noPermissions = isPermissionsLoaded && !canUpdateDiskConfiguration
227
228 const isDiskNoticeVisible = !isProjectPending && !(isAws || isAwsNimbus)
229
230 const { mutateAsync: updateDiskConfiguration, isPending: isUpdatingDisk } =
231 useUpdateDiskAttributesMutation({
232 // this is to suppress to toast message
233 onError: () => {},
234 onSuccess: () => setRefetchInterval(2000),
235 })
236 const { mutateAsync: updateSubscriptionAddon, isPending: isUpdatingCompute } =
237 useProjectAddonUpdateMutation({
238 // this is to suppress to toast message
239 onError: () => {},
240 onSuccess: () => {
241 //Manually set project status to RESIZING, Project status should be RESIZING on next project status request.
242 if (projectRef) setProjectStatus({ ref: projectRef, status: PROJECT_STATUS.RESIZING })
243 },
244 })
245 const { mutateAsync: updateDiskAutoscaleConfig, isPending: isUpdatingDiskAutoscaleConfig } =
246 useUpdateDiskAutoscaleConfigMutation({
247 // this is to suppress to toast message
248 onError: () => {},
249 })
250
251 const isUpdatingConfig = isUpdatingDisk || isUpdatingCompute || isUpdatingDiskAutoscaleConfig
252
253 const onSubmit = async (data: DiskStorageSchemaType) => {
254 let payload = data
255 let willUpdateDiskConfiguration = false
256 setMessageState(null)
257
258 // [Joshen] Skip disk configuration related stuff for AWS Nimbus
259 try {
260 if (
261 !isAwsK8s &&
262 !isAwsNimbus &&
263 (payload.storageType !== form.formState.defaultValues?.storageType ||
264 payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
265 payload.throughput !== form.formState.defaultValues?.throughput ||
266 payload.totalSize !== form.formState.defaultValues?.totalSize)
267 ) {
268 willUpdateDiskConfiguration = true
269
270 await updateDiskConfiguration({
271 ref: projectRef,
272 provisionedIOPS: payload.provisionedIOPS!,
273 storageType: payload.storageType,
274 totalSize: payload.totalSize!,
275 throughput: payload.throughput,
276 })
277 }
278
279 if (
280 !isAwsK8s &&
281 !isAwsNimbus &&
282 (payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
283 payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
284 payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb)
285 ) {
286 await updateDiskAutoscaleConfig({
287 projectRef,
288 growthPercent: payload.growthPercent,
289 minIncrementGb: payload.minIncrementGb,
290 maxSizeGb: payload.maxSizeGb,
291 })
292 }
293
294 if (payload.computeSize !== form.formState.defaultValues?.computeSize) {
295 await updateSubscriptionAddon({
296 projectRef: projectRef,
297 // cast variant to AddonVariantId to satisfy type
298 variant: payload.computeSize as AddonVariantId,
299 type: 'compute_instance',
300 suppressToast: true,
301 })
302 }
303
304 setIsDialogOpen(false)
305 form.reset(data as DiskStorageSchemaType)
306 toast.success(
307 `Successfully updated disk settings!${willUpdateDiskConfiguration ? ' The requested changes will be applied to your disk shortly.' : ''}`
308 )
309 } catch (error: unknown) {
310 setMessageState({
311 message: error instanceof Error ? error.message : 'An unknown error occurred',
312 type: 'error',
313 })
314 }
315 }
316
317 useEffect(() => {
318 if (!isDiskAttributesSuccess) return
319 // @ts-ignore
320 const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
321 const formValues = {
322 storageType: type,
323 provisionedIOPS: iops,
324 throughput: throughput_mbps,
325 totalSize: size_gb,
326 computeSize: form.getValues('computeSize'),
327 }
328
329 if (!('requested_modification' in data)) {
330 if (refetchInterval !== false) {
331 form.reset(formValues)
332 setRefetchInterval(false)
333 toast.success('Disk configuration changes have been successfully applied!')
334 }
335 } else {
336 setRefetchInterval(2000)
337 }
338 }, [data, isDiskAttributesSuccess, form, refetchInterval])
339
340 // We only support disk configurations for >=Large instances
341 // If a customer downgrades back to <Large, we should reset the storage settings to avoid incurring unnecessary costs
342 useEffect(() => {
343 if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) {
344 if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) {
345 form.setValue('storageType', DiskType.GP3)
346 form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput)
347 form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops)
348 }
349 }
350 }, [modifiedComputeSize, isDialogOpen, project])
351
352 useEffect(() => {
353 // Initialize field values properly when data has been loaded, preserving any user changes
354 if (isDiskAttributesSuccess || isSuccess) {
355 form.reset(defaultValues, {})
356 }
357 // eslint-disable-next-line react-hooks/exhaustive-deps
358 }, [isSuccess, isDiskAttributesSuccess])
359
360 useEffect(() => {
361 const fieldErrors = Object.keys(errors)
362 if (fieldErrors.length > 0) {
363 if (
364 fieldErrors.includes('throughput') ||
365 fieldErrors.includes('provisionedIOPS') ||
366 fieldErrors.includes('maxSizeGb')
367 ) {
368 setAdvancedSettingsOpenState(true)
369
370 // [Joshen] The timeout is to let the collapsible open prior to scrolling
371 const timeoutId = setTimeout(() => {
372 advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
373 }, 100)
374
375 return () => clearTimeout(timeoutId)
376 }
377 }
378 }, [errors])
379
380 return (
381 <>
382 <ScaffoldContainer className="relative flex flex-col gap-10" bottomPadding>
383 {isEntitlementsLoaded && isPlanUpgradeRequired && (
384 <UpgradeToPro
385 featureProposition="configure compute and disk"
386 primaryText="Only available on Pro Plan and above"
387 secondaryText="Upgrade to the Pro Plan to configure compute and disk settings."
388 />
389 )}
390
391 {(isProjectResizing ||
392 isProjectRequestingDiskChanges ||
393 (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && (
394 <div className="relative flex flex-col gap-10">
395 <DiskMangementRestartRequiredSection
396 visible={isProjectResizing}
397 title="Your project will now automatically restart."
398 description="Your project will be unavailable for up to 2 mins."
399 />
400 <NoticeBar
401 type="default"
402 visible={isProjectRequestingDiskChanges}
403 title="Disk configuration changes have been requested"
404 description="The requested changes will be applied to your disk shortly"
405 />
406 <NoticeBar
407 type="default"
408 visible={isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions}
409 title="You do not have permission to update disk configuration"
410 description="Please contact your organization administrator to update your disk configuration"
411 />
412 </div>
413 )}
414
415 <Separator />
416 </ScaffoldContainer>
417
418 <Form {...form}>
419 <form
420 id="disk-compute-form"
421 onSubmit={form.handleSubmit(onSubmit)}
422 className="flex flex-col gap-8"
423 >
424 <ScaffoldContainer className="relative flex flex-col gap-10" bottomPadding>
425 <ComputeSizeField form={form} disabled={disableComputeInputs} />
426
427 {isDiskNoticeVisible && <Separator />}
428
429 <SpendCapDisabledSection currentDiskSizeGb={defaultValues.totalSize} />
430
431 <div className="flex flex-col gap-y-4">
432 <NoticeBar
433 type="default"
434 visible={isDiskNoticeVisible}
435 title="Disk configuration is only available for projects in the AWS cloud provider"
436 description={
437 isAwsK8s
438 ? 'Configuring your disk for AWS (Revamped) projects is unavailable for now.'
439 : isBranch
440 ? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.'
441 : 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud prov to configure your disk.'
442 }
443 />
444
445 {isAws && (
446 <>
447 <div className="flex flex-col gap-y-3">
448 <DiskCountdownRadial />
449 {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && (
450 <Admonition
451 type="destructive"
452 title="Database size is currently over 90% of disk size"
453 description="Your project will enter read-only mode once you reach 95% of the disk space to prevent your database from exceeding the disk limitations"
454 >
455 <DocsButton
456 abbrev={false}
457 className="mt-2"
458 href={`${DOCS_URL}/guides/platform/database-size#read-only-mode`}
459 />
460 </Admonition>
461 )}
462 {isReadOnlyMode && (
463 <Admonition
464 type="destructive"
465 title="Project is currently in read-only mode"
466 description="You will need to manually override read-only mode and reduce the database size to below 95% of the disk size"
467 >
468 <DocsButton
469 abbrev={false}
470 className="mt-2"
471 href={`${DOCS_URL}/guides/platform/database-size#disabling-read-only-mode`}
472 />
473 </Admonition>
474 )}
475 </div>
476
477 <DiskSizeField
478 form={form}
479 disableInput={disableDiskSizeInput}
480 setAdvancedSettingsOpenState={setAdvancedSettingsOpenState}
481 />
482 </>
483 )}
484 </div>
485
486 {isAws && (
487 <>
488 <Separator />
489
490 <Collapsible
491 open={advancedSettingsOpen}
492 onOpenChange={() => setAdvancedSettingsOpenState((prev) => !prev)}
493 >
494 <CollapsibleTrigger className="px-card py-3 w-full border flex items-center gap-6 rounded-t data-closed:rounded-b group justify-between">
495 <div className="flex flex-col items-start">
496 <span className="text-sm text-foreground">Advanced disk settings</span>
497 <span className="text-sm text-foreground-light text-left">
498 Specify additional settings for your disk, including autoscaling
499 configuration, IOPS, throughput, and disk type.
500 </span>
501 </div>
502 <ChevronRight
503 size={16}
504 className="text-foreground-light transition-all group-data-open:rotate-90"
505 strokeWidth={1}
506 />
507 </CollapsibleTrigger>
508 <CollapsibleContent
509 ref={advancedSettingsRef}
510 className={cn(
511 'transition-all rounded-b',
512 'border border-t-0 data-closed:animate-collapsible-up data-open:animate-collapsible-down'
513 )}
514 >
515 <div className="flex flex-col gap-y-8 py-8">
516 <div className="px-card flex flex-col gap-y-8">
517 <AutoScaleFields form={form} />
518 </div>
519 <DialogSectionSeparator />
520 <div className="px-card flex flex-col gap-y-8">
521 <NoticeBar
522 type="default"
523 visible={!!disableIopsThroughputConfig}
524 title="Adjusting disk configuration requires LARGE Compute size or above"
525 description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`}
526 actions={
527 canUpdateDiskConfiguration ? (
528 <Button
529 type="default"
530 onClick={() => {
531 form.setValue('computeSize', 'ci_large')
532 }}
533 >
534 Change to LARGE Compute
535 </Button>
536 ) : (
537 <RequestUpgradeToBillingOwners
538 addon="computeSize"
539 featureProposition="adjust disk configuration"
540 />
541 )
542 }
543 />
544 <NoticeBar
545 type="default"
546 visible={
547 isDiskTooSmallForCustomIops &&
548 !disableIopsThroughputConfig &&
549 !disableDiskInputs
550 }
551 title={`Increase disk size to adjust IOPS or throughput`}
552 description={`This disk is too small to update IOPS or throughput, since gp3 volumes are capped at 500 IOPS per GB with a 3,000 IOPS minimum. Resizing to ${suggestedDiskSizeForCustomIops} GB unlocks custom IOPS and throughput, and leaves headroom for further adjustments (disk config changes are locked for 4 hours after each resize).`}
553 actions={
554 !disableDiskSizeInput ? (
555 <Button
556 type="default"
557 onClick={() => {
558 form.setValue('totalSize', suggestedDiskSizeForCustomIops, {
559 shouldDirty: true,
560 shouldValidate: true,
561 })
562 }}
563 >
564 Increase to {suggestedDiskSizeForCustomIops} GB
565 </Button>
566 ) : undefined
567 }
568 />
569 <StorageTypeField
570 form={form}
571 disableInput={disableIopsThroughputConfig || disableDiskInputs}
572 />
573 <IOPSField
574 form={form}
575 disableInput={
576 disableIopsThroughputConfig ||
577 disableDiskInputs ||
578 isDiskTooSmallForCustomIops
579 }
580 />
581 <ThroughputField
582 form={form}
583 disableInput={
584 disableIopsThroughputConfig ||
585 disableDiskInputs ||
586 isDiskTooSmallForCustomIops
587 }
588 />
589 </div>
590 </div>
591 </CollapsibleContent>
592 </Collapsible>
593 </>
594 )}
595 </ScaffoldContainer>
596
597 <AnimatePresence>
598 {isDirty ? (
599 <motion.div
600 initial={{ opacity: 0, y: 20 }}
601 animate={{ opacity: 1, y: 0 }}
602 exit={{ opacity: 0, y: 20 }}
603 transition={{ duration: 0.1, delay: 0.2 }}
604 className="z-10 w-full left-0 right-0 sticky bottom-0 bg-surface-100 border-t h-16 items-center flex"
605 >
606 <div
607 className={cn(
608 MAX_WIDTH_CLASSES,
609 PADDING_CLASSES,
610 'flex items-center gap-3 justify-end'
611 )}
612 >
613 <FormFooterChangeBadge formState={formState} />
614 <Button
615 type="default"
616 onClick={() => form.reset()}
617 disabled={!isDirty}
618 size="medium"
619 >
620 Cancel
621 </Button>
622 <DiskManagementReviewAndSubmitDialog
623 loading={isUpdatingConfig}
624 disabled={noPermissions}
625 form={form}
626 numReplicas={readReplicas.length}
627 isDialogOpen={isDialogOpen}
628 onSubmit={onSubmit}
629 setIsDialogOpen={setIsDialogOpen}
630 message={message}
631 />
632 </div>
633 </motion.div>
634 ) : null}
635 </AnimatePresence>
636 </form>
637 </Form>
638 </>
639 )
640}