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:
Joshen LimandKevin Grüneberg authored and GitHub committed 2024-09-18 18:38:58 +08:00
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' }
}
@@ -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>
)
}
@@ -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 />
@@ -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>
+11 -2
View File
@@ -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({
+15
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import './mocks/browser'
import { beforeAll, vi } from 'vitest'
import { setupServer } from 'msw/node'
import { APIMock } from './mocks/api'