DiskManagementForm.tsx640 lines · main
| 1 | // @ts-nocheck |
| 2 | import { zodResolver } from '@hookform/resolvers/zod' |
| 3 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 4 | import { useParams } from 'common' |
| 5 | import { AnimatePresence, motion } from 'framer-motion' |
| 6 | import { ChevronRight } from 'lucide-react' |
| 7 | import { useEffect, useRef, useState } from 'react' |
| 8 | import { useForm } from 'react-hook-form' |
| 9 | import { CloudProvider } from 'shared-data' |
| 10 | import { toast } from 'sonner' |
| 11 | import { |
| 12 | Button, |
| 13 | cn, |
| 14 | Collapsible, |
| 15 | CollapsibleContent, |
| 16 | CollapsibleTrigger, |
| 17 | DialogSectionSeparator, |
| 18 | Form, |
| 19 | Separator, |
| 20 | } from 'ui' |
| 21 | import { Admonition } from 'ui-patterns' |
| 22 | |
| 23 | import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge' |
| 24 | import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' |
| 25 | import { DiskManagementMessage } from './DiskManagement.types' |
| 26 | import { |
| 27 | calculateDiskSizeRequiredForIopsWithGp3, |
| 28 | mapComputeSizeNameToAddonVariantId, |
| 29 | } from './DiskManagement.utils' |
| 30 | import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection' |
| 31 | import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog' |
| 32 | import { AutoScaleFields } from './fields/AutoScaleFields' |
| 33 | import { ComputeSizeField } from './fields/ComputeSizeField' |
| 34 | import { DiskSizeField } from './fields/DiskSizeField' |
| 35 | import { IOPSField } from './fields/IOPSField' |
| 36 | import { StorageTypeField } from './fields/StorageTypeField' |
| 37 | import { ThroughputField } from './fields/ThroughputField' |
| 38 | import { DiskCountdownRadial } from './ui/DiskCountdownRadial' |
| 39 | import { |
| 40 | DISK_LIMITS, |
| 41 | DiskType, |
| 42 | PLAN_DETAILS, |
| 43 | RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, |
| 44 | } from './ui/DiskManagement.constants' |
| 45 | import { NoticeBar } from './ui/NoticeBar' |
| 46 | import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' |
| 47 | import { |
| 48 | MAX_WIDTH_CLASSES, |
| 49 | PADDING_CLASSES, |
| 50 | ScaffoldContainer, |
| 51 | } from '@/components/layouts/Scaffold' |
| 52 | import { DocsButton } from '@/components/ui/DocsButton' |
| 53 | import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' |
| 54 | import { UpgradeToPro } from '@/components/ui/UpgradeToPro' |
| 55 | import { |
| 56 | useDiskAttributesQuery, |
| 57 | useRemainingDurationForDiskAttributeUpdate, |
| 58 | } from '@/data/config/disk-attributes-query' |
| 59 | import { useUpdateDiskAttributesMutation } from '@/data/config/disk-attributes-update-mutation' |
| 60 | import { useDiskAutoscaleCustomConfigQuery } from '@/data/config/disk-autoscale-config-query' |
| 61 | import { useUpdateDiskAutoscaleConfigMutation } from '@/data/config/disk-autoscale-config-update-mutation' |
| 62 | import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' |
| 63 | import { useSetProjectStatus } from '@/data/projects/project-detail-query' |
| 64 | import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' |
| 65 | import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation' |
| 66 | import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' |
| 67 | import { AddonVariantId } from '@/data/subscriptions/types' |
| 68 | import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' |
| 69 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 70 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 71 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 72 | import { |
| 73 | useIsAwsCloudProvider, |
| 74 | useIsAwsK8sCloudProvider, |
| 75 | useIsAwsNimbusCloudProvider, |
| 76 | useSelectedProjectQuery, |
| 77 | } from '@/hooks/misc/useSelectedProject' |
| 78 | import { DOCS_URL, GB, PROJECT_STATUS } from '@/lib/constants' |
| 79 | |
| 80 | export 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 | } |