Files
Kevin Grüneberg e1bd23d66f fix: do not translate USD prices (#35466)
When translation tools go over prices, they wrongfully convert USD into other currencies without changing the price. $25 becomes Rp25 (Rupiah).

By using `translate='no'` we avoid the translation of pricing info. There are some tooltips left that are not covered yet, but this should be the majority already.

Kept the docs Pricing component as simple as possible, just wrapping in a span to avoid translation, not modifying any formatting.
2025-05-06 12:03:35 +08:00

546 lines
22 KiB
TypeScript

import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
calculateIOPSPrice,
calculateThroughputPrice,
} from 'components/interfaces/DiskManagement/DiskManagement.utils'
import {
DISK_PRICING,
DiskType,
} from 'components/interfaces/DiskManagement/ui/DiskManagement.constants'
import { DocsButton } from 'components/ui/DocsButton'
import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation'
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { Region, useReadReplicaSetUpMutation } from 'data/read-replicas/replica-setup-mutation'
import {
MAX_REPLICAS_ABOVE_XL,
MAX_REPLICAS_BELOW_XL,
useReadReplicasQuery,
} from 'data/read-replicas/replicas-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { AWS_REGIONS_DEFAULT, BASE_PATH } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import type { AWS_REGIONS_KEYS } from 'shared-data'
import { AWS_REGIONS } from 'shared-data'
import {
AlertDescription_Shadcn_,
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'
// [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually
interface DeployNewReplicaPanelProps {
visible: boolean
selectedDefaultRegion?: AWS_REGIONS_KEYS
onSuccess: () => void
onClose: () => void
}
const DeployNewReplicaPanel = ({
visible,
selectedDefaultRegion,
onSuccess,
onClose,
}: DeployNewReplicaPanelProps) => {
const { ref: projectRef } = useParams()
const project = useSelectedProject()
const org = useSelectedOrganization()
const { data } = useReadReplicasQuery({ projectRef })
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
const { data: diskConfiguration } = useDiskAttributesQuery({ projectRef })
const isNotOnTeamOrEnterprisePlan = useMemo(
() => !['team', 'enterprise'].includes(org?.plan.id ?? ''),
[org]
)
const { data: allOverdueInvoices } = useOverdueInvoicesQuery({
enabled: isNotOnTeamOrEnterprisePlan,
})
const overdueInvoices = (allOverdueInvoices ?? []).filter(
(x) => x.organization_id === project?.organization_id
)
const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan
// Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet
const [defaultRegion] = Object.entries(AWS_REGIONS).find(
([_, name]) => name === AWS_REGIONS_DEFAULT
) ?? ['ap-southeast-1']
// Will be following the primary's compute size for the time being
const defaultCompute =
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 = 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)
useProjectDetailQuery(
{ ref: projectRef },
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
if (data.is_physical_backups_enabled) setRefetchInterval(false)
},
}
)
const { mutate: enablePhysicalBackups, isLoading: isEnabling } = useEnablePhysicalBackupsMutation(
{
onSuccess: () => {
toast.success(
'Physical backups are currently being enabled, please check back in a few minutes!'
)
setRefetchInterval(5000)
},
}
)
const { mutate: setUpReplica, isLoading: isSettingUp } = useReadReplicaSetUpMutation({
onSuccess: () => {
const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === selectedRegion)?.name
toast.success(`Spinning up new replica in ${region ?? ' Unknown'}...`)
onSuccess()
onClose()
},
})
const currentPgVersion = Number(
(project?.dbVersion ?? '').split('supabase-postgres-')[1]?.split('.')[0]
)
const maxNumberOfReplicas = ['ci_micro', 'ci_small', 'ci_medium', 'ci_large'].includes(
selectedCompute
)
? MAX_REPLICAS_BELOW_XL
: MAX_REPLICAS_ABOVE_XL
const reachedMaxReplicas =
(data ?? []).filter((db) => db.identifier !== projectRef).length >= maxNumberOfReplicas
const isFreePlan = org?.plan.id === 'free'
const isAWSProvider = project?.cloud_provider === 'AWS'
const isWalgEnabled = project?.is_physical_backups_enabled
const currentComputeAddon = addons?.selected_addons.find(
(addon) => addon.type === 'compute_instance'
)
const isProWithSpendCapEnabled = org?.plan.id === 'pro' && !org.usage_billing_enabled
const isMinimallyOnSmallCompute =
currentComputeAddon?.variant.identifier !== undefined &&
currentComputeAddon?.variant.identifier !== 'ci_micro'
const canDeployReplica =
!reachedMaxReplicas &&
currentPgVersion >= 15 &&
isAWSProvider &&
!isFreePlan &&
isWalgEnabled &&
currentComputeAddon !== undefined &&
!hasOverdueInvoices &&
!isProWithSpendCapEnabled
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'
? AVAILABLE_REPLICA_REGIONS.filter((x) =>
['SOUTHEAST_ASIA', 'CENTRAL_EU', 'EAST_US'].includes(x.key)
)
: AVAILABLE_REPLICA_REGIONS
const onSubmit = async () => {
const regionKey = AWS_REGIONS[selectedRegion as AWS_REGIONS_KEYS].code
if (!projectRef) return console.error('Project is required')
if (!regionKey) return toast.error('Unable to deploy replica: Unsupported region selected')
const primary = data?.find((db) => db.identifier === projectRef)
setUpReplica({ projectRef, region: regionKey as Region, size: primary?.size ?? 't4g.small' })
}
useEffect(() => {
if (visible && isSuccess) {
if (selectedDefaultRegion !== undefined) {
setSelectedRegion(selectedDefaultRegion)
} else if (defaultRegion) {
setSelectedRegion(defaultRegion)
}
if (defaultCompute !== undefined) setSelectedCompute(defaultCompute)
}
}, [visible, isSuccess])
return (
<SidePanel
visible={visible}
onCancel={onClose}
loading={isSettingUp}
disabled={!canDeployReplica}
className={cn(showNewDiskManagementUI ? 'max-w-[500px]' : '')}
header="Deploy a new read replica"
onConfirm={() => onSubmit()}
confirmText="Deploy replica"
>
<SidePanel.Content className="flex flex-col py-4 gap-y-4">
{hasOverdueInvoices ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>Your organization has overdue invoices</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span>
Please resolve all outstanding invoices first before deploying a new read replica
</span>
<div className="mt-3">
<Button asChild type="default">
<Link href={`/org/${org?.slug}/billing#invoices`}>View invoices</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : !isAWSProvider ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Read replicas are only supported for projects provisioned via AWS
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span>
Projects provisioned by other cloud providers currently will not be able to use read
replicas
</span>
<DocsButton
abbrev={false}
className="mt-3"
href="https://supabase.com/docs/guides/platform/read-replicas#prerequisites"
/>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : currentPgVersion < 15 ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Read replicas can only be deployed with projects on Postgres version 15 and above
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
If you'd like to use read replicas, please contact us via support
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-2">
<Button type="default">
<Link
href={`/support/new?category=Sales&ref=${projectRef}&subject=Enquiry%20on%20read%20replicas&message=Project%20DB%20version:%20${project?.dbVersion}`}
target="_blank"
rel="noreferrer"
>
Contact support
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : !isMinimallyOnSmallCompute ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Project required to at least be on a Small compute
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span>
This is to ensure that read replicas can keep up with the primary databases'
activities.
</span>
<div className="flex items-center gap-x-2 mt-3">
<Button asChild type="default">
<Link
href={
isFreePlan
? `/org/${org?.slug}/billing?panel=subscriptionPlan&source=deployNewReplicaPanelSmallCompute`
: `/project/${projectRef}/settings/compute-and-disk`
}
>
{isFreePlan ? 'Upgrade to Pro' : 'Change compute size'}
</Link>
</Button>
<DocsButton
abbrev={false}
href="https://supabase.com/docs/guides/platform/read-replicas#prerequisites"
/>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : !isWalgEnabled ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
{refetchInterval !== false
? 'Physical backups are currently being enabled'
: 'Physical backups are required to deploy replicas'}
</AlertTitle_Shadcn_>
{refetchInterval === false && (
<AlertDescription_Shadcn_ className="mb-2">
Physical backups are used under the hood to spin up read replicas for your project.
</AlertDescription_Shadcn_>
)}
<AlertDescription_Shadcn_>
{refetchInterval !== false
? 'This warning will go away once physical backups have been enabled - check back in a few minutes!'
: 'Enabling physical backups will take a few minutes, after which you will be able to deploy read replicas.'}
</AlertDescription_Shadcn_>
{refetchInterval !== false ? (
<AlertDescription_Shadcn_ className="mt-2">
You may start deploying read replicas thereafter once this is completed.
</AlertDescription_Shadcn_>
) : (
<AlertDescription_Shadcn_ className="flex items-center gap-x-2 mt-3">
<Button
type="default"
loading={isEnabling}
disabled={isEnabling}
onClick={() => {
if (projectRef) enablePhysicalBackups({ ref: projectRef })
}}
>
Enable physical backups
</Button>
<DocsButton
abbrev={false}
href="https://supabase.com/docs/guides/platform/read-replicas#how-are-read-replicas-made"
/>
</AlertDescription_Shadcn_>
)}
</Alert_Shadcn_>
) : isProWithSpendCapEnabled ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Spend cap needs to be disabled to deploy replicas
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span>
Launching a replica incurs additional disk size that will exceed the plan's quota.
Disable the spend cap first to allow overages before launching a replica.
</span>
<div className="flex items-center gap-x-2 mt-3">
<Button asChild type="default">
<Link href={`/org/${org?.slug}/billing?panel=costControl`}>
Disable spend cap
</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : reachedMaxReplicas ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
You can only deploy up to {maxNumberOfReplicas} read replicas at once
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
If you'd like to spin up another read replica, please drop an existing replica first.
</AlertDescription_Shadcn_>
{maxNumberOfReplicas === MAX_REPLICAS_BELOW_XL && (
<>
<AlertDescription_Shadcn_>
<span>
Alternatively, you may deploy up to{' '}
<span className="text-foreground">{MAX_REPLICAS_ABOVE_XL}</span> replicas if
your project is on an XL compute or higher.
</span>
<div className="flex items-center gap-x-2 mt-3">
<Button asChild type="default">
<Link
href={
isFreePlan
? `/org/${org?.slug}/billing?panel=subscriptionPlan&source=deployNewReplicaPanelMaxReplicas`
: `/project/${projectRef}/settings/compute-and-disk`
}
>
Upgrade compute size
</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</>
)}
</Alert_Shadcn_>
) : null}
<div className="flex flex-col gap-y-6 mt-2">
<Listbox
size="small"
id="region"
name="region"
disabled={!canDeployReplica}
value={selectedRegion}
onChange={setSelectedRegion}
label="Select a region to deploy your read replica in"
>
{availableRegions.map((region) => (
<Listbox.Option
key={region.key}
label={region.name}
value={region.key}
addOnBefore={() => (
<img
alt="region icon"
className="w-5 rounded-sm"
src={`${BASE_PATH}/img/regions/${region.key}.svg`}
/>
)}
>
{region.name}
</Listbox.Option>
))}
</Listbox>
<div className="flex flex-col gap-y-2">
{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{' '}
<span translate="no">
{formatCurrency(
estComputeMonthlyCost +
additionalCostDiskSize +
Number(additionalCostIOPS) +
Number(additionalCostThroughput)
)}
/month
</span>
</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" translate="no">
{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" translate="no">
{formatCurrency(additionalCostDiskSize)}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="pl-0">IOPS</TableCell>
<TableCell>{iops?.toLocaleString()} IOPS</TableCell>
<TableCell className="text-right font-mono pr-0" translate="no">
{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" translate="no">
{selectedComputeMeta?.price_description}
</span>
.
</p>
)}
<p className="text-foreground-light text-sm">
Read more about{' '}
<Link
href="https://supabase.com/docs/guides/platform/manage-your-usage/read-replicas"
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground transition"
>
billing
</Link>{' '}
for read replicas.
</p>
</div>
</div>
</SidePanel.Content>
</SidePanel>
)
}
export default DeployNewReplicaPanel