From bcb847bbbb6e2a998d6e8a476b72e32e4643d6b7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 18 Sep 2024 18:38:58 +0800 Subject: [PATCH] Chore/update rr pricing for new disk mgt (#29278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update pricing details in DeployNewReplicaPanel * Update disk mgt review modal to consider RR pricing * Update tests * Hide utilization bar for read replicas in new disk mgt ui * Add guard * Update link to docs for deploy replica panel * Address feedback * Ensure IOPS and throughput prices are considered for replicas * Prettier * Attempt to fix tests * Attempt to fix tests * Fix calculateDiskSizePrice for replicas * Fixes * Prettier * Add ternary * Feeeex * Various small updates from Kevin's feedback * smol stuff * fixes * Update DiskManagementPanelSchema.ts --------- Co-authored-by: Kevin Grüneberg --- .../DiskManagement/BillingChangeBadge.tsx | 34 ++- .../DiskManagement.constants.tsx | 2 + .../DiskManagement/DiskManagement.test.ts | 12 + .../DiskManagement/DiskManagement.utils.ts | 46 +++- .../DiskManagementDiskSizeReadReplicas.tsx | 127 --------- .../DiskManagementPanelForm.tsx | 238 +++++++++-------- .../DiskManagementPanelSchema.ts | 2 +- .../DiskManagementPlanUpgradeRequired.tsx | 2 - .../DiskManagementReadReplicas.tsx | 251 ++++++++++++++++++ .../DiskManagementReviewAndSubmitDialog.tsx | 89 +++++-- .../DeployNewReplicaPanel.tsx | 143 +++++++++- apps/studio/lib/helpers.ts | 13 +- .../pages/project/[ref]/reports/database.tsx | 4 +- apps/studio/tests/mocks/browser.ts | 15 ++ apps/studio/tests/vitestSetup.ts | 1 + 15 files changed, 686 insertions(+), 293 deletions(-) delete mode 100644 apps/studio/components/interfaces/DiskManagement/DiskManagementDiskSizeReadReplicas.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/DiskManagementReadReplicas.tsx create mode 100644 apps/studio/tests/mocks/browser.ts diff --git a/apps/studio/components/interfaces/DiskManagement/BillingChangeBadge.tsx b/apps/studio/components/interfaces/DiskManagement/BillingChangeBadge.tsx index 4b39ccbb227..231d94551f8 100644 --- a/apps/studio/components/interfaces/DiskManagement/BillingChangeBadge.tsx +++ b/apps/studio/components/interfaces/DiskManagement/BillingChangeBadge.tsx @@ -1,14 +1,21 @@ import { AnimatePresence, motion } from 'framer-motion' +import { formatCurrency } from 'lib/helpers' import { ChevronRight } from 'lucide-react' -import { Badge } from 'ui' +import { Badge, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' interface BillingChangeBadgeProps { beforePrice?: number afterPrice?: number show: boolean | undefined + tooltip?: string } -const BillingChangeBadge = ({ beforePrice, afterPrice, show }: BillingChangeBadgeProps) => { +const BillingChangeBadge = ({ + beforePrice, + afterPrice, + show, + tooltip, +}: BillingChangeBadgeProps) => { return ( {beforePrice !== undefined && afterPrice !== undefined && show && ( @@ -19,13 +26,22 @@ const BillingChangeBadge = ({ beforePrice, afterPrice, show }: BillingChangeBadg transition={{ duration: 0.15 }} > -
- - ${beforePrice.toFixed(2)} - - - ${afterPrice.toFixed(2)} -
+ + +
+ + {formatCurrency(beforePrice)} + + + + {formatCurrency(afterPrice)} + +
+
+ {tooltip !== undefined && ( + {tooltip} + )} +
)} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.constants.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagement.constants.tsx index 98a64acc64e..a4090957e63 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.constants.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.constants.tsx @@ -45,6 +45,8 @@ export const DISK_LIMITS = { maxStorage: 16384, minIops: 100, maxIops: 256000, + includedIops: 0, + includedThroughput: 0, }, } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts index 76210dcdb97..3de7b4d6c47 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.test.ts @@ -28,6 +28,18 @@ describe('DiskManagement.utils.ts:calculateDiskSizePrice', () => { expect(result.oldPrice).toBe('1.56') expect(result.newPrice).toBe('1.95') }) + test('GP3 with 8GB to GP3 with 10GB, with 2 replicas', () => { + const result = calculateDiskSizePrice({ + planId: 'pro', + oldSize: 8, + oldStorageType: DiskType.GP3, + newSize: 10, + newStorageType: DiskType.GP3, + numReplicas: 2, + }) + expect(result.oldPrice).toBe('2.50') + expect(result.newPrice).toBe('3.38') + }) }) describe('DiskManagement.utils.ts:calculateIOPSPrice', () => { diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagement.utils.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagement.utils.ts index 67b4111fd8a..a219ad188f5 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagement.utils.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagement.utils.ts @@ -1,42 +1,49 @@ import { DISK_LIMITS, DISK_PRICING, DiskType, PLAN_DETAILS } from './DiskManagement.constants' +// Included disk size only applies to primary, not replicas export const calculateDiskSizePrice = ({ planId, oldSize, oldStorageType, newSize, newStorageType, + numReplicas = 0, }: { planId: string oldSize: number oldStorageType: DiskType newSize: number newStorageType: DiskType + numReplicas?: number }) => { const oldPricePerGB = DISK_PRICING[oldStorageType]?.storage ?? 0 const newPricePerGB = DISK_PRICING[newStorageType]?.storage ?? 0 const { includedDiskGB } = PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} - const oldPrice = (Math.max(oldSize - includedDiskGB[oldStorageType], 0) * oldPricePerGB).toFixed( - 2 - ) - const newPrice = (Math.max(newSize - includedDiskGB[newStorageType], 0) * newPricePerGB).toFixed( - 2 - ) + const oldPrice = Math.max(oldSize - includedDiskGB[oldStorageType], 0) * oldPricePerGB + const oldPriceReplica = oldSize * 1.25 * oldPricePerGB + const newPrice = Math.max(newSize - includedDiskGB[newStorageType], 0) * newPricePerGB + const newPriceReplica = newSize * 1.25 * newPricePerGB - return { oldPrice, newPrice } + return { + oldPrice: (oldPrice + numReplicas * oldPriceReplica).toFixed(2), + newPrice: (newPrice + numReplicas * newPriceReplica).toFixed(2), + } } +// Included IOPS applies to both primary and replicas export const calculateIOPSPrice = ({ oldStorageType, oldProvisionedIOPS, newStorageType, newProvisionedIOPS, + numReplicas = 0, }: { oldStorageType: DiskType oldProvisionedIOPS: number newStorageType: DiskType newProvisionedIOPS: number + numReplicas?: number }) => { if (newStorageType === DiskType.GP3) { const oldChargeableIOPS = Math.max( @@ -48,8 +55,13 @@ export const calculateIOPSPrice = ({ newProvisionedIOPS - DISK_LIMITS[DiskType.GP3].includedIops ) const oldPrice = oldChargeableIOPS * DISK_PRICING[oldStorageType]?.iops ?? 0 + const newPrice = newChargeableIOPS * DISK_PRICING[newStorageType]?.iops ?? 0 - return { oldPrice: oldPrice.toFixed(2), newPrice: newPrice.toFixed(2) } + + return { + oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), + newPrice: (newPrice * (1 + numReplicas)).toFixed(2), + } } else { const oldPrice = oldStorageType === 'gp3' @@ -57,20 +69,26 @@ export const calculateIOPSPrice = ({ DISK_PRICING[oldStorageType].iops : oldProvisionedIOPS * DISK_PRICING[oldStorageType]?.iops ?? 0 const newPrice = newProvisionedIOPS * DISK_PRICING[newStorageType]?.iops ?? 0 - return { oldPrice: oldPrice.toFixed(2), newPrice: newPrice.toFixed(2) } + return { + oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), + newPrice: (newPrice * (1 + numReplicas)).toFixed(2), + } } } // This is only applicable for GP3 storage type, no need to consider IO2 at all // Also assumes that disk size is > 400 GB (separate requirement to update throughput) +// Also, included throughput applies to both primary and replicas export const calculateThroughputPrice = ({ storageType, newThroughput, oldThroughput, + numReplicas = 0, }: { storageType: DiskType newThroughput: number oldThroughput: number + numReplicas?: number }) => { if (storageType === DiskType.GP3 && newThroughput) { const oldChargeableThroughput = Math.max( @@ -81,9 +99,13 @@ export const calculateThroughputPrice = ({ 0, newThroughput - DISK_LIMITS[DiskType.GP3].includedThroughput ) - const oldPrice = (oldChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput).toFixed(2) - const newPrice = (newChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput).toFixed(2) - return { oldPrice, newPrice } + const oldPrice = oldChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput + const newPrice = newChargeableThroughput * DISK_PRICING[DiskType.GP3].throughput + + return { + oldPrice: (oldPrice * (1 + numReplicas)).toFixed(2), + newPrice: (newPrice * (1 + numReplicas)).toFixed(2), + } } return { oldPrice: '0.00', newPrice: '0.00' } } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementDiskSizeReadReplicas.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementDiskSizeReadReplicas.tsx deleted file mode 100644 index 4c1a817f9ac..00000000000 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementDiskSizeReadReplicas.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { ChevronDown } from 'lucide-react' -import { useState } from 'react' - -import { useParams } from 'common' -import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { formatDatabaseID } from 'data/read-replicas/replicas.utils' -import { - Alert_Shadcn_ as Alert, - AlertDescription_Shadcn_ as AlertDescription, - AlertTitle_Shadcn_ as AlertTitle, - Collapsible_Shadcn_ as Collapsible, - CollapsibleContent_Shadcn_ as CollapsibleContent, - CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, - InfoIcon, -} from 'ui' -import BillingChangeBadge from './BillingChangeBadge' -import DiskSpaceBar from './DiskSpaceBar' - -interface DiskManagementDiskSizeReadReplicasProps { - isDirty: boolean - totalSize: number - usedSize: number - newTotalSize: number -} - -export const DiskManagementDiskSizeReadReplicas = ({ - isDirty, - totalSize, - usedSize, - newTotalSize, -}: DiskManagementDiskSizeReadReplicasProps) => { - const { ref: projectRef } = useParams() - const [isOpen, setIsOpen] = useState(false) - - const { data: databases } = useReadReplicasQuery({ projectRef }) - const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) - - if (readReplicas.length === 0) return null - - return ( - <> - - {isDirty && ( - - - - An extra 25% disk space is provisioned for Read replicas. - - Each replica is billed separately -
    - {readReplicas.map((replica, index) => ( -
  • -
    - - ID: {formatDatabaseID(replica.identifier)} ({replica.region}): - - -
    -
  • - ))} -
-
-
-
- )} -
- - -
-

