From bb086a84b8ffd8e5c89d2f4702e6d2a2524ebc89 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:44:41 +1000 Subject: [PATCH] feat(studio): remove read replicas from Replication (#49046) ## What kind of change does this PR introduce? Feature. Stack 4 of 5 for [PIPE-1007](https://linear.app/supabase/issue/PIPE-1007/move-read-replicas-out-of-replication-into-infrastructure). Contributes to PIPE-1008. ## What is the current behavior? Database / Replication lists, creates, and diagrams read replicas alongside pipelines. ## What is the new behavior? Replication is pipelines-only. No replica rows, type, or diagram nodes. `?destinationType=Read+Replica` redirects to Infrastructure. A short callout points create-mode users at the new home. ## Additional context Please review, but do not merge until [#48921](https://github.com/supabase/supabase/pull/48921) is ready to follow immediately. The flag is already on, so this PR is the user-facing cutover off Replication. ## To test `infrastructure:read_replicas` is an enabled-feature, on by default. There is no Feature Preview or ConfigCat switch. You should already see the Infrastructure Read replicas section. If you do not, your profile lists `infrastructure:read_replicas` in `disabled_features`. Open [Database / Replication](https://studio-staging-git-danny-pipe-1007-04-cut-from-77ef95-supabase.vercel.app/dashboard/project/_/database/replication?destinationType=Read+Replica). You should land on Infrastructure with the add-replica sheet, not a replica destination type. The Replication page itself should be pipelines-only. ## Summary by CodeRabbit * **New Features** * Added guidance directing users to Infrastructure to create read replicas. * Added automatic redirection for legacy read-replica links. * **Updates** * Replication destinations now focus exclusively on external analytics and pipeline destinations. * Updated destination selection, empty states, descriptions, and diagrams to reflect the streamlined experience. * Removed read replicas from the replication destination list and related creation flow. --------- Co-authored-by: Jeremias Menichelli --- .../read-replicas/getting-started.mdx | 2 +- .../Replication/DestinationIcon.test.tsx | 1 - .../Database/Replication/DestinationIcon.tsx | 1 - .../DestinationPanel/DestinationPanel.tsx | 25 +- .../DestinationPanel.types.ts | 1 - .../DestinationTypeSelection.test.tsx | 65 +++-- .../DestinationTypeSelection.tsx | 265 ++++++++---------- .../ReadReplicasMovedCallout.tsx | 24 ++ .../Database/Replication/DestinationRow.tsx | 8 +- .../Database/Replication/Destinations.tsx | 136 +++------ .../Replication/Replication.constants.ts | 3 - .../Replication/ReplicationDiagram/Edges.tsx | 79 +++--- .../EmptyReplicationDiagram.tsx | 3 +- .../Replication/ReplicationDiagram/Nodes.tsx | 55 +--- .../Replication/ReplicationDiagram/index.tsx | 59 +--- ...directLegacyReadReplicaDestination.test.ts | 71 +++++ ...useRedirectLegacyReadReplicaDestination.ts | 28 ++ .../studio/components/ui/DatabaseSelector.tsx | 2 +- .../[ref]/database/replication/index.tsx | 2 +- 19 files changed, 381 insertions(+), 449 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.test.ts create mode 100644 apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.ts diff --git a/apps/docs/content/guides/platform/read-replicas/getting-started.mdx b/apps/docs/content/guides/platform/read-replicas/getting-started.mdx index f46984eb816..9a0d885041f 100644 --- a/apps/docs/content/guides/platform/read-replicas/getting-started.mdx +++ b/apps/docs/content/guides/platform/read-replicas/getting-started.mdx @@ -26,7 +26,7 @@ Projects must meet these requirements to use Read Replicas: ## Creating a Read Replica -To add a Read Replica, go to the [Database Replication page](/dashboard/project/_/database/replication) in your project dashboard. +To add a Read Replica, go to the [Infrastructure](/dashboard/project/_/settings/infrastructure) settings page in your project dashboard. You can also manage Read Replicas using the Management API (beta functionality): diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationIcon.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationIcon.test.tsx index d530bd35801..3eb9a24d0e8 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationIcon.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationIcon.test.tsx @@ -5,7 +5,6 @@ import { DestinationIcon } from './DestinationIcon' import type { DestinationType } from './DestinationPanel/DestinationPanel.types' const DESTINATION_TYPES: DestinationType[] = [ - 'Read Replica', 'BigQuery', 'Analytics Bucket', 'DuckLake', diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationIcon.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationIcon.tsx index e760e0ddfa2..6daf1ddaa11 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationIcon.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationIcon.tsx @@ -7,7 +7,6 @@ import type { DestinationType } from './DestinationPanel/DestinationPanel.types' type DestinationIconComponent = ComponentType & { size?: string | number }> const destinationIconByType: Record = { - 'Read Replica': Database, BigQuery, 'Analytics Bucket': AnalyticsBucket, DuckLake: Database, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index e58c8df34bc..7d139348670 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -24,7 +24,6 @@ import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha' import { DestinationForm } from './DestinationForm' import { DestinationType } from './DestinationPanel.types' import { DestinationTypeSelection } from './DestinationTypeSelection' -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' @@ -32,11 +31,7 @@ import { checkLocalETLNotSetUp } from '@/data/replication/utils' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { DOCS_URL } from '@/lib/constants' -interface DestinationPanelProps { - onSuccessCreateReadReplica?: () => void -} - -export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => { +export const DestinationPanel = () => { const { ref: projectRef } = useParams() const enablePgReplicate = useIsETLPrivateAlpha() const { error: destinationsError } = useReplicationDestinationsQuery({ projectRef }) @@ -45,7 +40,6 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane const [urlDestinationType, setDestinationType] = useQueryState( 'destinationType', parseAsStringEnum([ - 'Read Replica', 'BigQuery', 'Analytics Bucket', 'DuckLake', @@ -148,7 +142,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane {editMode ? 'Update the configuration for this destination.' - : 'Add a read replica or an external destination.'} + : 'Connect an external destination for analytics workloads.'} - {destinationType === 'Read Replica' ? ( - onSuccessCreateReadReplica?.()} - /> - ) : !enablePgReplicate ? ( + {!enablePgReplicate ? (
{pipelinesTypeSelection} @@ -174,8 +160,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane

Request Pipelines access

Pipelines is in public alpha and - being rolled out gradually. Request access below to join the waitlist. Read - replicas are available now. + being rolled out gradually. Request access below to join the waitlist.

@@ -207,7 +192,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane ) : ( ({ useIsETLClickHousePrivateAlpha: () => mockClickHouseEnabled(), })) +const mockInfrastructureReadReplicas = vi.fn(() => true) + vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ - useIsFeatureEnabled: () => ({ infrastructureReadReplicas: true }), + useIsFeatureEnabled: () => ({ + infrastructureReadReplicas: mockInfrastructureReadReplicas(), + }), })) // Background queries from useDestinationInformation (sources + pipelines fire @@ -54,6 +58,10 @@ const addBackgroundMocks = () => { } describe('DestinationTypeSelection', () => { + beforeEach(() => { + mockInfrastructureReadReplicas.mockReturnValue(true) + }) + test('shows placeholder when no type is selected', async () => { mockBigQueryEnabled.mockReturnValue(false) mockIcebergEnabled.mockReturnValue(false) @@ -67,22 +75,6 @@ describe('DestinationTypeSelection', () => { expect(await screen.findByText('Select a destination type')).toBeInTheDocument() }) - test('renders Read Replica in the Other group when dropdown is opened', async () => { - mockBigQueryEnabled.mockReturnValue(false) - mockIcebergEnabled.mockReturnValue(false) - mockDucklakeEnabled.mockReturnValue(false) - mockSnowflakeEnabled.mockReturnValue(false) - mockClickHouseEnabled.mockReturnValue(false) - addBackgroundMocks() - - customRender() - - fireEvent.click(await screen.findByRole('combobox')) - - expect(await screen.findByText('Other')).toBeInTheDocument() - expect(screen.getByText('Read Replica')).toBeInTheDocument() - }) - test('renders the Pipelines group with BigQuery when the flag is enabled', async () => { mockBigQueryEnabled.mockReturnValue(true) mockIcebergEnabled.mockReturnValue(false) @@ -111,8 +103,7 @@ describe('DestinationTypeSelection', () => { fireEvent.click(await screen.findByRole('combobox')) - expect(await screen.findByText('Other')).toBeInTheDocument() - expect(screen.getByText('Read Replica')).toBeInTheDocument() + expect(screen.queryByText('Read Replica')).not.toBeInTheDocument() expect(screen.queryByText('BigQuery')).not.toBeInTheDocument() expect(screen.queryByText('DuckLake')).not.toBeInTheDocument() expect(screen.queryByText('Analytics Bucket')).not.toBeInTheDocument() @@ -165,5 +156,39 @@ describe('DestinationTypeSelection', () => { customRender(, { nuqs: { searchParams: { edit: '1' } } }) expect(await screen.findByRole('combobox')).toBeDisabled() + expect(screen.queryByText('Read replicas have moved')).not.toBeInTheDocument() + }) + + test('shows a callout pointing read replicas to Infrastructure in create mode', async () => { + mockBigQueryEnabled.mockReturnValue(false) + mockIcebergEnabled.mockReturnValue(false) + mockDucklakeEnabled.mockReturnValue(false) + mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) + mockInfrastructureReadReplicas.mockReturnValue(true) + addBackgroundMocks() + + customRender() + + expect(await screen.findByText('Read replicas have moved')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Add read replica' })).toHaveAttribute( + 'href', + expect.stringContaining('/settings/infrastructure?addReplica=true') + ) + }) + + test('hides the read replicas callout when Infrastructure read replicas are disabled', async () => { + mockBigQueryEnabled.mockReturnValue(false) + mockIcebergEnabled.mockReturnValue(false) + mockDucklakeEnabled.mockReturnValue(false) + mockSnowflakeEnabled.mockReturnValue(false) + mockClickHouseEnabled.mockReturnValue(false) + mockInfrastructureReadReplicas.mockReturnValue(false) + addBackgroundMocks() + + customRender() + + expect(await screen.findByText('Select a destination type')).toBeInTheDocument() + expect(screen.queryByText('Read replicas have moved')).not.toBeInTheDocument() }) }) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index f5123816e9b..f3e23de6e61 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -6,7 +6,6 @@ import { SelectGroup, SelectItem, SelectLabel, - SelectSeparator, SelectTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -21,6 +20,7 @@ import { useIsETLSnowflakePrivateAlpha, } from '../useIsETLPrivateAlpha' import { DestinationType } from './DestinationPanel.types' +import { ReadReplicasMovedCallout } from './ReadReplicasMovedCallout' import { InlineLink } from '@/components/ui/InlineLink' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -32,9 +32,13 @@ interface DestinationTypeOption { enabled: boolean } -interface DestinationTypeGroup { - label: string - options: DestinationTypeOption[] +const STAGE_BADGE_VARIANT: Record< + NonNullable, + 'warning' | 'destructive' | 'default' +> = { + 'Early Access': 'warning', + Deprecated: 'destructive', + 'Public Alpha': 'default', } export const DestinationTypeSelection = () => { @@ -48,7 +52,6 @@ export const DestinationTypeSelection = () => { const [urlDestinationType, setDestinationType] = useQueryState( 'destinationType', parseAsStringEnum([ - 'Read Replica', 'BigQuery', 'Analytics Bucket', 'DuckLake', @@ -69,75 +72,51 @@ export const DestinationTypeSelection = () => { const { type: existingDestinationType } = useDestinationInformation({ id: edit }) const destinationType = existingDestinationType ?? urlDestinationType - // In edit mode the type is locked, so only surface the option that matches the - // destination being edited. Otherwise show every type the project has access to. const isOptionVisible = (value: DestinationType, hasAccess: boolean) => editMode ? destinationType === value : hasAccess - const groups: DestinationTypeGroup[] = [ - { - label: 'Other', - options: [ - { - value: 'Read Replica', - label: 'Read Replica', - description: - 'Deploy a read-only database in another region for lower latency and workload isolation', - stage: null, - enabled: isOptionVisible('Read Replica', infrastructureReadReplicas), - }, - ], - }, - { - label: 'Pipelines', - options: [ - { - value: 'Analytics Bucket', - label: 'Analytics Bucket', - description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows', - stage: 'Deprecated', - enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg), - }, - { - value: 'BigQuery', - label: 'BigQuery', - description: "Replicate changes to Google Cloud's data warehouse for analytics and BI", - stage: 'Public Alpha', - enabled: isOptionVisible('BigQuery', etlEnableBigQuery), - }, - { - value: 'DuckLake', - label: 'DuckLake', - description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage', - stage: 'Early Access', - enabled: isOptionVisible('DuckLake', etlEnableDucklake), - }, - { - value: 'Snowflake', - label: 'Snowflake', - description: - 'Replicate changes to Snowflake for warehouse analytics and downstream data workflows', - stage: 'Early Access', - enabled: isOptionVisible('Snowflake', etlEnableSnowflake), - }, - { - value: 'ClickHouse', - label: 'ClickHouse', - description: 'Stream changes to a ClickHouse cluster for fast columnar analytics', - stage: 'Early Access', - enabled: isOptionVisible('ClickHouse', etlEnableClickHouse), - }, - ], - }, - ] + const options: DestinationTypeOption[] = ( + [ + { + value: 'Analytics Bucket', + label: 'Analytics Bucket', + description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows', + stage: 'Deprecated', + enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg), + }, + { + value: 'BigQuery', + label: 'BigQuery', + description: "Replicate changes to Google Cloud's data warehouse for analytics and BI", + stage: 'Public Alpha', + enabled: isOptionVisible('BigQuery', etlEnableBigQuery), + }, + { + value: 'DuckLake', + label: 'DuckLake', + description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage', + stage: 'Early Access', + enabled: isOptionVisible('DuckLake', etlEnableDucklake), + }, + { + value: 'Snowflake', + label: 'Snowflake', + description: + 'Replicate changes to Snowflake for warehouse analytics and downstream data workflows', + stage: 'Early Access', + enabled: isOptionVisible('Snowflake', etlEnableSnowflake), + }, + { + value: 'ClickHouse', + label: 'ClickHouse', + description: 'Stream changes to a ClickHouse cluster for fast columnar analytics', + stage: 'Early Access', + enabled: isOptionVisible('ClickHouse', etlEnableClickHouse), + }, + ] satisfies DestinationTypeOption[] + ).filter((option) => option.enabled) - const visibleGroups = groups - .map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) })) - .filter((group) => group.options.length > 0) - - const selectedOption = visibleGroups - .flatMap((group) => group.options) - .find((option) => option.value === destinationType) + const selectedOption = options.find((option) => option.value === destinationType) const stageDescription = selectedOption?.stage === 'Public Alpha' ? ( @@ -168,86 +147,78 @@ export const DestinationTypeSelection = () => { ) : undefined return ( - - setDestinationType(value as DestinationType)} + > + + {selectedOption ? ( +
+ +
+ {selectedOption.label} + {selectedOption.stage && ( + + {selectedOption.stage} + + )} +
-
- ) : ( - Select a destination type - )} - - - {visibleGroups.map((group, index) => ( - - {index > 0 && } - {group.label} - {group.options.map((option) => ( - -
- -
-
- {option.label} - {option.stage && ( - - {option.stage} - - )} + ) : ( + Select a destination type + )} + + + {options.length > 0 && ( + + Pipelines + {options.map((option) => ( + +
+ +
+
+ {option.label} + {option.stage && ( + + {option.stage} + + )} +
+ + {option.description} +
- {option.description}
-
- - ))} - - ))} - - - + + ))} + + )} + + + + {!editMode && infrastructureReadReplicas && ( +
+ +
+ )} + ) } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout.tsx new file mode 100644 index 00000000000..1f61ffdcda7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout.tsx @@ -0,0 +1,24 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' + +import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' + +export const ReadReplicasMovedCallout = () => { + const { ref: projectRef } = useParams() + + return ( + + Add read replica + + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 2dac917022b..8b687950546 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -150,11 +150,9 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => { {isPipelineSuccess && ( - + {type ? ( + + ) : null} diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 82ea3551774..7e95410cede 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,7 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { Database } from 'icons' -import { MoreVertical, Plus, Search, X } from 'lucide-react' +import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react' import Link from 'next/link' import { parseAsStringEnum, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' @@ -30,7 +29,6 @@ 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, @@ -38,19 +36,17 @@ import { useIsETLIcebergPrivateAlpha, useIsETLSnowflakePrivateAlpha, } from './useIsETLPrivateAlpha' -import { ReadReplicaRow } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow' +import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination' 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' @@ -62,38 +58,35 @@ export const Destinations = () => { const { ref: projectRef } = useParams() const { data: organization } = useSelectedOrganizationQuery() + useRedirectLegacyReadReplicaDestination() + 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 newDestinationDefaultType: DestinationType | null = etlEnableBigQuery + ? 'BigQuery' + : etlEnableIceberg + ? 'Analytics Bucket' + : etlEnableDucklake + ? 'DuckLake' + : etlEnableSnowflake + ? 'Snowflake' + : etlEnableClickHouse + ? 'ClickHouse' + : null const prefetchedRef = useRef(false) const searchInputRef = useRef(null) const [filterString, setFilterString] = useState('') - const [statusRefetchInterval, setStatusRefetchInterval] = useState(5000) const [showEnablePipelinesDialog, setShowEnablePipelinesDialog] = useState(false) const [showDisablePipelinesDialog, setShowDisablePipelinesDialog] = useState(false) - const [_, setDestinationType] = useQueryState( + const [, setDestinationType] = useQueryState( 'destinationType', parseAsStringEnum([ - 'Read Replica', 'BigQuery', 'Analytics Bucket', 'DuckLake', @@ -105,30 +98,6 @@ export const Destinations = () => { }) ) - 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, @@ -169,10 +138,9 @@ export const Destinations = () => { destinations.length === 0 && pipelines.length === 0 - const isLoading = isDestinationsLoading || isDatabasesLoading - + const isLoading = isDestinationsLoading const isLocalETLNotSetUp = checkLocalETLNotSetUp(destinationsError) - const hasErrorsFetchingData = (!isLocalETLNotSetUp && isDestinationsError) || isDatabasesError + const hasErrorsFetchingData = !isLocalETLNotSetUp && isDestinationsError const openDestinationPanel = () => { if (!newDestinationDefaultType) return @@ -211,26 +179,6 @@ export const Destinations = () => { } }, [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 (
@@ -318,15 +266,12 @@ export const Destinations = () => {
{hasErrorsFetchingData && ( - + )} {isLoading ? ( - ) : hasReplicas || hasDestinations ? ( + ) : hasDestinations ? ( @@ -347,33 +292,20 @@ export const Destinations = () => { - {filteredReplicas.map((replica) => { - return ( - setStatusRefetchInterval(5000)} - /> - ) - })} - {filteredDestinations.map((destination) => ( ))} - {!isLoading && - filteredDestinations.length === 0 && - filteredReplicas.length === 0 && - (hasReplicas || hasDestinations) && ( - - -

No results found

-

- Your search for "{filterString}" did not return any results. -

-
-
- )} + {!isLoading && filteredDestinations.length === 0 && hasDestinations && ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results. +

+
+
+ )}
@@ -382,9 +314,9 @@ export const Destinations = () => { !isLoading && !hasErrorsFetchingData && (
- setStatusRefetchInterval(5000)} /> + { const { ref: projectRef = 'default' } = useParams() - const { type, identifier, shiftEdgeEnd } = (data || {}) as EdgeData - const isReplica = type === 'replica' + const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData - // Subscribe to the same live status the nodes use, so the line and the node update together. - const { data: databases = [] } = useReadReplicasQuery( - { projectRef }, - { enabled: isReplica, refetchInterval: STATUS_REFRESH_FREQUENCY_MS } - ) - const replica = databases.find((x) => x.identifier === identifier) - - const { data: pipelinesData } = useReplicationPipelinesQuery( - { projectRef }, - { enabled: !isReplica } - ) + const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef }) const pipeline = (pipelinesData?.pipelines ?? []).find( (p) => p.destination_id.toString() === identifier ) const { data: pipelineStatusData } = useReplicationPipelineStatusQuery( { projectRef, pipelineId: pipeline?.id }, - { enabled: !isReplica && !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS } + { enabled: !!pipeline?.id, refetchInterval: STATUS_REFRESH_FREQUENCY_MS } ) const { getRequestStatus } = usePipelineRequestStatus() const requestStatus = pipeline?.id @@ -120,22 +108,6 @@ export const SmoothstepEdge = ({ : PipelineStatusRequestStatus.None const replicationState = useMemo(() => { - if (isReplica) { - const status = replica?.status - return { - isReplicating: status === 'ACTIVE_HEALTHY', - isComingUp: - status !== undefined && - [ - REPLICA_STATUS.COMING_UP, - REPLICA_STATUS.INIT_READ_REPLICA, - REPLICA_STATUS.UNKNOWN, - ].includes(status), - isFailed: - status !== undefined && - [REPLICA_STATUS.ACTIVE_UNHEALTHY, REPLICA_STATUS.INIT_FAILED].includes(status), - } - } const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None const statusName = getStatusName(pipelineStatusData?.status) return { @@ -143,19 +115,27 @@ export const SmoothstepEdge = ({ isComingUp: isTransitioning || statusName === 'starting' || statusName === 'stopping', isFailed: statusName === 'failed', } - }, [isReplica, replica?.status, pipelineStatusData?.status, requestStatus]) + }, [pipelineStatusData?.status, requestStatus]) const [edgePath, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, - targetX, + targetX: shiftEdgeEnd ? targetX - 8 : targetX, targetY, targetPosition, }) - const { Icon, color, opacity, dashArray, shouldAnimate, shouldSpin, isFilled, strokeWidth } = - getEdgeVisual(replicationState) + const { + Icon, + color, + opacity, + dashArray, + shouldAnimate, + shouldSpin, + isFilled, + strokeWidth = 2, + } = getEdgeVisual(replicationState) return ( <> @@ -165,26 +145,33 @@ export const SmoothstepEdge = ({ style={{ ...style, stroke: color, + strokeWidth, opacity, strokeDasharray: dashArray, animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined, }} /> -
- +
+ +
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx index 105e544adf7..5f975647b03 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/EmptyReplicationDiagram.tsx @@ -1,7 +1,7 @@ import { Background, ColorMode, ReactFlow, ReactFlowProvider } from '@xyflow/react' import { useTheme } from 'next-themes' -import { PrimaryDatabaseNode, ReadReplicaNode, ReplicationNode } from './Nodes' +import { PrimaryDatabaseNode, ReplicationNode } from './Nodes' import '@xyflow/react/dist/style.css' @@ -18,7 +18,6 @@ export const EmptyReplicationDiagram = () => { const nodeTypes = { primary: PrimaryDatabaseNode, replication: ReplicationNode, - readReplica: ReadReplicaNode, } const edgeTypes = { smoothstep: SmoothstepEdge } diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx index b4d1afe51a3..b17435b83e0 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx @@ -1,6 +1,6 @@ import { Handle, Position } from '@xyflow/react' import { useParams } from 'common' -import { PropsWithChildren, useMemo } from 'react' +import { PropsWithChildren } from 'react' import { AWS_REGIONS } from 'shared-data' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -8,9 +8,6 @@ import { DestinationIcon } from '../DestinationIcon' import { getStatusName } from '../Pipeline.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' import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query' import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query' @@ -37,14 +34,10 @@ export const PrimaryDatabaseNode = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const { data: databases = [] } = useReadReplicasQuery({ projectRef }) - const hasReadReplicas = databases.some((x) => x.identifier !== projectRef) - const { data: destinationsData } = useReplicationDestinationsQuery({ projectRef }) const hasDestinations = (destinationsData?.destinations ?? []).length > 0 const region = Object.values(AWS_REGIONS).find((x) => x.code === project?.region) - const hasReplication = hasReadReplicas || hasDestinations return ( @@ -63,7 +56,7 @@ export const PrimaryDatabaseNode = () => { ) @@ -118,47 +111,3 @@ export const ReplicationNode = ({ id }: { id: string }) => { ) } - -export const ReadReplicaNode = ({ id }: { id: string }) => { - const { ref: projectRef } = useParams() - const { data: databases = [] } = useReadReplicasQuery({ projectRef }) - const database = databases.find((x) => x.identifier === id) - - const region = Object.values(AWS_REGIONS).find((x) => x.code === database?.region) - const formattedId = formatDatabaseID(database?.identifier ?? '') - const statusLabel = useMemo( - () => getStatusLabel({ status: database?.status }), - [database?.status] - ) - - return ( - - -
-
-

Read Replica

- - -
-
-
- - {statusLabel} - -
-

{region?.displayName}

-
- ID: {formattedId} - • - {region?.code} -
-
- - - ) -} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx index 4f1412a4cce..7ebc6daf965 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/index.tsx @@ -3,9 +3,8 @@ import { useParams } from 'common' import { useTheme } from 'next-themes' import { useEffect, useMemo } from 'react' -import { PrimaryDatabaseNode, ReadReplicaNode, ReplicationNode } from './Nodes' +import { PrimaryDatabaseNode, ReplicationNode } from './Nodes' import { getDagreGraphLayout } from './ReplicationDiagram.utils' -import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' import { timeout } from '@/lib/helpers' @@ -25,7 +24,6 @@ export const ReplicationDiagram = () => { const nodeTypes = { primary: PrimaryDatabaseNode, replication: ReplicationNode, - readReplica: ReadReplicaNode, } const edgeTypes = { smoothstep: SmoothstepEdge } @@ -35,14 +33,6 @@ const ReplicationDiagramContent = () => { const { resolvedTheme } = useTheme() const { ref: projectRef = 'default' } = useParams() - const { data: databases = [], isSuccess: isSuccessReplicas } = useReadReplicasQuery({ - projectRef, - }) - const readReplicas = useMemo( - () => databases.filter((x) => x.identifier !== projectRef), - [databases, projectRef] - ) - const { data, error: destinationsError, @@ -58,12 +48,6 @@ const ReplicationDiagramContent = () => { const nodes = useMemo(() => { return [ { id: projectRef, type: 'primary', data: {}, position: { x: 0, y: 5 } }, - ...readReplicas.map((x) => ({ - id: x.identifier, - type: 'readReplica', - data: {}, - position: { x: 0, y: 0 }, - })), ...destinations.map((x) => ({ id: x.id.toString(), type: 'replication', @@ -71,31 +55,20 @@ const ReplicationDiagramContent = () => { position: { x: 0, y: 0 }, })), ] - }, [destinations, projectRef, readReplicas]) + }, [destinations, projectRef]) const edges = useMemo(() => { - const shiftEdgeEnd = readReplicas.length + destinations.length > 1 + const shiftEdgeEnd = destinations.length > 1 - return [ - ...readReplicas.map((x) => ({ - id: `${projectRef}-${x.identifier}`, - source: projectRef, - target: x.identifier, - type: 'smoothstep', - className: 'cursor-default!', - // The edge subscribes to live status itself (see Edges.tsx) so it stays in sync with nodes. - data: { type: 'replica', identifier: x.identifier, shiftEdgeEnd }, - })), - ...destinations.map((x) => ({ - id: `${projectRef}-${x.id}`, - source: projectRef, - target: x.id.toString(), - type: 'smoothstep', - className: 'cursor-default!', - data: { type: 'etl', identifier: x.id.toString(), shiftEdgeEnd }, - })), - ] - }, [destinations, projectRef, readReplicas]) + return destinations.map((x) => ({ + id: `${projectRef}-${x.id}`, + source: projectRef, + target: x.id.toString(), + type: 'smoothstep', + className: 'cursor-default!', + data: { type: 'etl', identifier: x.id.toString(), shiftEdgeEnd }, + })) + }, [destinations, projectRef]) const backgroundPatternColor = resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' @@ -111,14 +84,10 @@ const ReplicationDiagramContent = () => { } useEffect(() => { - if ( - nodes.length > 0 && - (isSuccessDestinations || skipRenderingDestinations) && - isSuccessReplicas - ) { + if (nodes.length > 0 && (isSuccessDestinations || skipRenderingDestinations)) { setReactFlow() } - }, [nodes, isSuccessDestinations, skipRenderingDestinations, isSuccessReplicas]) + }, [nodes, isSuccessDestinations, skipRenderingDestinations]) return (
diff --git a/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.test.ts b/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.test.ts new file mode 100644 index 00000000000..b61898c3d57 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.test.ts @@ -0,0 +1,71 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination' + +const mockReplace = vi.fn() +const mockQuery = vi.fn(() => ({}) as Record) +const mockIsReady = vi.fn(() => true) +const mockProjectRef = vi.fn(() => 'abc123') +const mockInfrastructureReadReplicas = vi.fn(() => true) + +vi.mock('next/router', () => ({ + useRouter: () => ({ + isReady: mockIsReady(), + query: mockQuery(), + replace: mockReplace, + }), +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: () => ({ ref: mockProjectRef() }), + } +}) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: () => ({ + infrastructureReadReplicas: mockInfrastructureReadReplicas(), + }), +})) + +describe('useRedirectLegacyReadReplicaDestination', () => { + beforeEach(() => { + mockReplace.mockClear() + mockQuery.mockReturnValue({}) + mockIsReady.mockReturnValue(true) + mockProjectRef.mockReturnValue('abc123') + mockInfrastructureReadReplicas.mockReturnValue(true) + }) + + test('redirects legacy Read Replica destinationType to Infrastructure', async () => { + mockQuery.mockReturnValue({ destinationType: 'Read Replica' }) + + renderHook(() => useRedirectLegacyReadReplicaDestination()) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith( + '/project/abc123/settings/infrastructure?addReplica=true' + ) + }) + }) + + test('does not redirect for other destination types', () => { + mockQuery.mockReturnValue({ destinationType: 'BigQuery' }) + + renderHook(() => useRedirectLegacyReadReplicaDestination()) + + expect(mockReplace).not.toHaveBeenCalled() + }) + + test('does not redirect when Infrastructure read replicas are disabled', () => { + mockInfrastructureReadReplicas.mockReturnValue(false) + mockQuery.mockReturnValue({ destinationType: 'Read Replica' }) + + renderHook(() => useRedirectLegacyReadReplicaDestination()) + + expect(mockReplace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.ts b/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.ts new file mode 100644 index 00000000000..f980e0f6042 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/useRedirectLegacyReadReplicaDestination.ts @@ -0,0 +1,28 @@ +import { useParams } from 'common' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' + +const LEGACY_READ_REPLICA_DESTINATION_TYPE = 'Read Replica' + +/** + * Bookmarks and old CTAs used `/database/replication?destinationType=Read+Replica`. + * That type no longer exists on this page; send those users to Infrastructure. + */ +export const useRedirectLegacyReadReplicaDestination = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) + + useEffect(() => { + if (!infrastructureReadReplicas || !projectRef || !router.isReady) return + + const destinationType = router.query.destinationType + const legacyType = Array.isArray(destinationType) ? destinationType[0] : destinationType + if (legacyType !== LEGACY_READ_REPLICA_DESTINATION_TYPE) return + + router.replace(getAddReadReplicaPath(projectRef)) + }, [infrastructureReadReplicas, projectRef, router]) +} diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 8c4de479bc5..212ffc77a96 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -22,9 +22,9 @@ import { TooltipTrigger, } from 'ui' -import { REPLICA_STATUS } from '../interfaces/Database/Replication/Replication.constants' import { Markdown } from '@/components/interfaces/Markdown' import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx index 98097e6481a..462c3c49ae5 100644 --- a/apps/studio/pages/project/[ref]/database/replication/index.tsx +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -47,7 +47,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { Replication - Read replicas and analytics pipelines + Send data to external destinations