Files
Danny White 2c8cc7ec8a 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 -->
2026-08-14 15:21:24 +10:00

416 lines
15 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { Database } from 'icons'
import { MoreVertical, Plus, Search, X } from 'lucide-react'
import Link from 'next/link'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DestinationPanel } from './DestinationPanel/DestinationPanel'
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { DestinationRow } from './DestinationRow'
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
import { EnablePipelinesModal } from './EnablePipelinesCallout'
import { REPLICA_STATUS } from './Replication.constants'
import {
useIsETLBigQueryPrivateAlpha,
useIsETLClickHousePrivateAlpha,
useIsETLDucklakePrivateAlpha,
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'
import { Shortcut } from '@/components/ui/Shortcut'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
import { replicationKeys } from '@/data/replication/keys'
import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
import { onSearchInputEscape } from '@/lib/keyboard'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
export const Destinations = () => {
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
const { data: organization } = useSelectedOrganizationQuery()
const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha()
const etlEnableIceberg = useIsETLIcebergPrivateAlpha()
const etlEnableDucklake = useIsETLDucklakePrivateAlpha()
const etlEnableSnowflake = useIsETLSnowflakePrivateAlpha()
const etlEnableClickHouse = useIsETLClickHousePrivateAlpha()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
const newDestinationDefaultType = infrastructureReadReplicas
? 'Read Replica'
: etlEnableBigQuery
? 'BigQuery'
: etlEnableIceberg
? 'Analytics Bucket'
: etlEnableDucklake
? 'DuckLake'
: etlEnableSnowflake
? 'Snowflake'
: etlEnableClickHouse
? 'ClickHouse'
: null
const prefetchedRef = useRef(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const [filterString, setFilterString] = useState<string>('')
const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
const [showEnablePipelinesDialog, setShowEnablePipelinesDialog] = useState(false)
const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false)
const [_, setDestinationType] = useQueryState(
'destinationType',
parseAsStringEnum<DestinationType>([
'Read Replica',
'BigQuery',
'Analytics Bucket',
'DuckLake',
'Snowflake',
'ClickHouse',
]).withOptions({
history: 'push',
clearOnDefault: true,
})
)
const {
data: databases = [],
error: databasesError,
isPending: isDatabasesLoading,
isError: isDatabasesError,
isSuccess: isDatabasesSuccess,
} = useReadReplicasQuery({ projectRef }, { refetchInterval: statusRefetchInterval })
// Memoise so the array reference is stable across renders. Without this
// the polling useEffect below has an unstable dep, runs every render, and
// its `setStatusRefetchInterval(false)` churn keeps the parent re-rendering
// — which trips a latent ref-instability bug in @radix-ui/react-slot
// (`composeRefs` is called per render instead of `useComposedRefs`) and
// tanks the page with "Maximum update depth exceeded" via the Tooltip
// trigger refs.
const readReplicas = useMemo(
() => databases.filter((x) => x.identifier !== projectRef),
[databases, projectRef]
)
const hasReplicas = isDatabasesSuccess && readReplicas.length > 0
const filteredReplicas =
filterString.length === 0
? readReplicas
: readReplicas.filter((replica) => replica.identifier.includes(filterString.toLowerCase()))
const {
data: destinationsData,
error: destinationsError,
isPending: isDestinationsLoading,
isError: isDestinationsError,
isSuccess: isDestinationsSuccess,
} = useReplicationDestinationsQuery({
projectRef,
})
const destinations = destinationsData?.destinations ?? []
const hasDestinations = isDestinationsSuccess && destinationsData?.destinations.length > 0
const filteredDestinations =
filterString.length === 0
? (destinations ?? [])
: (destinations ?? []).filter((destination) =>
destination.name.toLowerCase().includes(filterString.toLowerCase())
)
const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
projectRef,
})
const pipelines = pipelinesData?.pipelines ?? []
const { data: sourcesData, isSuccess: isSourcesSuccess } = useReplicationSourcesQuery({
projectRef,
})
const externalReplicationSource = useMemo(
() => sourcesData?.sources.find((source) => source.name === projectRef),
[projectRef, sourcesData?.sources]
)
const replicationNotEnabled = isSourcesSuccess && !externalReplicationSource
const canDisablePipelines =
isSourcesSuccess &&
isDestinationsSuccess &&
isPipelinesSuccess &&
!!externalReplicationSource &&
destinations.length === 0 &&
pipelines.length === 0
const isLoading = isDestinationsLoading || isDatabasesLoading
const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError)
const hasErrorsFetchingData = (!isLocalETLNotSetUp && isDestinationsError) || isDatabasesError
const openDestinationPanel = () => {
if (!newDestinationDefaultType) return
setDestinationType(newDestinationDefaultType)
}
useShortcut(
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
},
{ label: 'Search destinations' }
)
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setFilterString(''))
useEffect(() => {
if (
projectRef &&
!prefetchedRef.current &&
pipelinesData?.pipelines &&
pipelinesData.pipelines.length > 0 &&
isPipelinesSuccess
) {
prefetchedRef.current = true
pipelinesData.pipelines.forEach((p) => {
if (!p?.id) return
queryClient.prefetchQuery({
queryKey: replicationKeys.pipelinesVersion(projectRef, p.id),
queryFn: ({ signal }) =>
fetchReplicationPipelineVersion({ projectRef, pipelineId: p.id }, signal),
staleTime: Infinity,
})
})
}
}, [projectRef, pipelinesData?.pipelines, isPipelinesSuccess, queryClient])
useEffect(() => {
if (!isDatabasesSuccess) return
const pollReplicas = async () => {
const fixedStatuses = [
REPLICA_STATUS.ACTIVE_HEALTHY,
REPLICA_STATUS.ACTIVE_UNHEALTHY,
REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
]
const replicasInTransition = readReplicas.filter((db) => !fixedStatuses.includes(db.status))
const hasTransientStatus = replicasInTransition.length > 0
// If all replicas are active healthy, stop fetching statuses
if (!hasTransientStatus) setStatusRefetchInterval(false)
}
pollReplicas()
}, [isDatabasesSuccess, readReplicas])
return (
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Input
ref={searchInputRef}
placeholder="Filter destinations"
size="tiny"
icon={<Search />}
value={filterString}
className="w-full lg:w-52"
onChange={(e) => setFilterString(e.target.value)}
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
actions={
filterString.length > 0 && (
<Button
aria-label="Clear filter"
variant="text"
icon={<X />}
className="p-0 h-5 w-5"
onClick={() => setFilterString('')}
/>
)
}
/>
</div>
<div className="flex items-center gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
variant="default"
icon={<MoreVertical />}
className="px-1"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem asChild>
<Link href={`/org/${organization?.slug}/usage#pipeline-initial-sync-data`}>
View Pipelines usage
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{replicationNotEnabled ? (
<DropdownMenuItem onClick={() => setShowEnablePipelinesDialog(true)}>
Enable Pipelines
</DropdownMenuItem>
) : (
<DropdownMenuItemTooltip
disabled={!canDisablePipelines}
tooltip={{
content: {
side: 'left',
text: 'Remove all existing destinations before disabling Pipelines',
},
}}
onClick={() => setShowDisablePipelinesDialog(true)}
>
Disable Pipelines
</DropdownMenuItemTooltip>
)}
</DropdownMenuContent>
</DropdownMenu>
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />
<Shortcut
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
label="Add destination"
onTrigger={openDestinationPanel}
options={{ enabled: !!newDestinationDefaultType }}
side="bottom"
>
<Button
variant="primary"
icon={<Plus />}
disabled={!newDestinationDefaultType}
onClick={openDestinationPanel}
>
Add destination
</Button>
</Shortcut>
</div>
</div>
<div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
{hasErrorsFetchingData && (
<AlertError
error={destinationsError || databasesError}
subject="Failed to retrieve destinations"
/>
)}
{isLoading ? (
<GenericSkeletonLoader />
) : hasReplicas || hasDestinations ? (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead key="type" className="w-[20px]" />
<TableHead key="name" className="w-[250px]">
Name
</TableHead>
<TableHead key="status" className="w-[150px]">
Status
</TableHead>
<TableHead key="lag" className="w-[150px]">
Lag
</TableHead>
<TableHead key="publication">Publication</TableHead>
<TableHead key="actions" />
</TableRow>
</TableHeader>
<TableBody>
{filteredReplicas.map((replica) => {
return (
<ReadReplicaRow
key={replica.identifier}
replica={replica}
onUpdateReplica={() => setStatusRefetchInterval(5000)}
/>
)
})}
{filteredDestinations.map((destination) => (
<DestinationRow key={destination.id} destinationId={destination.id} />
))}
{!isLoading &&
filteredDestinations.length === 0 &&
filteredReplicas.length === 0 &&
(hasReplicas || hasDestinations) && (
<TableRow>
<TableCell colSpan={6}>
<p>No results found</p>
<p className="text-foreground-light">
Your search for "{filterString}" did not return any results.
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
!isLoading &&
!hasErrorsFetchingData && (
<EmptyStatePresentational
icon={Database}
title="Add a replication destination"
description="Deploy a read replica for lower latency and workload isolation, or connect a Pipelines destination for analytics workloads."
>
<Button
variant="default"
icon={<Plus />}
disabled={!newDestinationDefaultType}
onClick={openDestinationPanel}
>
Add destination
</Button>
</EmptyStatePresentational>
)
)}
</div>
<DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
<EnablePipelinesModal
open={showEnablePipelinesDialog}
onOpenChange={setShowEnablePipelinesDialog}
/>
<DisablePipelinesDialog
open={showDisablePipelinesDialog}
setOpen={setShowDisablePipelinesDialog}
/>
</div>
)
}