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