From 618e0031369a1c641f9f9840dd18dcbff0e069a9 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 29 Jul 2026 10:56:19 +1000 Subject: [PATCH] Modernize compute and disk configuration (#48368) ## Summary This is a precursor to unifying "compute and disk" and "infrastructure pages". First step is just updating the existing compute and disk page to make use of standard page and form patterns. - Reorganizes the existing Compute and Disk form into the established settings layout pattern, with Scaling, Compute, Disk, and Advanced sections. - Moves billing deltas to section headers while retaining the sticky review footer. - Adds animated notices, validation-error scrolling, and a responsive compute-size selector (2 columns by default, 3 from 680px, and 4 from 900px). - Preserves permissions, entitlements, cooldowns, read-only warnings, replica pricing, GP3 validation, Nano/PITR locks, and the free Micro upgrade treatment. - Keeps the redesigned experience on `/settings/compute-and-disk` so it can be reviewed independently of the route cutover. ## Stack 1. #48368 (this PR) 2. #48369 3. #48370 ## How to test 1. Check out `chore/infra-compute-1-config` and start Studio with `pnpm dev:studio`. 2. Open `/project//settings/compute-and-disk`. 3. Confirm the page header and Scaling, Compute, Disk, and Advanced sections follow the standard settings-page spacing, with the plan notice directly below the Scaling header. 4. Change the compute size and disk configuration. Confirm billing deltas appear in the relevant section headers and the sticky review footer summarizes and applies the pending changes. 5. Enter invalid GP3 IOPS or throughput values and submit. Confirm the validation message is shown and the first invalid field is scrolled into view. 6. Exercise representative project states: insufficient permissions, read-only mode, resize cooldown, Nano with PITR, a project with read replicas, and a free Micro upgrade. Confirm the existing locks, warnings, pricing, and upgrade treatment remain intact. 7. Resize the viewport and confirm the compute cards use 2 columns by default, 3 columns from 680px, and no more than 4 columns from 900px. ## Summary by CodeRabbit - **New Features** - Reworked the Compute and Disk settings UI into clearer sectioned pages, including smoother navigation to the first validation error. - Added a transitional in-form NoticeBar for consistent alert rendering. - Improved billing change badges to reflect accurate before/after totals. - **Bug Fixes** - Fixed disk IOPS/throughput pricing to include replica-based charges. - Strengthened disk sizing validation (GP3 limits, legacy/size edge cases, spend-cap behavior, and provider-specific constraints). - **Refactor** - Updated key form field layouts for improved readability. - **Tests** - Expanded schema validation and pricing/billing badge test coverage. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../DiskManagement/DiskManagement.test.ts | 118 ++++ .../DiskManagementForm.sections.tsx | 327 ++++++++++ .../DiskManagement/DiskManagementForm.tsx | 471 ++++++--------- ...agementReviewAndSubmitDialog.hooks.test.ts | 49 ++ ...skManagementReviewAndSubmitDialog.hooks.ts | 137 +++-- .../DiskManagement/fields/AutoScaleFields.tsx | 6 +- .../fields/ComputeSizeField.tsx | 559 ++++++++---------- .../DiskManagement/fields/DiskSizeField.tsx | 233 ++++---- .../DiskManagement/fields/IOPSField.tsx | 27 +- .../fields/StorageTypeField.tsx | 2 +- .../DiskManagement/fields/ThroughputField.tsx | 26 +- .../fields/useShowMicroUpgradeBadge.ts | 19 + .../DiskManagement/ui/NoticeBar.tsx | 31 + .../[ref]/settings/compute-and-disk.tsx | 38 +- 14 files changed, 1195 insertions(+), 848 deletions(-) create mode 100644 apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts create mode 100644 apps/studio/components/interfaces/DiskManagement/fields/useShowMicroUpgradeBadge.ts create mode 100644 apps/studio/components/interfaces/DiskManagement/ui/NoticeBar.tsx diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts index c7e44d07d1ee4..74a67404a0620 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest' +import { CreateDiskStorageSchema } from './DiskManagement.schema' import { calculateBaselineIopsForComputeSize, calculateComputeSizeRequiredForIops, @@ -147,6 +148,18 @@ describe('DiskManagement.utils.ts:calculateIOPSPrice', () => { expect(result.oldPrice).toBe('357.00') expect(result.newPrice).toBe('595.00') }) + test('includes IOPS charges for read replicas', () => { + const result = calculateIOPSPrice({ + oldStorageType: DiskType.GP3, + oldProvisionedIOPS: 3000, + newStorageType: DiskType.GP3, + newProvisionedIOPS: 5000, + numReplicas: 2, + }) + + expect(result.oldPrice).toBe('0.00') + expect(result.newPrice).toBe('144.00') + }) }) describe('DiskManagement.utils.ts:calculateThroughputPrice', () => { @@ -168,4 +181,109 @@ describe('DiskManagement.utils.ts:calculateThroughputPrice', () => { expect(result.oldPrice).toBe('0.00') expect(result.newPrice).toBe('0.00') }) + test('includes throughput charges for read replicas', () => { + const result = calculateThroughputPrice({ + storageType: DiskType.GP3, + oldThroughput: 125, + newThroughput: 150, + numReplicas: 2, + }) + + expect(result.oldPrice).toBe('0.00') + expect(result.newPrice).toBe('7.13') + }) +}) + +describe('CreateDiskStorageSchema', () => { + const validGp3Config = { + storageType: DiskType.GP3, + totalSize: 8, + provisionedIOPS: 3000, + throughput: 125, + computeSize: 'ci_large' as const, + growthPercent: null, + minIncrementGb: null, + maxSizeGb: null, + } + + test('enforces the GP3 500 IOPS per GB limit', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider: 'AWS', + isSpendCapEnabled: false, + }) + + const result = schema.safeParse({ + ...validGp3Config, + provisionedIOPS: 6000, + }) + + expect(result.success).toBe(false) + if (result.success) return + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['provisionedIOPS'], + message: 'Larger Disk size of at least 12 GB required. Current max is 4,000 IOPS.', + }) + ) + }) + + test('allows a legacy disk below 8 GB when its size is unchanged', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 2, + cloudProvider: 'AWS', + isSpendCapEnabled: false, + }) + + expect( + schema.safeParse({ + ...validGp3Config, + totalSize: 2, + }).success + ).toBe(true) + }) + + test('prevents disk growth above 8 GB while spend cap is enabled', () => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider: 'AWS', + isSpendCapEnabled: true, + }) + + const result = schema.safeParse({ + ...validGp3Config, + totalSize: 10, + }) + + expect(result.success).toBe(false) + if (result.success) return + + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ['totalSize'], + message: 'Disable spend cap to increase disk above 8 GB.', + }) + ) + }) + + test.each(['FLY', 'AWS_NIMBUS', 'AWS_K8S'] as const)( + 'skips platform disk constraints for %s projects', + (cloudProvider) => { + const schema = CreateDiskStorageSchema({ + defaultTotalSize: 8, + cloudProvider, + isSpendCapEnabled: true, + }) + + expect( + schema.safeParse({ + ...validGp3Config, + totalSize: 1, + provisionedIOPS: 100_000, + throughput: 10_000, + }).success + ).toBe(true) + } + ) }) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx new file mode 100644 index 0000000000000..8acdbfccf6523 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.sections.tsx @@ -0,0 +1,327 @@ +import { type RefObject } from 'react' +import { type UseFormReturn } from 'react-hook-form' +import { Button, Card, CardContent } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import { DiskStorageSchemaType } from './DiskManagement.schema' +import { AutoScaleFields } from './fields/AutoScaleFields' +import { + ComputeSectionBillingBadge, + ComputeSizeField, + ComputeSizeFieldMeta, +} from './fields/ComputeSizeField' +import { DiskSizeField } from './fields/DiskSizeField' +import { IOPSField } from './fields/IOPSField' +import { StorageTypeField } from './fields/StorageTypeField' +import { ThroughputField } from './fields/ThroughputField' +import { BillingChangeBadge } from './ui/BillingChangeBadge' +import { DiskCountdownRadial } from './ui/DiskCountdownRadial' +import { DiskSpaceBar } from './ui/DiskSpaceBar' +import { NoticeBar } from './ui/NoticeBar' +import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' +import { DocsButton } from '@/components/ui/DocsButton' +import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' +import { DOCS_URL } from '@/lib/constants' + +interface ComputeSectionProps { + form: UseFormReturn + settingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + disabled: boolean +} + +export function ComputeSection({ + form, + settingsRef, + showBillingBadge, + beforePrice, + afterPrice, + disabled, +}: ComputeSectionProps) { + return ( + + + + Compute size + + + + + + + + + + + + + + ) +} + +interface DiskSectionProps { + form: UseFormReturn + settingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + isAws: boolean + isAwsK8s: boolean + isBranch: boolean + isNoticeVisible: boolean + isReadOnlyMode: boolean + usedPercentage: number + isWithinCooldownWindow: boolean + currentDiskSizeGb?: number + disableDiskSizeInput: boolean +} + +function getDiskNoticeDescription({ + isAwsK8s, + isBranch, +}: Pick) { + if (isAwsK8s) { + return 'Configuring your disk for AWS (Revamped) projects is unavailable for now.' + } + if (isBranch) { + return 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.' + } + return 'The Fly Postgres offering is deprecated - please migrate your instance to the AWS cloud provider to configure your disk.' +} + +export function DiskSection({ + form, + settingsRef, + showBillingBadge, + beforePrice, + afterPrice, + isAws, + isAwsK8s, + isBranch, + isNoticeVisible, + isReadOnlyMode, + usedPercentage, + isWithinCooldownWindow, + currentDiskSizeGb, + disableDiskSizeInput, +}: DiskSectionProps) { + const noticeDescription = getDiskNoticeDescription({ isAwsK8s, isBranch }) + + return ( + + + + Disk + + Configure provisioned storage for your primary database. + + + + + + + + + {isAws && } + + + + + + {isAws && ( + <> +
+ + {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( + + + + )} + {isReadOnlyMode && ( + + + + )} +
+ + + + + + + + )} +
+
+ ) +} + +interface AdvancedSectionProps { + form: UseFormReturn + autoscaleSettingsRef: RefObject + storageSettingsRef: RefObject + showBillingBadge: boolean + beforePrice: number + afterPrice: number + disableIopsThroughputConfig?: boolean + canUpdateDiskConfiguration: boolean + isDiskTooSmallForCustomIops: boolean + disableDiskInputs: boolean + disableDiskSizeInput: boolean + suggestedDiskSizeForCustomIops: number +} + +export function AdvancedSection({ + form, + autoscaleSettingsRef, + storageSettingsRef, + showBillingBadge, + beforePrice, + afterPrice, + disableIopsThroughputConfig, + canUpdateDiskConfiguration, + isDiskTooSmallForCustomIops, + disableDiskInputs, + disableDiskSizeInput, + suggestedDiskSizeForCustomIops, +}: AdvancedSectionProps) { + return ( + + + + Advanced + + Configure autoscaling, storage type, IOPS, and throughput. + + + + + + + + + + + + + + + + { + form.setValue('computeSize', 'ci_large', { + shouldDirty: true, + shouldValidate: true, + }) + form.trigger('provisionedIOPS') + form.trigger('throughput') + }} + > + Change to LARGE Compute + + ) : ( + + ) + } + /> + { + form.setValue('totalSize', suggestedDiskSizeForCustomIops, { + shouldDirty: true, + shouldValidate: true, + }) + }} + > + Increase to {suggestedDiskSizeForCustomIops} GB + + ) : undefined + } + /> + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index c78aab5d4e119..f3e54c03a108f 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -2,53 +2,39 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { toast } from 'sonner' +import { Button, cn, Form } from 'ui' +import { PageContainer } from 'ui-patterns/PageContainer' import { - Button, - cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - DialogSectionSeparator, - Form, - Separator, -} from 'ui' -import { Admonition } from 'ui-patterns/Admonition' - -import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge' + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' import { DiskManagementMessage } from './DiskManagement.types' import { calculateDiskSizeRequiredForIopsWithGp3, mapComputeSizeNameToAddonVariantId, } from './DiskManagement.utils' +import { AdvancedSection, ComputeSection, DiskSection } from './DiskManagementForm.sections' import { DiskMangementRestartRequiredSection } from './DiskManagementRestartRequiredSection' import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog' -import { AutoScaleFields } from './fields/AutoScaleFields' -import { ComputeSizeField } from './fields/ComputeSizeField' -import { DiskSizeField } from './fields/DiskSizeField' -import { IOPSField } from './fields/IOPSField' -import { StorageTypeField } from './fields/StorageTypeField' -import { ThroughputField } from './fields/ThroughputField' -import { DiskCountdownRadial } from './ui/DiskCountdownRadial' +import { useDiskManagementReviewChanges } from './DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks' +import { BillingChangeBadge } from './ui/BillingChangeBadge' import { DISK_LIMITS, DiskType, PLAN_DETAILS, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3, } from './ui/DiskManagement.constants' -import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' -import { - MAX_WIDTH_CLASSES, - PADDING_CLASSES, - ScaffoldContainer, -} from '@/components/layouts/Scaffold' -import { DocsButton } from '@/components/ui/DocsButton' -import { RequestUpgradeToBillingOwners } from '@/components/ui/RequestUpgradeToBillingOwners' +import { NoticeBar } from './ui/NoticeBar' +import { PADDING_CLASSES } from '@/components/layouts/Scaffold' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useDiskAttributesQuery, @@ -73,7 +59,7 @@ import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery, } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL, GB, PROJECT_STATUS } from '@/lib/constants' +import { GB, PROJECT_STATUS } from '@/lib/constants' export function DiskManagementForm() { const { ref: projectRef } = useParams() @@ -81,7 +67,10 @@ export function DiskManagementForm() { const { data: org } = useSelectedOrganizationQuery() const { setProjectStatus } = useSetProjectStatus() - const advancedSettingsRef = useRef(null) + const autoscaleSettingsRef = useRef(null) + const storageSettingsRef = useRef(null) + const computeSettingsRef = useRef(null) + const diskSizeSettingsRef = useRef(null) const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -110,7 +99,6 @@ export function DiskManagementForm() { const [isDialogOpen, setIsDialogOpen] = useState(false) const [refetchInterval, setRefetchInterval] = useState(false) const [message, setMessageState] = useState(null) - const [advancedSettingsOpen, setAdvancedSettingsOpenState] = useState(false) const { data: databases, isSuccess: isReadReplicasSuccess } = useReadReplicasQuery({ projectRef }) const { data, isSuccess: isDiskAttributesSuccess } = useDiskAttributesQuery( @@ -183,6 +171,18 @@ export function DiskManagementForm() { const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) const isPlanUpgradeRequired = !hasAccess + const { + computeSizePrice, + diskSizePrice, + totalBeforePrice, + totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, + } = useDiskManagementReviewChanges(form, readReplicas.length) + const { formState } = form const errors = formState.errors const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 @@ -346,7 +346,7 @@ export function DiskManagementForm() { form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) } } - }, [modifiedComputeSize, isDialogOpen, project]) + }, [modifiedComputeSize, form, isDialogOpen, project]) useEffect(() => { // Initialize field values properly when data has been loaded, preserving any user changes @@ -358,287 +358,166 @@ export function DiskManagementForm() { useEffect(() => { const fieldErrors = Object.keys(errors) - if (fieldErrors.length > 0) { - if ( - fieldErrors.includes('throughput') || - fieldErrors.includes('provisionedIOPS') || - fieldErrors.includes('maxSizeGb') - ) { - setAdvancedSettingsOpenState(true) + if (fieldErrors.length === 0) return + + const scrollTargets = [ + { + hasError: + fieldErrors.includes('maxSizeGb') || + fieldErrors.includes('growthPercent') || + fieldErrors.includes('minIncrementGb'), + ref: autoscaleSettingsRef, + }, + { + hasError: fieldErrors.includes('throughput') || fieldErrors.includes('provisionedIOPS'), + ref: storageSettingsRef, + }, + { hasError: fieldErrors.includes('totalSize'), ref: diskSizeSettingsRef }, + { hasError: fieldErrors.includes('computeSize'), ref: computeSettingsRef }, + ] + const scrollTarget = scrollTargets.find(({ hasError }) => hasError)?.ref ?? null - // [Joshen] The timeout is to let the collapsible open prior to scrolling - const timeoutId = setTimeout(() => { - advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 100) + if (!scrollTarget) return - return () => clearTimeout(timeoutId) - } - } + const timeoutId = setTimeout(() => { + scrollTarget.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + + return () => clearTimeout(timeoutId) }, [errors]) return ( - <> - - {isEntitlementsLoaded && isPlanUpgradeRequired && ( - - )} - - {(isProjectResizing || - isProjectRequestingDiskChanges || - (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( -
- - {isProjectRequestingDiskChanges && ( - +
+ + {(isProjectResizing || + isProjectRequestingDiskChanges || + (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( +
+ + - )} - {isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions && ( - - )} -
- )} - - - - - - - - - - {isDiskNoticeVisible && } - - - -
- {isDiskNoticeVisible && ( - - )} - {isAws && ( - <> -
- - {!isReadOnlyMode && usedPercentage >= 90 && isWithinCooldownWindow && ( - - - - )} - {isReadOnlyMode && ( - - - - )} -
- - - - )}
+ )} + + + + + Scaling + + + {isEntitlementsLoaded && isPlanUpgradeRequired && ( + + )} + + - {isAws && ( - <> - + - setAdvancedSettingsOpenState((prev) => !prev)} - > - -
- Advanced disk settings - - Specify additional settings for your disk, including autoscaling - configuration, IOPS, throughput, and disk type. - -
- -
- -
-
- -
- -
- {!!disableIopsThroughputConfig && ( - { - form.setValue('computeSize', 'ci_large') - }} - > - Change to LARGE Compute - - ) : ( - - ) - } - /> - )} - {isDiskTooSmallForCustomIops && - !disableIopsThroughputConfig && - !disableDiskInputs && ( - { - form.setValue('totalSize', suggestedDiskSizeForCustomIops, { - shouldDirty: true, - shouldValidate: true, - }) - }} - > - Increase to {suggestedDiskSizeForCustomIops} GB - - ) : undefined - } - /> - )} - - - -
-
-
-
- - )} -
- - - {isDirty ? ( - + )} + + +
+ + + {isDirty ? ( + +
-
+ - -
- - ) : null} - - - - + Cancel + + +
+
+ ) : null} +
+ + ) } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts new file mode 100644 index 0000000000000..9c9edcfbf1fa9 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest' + +import { shouldShowComputeBillingBadge } from './DiskManagementReviewAndSubmitDialog.hooks' + +describe('shouldShowComputeBillingBadge', () => { + test('shows for a dirty, valid compute price change', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(true) + }) + + test('hides when the selected compute size has the same price', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '10', + }) + ).toBe(false) + }) + + test('hides when the form is not dirty', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: false, + hasComputeSizeError: false, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(false) + }) + + test('hides when compute size validation fails', () => { + expect( + shouldShowComputeBillingBadge({ + isDirty: true, + hasComputeSizeError: true, + oldPrice: '10.00', + newPrice: '20.00', + }) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts index a61cd8442096a..56db829918d98 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { UseFormReturn } from 'react-hook-form' +import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form' import { DiskStorageSchemaType } from '../DiskManagement.schema' import { ComputeInstanceAddonVariantId } from '../DiskManagement.types' @@ -26,10 +26,48 @@ const COMPUTE_SIZES_BELOW_LARGE: Array = [ 'ci_medium', ] +export function shouldShowComputeBillingBadge({ + isDirty, + hasComputeSizeError, + oldPrice, + newPrice, +}: { + isDirty: boolean + hasComputeSizeError: boolean + oldPrice: string | number + newPrice: string | number +}) { + return isDirty && !hasComputeSizeError && Number(oldPrice) !== Number(newPrice) +} + export function useDiskManagementReviewChanges( form: UseFormReturn, numReplicas: number ) { + const [ + computeSize, + totalSize, + storageType, + provisionedIOPS, + throughput, + growthPercent, + minIncrementGb, + maxSizeGb, + ] = useWatch({ + control: form.control, + name: [ + 'computeSize', + 'totalSize', + 'storageType', + 'provisionedIOPS', + 'throughput', + 'growthPercent', + 'minIncrementGb', + 'maxSizeGb', + ], + }) + const { isDirty, errors, defaultValues } = useFormState({ control: form.control }) + const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const isAwsNimbus = useIsAwsNimbusCloudProvider() @@ -48,29 +86,29 @@ export function useDiskManagementReviewChanges( const computeSizePrice = calculateComputeSizePrice({ availableOptions, - oldComputeSize: form.formState.defaultValues?.computeSize || 'ci_micro', - newComputeSize: form.getValues('computeSize'), + oldComputeSize: defaultValues?.computeSize || 'ci_micro', + newComputeSize: computeSize, plan: planId, }) const diskSizePrice = calculateDiskSizePrice({ planId, - oldSize: form.formState.defaultValues?.totalSize || 0, - oldStorageType: form.formState.defaultValues?.storageType as DiskType, - newSize: form.getValues('totalSize'), - newStorageType: form.getValues('storageType') as DiskType, + oldSize: defaultValues?.totalSize || 0, + oldStorageType: defaultValues?.storageType as DiskType, + newSize: totalSize, + newStorageType: storageType as DiskType, numReplicas, }) const iopsPrice = calculateIOPSPrice({ - oldStorageType: form.formState.defaultValues?.storageType as DiskType, - oldProvisionedIOPS: form.formState.defaultValues?.provisionedIOPS || 0, - newStorageType: form.getValues('storageType') as DiskType, - newProvisionedIOPS: form.getValues('provisionedIOPS'), + oldStorageType: defaultValues?.storageType as DiskType, + oldProvisionedIOPS: defaultValues?.provisionedIOPS || 0, + newStorageType: storageType as DiskType, + newProvisionedIOPS: provisionedIOPS, numReplicas, }) const throughputPrice = calculateThroughputPrice({ - storageType: form.getValues('storageType') as DiskType, - newThroughput: form.getValues('throughput') || 0, - oldThroughput: form.formState.defaultValues?.throughput || 0, + storageType: storageType as DiskType, + newThroughput: throughput || 0, + oldThroughput: defaultValues?.throughput || 0, numReplicas, }) @@ -86,49 +124,55 @@ export function useDiskManagementReviewChanges( Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + const advancedBeforePrice = Number(iopsPrice.oldPrice) + Number(throughputPrice.oldPrice) + const advancedAfterPrice = Number(iopsPrice.newPrice) + Number(throughputPrice.newPrice) + + const showComputeBillingBadge = shouldShowComputeBillingBadge({ + isDirty, + hasComputeSizeError: !!errors.computeSize, + oldPrice: computeSizePrice.oldPrice, + newPrice: computeSizePrice.newPrice, + }) + + const showDiskBillingBadge = + isDirty && + !errors.totalSize && + Number(diskSizePrice.oldPrice) !== Number(diskSizePrice.newPrice) + + const showAdvancedBillingBadge = + isDirty && + advancedBeforePrice !== advancedAfterPrice && + !errors.provisionedIOPS && + !errors.throughput + // --- Change flags --- - const hasComputeChanges = - form.formState.defaultValues?.computeSize !== form.getValues('computeSize') + const hasComputeChanges = defaultValues?.computeSize !== computeSize const hasTotalSizeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.totalSize !== form.getValues('totalSize') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.totalSize !== totalSize const hasStorageTypeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.storageType !== form.getValues('storageType') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.storageType !== storageType const hasThroughputChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.throughput !== form.getValues('throughput') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.throughput !== throughput const hasIOPSChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.provisionedIOPS !== provisionedIOPS const hasGrowthPercentChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.growthPercent !== growthPercent const hasMinIncrementChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.minIncrementGb !== minIncrementGb const hasMaxSizeChanges = - !isAwsK8sProject && - !isAwsNimbus && - form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb') + !isAwsK8sProject && !isAwsNimbus && defaultValues?.maxSizeGb !== maxSizeGb // --- Derived predicates --- - const storageTypeAfter = form.getValues('storageType') as DiskType + const storageTypeAfter = storageType as DiskType // Show hero whenever any line-item price actually changes, not just compute const anyBillableDiskChange = @@ -143,11 +187,9 @@ export function useDiskManagementReviewChanges( const hasExtendedDowntimeRisk = hasComputeChanges && (COMPUTE_SIZES_BELOW_LARGE.includes( - (form.formState.defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId + (defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId ) || - COMPUTE_SIZES_BELOW_LARGE.includes( - form.getValues('computeSize') as ComputeInstanceAddonVariantId - )) + COMPUTE_SIZES_BELOW_LARGE.includes(computeSize as ComputeInstanceAddonVariantId)) // Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is // derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so @@ -171,10 +213,8 @@ export function useDiskManagementReviewChanges( // --- Labels --- - const oldComputeLabel = mapAddOnVariantIdToComputeSize( - form.formState.defaultValues?.computeSize ?? 'ci_nano' - ) - const newComputeLabel = mapAddOnVariantIdToComputeSize(form.getValues('computeSize')) + const oldComputeLabel = mapAddOnVariantIdToComputeSize(defaultValues?.computeSize ?? 'ci_nano') + const newComputeLabel = mapAddOnVariantIdToComputeSize(computeSize) return { // prices @@ -184,6 +224,11 @@ export function useDiskManagementReviewChanges( throughputPrice, totalBeforePrice, totalAfterPrice, + advancedBeforePrice, + advancedAfterPrice, + showComputeBillingBadge, + showDiskBillingBadge, + showAdvancedBillingBadge, // change flags hasComputeChanges, hasTotalSizeChanges, diff --git a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx index e6a675e6ddace..82bfd945a6d92 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/AutoScaleFields.tsx @@ -55,7 +55,7 @@ export const AutoScaleFields = ({ form }: AutoScaleFieldProps) => { render={({ field }) => { return ( { render={({ field }) => { return ( { render={({ field }) => { return ( Hardware resources allocated to your Postgres database

+} + +type ComputeSectionBillingBadgeProps = { + form: UseFormReturn + show: boolean + beforePrice: number + afterPrice: number +} + +export function ComputeSectionBillingBadge({ + form, + show, + beforePrice, + afterPrice, +}: ComputeSectionBillingBadgeProps) { + const computeSize = form.watch('computeSize') + const { showMicroUpgradeBadge } = useShowMicroUpgradeBadge() + + return ( + + ) +} + 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 { project, isProjectLoading, isEntitlementLoading, showMicroUpgradeBadge } = + useShowMicroUpgradeBadge() const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price') - const { computeSize } = form.watch() - const { data: addons, isPending: isAddonsLoading, @@ -67,7 +86,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const isLoading = isProjectLoading || isAddonsLoading || isEntitlementLoading - const { control, formState, setValue, trigger } = form + const { control, setValue, trigger } = form const availableAddons = useMemo(() => { return addons?.available_addons ?? [] @@ -81,319 +100,239 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { 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 + const showSkeletons = isLoading + const showLoadError = !isLoading && !!addonsError + const showComputeOptions = !isLoading && !addonsError return ( ( - { - setValue('computeSize', value, { - shouldDirty: true, - shouldValidate: true, - }) - trigger('provisionedIOPS') - trigger('throughput') - }} - defaultValue={field.value} - disabled={disabled} - > - + { + setValue('computeSize', value, { + shouldDirty: true, + shouldValidate: true, + }) + trigger('provisionedIOPS') + trigger('throughput') + }} + defaultValue={field.value} + disabled={disabled} + className={cn( + !addonsError && 'grid grid-cols-2 gap-4 @[680px]:grid-cols-3 @[900px]:grid-cols-4' + )} + > + {showSkeletons && + Array(SKELETON_PLACEHOLDER_COUNT) + .fill(0) + .map((_, i) => )} + {showLoadError && ( + +

{addonsError?.message}

+
+ )} + {showComputeOptions && ( <> - -

- Hardware resources allocated to your Postgres database -

+ {availableOptions.map((compute) => { + 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 - {showUpgradeBadge && form.watch('computeSize') === 'ci_nano' && ( - - )} - - } - > -
-
- {isLoading ? ( - Array(INITIALLY_VISIBLE_COUNT) - .fill(0) - .map((_, i) => ) - ) : addonsError ? ( - -

{addonsError?.message}

-
- ) : ( - <> - {visibleOptions.map((compute) => { - 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 + // Nano on a paid plan is billed at the Micro rate + const isNanoBilledAsMicro = + org?.plan.id !== 'free' && + project?.infra_compute_size === 'nano' && + compute.identifier === 'ci_nano' - 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 price = isNanoBilledAsMicro + ? 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 CPU` - } - if (cpuCores) { - return `${cpuCores} CPU` - } - return 'CPU' - })() + const cpuLabel = (() => { + const cpuCores = compute.meta?.cpu_cores + if (typeof cpuCores === 'number') { + return `${cpuCores}-core CPU` + } + if (cpuCores) { + return `${cpuCores} CPU` + } + return 'CPU' + })() - return ( - - -
- {showUpgradeBadge && compute.identifier === 'ci_micro' && ( -
- No additional charge + return ( + + +
+ {showMicroUpgradeBadge && compute.identifier === 'ci_micro' && ( + + +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + Free Upgrade
- )} -
-
- -
- {lockedOption ? ( -
- -
- ) : ( - showComputePrice && ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - - ) - )} -
-
- -
-
-
- - - {compute.identifier === 'ci_nano' && 'Up to '} - {compute.meta?.memory_gb ?? 0} GB memory - -
-
- - {cpuLabel} -
+ + e.stopPropagation()} + > +

+ Upgrade to Micro Compute +

+

+ This Project is already paying for Micro Compute. You can + upgrade to Micro Compute at any time when convenient. +

+
+ + )} +
+
+ +
+ {lockedOption && ( +
+
-
+ )} + {!lockedOption && showComputePrice && ( + <> + + ${price} + + + {' '} + /{' '} + {compute.price_interval === 'monthly' ? 'month' : 'hour'} + + + )}
- - {lockedMicroDueToPITR && ( - - Project has PITR enabled which requires a minimum of Small - compute. Please{' '} - - disable PITR - {' '} - first before selecting Micro - - )} - - } - /> - ) - })} - {showAllSizes && ( - 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={ - -
-
- - -
- Contact Us -
-
-
-
-
- - Custom memory -
-
- - Custom CPU +
+
+
+ + + {compute.identifier === 'ci_nano' && 'Up to '} + {compute.meta?.memory_gb ?? 0} GB memory + +
+
+ + {cpuLabel} +
- - } - /> - )} - - )} -
-
+ + {lockedMicroDueToPITR && ( + + Project has PITR enabled which requires a minimum of Small compute. + Please{' '} + + disable PITR + {' '} + first before selecting Micro + + )} + + } + /> + ) + })} +
+ +
+
+ - {!isLoading && !addonsError && hasHiddenOptions && ( - +
+ Contact Us +
+
+
+
+
+ + Custom memory +
+
+ + Custom CPU +
+
+
+
+
+
+ )} - - + +
)} /> ) diff --git a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx index df65d74b239bf..8b4342b999f03 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/DiskSizeField.tsx @@ -1,7 +1,7 @@ import { useParams } from 'common' import dayjs from 'dayjs' import { RotateCcw } from 'lucide-react' -import { UseFormReturn } from 'react-hook-form' +import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form' import { Button, FormControl, @@ -15,33 +15,24 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' -import { calculateDiskSizePrice } from '../DiskManagement.utils' -import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants' import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas' -import { DiskSpaceBar } from '../ui/DiskSpaceBar' import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection' import FormMessage from '../ui/FormMessage' -import { DocsButton } from '@/components/ui/DocsButton' import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { DOCS_URL, GB } from '@/lib/constants' +import { GB } from '@/lib/constants' type DiskSizeFieldProps = { form: UseFormReturn disableInput: boolean - setAdvancedSettingsOpenState: (state: boolean) => void } -export function DiskSizeField({ - form, - disableInput, - setAdvancedSettingsOpenState, -}: DiskSizeFieldProps) { +export function DiskSizeField({ form, disableInput }: DiskSizeFieldProps) { const { ref: projectRef } = useParams() - const { control, formState, setValue, trigger, getValues, resetField, watch } = form + const { control, setValue, trigger, resetField } = form const { data: org } = useSelectedOrganizationQuery() const { data: project } = useSelectedProjectQuery() @@ -70,8 +61,8 @@ export function DiskSizeField({ dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < 10 || project?.status === 'COMING_UP' - const watchedStorageType = watch('storageType') - const watchedTotalSize = watch('totalSize') + const watchedStorageType = useWatch({ control, name: 'storageType' }) + const watchedTotalSize = useWatch({ control, name: 'totalSize' }) const planId = org?.plan.id ?? 'free' @@ -79,125 +70,115 @@ export function DiskSizeField({ PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} const includedDiskGB = includedDiskGBMeta[watchedStorageType] - const { defaultValues, dirtyFields, isDirty, errors } = formState - const diskSizePrice = calculateDiskSizePrice({ - planId, - oldSize: defaultValues?.totalSize || 0, - oldStorageType: defaultValues?.storageType as DiskType, - newSize: getValues('totalSize'), - newStorageType: getValues('storageType') as DiskType, - }) + const { defaultValues, dirtyFields } = useFormState({ control }) const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 return ( -
-
- ( - - - - e.currentTarget.blur()} - onChange={(e) => { - setValue('totalSize', e.target.valueAsNumber, { - shouldDirty: true, - shouldValidate: true, - }) + ( + + {includedDiskGB > 0 && org?.plan.id && ( +

+ Your plan includes up to {includedDiskGB} GB of {watchedStorageType} storage. +

+ )} + + { + setValue('storageType', 'io2', { shouldDirty: true }) trigger('provisionedIOPS') - trigger('throughput') + trigger('totalSize') }} - min={includedDiskGB} - /> - - GB - {isDirty ? ( - { - resetField('totalSize') - trigger('provisionedIOPS') - }} - title="Reset" - > - - ) : null} - -
-
-
- )} - /> -
- - - {includedDiskGB > 0 && - org?.plan.id && - `Your plan includes up to ${includedDiskGB} GB of ${watchedStorageType} storage.`} - -
- + > + Change to High Performance SSD + + } + /> + + {isProjectNew ? ( + + ) : ( + error && ( + + {error?.message} + + ) + )} + +
-
- { - setValue('storageType', 'io2') + } + > + + + e.currentTarget.blur()} + onChange={(e) => { + // valueAsNumber is NaN while the input is empty, which would otherwise + // propagate into the price calculations and the read replica sizing + const value = e.target.valueAsNumber + setValue('totalSize', Number.isNaN(value) ? 0 : value, { + shouldDirty: true, + shouldValidate: true, + }) trigger('provisionedIOPS') - trigger('totalSize') - setAdvancedSettingsOpenState(true) + trigger('throughput') }} - > - Change to High Performance SSD - - } - /> -
-
-
- - - {isProjectNew ? ( - - ) : ( - error && ( - - {error?.message} - - ) - )} - - -
-
+ min={includedDiskGB} + /> + + GB + {isDirty ? ( + { + resetField('totalSize') + trigger('provisionedIOPS') + trigger('throughput') + }} + title="Reset" + > + + ) : null} + + + + + )} + /> ) } diff --git a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx index 4545339266057..71f0a40bed55f 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/IOPSField.tsx @@ -13,10 +13,8 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { DiskStorageSchemaType } from '../DiskManagement.schema' import { calculateComputeSizeRequiredForIops, - calculateIOPSPrice, mapAddOnVariantIdToComputeSize, } from '../DiskManagement.utils' -import { BillingChangeBadge } from '../ui/BillingChangeBadge' import { ComputeSizeRecommendationSection } from '../ui/ComputeSizeRecommendationSection' import { DiskType, RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3 } from '../ui/DiskManagement.constants' import { DiskManagementIOPSReadReplicas } from '../ui/DiskManagementReadReplicas' @@ -37,13 +35,6 @@ export function IOPSField({ form, disableInput }: IOPSFieldProps) { const { isError } = useDiskAttributesQuery({ projectRef }) - const iopsPrice = calculateIOPSPrice({ - oldStorageType: formState.defaultValues?.storageType as DiskType, - oldProvisionedIOPS: formState.defaultValues?.provisionedIOPS || 0, - newStorageType: getValues('storageType') as DiskType, - newProvisionedIOPS: getValues('provisionedIOPS'), - }) - const disableIopsInput = RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3.includes(watchedComputeSize) && watchedStorageType === 'gp3' @@ -55,7 +46,7 @@ export function IOPSField({ form, disableInput }: IOPSFieldProps) { const reccomendedComputeSize = calculateComputeSizeRequiredForIops(watchedIOPS) return ( } labelOptional={ - <> - -

Input/output operations per second.

- +

Input/output operations per second.

} > diff --git a/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx index dbd30360c2bf7..0c235d4f31344 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/StorageTypeField.tsx @@ -45,7 +45,7 @@ export function StorageTypeField({ form, disableInput }: StorageTypeFieldProps) name="storageType" control={control} render={({ field }) => ( - +