mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Jeremias Menichelli <jmenichelli@gmail.com>
This commit is contained in:
1 parent
7fd7ace118
commit
bb086a84b8
19 files changed
+381
-449
No files matched your search
@@ -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):
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
||||
type DestinationIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: string | number }>
|
||||
|
||||
const destinationIconByType: Record<DestinationType, DestinationIconComponent> = {
|
||||
'Read Replica': Database,
|
||||
BigQuery,
|
||||
'Analytics Bucket': AnalyticsBucket,
|
||||
DuckLake: Database,
|
||||
|
||||
+5
-20
@@ -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<DestinationType>([
|
||||
'Read Replica',
|
||||
'BigQuery',
|
||||
'Analytics Bucket',
|
||||
'DuckLake',
|
||||
@@ -148,7 +142,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
<SheetDescription>
|
||||
{editMode
|
||||
? 'Update the configuration for this destination.'
|
||||
: 'Add a read replica or an external destination.'}
|
||||
: 'Connect an external destination for analytics workloads.'}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<DocsButton
|
||||
@@ -157,15 +151,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
/>
|
||||
</SheetHeader>
|
||||
|
||||
{destinationType === 'Read Replica' ? (
|
||||
<ReadReplicaForm
|
||||
typeSelection={typeSelection}
|
||||
checkIsDirtyRef={checkIsDirtyRef}
|
||||
onClose={onClose}
|
||||
onCancel={confirmOnClose}
|
||||
onSuccess={() => onSuccessCreateReadReplica?.()}
|
||||
/>
|
||||
) : !enablePgReplicate ? (
|
||||
{!enablePgReplicate ? (
|
||||
<div className="grow overflow-auto min-h-0">
|
||||
{pipelinesTypeSelection}
|
||||
<SheetSection>
|
||||
@@ -174,8 +160,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
<h4>Request Pipelines access</h4>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Pipelines is in <span className="text-foreground">public alpha</span> 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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
@@ -207,7 +192,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
|
||||
) : (
|
||||
<DestinationForm
|
||||
visible={visible}
|
||||
selectedType={destinationType ?? 'Read Replica'}
|
||||
selectedType={destinationType ?? 'BigQuery'}
|
||||
existingDestination={existingDestination}
|
||||
typeSelection={pipelinesTypeSelection}
|
||||
checkIsDirtyRef={checkIsDirtyRef}
|
||||
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
export type DestinationType =
|
||||
| 'Read Replica'
|
||||
| 'BigQuery'
|
||||
| 'Analytics Bucket'
|
||||
| 'DuckLake'
|
||||
|
||||
+45
-20
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { DestinationTypeSelection } from './DestinationTypeSelection'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
@@ -30,8 +30,12 @@ vi.mock('../useIsETLPrivateAlpha', () => ({
|
||||
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(<DestinationTypeSelection />)
|
||||
|
||||
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(<DestinationTypeSelection />, { 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(<DestinationTypeSelection />)
|
||||
|
||||
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(<DestinationTypeSelection />)
|
||||
|
||||
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Read replicas have moved')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+118
-147
@@ -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<DestinationTypeOption['stage']>,
|
||||
'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<DestinationType>([
|
||||
'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 (
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
className="p-5 [&>div]:gap-y-1 [&>div>span]:text-foreground-lighter"
|
||||
label="Type"
|
||||
description={typeDescription}
|
||||
>
|
||||
<Select
|
||||
disabled={editMode}
|
||||
value={destinationType ?? undefined}
|
||||
onValueChange={(value) => setDestinationType(value as DestinationType)}
|
||||
<>
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="horizontal"
|
||||
className="p-5 [&>div]:gap-y-1 [&>div>span]:text-foreground-lighter"
|
||||
label="Type"
|
||||
description={typeDescription}
|
||||
>
|
||||
<SelectTrigger className="h-auto py-2">
|
||||
{selectedOption ? (
|
||||
<div className="flex items-center gap-x-3 text-left">
|
||||
<DestinationIcon
|
||||
type={selectedOption.value}
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.stage && (
|
||||
<Badge
|
||||
variant={
|
||||
selectedOption.stage === 'Early Access'
|
||||
? 'warning'
|
||||
: selectedOption.stage === 'Deprecated'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{selectedOption.stage}
|
||||
</Badge>
|
||||
)}
|
||||
<Select
|
||||
disabled={editMode}
|
||||
value={destinationType ?? undefined}
|
||||
onValueChange={(value) => setDestinationType(value as DestinationType)}
|
||||
>
|
||||
<SelectTrigger className="h-auto py-2">
|
||||
{selectedOption ? (
|
||||
<div className="flex items-center gap-x-3 text-left">
|
||||
<DestinationIcon
|
||||
type={selectedOption.value}
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[selectedOption.stage]}>
|
||||
{selectedOption.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Select a destination type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{visibleGroups.map((group, index) => (
|
||||
<SelectGroup key={group.label}>
|
||||
{index > 0 && <SelectSeparator />}
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<DestinationIcon
|
||||
type={option.value}
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
{option.stage && (
|
||||
<Badge
|
||||
variant={
|
||||
option.stage === 'Early Access'
|
||||
? 'warning'
|
||||
: option.stage === 'Deprecated'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{option.stage}
|
||||
</Badge>
|
||||
)}
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Select a destination type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{options.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Pipelines</SelectLabel>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<DestinationIcon
|
||||
type={option.value}
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
{option.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[option.stage]}>
|
||||
{option.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-lighter">{option.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
{!editMode && infrastructureReadReplicas && (
|
||||
<div className="px-5 pb-5">
|
||||
<ReadReplicasMovedCallout />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+24
@@ -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 (
|
||||
<Admonition
|
||||
type="note"
|
||||
layout="responsive"
|
||||
title="Read replicas have moved"
|
||||
description="Read replicas are now managed on the Infrastructure page, alongside compute and disk."
|
||||
actions={
|
||||
<Button asChild variant="default" size="tiny">
|
||||
<Link href={getAddReadReplicaPath(projectRef)}>Add read replica</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -150,11 +150,9 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
{isPipelineSuccess && (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<DestinationIcon
|
||||
type={type ?? 'Read Replica'}
|
||||
size={18}
|
||||
className="text-foreground-light"
|
||||
/>
|
||||
{type ? (
|
||||
<DestinationIcon type={type} size={18} className="text-foreground-light" />
|
||||
) : null}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="max-w-[180px]">
|
||||
|
||||
@@ -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<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(
|
||||
const [, setDestinationType] = useQueryState(
|
||||
'destinationType',
|
||||
parseAsStringEnum<DestinationType>([
|
||||
'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 (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -318,15 +266,12 @@ export const Destinations = () => {
|
||||
|
||||
<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"
|
||||
/>
|
||||
<AlertError error={destinationsError} subject="Failed to retrieve destinations" />
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : hasReplicas || hasDestinations ? (
|
||||
) : hasDestinations ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
@@ -347,33 +292,20 @@ export const Destinations = () => {
|
||||
</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>
|
||||
)}
|
||||
{!isLoading && filteredDestinations.length === 0 && 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>
|
||||
@@ -382,9 +314,9 @@ export const Destinations = () => {
|
||||
!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."
|
||||
icon={Workflow}
|
||||
title="Add a destination"
|
||||
description="Connect an external destination for analytics workloads."
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -399,7 +331,7 @@ export const Destinations = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
|
||||
<DestinationPanel />
|
||||
|
||||
<EnablePipelinesModal
|
||||
open={showEnablePipelinesDialog}
|
||||
|
||||
@@ -8,6 +8,3 @@ export enum PipelineStatusName {
|
||||
STOPPING = 'stopping',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
/** @deprecated Import from Settings/Infrastructure/ReadReplicas/ReadReplicas.constants */
|
||||
export { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
|
||||
+33
-46
@@ -5,8 +5,7 @@ import { useMemo } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { getStatusName } from '../Pipeline.utils'
|
||||
import { REPLICA_STATUS, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
|
||||
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
|
||||
import {
|
||||
@@ -93,26 +92,15 @@ export const SmoothstepEdge = ({
|
||||
data,
|
||||
}: EdgeProps) => {
|
||||
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<ReplicationState>(() => {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="bg-surface-100 p-1 rounded-sm absolute nodrag nopan border"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${shiftEdgeEnd ? targetX - 30 : labelX}px,${shiftEdgeEnd ? targetY : labelY}px)`,
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
<Icon
|
||||
size={12}
|
||||
strokeWidth={strokeWidth ?? 2}
|
||||
fill={isFilled ? 'currentColor' : 'none'}
|
||||
className={cn(shouldSpin && 'animate-spin')}
|
||||
style={{ color }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100'
|
||||
)}
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={cn(shouldSpin && 'animate-spin')}
|
||||
style={{ color, fill: isFilled ? color : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
|
||||
+1
-2
@@ -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 }
|
||||
|
||||
+2
-53
@@ -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 (
|
||||
<NodeContainer>
|
||||
@@ -63,7 +56,7 @@ export const PrimaryDatabaseNode = () => {
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className={hasReplication ? 'opacity-25' : 'opacity-0'}
|
||||
className={hasDestinations ? 'opacity-25' : 'opacity-0'}
|
||||
/>
|
||||
</NodeContainer>
|
||||
)
|
||||
@@ -118,47 +111,3 @@ export const ReplicationNode = ({ id }: { id: string }) => {
|
||||
</NodeContainer>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<NodeContainer className="justify-start gap-x-3">
|
||||
<DestinationIcon type="Read Replica" size={20} className="text-foreground-light" />
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm">Read Replica</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="w-6 h-full flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
database?.status === 'ACTIVE_HEALTHY' ? 'bg-brand' : 'bg-selection'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{statusLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">{region?.displayName}</p>
|
||||
<div className="flex gap-x-2 items-center text-sm text-foreground-light">
|
||||
<span>ID: {formattedId}</span>
|
||||
<span>•</span>
|
||||
<span>{region?.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} className="opacity-25" />
|
||||
</NodeContainer>
|
||||
)
|
||||
}
|
||||
+14
-45
@@ -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 (
|
||||
<div className="nowheel relative h-[350px] w-full border border-muted rounded-md overflow-hidden">
|
||||
|
||||
+71
@@ -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<string, string | string[] | undefined>)
|
||||
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<typeof import('common')>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
+28
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -47,7 +47,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>Replication</PageHeaderTitle>
|
||||
<PageHeaderDescription>Read replicas and analytics pipelines</PageHeaderDescription>
|
||||
<PageHeaderDescription>Send data to external destinations</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
|
||||
Reference in new issue
Block a user