mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Chore/update rr pricing for new disk mgt (#29278)
* 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 <k.grueneberg1994@gmail.com>
This commit is contained in:
1 parent
1b27b561a2
commit
bcb847bbbb
15 files changed
+686
-293
No files matched your search
@@ -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 (
|
||||
<AnimatePresence>
|
||||
{beforePrice !== undefined && afterPrice !== undefined && show && (
|
||||
@@ -19,13 +26,22 @@ const BillingChangeBadge = ({ beforePrice, afterPrice, show }: BillingChangeBadg
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Badge variant="default" className="bg-alternative bg-opacity-100">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-mono text-foreground-muted">
|
||||
${beforePrice.toFixed(2)}
|
||||
</span>
|
||||
<ChevronRight size={12} strokeWidth={2} className="text-foreground-muted" />
|
||||
<span className="text-xs font-mono text-foreground">${afterPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-mono text-foreground-muted">
|
||||
{formatCurrency(beforePrice)}
|
||||
</span>
|
||||
<ChevronRight size={12} strokeWidth={2} className="text-foreground-muted" />
|
||||
<span className="text-xs font-mono text-foreground">
|
||||
{formatCurrency(afterPrice)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
{tooltip !== undefined && (
|
||||
<TooltipContent_Shadcn_ side="bottom">{tooltip}</TooltipContent_Shadcn_>
|
||||
)}
|
||||
</Tooltip_Shadcn_>
|
||||
</Badge>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,8 @@ export const DISK_LIMITS = {
|
||||
maxStorage: 16384,
|
||||
minIops: 100,
|
||||
maxIops: 256000,
|
||||
includedIops: 0,
|
||||
includedThroughput: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
-127
@@ -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 (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert variant="default" className="bg-transparent">
|
||||
<InfoIcon />
|
||||
<AlertTitle>An extra 25% disk space is provisioned for Read replicas.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Each replica is billed separately
|
||||
<ul className="list-disc pl-4 my-3 flex flex-col gap-2">
|
||||
{readReplicas.map((replica, index) => (
|
||||
<li key={index} className="marker:text-foreground-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
ID: {formatDatabaseID(replica.identifier)} ({replica.region}):
|
||||
</span>
|
||||
<BillingChangeBadge
|
||||
beforePrice={(totalSize * 0.25) / readReplicas.length}
|
||||
afterPrice={(newTotalSize * 0.25) / readReplicas.length}
|
||||
show={true}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center cursor-pointer rounded gap-2 mt-3 text-foreground-light hover:text-foreground data-[state=open]:text-foreground group">
|
||||
<h3 className="text-sm">Read replica disk size information</h3>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 group-data-[state=open]:transform group-data-[state=open]:rotate-180 group-data-[state=open]:text-foreground`}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial="collapsed"
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
open: { opacity: 1, height: 'auto' },
|
||||
collapsed: { opacity: 0, height: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||
>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<p className="flex flex-col gap-y-1 text-sm text-foreground-light mb-3">
|
||||
<span>All read replicas are provisioned with the following:</span>
|
||||
<span className="text-foreground-lighter">
|
||||
Read replicas have 25% more disk size than the primary database to account for
|
||||
WAL files{' '}
|
||||
</span>
|
||||
</p>
|
||||
<DiskSpaceBar
|
||||
showNewBar={isDirty}
|
||||
totalSize={totalSize}
|
||||
usedSize={usedSize}
|
||||
newTotalSize={newTotalSize}
|
||||
/>
|
||||
</motion.div>
|
||||
</CollapsibleContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Collapsible>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<number | false>(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' ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
IOPS must be{' '}
|
||||
{watchedTotalSize >= 8
|
||||
? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.`
|
||||
: `at least ${minIOPS}`}
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
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
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
IOPS must be{' '}
|
||||
{watchedTotalSize >= 8
|
||||
? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.`
|
||||
: `at least ${minIOPS.toLocaleString()}`}
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-64">
|
||||
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
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
{watchedStorageType === 'io2' ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
IOPS must be{' '}
|
||||
{watchedTotalSize >= 8
|
||||
? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.`
|
||||
: `at least ${minIOPS}`}
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
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
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
IOPS must be{' '}
|
||||
{watchedTotalSize >= 8
|
||||
? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.`
|
||||
: `at least ${minIOPS.toLocaleString()}`}
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-64">
|
||||
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
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
)}
|
||||
{!form.formState.errors.provisionedIOPS &&
|
||||
field.value > maxIopsBasedOnCompute && (
|
||||
<p>
|
||||
Note: Final usable IOPS will be at{' '}
|
||||
<span className="text-foreground">
|
||||
{maxIopsBasedOnCompute.toLocaleString()}
|
||||
</span>{' '}
|
||||
based on your current compute size of {currentCompute?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!form.formState.errors.provisionedIOPS && (
|
||||
<DiskManagementIOPSReadReplicas
|
||||
isDirty={form.formState.dirtyFields.provisionedIOPS !== undefined}
|
||||
oldIOPS={iops ?? 0}
|
||||
newIOPS={field.value}
|
||||
oldStorageType={form.formState.defaultValues?.storageType as DiskType}
|
||||
newStorageType={form.getValues('storageType') as DiskType}
|
||||
/>
|
||||
)}
|
||||
{field.value > maxIopsBasedOnCompute && (
|
||||
<p>
|
||||
Note: Final usable IOPS will be at{' '}
|
||||
<span className="text-foreground">
|
||||
{maxIopsBasedOnCompute.toLocaleString()}
|
||||
</span>{' '}
|
||||
based on your current compute size of {currentCompute?.name}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
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={
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
Throughput must be between {minThroughput} and {maxThroughput}{' '}
|
||||
MB/s based on your IOPS.
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-64">
|
||||
Min throughput is at 125MB/s, while max throughput is at
|
||||
0.25MB/s * IOPS or 1000, whichever is lower
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>
|
||||
Throughput must be between {minThroughput.toLocaleString()}{' '}
|
||||
and {maxThroughput?.toLocaleString()} MB/s based on your IOPS.
|
||||
</span>
|
||||
<Tooltip_Shadcn_>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="transition hover:text-foreground"
|
||||
/>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
<TooltipContent_Shadcn_ side="bottom" className="w-64">
|
||||
Min throughput is at 125MB/s, while max throughput is at
|
||||
0.25MB/s * IOPS or 1,000, whichever is lower
|
||||
</TooltipContent_Shadcn_>
|
||||
</Tooltip_Shadcn_>
|
||||
</div>
|
||||
{!form.formState.errors.throughput &&
|
||||
field.value !== undefined &&
|
||||
field.value > maxThroughputBasedOnCompute && (
|
||||
<p>
|
||||
Note: Final usable throughput will be at{' '}
|
||||
<span className="text-foreground">
|
||||
{maxThroughputBasedOnCompute.toFixed(0)}
|
||||
</span>{' '}
|
||||
MB/s based on your current compute size of{' '}
|
||||
{currentCompute?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{field.value !== undefined &&
|
||||
field.value > maxThroughputBasedOnCompute && (
|
||||
<p>
|
||||
Note: Final usable throughput will be at{' '}
|
||||
<span className="text-foreground">
|
||||
{maxThroughputBasedOnCompute.toFixed(0)}
|
||||
</span>{' '}
|
||||
MB/s based on your current compute size of{' '}
|
||||
{currentCompute?.name}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
{!form.formState.errors.throughput && (
|
||||
<DiskManagementThroughputReadReplicas
|
||||
isDirty={form.formState.dirtyFields.throughput !== undefined}
|
||||
oldThroughput={throughput_mbps ?? 0}
|
||||
newThroughput={field.value ?? 0}
|
||||
oldStorageType={
|
||||
form.formState.defaultValues?.storageType as DiskType
|
||||
}
|
||||
newStorageType={form.getValues('storageType') as DiskType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
@@ -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}
|
||||
/>
|
||||
<DiskManagementDiskSizeReadReplicas
|
||||
isDirty={form.formState.dirtyFields.totalSize !== undefined}
|
||||
totalSize={size_gb * 1.25}
|
||||
usedSize={mainDiskUsed}
|
||||
newTotalSize={
|
||||
form.getValues('totalSize') <= size_gb
|
||||
? size_gb * 1.25
|
||||
: form.getValues('totalSize') * 1.25
|
||||
}
|
||||
newTotalSize={watchedTotalSize * 1.25}
|
||||
oldStorageType={form.formState.defaultValues?.storageType as DiskType}
|
||||
newStorageType={form.getValues('storageType') as DiskType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,15 +712,13 @@ export function DiskManagementPanelForm() {
|
||||
Cancel
|
||||
</Button>
|
||||
<DiskManagementReviewAndSubmitDialog
|
||||
onSubmit={onSubmit}
|
||||
isDialogOpen={isDialogOpen}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
isWithinCooldown={disableInput}
|
||||
form={form}
|
||||
loading={isUpdatingDiskConfiguration}
|
||||
diskSizePrice={diskSizePrice}
|
||||
iopsPrice={iopsPrice}
|
||||
throughputPrice={throughputPrice}
|
||||
form={form}
|
||||
numReplicas={readReplicas.length}
|
||||
isDialogOpen={isDialogOpen}
|
||||
isWithinCooldown={disableInput}
|
||||
onSubmit={onSubmit}
|
||||
setIsDialogOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert variant="default" className="bg-transparent">
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
Read replicas are provisioned with extra 25% disk size to account for WAL files
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Each replica will have a disk size of {newTotalSize}GB, and are billed separately
|
||||
<ul className="list-disc pl-4 my-3 flex flex-col gap-2">
|
||||
{readReplicas.map((replica, index) => (
|
||||
<li key={index} className="marker:text-foreground-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
ID: {formatDatabaseID(replica.identifier)} ({replica.region}):
|
||||
</span>
|
||||
<BillingChangeBadge
|
||||
show
|
||||
beforePrice={beforePrice}
|
||||
afterPrice={afterPrice}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Hide for now until we have the utilization for each RR specifically */}
|
||||
{/* <Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center cursor-pointer rounded gap-2 mt-3 text-foreground-light hover:text-foreground data-[state=open]:text-foreground group">
|
||||
<h3 className="text-sm">Read replica disk size information</h3>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 group-data-[state=open]:transform group-data-[state=open]:rotate-180 group-data-[state=open]:text-foreground`}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial="collapsed"
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
open: { opacity: 1, height: 'auto' },
|
||||
collapsed: { opacity: 0, height: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||
>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<p className="flex flex-col gap-y-1 text-sm text-foreground-light mb-3">
|
||||
<span className="text-foreground-lighter">
|
||||
Read replicas have 25% more disk size than the primary database to account for
|
||||
WAL files{' '}
|
||||
</span>
|
||||
</p>
|
||||
<DiskSpaceBar
|
||||
showNewBar={isDirty}
|
||||
totalSize={totalSize}
|
||||
usedSize={usedSize}
|
||||
newTotalSize={newTotalSize}
|
||||
/>
|
||||
</motion.div>
|
||||
</CollapsibleContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Collapsible> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert variant="default" className="bg-transparent">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Read replica IOPS will also be updated to the same value</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 my-3 flex flex-col gap-2">
|
||||
{readReplicas.map((replica, index) => (
|
||||
<li key={index} className="marker:text-foreground-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
ID: {formatDatabaseID(replica.identifier)} ({replica.region}):
|
||||
</span>
|
||||
<BillingChangeBadge show beforePrice={beforePrice} afterPrice={afterPrice} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert variant="default" className="bg-transparent">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Read replica throughput will also be updated to the same value</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 my-3 flex flex-col gap-2">
|
||||
{readReplicas.map((replica, index) => (
|
||||
<li key={index} className="marker:text-foreground-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
ID: {formatDatabaseID(replica.identifier)} ({replica.region}):
|
||||
</span>
|
||||
<BillingChangeBadge show beforePrice={beforePrice} afterPrice={afterPrice} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
+65
-24
@@ -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 = () => (
|
||||
<TableRow>
|
||||
@@ -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) => (
|
||||
<TableRow>
|
||||
<TableCell className="pl-5">
|
||||
@@ -80,9 +90,14 @@ const TableDataRow = ({
|
||||
{hidePrice ? (
|
||||
<span className="text-xs font-mono">-</span>
|
||||
) : beforePrice !== afterPrice ? (
|
||||
<BillingChangeBadge show={true} beforePrice={beforePrice} afterPrice={afterPrice} />
|
||||
<BillingChangeBadge
|
||||
show={true}
|
||||
beforePrice={beforePrice}
|
||||
afterPrice={afterPrice}
|
||||
tooltip={priceTooltip}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-mono">${beforePrice}</span>
|
||||
<span className="text-xs font-mono">{formatCurrency(beforePrice)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -91,9 +106,7 @@ const TableDataRow = ({
|
||||
interface DiskSizeMeterProps {
|
||||
loading: boolean
|
||||
form: UseFormReturn<DiskStorageSchemaType>
|
||||
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 (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -160,30 +201,24 @@ export const DiskManagementReviewAndSubmitDialog = ({
|
||||
beforePrice={0}
|
||||
afterPrice={0}
|
||||
/>
|
||||
<TableDataRow
|
||||
attribute="Disk size"
|
||||
defaultValue={form.formState.defaultValues?.totalSize ?? 0}
|
||||
newValue={form.getValues('totalSize')}
|
||||
unit="GB"
|
||||
beforePrice={Number(diskSizePrice.oldPrice)}
|
||||
afterPrice={Number(diskSizePrice.newPrice)}
|
||||
/>
|
||||
<TableDataRow
|
||||
attribute="IOPS"
|
||||
defaultValue={form.formState.defaultValues?.provisionedIOPS ?? 0}
|
||||
newValue={form.getValues('provisionedIOPS')}
|
||||
defaultValue={form.formState.defaultValues?.provisionedIOPS?.toLocaleString() ?? 0}
|
||||
newValue={form.getValues('provisionedIOPS')?.toLocaleString()}
|
||||
unit="IOPS"
|
||||
beforePrice={Number(iopsPrice.oldPrice)}
|
||||
afterPrice={Number(iopsPrice.newPrice)}
|
||||
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
|
||||
/>
|
||||
{form.getValues('storageType') === 'gp3' ? (
|
||||
<TableDataRow
|
||||
attribute="Throughput"
|
||||
defaultValue={form.formState.defaultValues?.throughput ?? 0}
|
||||
newValue={form.getValues('throughput') ?? 0}
|
||||
defaultValue={form.formState.defaultValues?.throughput?.toLocaleString() ?? 0}
|
||||
newValue={form.getValues('throughput')?.toLocaleString() ?? 0}
|
||||
unit="MB/s"
|
||||
beforePrice={Number(throughputPrice.oldPrice)}
|
||||
afterPrice={Number(throughputPrice.newPrice)}
|
||||
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
|
||||
/>
|
||||
) : (
|
||||
<TableRow>
|
||||
@@ -199,10 +234,16 @@ export const DiskManagementReviewAndSubmitDialog = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableDataRow
|
||||
attribute="Disk size"
|
||||
defaultValue={form.formState.defaultValues?.totalSize?.toLocaleString() ?? 0}
|
||||
newValue={form.getValues('totalSize')?.toLocaleString()}
|
||||
unit="GB"
|
||||
beforePrice={Number(diskSizePrice.oldPrice)}
|
||||
afterPrice={Number(diskSizePrice.newPrice)}
|
||||
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
|
||||
/>
|
||||
</TableBody>
|
||||
<TableCaption className="mt-2 mb-2">
|
||||
Please take note of the above billing changes
|
||||
</TableCaption>
|
||||
</Table>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
+134
-9
@@ -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<number | false>(false)
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>(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 = ({
|
||||
</Listbox>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-foreground-light text-sm">
|
||||
Read replicas will be on the same compute size as your primary database. Deploying a
|
||||
read replica on the {selectedComputeMeta?.name} size incurs additional{' '}
|
||||
<span className="text-foreground">{selectedComputeMeta?.price_description}</span>.
|
||||
</p>
|
||||
{showNewDiskManagementUI ? (
|
||||
<>
|
||||
<Collapsible_Shadcn_>
|
||||
<CollapsibleTrigger_Shadcn_ className="w-full flex items-center justify-between [&[data-state=open]>svg]:!-rotate-180">
|
||||
<p className="text-sm text-left">
|
||||
New replica will cost an additional{' '}
|
||||
{formatCurrency(
|
||||
estComputeMonthlyCost +
|
||||
additionalCostDiskSize +
|
||||
Number(additionalCostIOPS) +
|
||||
Number(additionalCostThroughput)
|
||||
)}
|
||||
/month
|
||||
</p>
|
||||
<ChevronDown size={14} className="transition" />
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="flex flex-col gap-y-1 mt-1">
|
||||
<p className="text-foreground-light text-sm">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-foreground-light text-sm">
|
||||
The additional cost for the replica breaks down to:
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader className="font-mono uppercase text-xs [&_th]:h-auto [&_th]:pb-2 [&_th]:pt-4">
|
||||
<TableRow>
|
||||
<TableHead className="w-[140px] pl-0">Item</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right pr-0">Cost (/month)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_td]:py-0 [&_tr]:h-[50px] [&_tr]:border-dotted">
|
||||
<TableRow>
|
||||
<TableCell className="pl-0">Compute size</TableCell>
|
||||
<TableCell>{selectedComputeMeta?.name}</TableCell>
|
||||
<TableCell className="text-right font-mono pr-0">
|
||||
{formatCurrency(estComputeMonthlyCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0">Disk size</TableCell>
|
||||
<TableCell>
|
||||
{((size_gb ?? 0) * 1.25).toLocaleString()} GB ({type})
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono pr-0">
|
||||
{formatCurrency(additionalCostDiskSize)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0">IOPS</TableCell>
|
||||
<TableCell>{iops?.toLocaleString()} IOPS</TableCell>
|
||||
<TableCell className="text-right font-mono pr-0">
|
||||
{formatCurrency(+additionalCostIOPS)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{type === 'gp3' && (
|
||||
<TableRow>
|
||||
<TableCell className="pl-0">Throughput</TableCell>
|
||||
<TableCell>{throughput_mbps?.toLocaleString()} MB/s</TableCell>
|
||||
<TableCell className="text-right font-mono pr-0">
|
||||
{formatCurrency(+additionalCostThroughput)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">
|
||||
Read replicas will be on the same compute size as your primary database. Deploying a
|
||||
read replica on the{' '}
|
||||
<span className="text-foreground">{selectedComputeMeta?.name}</span> size incurs
|
||||
additional{' '}
|
||||
<span className="text-foreground">{selectedComputeMeta?.price_description}</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-foreground-light text-sm">
|
||||
Read more about{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/org-based-billing#billing-for-compute-compute-hours"
|
||||
href="https://supabase.com/docs/guides/platform/org-based-billing#read-replicas"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground transition"
|
||||
>
|
||||
usage-based billing
|
||||
billing
|
||||
</Link>{' '}
|
||||
for compute.
|
||||
for read replicas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import './mocks/browser'
|
||||
import { beforeAll, vi } from 'vitest'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { APIMock } from './mocks/api'
|
||||
|
||||
Reference in new issue
Block a user