Files
supabase/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx
T
CharisandJoshen Lim d8f7cc0d57 feat(support form): attach dashboard logs (#39539)
* o11y: mirror and sanitize breadcrumbs

Mirror Sentry breadcrumbs as the basis for our own support logging. Also
adds more sanitization to breadcrumbs.

* feat(support form): toggle for attaching dashboard  logs

Add a toggle to the support form when the category is "Dashboard bug",
to attach recent dashboard logs. Users can preview the attached logs and
opt out.

* feat(support links): dedicated support link component

Add a new component for support links, which:
- Uses the serializer for support link params to ensure
serialization/deserialization pairs correctly
- Snapshots breadcrumbs so the attached log on the support form will be
cut off at the support link click (otherwise we will get support form
actions cluttering up the log)

* tests(support form): extend timeout on flaky test

* Minor clean up

* fix(support form): allow url to specifically indicate no specified project

* minor nits

* Fix tests

* Fix tests

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-10-22 08:57:49 -04:00

569 lines
23 KiB
TypeScript

import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { SupportCategories } from '@supabase/shared-types/out/constants'
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 { SupportLink } from 'components/interfaces/Support/SupportLink'
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 { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { AWS_REGIONS_DEFAULT, BASE_PATH, DOCS_URL } 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 { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
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
const isAwsK8s = useIsAwsK8sCloudProvider()
// 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 &&
!isAwsK8s &&
!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={`${DOCS_URL}/guides/platform/read-replicas#prerequisites`}
/>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : isAwsK8s ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Read replicas are not supported for AWS (Revamped) projects
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span>
Projects provisioned by other cloud providers currently will not be able to use read
replicas
</span>
</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">
<SupportLink
queryParams={{
projectRef,
category: SupportCategories.SALES_ENQUIRY,
subject: 'Enquiry on read replicas',
message: `Project DB version: ${project?.dbVersion}`,
}}
>
Contact support
</SupportLink>
</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={`${DOCS_URL}/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={`${DOCS_URL}/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.region}.svg`}
/>
)}
>
<p className="flex items-center gap-x-2">
<span>{region.name}</span>
<span className="text-xs text-foreground-lighter font-mono">{region.region}</span>
</p>
</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={`${DOCS_URL}/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