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 && (
-
+
-
- >
+ 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={
-
-
-
-
-
-
-
- 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 }) => (
-
+