feat(studio): relocate read replica modules under Infrastructure (#49043)

## What kind of change does this PR introduce?

Feature. Stack 1 of 5 for
[PIPE-1007](https://linear.app/supabase/issue/PIPE-1007/move-read-replicas-out-of-replication-into-infrastructure).

## What is the current behavior?

Read replica UI lives under Database / Replication.

## What is the new behavior?

Moves replica form, row, and modals into Settings/Infrastructure and
extracts `REPLICA_STATUS` plus path helpers. URLs and user-facing
behaviour are unchanged.

## Additional context

Keep `infrastructure:read_replicas` off in prod until the full stack
lands.

Stack: this PR →
[#49044](https://github.com/supabase/supabase/pull/49044) →
[#49045](https://github.com/supabase/supabase/pull/49045) →
[#49046](https://github.com/supabase/supabase/pull/49046) →
[#48921](https://github.com/supabase/supabase/pull/48921)

## To test

Open [Database /
Replication](https://studio-staging-git-danny-pipe-1007-01-relocate-701014-supabase.vercel.app/dashboard/project/_/database/replication?destinationType=Read+Replica).
Confirm add replica still works as today. No new Infrastructure section
yet.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added navigation for viewing a specific read replica and starting the
add-replica setup flow.
- Improved read-replica setup validation, including unsupported regions
and invalid PostgreSQL versions.

- **Bug Fixes**
  - Corrected default region and success messaging behavior.
- Prevented incomplete pricing labels and clarified eligibility
warnings.
  - Improved display handling for localized pricing values.

- **Tests**
- Added coverage for read-replica navigation paths and fallback
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-08-14 15:21:24 +10:00
1 parent 67d4fed40d
commit 2c8cc7ec8a
23 files changed
+93 -46

No files matched your search

@@ -24,7 +24,7 @@ import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha'
import { DestinationForm } from './DestinationForm'
import { DestinationType } from './DestinationPanel.types'
import { DestinationTypeSelection } from './DestinationTypeSelection'
import { ReadReplicaForm } from './ReadReplicaForm'
import { ReadReplicaForm } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { DocsButton } from '@/components/ui/DocsButton'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
@@ -30,7 +30,6 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { DestinationRow } from './DestinationRow'
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
import { EnablePipelinesModal } from './EnablePipelinesCallout'
import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
import { REPLICA_STATUS } from './Replication.constants'
import {
useIsETLBigQueryPrivateAlpha,
@@ -39,6 +38,7 @@ import {
useIsETLIcebergPrivateAlpha,
useIsETLSnowflakePrivateAlpha,
} from './useIsETLPrivateAlpha'
import { ReadReplicaRow } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
@@ -1,7 +1,3 @@
import { components } from 'api-types'
import { PROJECT_STATUS } from '@/lib/constants'
export const STATUS_REFRESH_FREQUENCY_MS: number = 10000 // 10 seconds
export enum PipelineStatusName {
@@ -13,10 +9,5 @@ export enum PipelineStatusName {
UNKNOWN = 'unknown',
}
export const REPLICA_STATUS: {
[key: string]: components['schemas']['DatabaseStatusResponse']['status']
} = {
...PROJECT_STATUS,
INIT_READ_REPLICA: 'INIT_READ_REPLICA',
INIT_READ_REPLICA_FAILED: 'INIT_READ_REPLICA_FAILED',
}
/** @deprecated Import from Settings/Infrastructure/ReadReplicas/ReadReplicas.constants */
export { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
@@ -6,9 +6,9 @@ import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { DestinationIcon } from '../DestinationIcon'
import { getStatusName } from '../Pipeline.utils'
import { getStatusLabel } from '../ReadReplicas/ReadReplicas.utils'
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { getReplicationDestinationType } from './Nodes.utils'
import { getStatusLabel } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { getInfrastructurePath } from './Infrastructure.utils'
import {
getAddReadReplicaPath,
getInfrastructurePath,
getReadReplicaPath,
} from './Infrastructure.utils'
describe('getInfrastructurePath', () => {
it('builds the path for a project ref', () => {
@@ -13,3 +17,29 @@ describe('getInfrastructurePath', () => {
expect(getInfrastructurePath()).toBe('/project/_/settings/infrastructure')
})
})
describe('getReadReplicaPath', () => {
it('builds the replica detail path', () => {
expect(getReadReplicaPath('project-ref', 'replica-1')).toBe(
'/project/project-ref/settings/infrastructure/replica/replica-1'
)
})
it('falls back to the default project placeholder', () => {
expect(getReadReplicaPath(undefined, 'replica-1')).toBe(
'/project/_/settings/infrastructure/replica/replica-1'
)
})
})
describe('getAddReadReplicaPath', () => {
it('opens the add-replica sheet on infrastructure', () => {
expect(getAddReadReplicaPath('project-ref')).toBe(
'/project/project-ref/settings/infrastructure?addReplica=true'
)
})
it('falls back to the default project placeholder', () => {
expect(getAddReadReplicaPath()).toBe('/project/_/settings/infrastructure?addReplica=true')
})
})
@@ -1,2 +1,8 @@
export const getInfrastructurePath = (projectRef?: string) =>
`/project/${projectRef ?? '_'}/settings/infrastructure`
export const getReadReplicaPath = (projectRef: string | undefined, replicaId: string) =>
`/project/${projectRef ?? '_'}/settings/infrastructure/replica/${replicaId}`
export const getAddReadReplicaPath = (projectRef?: string) =>
`/project/${projectRef ?? '_'}/settings/infrastructure?addReplica=true`
@@ -4,7 +4,7 @@ import { Loader2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { EdgeData } from './InstanceConfiguration.constants'
import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { useReplicationLagQuery } from '@/data/read-replicas/replica-lag-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
@@ -21,7 +21,7 @@ import { SmoothstepEdge } from './Edge'
import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
import MapView from './MapView'
import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { AlertError } from '@/components/ui/AlertError'
import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
@@ -33,7 +33,7 @@ import {
} from './InstanceConfiguration.constants'
import { formatSeconds } from './InstanceConfiguration.utils'
import { metricColor } from './InstanceNode.utils'
import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { SparkBar } from '@/components/ui/SparkBar'
import {
DatabaseInitEstimations,
@@ -27,7 +27,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants'
import GeographyData from './MapData.json'
import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -3,7 +3,7 @@ import { useParams } from 'common'
import { toast } from 'sonner'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { REPLICA_STATUS } from '../Replication.constants'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import { InlineLink } from '@/components/ui/InlineLink'
import { replicaKeys } from '@/data/read-replicas/keys'
import { useReadReplicaRemoveMutation } from '@/data/read-replicas/replica-remove-mutation'
@@ -17,7 +17,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { REPLICA_STATUS } from '../Replication.constants'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
import { useInfraMonitoringAttributesQuery } from '@/data/analytics/infra-monitoring-query'
@@ -99,7 +99,9 @@ export const ReadReplicaPricingDialog = () => {
<TableRow>
<TableCell>Throughput</TableCell>
<TableCell>{throughput.label}</TableCell>
<TableCell className="text-right font-mono">{throughput.cost}</TableCell>
<TableCell className="text-right font-mono" translate="no">
{throughput.cost}
</TableCell>
</TableRow>
)}
</TableBody>
@@ -43,7 +43,7 @@ export const ReadReplicaForm = ({
const [defaultRegion] = Object.entries(AWS_REGIONS).find(
([_, name]) => name === AWS_REGIONS_DEFAULT
) ?? ['ap-southeast-1']
) ?? ['SOUTHEAST_ASIA']
const { can: canDeployReplica } = useCheckEligibilityDeployReplica()
const [selectedRegion, setSelectedRegion] = useState<string>(defaultRegion)
@@ -61,7 +61,7 @@ export const ReadReplicaForm = ({
const { mutate: setUpReplica, isPending: isSettingUp } = useReadReplicaSetUpMutation({
onSuccess: () => {
const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === selectedRegion)?.name
toast.success(`Spinning up new replica in ${region ?? ' Unknown'}...`)
toast.success(`Spinning up new replica in ${region ?? 'Unknown'}...`)
onSuccess?.()
onClose()
},
@@ -75,8 +75,9 @@ export const ReadReplicaForm = ({
: AVAILABLE_REPLICA_REGIONS
const onSubmit = async () => {
const regionKey = AWS_REGIONS[selectedRegion as AWS_REGIONS_KEYS].code
if (!projectRef) return console.error('Project is required')
const regionKey = AWS_REGIONS[selectedRegion as AWS_REGIONS_KEYS]?.code
if (!regionKey) return toast.error('Unable to deploy replica: Unsupported region selected')
const primary = data?.find((db) => db.identifier === projectRef)
@@ -1,5 +1,4 @@
import { useParams } from 'common'
import { useMemo } from 'react'
import { useOverdueInvoicesQuery } from '@/data/invoices/invoices-overdue-query'
import {
@@ -20,10 +19,7 @@ export const useCheckEligibilityDeployReplica = () => {
const { hasAccess: hasReadReplicaAccess } = useCheckEntitlements('instances.read_replicas')
const isAWSProvider = project?.cloud_provider === 'AWS'
const isWalgEnabled = project?.is_physical_backups_enabled
const isNotOnHigherPlan = useMemo(
() => !['team', 'enterprise', 'platform'].includes(org?.plan.id ?? ''),
[org]
)
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(org?.plan.id ?? '')
const isProWithSpendCapEnabled = org?.plan.id === 'pro' && !org.usage_billing_enabled
const { data: allOverdueInvoices } = useOverdueInvoicesQuery({
@@ -48,12 +44,14 @@ export const useCheckEligibilityDeployReplica = () => {
const isReachedMaxReplicas =
(databases ?? []).filter((db) => db.identifier !== projectRef).length >= maxNumberOfReplicas
const currentPgVersion = Number(
const parsedPgVersion = Number(
(project?.dbVersion ?? '').split('supabase-postgres-')[1]?.split('.')[0]
)
const currentPgVersion = Number.isNaN(parsedPgVersion) ? undefined : parsedPgVersion
const canDeployReplica =
!isReachedMaxReplicas &&
currentPgVersion !== undefined &&
currentPgVersion >= 15 &&
isAWSProvider &&
hasReadReplicaAccess &&
@@ -68,7 +66,7 @@ export const useCheckEligibilityDeployReplica = () => {
hasOverdueInvoices,
isAWSProvider,
isAwsK8s,
isPgVersionBelow15: currentPgVersion < 15,
isPgVersionBelow15: currentPgVersion === undefined || currentPgVersion < 15,
isBelowSmallCompute,
isWalgNotEnabled: !isWalgEnabled,
isProWithSpendCapEnabled,
@@ -65,15 +65,21 @@ export const useGetReplicaCost = () => {
},
disk: {
type,
label: `${((size_gb ?? 0) * 1.25).toLocaleString()} GB (${type})`,
label:
size_gb !== undefined && size_gb !== null && type !== undefined && type !== null
? `${(size_gb * 1.25).toLocaleString()} GB (${type})`
: undefined,
cost: formatCurrency(additionalCostDiskSize),
},
iops: {
label: `${iops?.toLocaleString()} IOPS`,
label: iops !== undefined && iops !== null ? `${iops.toLocaleString()} IOPS` : undefined,
cost: formatCurrency(+additionalCostIOPS),
},
throughput: {
label: `${throughput_mbps?.toLocaleString()} MB/s`,
label:
throughput_mbps !== undefined && throughput_mbps !== null
? `${throughput_mbps.toLocaleString()} MB/s`
: undefined,
cost: formatCurrency(+additionalCostThroughput),
},
}
@@ -20,8 +20,8 @@ import {
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { REPLICA_STATUS } from '../Replication.constants'
import { DropReplicaConfirmationModal } from './DropReplicaConfirmationModal'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import { getIsInTransition, getStatusLabel } from './ReadReplicas.utils'
import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal'
import { useReplicationLagQuery } from '@/data/read-replicas/replica-lag-query'
@@ -0,0 +1,11 @@
import { components } from 'api-types'
import { PROJECT_STATUS } from '@/lib/constants'
export const REPLICA_STATUS: {
[key: string]: components['schemas']['DatabaseStatusResponse']['status']
} = {
...PROJECT_STATUS,
INIT_READ_REPLICA: 'INIT_READ_REPLICA',
INIT_READ_REPLICA_FAILED: 'INIT_READ_REPLICA_FAILED',
}
@@ -1,4 +1,4 @@
import { REPLICA_STATUS } from '../Replication.constants'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import { ReplicaInitializationStatus } from '@/data/read-replicas/replicas-status-query'
export const getIsInTransition = ({
@@ -3,7 +3,7 @@ import { useParams } from 'common'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { REPLICA_STATUS } from '../Replication.constants'
import { REPLICA_STATUS } from './ReadReplicas.constants'
import { useProjectRestartMutation } from '@/data/projects/project-restart-mutation'
import { replicaKeys } from '@/data/read-replicas/keys'
import { Database } from '@/data/read-replicas/replicas-query'
@@ -8,14 +8,14 @@ import { AWS_REGIONS } from 'shared-data'
import { Badge, Button } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { DropReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal'
import { ReadReplicaDetails } from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails'
import { DropReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal'
import { ReadReplicaDetails } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import {
getIsInTransition,
getStatusLabel,
} from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils'
import { RestartReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal'
import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants'
} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils'
import { RestartReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
@@ -1,13 +1,13 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings'
import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica'
import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings'
import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica'
import { READ_REPLICAS_MAX_COUNT } from '@/data/read-replicas/replicas-query'
import { customRender } from '@/tests/lib/custom-render'
vi.mock(
'@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica'
'@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica'
)
vi.mock('@/data/projects/project-detail-query', () => ({
useProjectDetailQuery: () => ({ data: undefined, isSuccess: false }),