Read replica disk size information

- -
-
- - {isOpen && ( - - - -

- All read replicas are provisioned with the following: - - Read replicas have 25% more disk size than the primary database to account for - WAL files{' '} - -

- -
-
-
- )} -
-
- - ) -} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 30dba5b764b..9a97227f80e 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -15,6 +15,7 @@ import { } from 'data/config/disk-attributes-query' import { useUpdateDiskAttributesMutation } from 'data/config/disk-attributes-update-mutation' import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' @@ -56,7 +57,11 @@ import { calculateIOPSPrice, calculateThroughputPrice, } from './DiskManagement.utils' -import { DiskManagementDiskSizeReadReplicas } from './DiskManagementDiskSizeReadReplicas' +import { + DiskManagementDiskSizeReadReplicas, + DiskManagementIOPSReadReplicas, + DiskManagementThroughputReadReplicas, +} from './DiskManagementReadReplicas' import { DiskStorageSchema, DiskStorageSchemaType } from './DiskManagementPanelSchema' import { DiskManagementPlanUpgradeRequired } from './DiskManagementPlanUpgradeRequired' import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog' @@ -69,6 +74,9 @@ export function DiskManagementPanelForm() { const [remainingTime, setRemainingTime] = useState(0) const [refetchInterval, setRefetchInterval] = useState(false) + const { data: databases } = useReadReplicasQuery({ projectRef }) + const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) + const { data, isSuccess } = useDiskAttributesQuery( { projectRef }, { @@ -364,64 +372,76 @@ export function DiskManagementPanelForm() { layout="horizontal" label="IOPS" description={ - <> - {watchedStorageType === 'io2' ? ( -
- - IOPS must be{' '} - {watchedTotalSize >= 8 - ? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.` - : `at least ${minIOPS}`} - - - - - - - For io2 storage type, min IOPS is at {minIOPS}, while max IOPS is - at 1000 * disk size in GB or{' '} - {IOPS_RANGE[DiskType.IO2].max.toLocaleString()}, whichever is - lower - - -
- ) : ( -
- - IOPS must be{' '} - {watchedTotalSize >= 8 - ? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.` - : `at least ${minIOPS.toLocaleString()}`} - - - - - - - For gp3 storage type, min IOPS is at {minIOPS} while max IOPS is - at 500 * disk size in GB or{' '} - {IOPS_RANGE[DiskType.GP3].max.toLocaleString()}, whichever is - lower - - -
+
+
+ {watchedStorageType === 'io2' ? ( +
+ + IOPS must be{' '} + {watchedTotalSize >= 8 + ? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.` + : `at least ${minIOPS}`} + + + + + + + For io2 storage type, min IOPS is at {minIOPS}, while max IOPS + is at 1000 * disk size in GB or{' '} + {IOPS_RANGE[DiskType.IO2].max.toLocaleString()}, whichever is + lower + + +
+ ) : ( +
+ + IOPS must be{' '} + {watchedTotalSize >= 8 + ? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.` + : `at least ${minIOPS.toLocaleString()}`} + + + + + + + For gp3 storage type, min IOPS is at {minIOPS} while max IOPS is + at 500 * disk size in GB or{' '} + {IOPS_RANGE[DiskType.GP3].max.toLocaleString()}, whichever is + lower + + +
+ )} + {!form.formState.errors.provisionedIOPS && + field.value > maxIopsBasedOnCompute && ( +

+ Note: Final usable IOPS will be at{' '} + + {maxIopsBasedOnCompute.toLocaleString()} + {' '} + based on your current compute size of {currentCompute?.name} +

+ )} +
+ {!form.formState.errors.provisionedIOPS && ( + )} - {field.value > maxIopsBasedOnCompute && ( -

- Note: Final usable IOPS will be at{' '} - - {maxIopsBasedOnCompute.toLocaleString()} - {' '} - based on your current compute size of {currentCompute?.name} -

- )} - +
} labelOptional="Input/output operations per second. Higher IOPS is suitable for applications requiring high throughput." > @@ -477,37 +497,51 @@ export function DiskManagementPanelForm() { label="Throughput (MB/s)" layout="horizontal" description={ - <> -
- - Throughput must be between {minThroughput} and {maxThroughput}{' '} - MB/s based on your IOPS. - - - - - - - Min throughput is at 125MB/s, while max throughput is at - 0.25MB/s * IOPS or 1000, whichever is lower - - +
+
+
+ + Throughput must be between {minThroughput.toLocaleString()}{' '} + and {maxThroughput?.toLocaleString()} MB/s based on your IOPS. + + + + + + + Min throughput is at 125MB/s, while max throughput is at + 0.25MB/s * IOPS or 1,000, whichever is lower + + +
+ {!form.formState.errors.throughput && + field.value !== undefined && + field.value > maxThroughputBasedOnCompute && ( +

+ Note: Final usable throughput will be at{' '} + + {maxThroughputBasedOnCompute.toFixed(0)} + {' '} + MB/s based on your current compute size of{' '} + {currentCompute?.name} +

+ )}
- {field.value !== undefined && - field.value > maxThroughputBasedOnCompute && ( -

- Note: Final usable throughput will be at{' '} - - {maxThroughputBasedOnCompute.toFixed(0)} - {' '} - MB/s based on your current compute size of{' '} - {currentCompute?.name} -

- )} - + {!form.formState.errors.throughput && ( + + )} +
} >
@@ -632,21 +666,15 @@ export function DiskManagementPanelForm() { showNewBar={form.formState.dirtyFields.totalSize !== undefined} totalSize={size_gb} usedSize={mainDiskUsed} - newTotalSize={ - form.getValues('totalSize') <= size_gb - ? size_gb - : form.getValues('totalSize') - } + newTotalSize={watchedTotalSize} />
@@ -684,15 +712,13 @@ export function DiskManagementPanelForm() { Cancel diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts index 548bfd51db0..9557b152ee0 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts @@ -75,7 +75,7 @@ export const DiskStorageSchema = baseSchema.superRefine((data, ctx) => { if (throughput !== undefined && (throughput < 125 || throughput > maxThroughput)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Throughput must be set between 125 and ${maxThroughput} MB/s.`, + message: `Throughput must be set between 125 and ${maxThroughput?.toLocaleString()} MB/s.`, path: ['throughput'], }) } diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPlanUpgradeRequired.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPlanUpgradeRequired.tsx index 48534c9d1ea..6e347ddafc2 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPlanUpgradeRequired.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPlanUpgradeRequired.tsx @@ -1,7 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import Link from 'next/link' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Alert_Shadcn_ as Alert, @@ -14,7 +13,6 @@ import { export function DiskManagementPlanUpgradeRequired() { const org = useSelectedOrganization() - const { data } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) return ( diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReadReplicas.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementReadReplicas.tsx new file mode 100644 index 00000000000..e892266f0ce --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReadReplicas.tsx @@ -0,0 +1,251 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { useState } from 'react' + +import { useParams } from 'common' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' +import { + Alert_Shadcn_ as Alert, + AlertDescription_Shadcn_ as AlertDescription, + AlertTitle_Shadcn_ as AlertTitle, + InfoIcon, +} from 'ui' +import BillingChangeBadge from './BillingChangeBadge' +import { DISK_LIMITS, DISK_PRICING, DiskType } from './DiskManagement.constants' + +interface DiskManagementDiskSizeReadReplicasProps { + isDirty: boolean + totalSize: number + usedSize: number + newTotalSize: number + oldStorageType: DiskType + newStorageType: DiskType +} + +export const DiskManagementDiskSizeReadReplicas = ({ + isDirty, + totalSize, + usedSize, + newTotalSize, + oldStorageType, + newStorageType, +}: DiskManagementDiskSizeReadReplicasProps) => { + const { ref: projectRef } = useParams() + const [isOpen, setIsOpen] = useState(false) + + const { data: databases } = useReadReplicasQuery({ projectRef }) + const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) + const beforePrice = totalSize * DISK_PRICING[oldStorageType]?.storage ?? 0 + const afterPrice = newTotalSize * DISK_PRICING[newStorageType]?.storage ?? 0 + + if (readReplicas.length === 0) return null + + return ( + <> + + {isDirty && ( + + + + + Read replicas are provisioned with extra 25% disk size to account for WAL files + + + Each replica will have a disk size of {newTotalSize}GB, and are billed separately +
    + {readReplicas.map((replica, index) => ( +
  • +
    + + ID: {formatDatabaseID(replica.identifier)} ({replica.region}): + + +
    +
  • + ))} +
+
+
+
+ )} +
+ {/* Hide for now until we have the utilization for each RR specifically */} + {/* + +
+

Read replica disk size information

+ +
+
+ + {isOpen && ( + + + +

+ + Read replicas have 25% more disk size than the primary database to account for + WAL files{' '} + +

+ +
+
+
+ )} +
+
*/} + + ) +} + +export const DiskManagementIOPSReadReplicas = ({ + isDirty, + oldIOPS, + newIOPS, + oldStorageType, + newStorageType, +}: { + isDirty: boolean + oldIOPS: number + newIOPS: number + oldStorageType: DiskType + newStorageType: DiskType +}) => { + const { ref: projectRef } = useParams() + const { data: databases } = useReadReplicasQuery({ projectRef }) + const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) + + const beforePrice = + (oldIOPS - DISK_LIMITS[oldStorageType]?.includedIops) * DISK_PRICING[oldStorageType]?.iops ?? 0 + const afterPrice = + (newIOPS - DISK_LIMITS[newStorageType]?.includedIops) * DISK_PRICING[newStorageType]?.iops ?? 0 + + if (readReplicas.length === 0) return null + + return ( + + {isDirty && ( + + + + Read replica IOPS will also be updated to the same value + +
    + {readReplicas.map((replica, index) => ( +
  • +
    + + ID: {formatDatabaseID(replica.identifier)} ({replica.region}): + + +
    +
  • + ))} +
+
+
+
+ )} +
+ ) +} + +export const DiskManagementThroughputReadReplicas = ({ + isDirty, + oldThroughput, + newThroughput, + oldStorageType, + newStorageType, +}: { + isDirty: boolean + oldThroughput: number + newThroughput: number + oldStorageType: DiskType + newStorageType: DiskType +}) => { + const { ref: projectRef } = useParams() + const { data: databases } = useReadReplicasQuery({ projectRef }) + const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef) + + const beforePrice = + oldStorageType === DiskType.GP3 + ? (oldThroughput - DISK_LIMITS[oldStorageType].includedThroughput) * + DISK_PRICING[oldStorageType]?.throughput ?? 0 + : 0 + const afterPrice = + newStorageType === DiskType.GP3 + ? (newThroughput - DISK_LIMITS[newStorageType].includedThroughput) * + DISK_PRICING[newStorageType]?.throughput ?? 0 + : 0 + + if (readReplicas.length === 0) return null + + return ( + + {isDirty && ( + + + + Read replica throughput will also be updated to the same value + +
    + {readReplicas.map((replica, index) => ( +
  • +
    + + ID: {formatDatabaseID(replica.identifier)} ({replica.region}): + + +
    +
  • + ))} +
+
+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx index 4a4fbce6f9f..db873b5a8ff 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx @@ -15,7 +15,6 @@ import { DialogTrigger, Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, @@ -24,6 +23,15 @@ import { import BillingChangeBadge from './BillingChangeBadge' import { DiskStorageSchemaType } from './DiskManagementPanelSchema' import { DiskMangementCoolDownSection } from './DiskManagementCoolDownSection' +import { + calculateDiskSizePrice, + calculateIOPSPrice, + calculateThroughputPrice, +} from './DiskManagement.utils' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { DiskType } from './DiskManagement.constants' +import { formatCurrency } from 'lib/helpers' const TableHeaderRow = () => ( @@ -42,6 +50,7 @@ interface TableDataRowProps { beforePrice: number afterPrice: number hidePrice?: boolean + priceTooltip?: string } const TableDataRow = ({ @@ -52,6 +61,7 @@ const TableDataRow = ({ beforePrice, afterPrice, hidePrice = false, + priceTooltip, }: TableDataRowProps) => ( @@ -80,9 +90,14 @@ const TableDataRow = ({ {hidePrice ? ( - ) : beforePrice !== afterPrice ? ( - + ) : ( - ${beforePrice} + {formatCurrency(beforePrice)} )} @@ -91,9 +106,7 @@ const TableDataRow = ({ interface DiskSizeMeterProps { loading: boolean form: UseFormReturn - iopsPrice: { oldPrice: string; newPrice: string } - throughputPrice: { oldPrice: string; newPrice: string } - diskSizePrice: { oldPrice: string; newPrice: string } + numReplicas: number isDialogOpen: boolean isWithinCooldown: boolean setIsDialogOpen: (isOpen: boolean) => void @@ -105,14 +118,42 @@ export const DiskManagementReviewAndSubmitDialog = ({ setIsDialogOpen, isWithinCooldown, form, + numReplicas, loading, onSubmit, - iopsPrice, - throughputPrice, - diskSizePrice, }: DiskSizeMeterProps) => { + const org = useSelectedOrganization() + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) + + const planId = subscription?.plan.id ?? '' const isDirty = Object.keys(form.formState.dirtyFields).length > 0 + const replicaTooltipText = `Price change includes primary database and ${numReplicas} replica${numReplicas > 1 ? 's' : ''}` + + 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, + 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'), + numReplicas, + }) + + const throughputPrice = calculateThroughputPrice({ + storageType: form.getValues('storageType') as DiskType, + newThroughput: form.getValues('throughput') || 0, + oldThroughput: form.formState.defaultValues?.throughput || 0, + numReplicas, + }) + return ( @@ -160,30 +201,24 @@ export const DiskManagementReviewAndSubmitDialog = ({ beforePrice={0} afterPrice={0} /> - 0 ? replicaTooltipText : undefined} /> {form.getValues('storageType') === 'gp3' ? ( 0 ? replicaTooltipText : undefined} /> ) : ( @@ -199,10 +234,16 @@ export const DiskManagementReviewAndSubmitDialog = ({ )} + 0 ? replicaTooltipText : undefined} + /> - - Please take note of the above billing changes - diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index 849b869e4c0..d82834adf99 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -1,9 +1,15 @@ -import { ExternalLink } from 'lucide-react' +import { ChevronDown, ExternalLink } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { + DISK_LIMITS, + DISK_PRICING, + DiskType, +} from 'components/interfaces/DiskManagement/DiskManagement.constants' +import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { Region, useReadReplicaSetUpMutation } from 'data/read-replicas/replica-setup-mutation' @@ -16,6 +22,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' import { AWS_REGIONS_DEFAULT, BASE_PATH } from 'lib/constants' import type { AWS_REGIONS_KEYS } from 'shared-data' import { AWS_REGIONS } from 'shared-data' @@ -24,11 +31,26 @@ import { AlertTitle_Shadcn_, Alert_Shadcn_, Button, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, Listbox, SidePanel, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, WarningIcon, + cn, } from 'ui' import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants' +import { + calculateIOPSPrice, + calculateThroughputPrice, +} from 'components/interfaces/DiskManagement/DiskManagement.utils' +import { formatCurrency } from 'lib/helpers' // [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually @@ -48,10 +70,12 @@ const DeployNewReplicaPanel = ({ const { ref: projectRef } = useParams() const project = useSelectedProject() const org = useSelectedOrganization() + const diskManagementV2 = useFlag('diskManagementV2') const { data } = useReadReplicasQuery({ projectRef }) const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef }) const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) + const { data: diskConfiguration } = useDiskAttributesQuery({ projectRef }) // Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet const [defaultRegion] = Object.entries(AWS_REGIONS).find( @@ -62,6 +86,31 @@ const DeployNewReplicaPanel = ({ addons?.selected_addons.find((addon) => addon.type === 'compute_instance')?.variant .identifier ?? 'ci_micro' + // @ts-ignore + const { size_gb, type, throughput_mbps, iops } = diskConfiguration?.attributes ?? {} + const showNewDiskManagementUI = + diskManagementV2 && + subscription?.usage_based_billing_project_addons && + project?.cloud_provider === 'AWS' + const readReplicaDiskSizes = (size_gb ?? 0) * 1.25 + const additionalCostDiskSize = readReplicaDiskSizes * DISK_PRICING[type as DiskType]?.storage ?? 0 + const additionalCostIOPS = calculateIOPSPrice({ + oldStorageType: type as DiskType, + newStorageType: type as DiskType, + oldProvisionedIOPS: 0, + newProvisionedIOPS: iops ?? 0, + numReplicas: 0, + }).newPrice + const additionalCostThroughput = + type === 'gp3' + ? calculateThroughputPrice({ + storageType: type as DiskType, + newThroughput: throughput_mbps ?? 0, + oldThroughput: 0, + numReplicas: 0, + }).newPrice + : 0 + const [refetchInterval, setRefetchInterval] = useState(false) const [selectedRegion, setSelectedRegion] = useState(defaultRegion) const [selectedCompute, setSelectedCompute] = useState(defaultCompute) @@ -128,6 +177,7 @@ const DeployNewReplicaPanel = ({ const computeAddons = addons?.available_addons.find((addon) => addon.type === 'compute_instance')?.variants ?? [] const selectedComputeMeta = computeAddons.find((addon) => addon.identifier === selectedCompute) + const estComputeMonthlyCost = Math.floor((selectedComputeMeta?.price ?? 0) * 730) // 730 hours in a month const availableRegions = process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' @@ -160,6 +210,7 @@ const DeployNewReplicaPanel = ({ onCancel={onClose} loading={isSettingUp} disabled={!canDeployReplica} + className={cn(showNewDiskManagementUI ? 'max-w-[500px]' : '')} header="Deploy a new read replica" onConfirm={() => onSubmit()} confirmText="Deploy replica" @@ -366,23 +417,97 @@ const DeployNewReplicaPanel = ({
-

- Read replicas will be on the same compute size as your primary database. Deploying a - read replica on the {selectedComputeMeta?.name} size incurs additional{' '} - {selectedComputeMeta?.price_description}. -

+ {showNewDiskManagementUI ? ( + <> + + +

+ New replica will cost an additional{' '} + {formatCurrency( + estComputeMonthlyCost + + additionalCostDiskSize + + Number(additionalCostIOPS) + + Number(additionalCostThroughput) + )} + /month +

+ +
+ +

+ Read replicas will match the compute size of your primary database and will + include 25% more disk size than the primary database to accommodate WAL files. +

+

+ The additional cost for the replica breaks down to: +

+ + + + Item + Description + Cost (/month) + + + + + Compute size + {selectedComputeMeta?.name} + + {formatCurrency(estComputeMonthlyCost)} + + + + Disk size + + {((size_gb ?? 0) * 1.25).toLocaleString()} GB ({type}) + + + {formatCurrency(additionalCostDiskSize)} + + + + IOPS + {iops?.toLocaleString()} IOPS + + {formatCurrency(+additionalCostIOPS)} + + + {type === 'gp3' && ( + + Throughput + {throughput_mbps?.toLocaleString()} MB/s + + {formatCurrency(+additionalCostThroughput)} + + + )} + +
+
+
+ + ) : ( +

+ Read replicas will be on the same compute size as your primary database. Deploying a + read replica on the{' '} + {selectedComputeMeta?.name} size incurs + additional{' '} + {selectedComputeMeta?.price_description}. +

+ )}

Read more about{' '} - usage-based billing + billing {' '} - for compute. + for read replicas.

diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 6ca37af675d..b8f64247e0e 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -275,7 +275,14 @@ export const getDistanceLatLonKM = (lat1: number, lon1: number, lat2: number, lo return d } -const currencyFormatter = Intl.NumberFormat('en-US', { +const currencyFormatterDefault = Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +const currencyFormatterSmallValues = Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, @@ -284,7 +291,9 @@ const currencyFormatter = Intl.NumberFormat('en-US', { export const formatCurrency = (amount: number | undefined | null): string | null => { if (amount === undefined || amount === null) { return null + } else if (amount < 0.01) { + return currencyFormatterSmallValues.format(amount) } else { - return currencyFormatter.format(amount) + return currencyFormatterDefault.format(amount) } } diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 479a18d74df..e5453c1dd69 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -55,7 +55,9 @@ const DatabaseUsage = () => { const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) const showNewDiskManagementUI = - subscription?.usage_based_billing_project_addons && diskManagementV2 + subscription?.usage_based_billing_project_addons && + diskManagementV2 && + project?.cloud_provider === 'AWS' const report = useDatabaseReport() const { data } = useDatabaseSizeQuery({ diff --git a/apps/studio/tests/mocks/browser.ts b/apps/studio/tests/mocks/browser.ts new file mode 100644 index 00000000000..286a601990c --- /dev/null +++ b/apps/studio/tests/mocks/browser.ts @@ -0,0 +1,15 @@ +import { vi } from 'vitest' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) diff --git a/apps/studio/tests/vitestSetup.ts b/apps/studio/tests/vitestSetup.ts index 47118e0e120..303f9225a0c 100644 --- a/apps/studio/tests/vitestSetup.ts +++ b/apps/studio/tests/vitestSetup.ts @@ -1,3 +1,4 @@ +import './mocks/browser' import { beforeAll, vi } from 'vitest' import { setupServer } from 'msw/node' import { APIMock } from './mocks/api'