feat(etl): Add UI elements to disable external replication (#45035)

This commit is contained in:
Riccardo Busetti
2026-04-24 08:17:58 +02:00
committed by GitHub
parent f9df7aa71a
commit 8347877957
12 changed files with 360 additions and 545 deletions
@@ -102,7 +102,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
<SheetDescription>
{editMode
? 'Update the configuration for this destination'
: 'A destination is an external platform that automatically receives your database changes in real time.'}
: 'A destination can be a read replica or an external platform that receives your database changes in real time.'}
</SheetDescription>
</SheetHeader>
@@ -122,7 +122,7 @@ export const DestinationTypeSelection = () => {
{destinationType !== 'Read Replica' && (
<p className="mt-3 text-sm text-foreground-light">
Replication is in alpha. Expect rapid changes and possible breaking updates.{' '}
External replication is in alpha. Expect rapid changes and possible breaking updates.{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
Leave feedback
</InlineLink>
@@ -1,13 +1,17 @@
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { Plus, Search, X } from 'lucide-react'
import { MoreVertical, Plus, Search, X } from 'lucide-react'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
CardContent,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableBody,
TableCell,
@@ -22,6 +26,7 @@ import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConf
import { DestinationPanel } from './DestinationPanel/DestinationPanel'
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
import { DestinationRow } from './DestinationRow'
import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
import { useIsETLBigQueryPrivateAlpha, useIsETLIcebergPrivateAlpha } from './useIsETLPrivateAlpha'
@@ -32,6 +37,7 @@ import { useReplicationDestinationsQuery } from '@/data/replication/destinations
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 { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { DOCS_URL } from '@/lib/constants'
@@ -54,6 +60,8 @@ export const Destinations = () => {
const prefetchedRef = useRef(false)
const [filterString, setFilterString] = useState<string>('')
const [statusRefetchInterval, setStatusRefetchInterval] = useState<number | false>(5000)
const [showDisableExternalReplicationDialog, setShowDisableExternalReplicationDialog] =
useState(false)
const [_, setDestinationType] = useQueryState(
'destinationType',
@@ -102,10 +110,31 @@ export const Destinations = () => {
const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
projectRef,
})
const pipelines = pipelinesData?.pipelines ?? []
const { data: sourcesData, isSuccess: isSourcesSuccess } = useReplicationSourcesQuery({
projectRef,
})
const externalReplicationSource = useMemo(
() => sourcesData?.sources.find((source) => source.name === projectRef),
[projectRef, sourcesData?.sources]
)
const canDisableExternalReplication =
isSourcesSuccess &&
isDestinationsSuccess &&
isPipelinesSuccess &&
!!externalReplicationSource &&
destinations.length === 0 &&
pipelines.length === 0
const isLoading = isDestinationsLoading || isDatabasesLoading
const hasErrorsFetchingData = isDestinationsError || isDatabasesError
const openDestinationPanel = () => {
if (!newDestinationDefaultType) return
setDestinationType(newDestinationDefaultType)
}
useEffect(() => {
if (
projectRef &&
@@ -176,11 +205,23 @@ export const Destinations = () => {
type="default"
icon={<Plus />}
disabled={!newDestinationDefaultType}
onClick={() => setDestinationType(newDestinationDefaultType)}
onClick={openDestinationPanel}
>
Add destination
</Button>
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />
{canDisableExternalReplication && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<MoreVertical />} className="w-7" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={() => setShowDisableExternalReplicationDialog(true)}>
Disable external replication
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
@@ -264,7 +305,8 @@ export const Destinations = () => {
</p>
<Button
icon={<Plus />}
onClick={() => setDestinationType('Read Replica')}
disabled={!newDestinationDefaultType}
onClick={openDestinationPanel}
className="mt-4"
>
Add destination
@@ -275,6 +317,11 @@ export const Destinations = () => {
</div>
<DestinationPanel onSuccessCreateReadReplica={() => setStatusRefetchInterval(5000)} />
<DisableExternalReplicationDialog
open={showDisableExternalReplicationDialog}
setOpen={setShowDisableExternalReplicationDialog}
/>
</>
)
}
@@ -0,0 +1,74 @@
import { useParams } from 'common'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
} from 'ui'
import { useDeleteReplicationTenantMutation } from '@/data/replication/delete-tenant-mutation'
interface DisableExternalReplicationDialogProps {
open: boolean
setOpen: (value: boolean) => void
}
export const DisableExternalReplicationDialog = ({
open,
setOpen,
}: DisableExternalReplicationDialogProps) => {
const { ref: projectRef } = useParams()
const { mutateAsync: deleteReplicationTenant, isPending: isSubmitting } =
useDeleteReplicationTenantMutation({
onSuccess: () => {
toast.success('External replication has been disabled')
setOpen(false)
},
})
const onConfirm = async () => {
if (!projectRef) return console.error('Project ref is required')
await deleteReplicationTenant({ projectRef })
}
return (
<AlertDialog open={open} onOpenChange={(open) => !isSubmitting && setOpen(open)}>
<AlertDialogContent size="medium">
<AlertDialogHeader>
<AlertDialogTitle>Confirm to disable external replication</AlertDialogTitle>
<AlertDialogDescription className="space-y-2 text-sm">
<p>
This will remove the <code className="text-code-inline">etl</code> schema and all
connected resources from your database. Any active pipelines sending changes to
external destinations will stop.
</p>
<p>Read replicas are not affected.</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
<AlertDialogAction variant="danger" asChild>
<Button
type="danger"
loading={isSubmitting}
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault()
onConfirm()
}}
>
Disable external replication
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -28,11 +28,11 @@ const EnableReplicationModal = () => {
const { mutate: createTenantSource, isPending: creatingTenantSource } =
useCreateTenantSourceMutation({
onSuccess: () => {
toast.success('Replication has been successfully enabled!')
toast.success('External replication has been successfully enabled!')
setOpen(false)
},
onError: (error) => {
toast.error(`Failed to enable replication: ${error.message}`)
toast.error(`Failed to enable external replication: ${error.message}`)
},
})
@@ -45,12 +45,12 @@ const EnableReplicationModal = () => {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="primary" className="w-min">
Enable replication
Enable external replication
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Enable Replication</DialogTitle>
<DialogTitle>Enable external replication</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2 !p-0">
@@ -74,7 +74,7 @@ const EnableReplicationModal = () => {
Cancel
</Button>
<Button type="primary" loading={creatingTenantSource} onClick={onEnableReplication}>
Enable replication
Enable external replication
</Button>
</DialogFooter>
</DialogContent>
@@ -94,10 +94,10 @@ export const EnableReplicationCallout = ({
return (
<div className={cn('border rounded-md p-4 md:p-12 flex flex-col gap-y-4', className)}>
<div className="flex flex-col gap-y-1">
<h4>Replicate data to external destinations in real-time</h4>
<h4>Replicate data to external destinations in real time</h4>
<p className="text-sm text-foreground-light">
{hasAccess ? 'Enable replication' : 'Upgrade to the Pro plan'} to start replicating your
database changes to {type ?? 'data warehouses and analytics platforms'}
{hasAccess ? 'Enable external replication' : 'Upgrade to the Pro plan'} to start
replicating your database changes to {type ?? 'data warehouses and analytics platforms'}
</p>
</div>
<div className="flex gap-x-2">
@@ -444,7 +444,7 @@ export const ReplicationPipelineStatus = () => {
tooltip={{
content: {
side: 'left',
text: hasErroredTables ? 'No tables require manual retry' : undefined,
text: !hasErroredTables ? 'No tables require manual retry' : undefined,
},
}}
>
@@ -26,7 +26,7 @@ export const DESCRIPTIONS: Record<string, string> = {
}
// [Joshen] For context we've decided to decouple ETL from Analytics Buckets for now
// So this flag just hides all "connect table" ETL flow related UI
// So this flag just hides all ETL-related user flows in Analytics Buckets
// Depending on future decision if we intend to keep it that way, then we might be able
// to clean up + deprecate ConnectTablesDialog and other ETL related UI within Analytics Buckets
// to clean up + deprecate the remaining ETL-related UI within Analytics Buckets
export const HIDE_REPLICATION_USER_FLOW = true
@@ -1,9 +1,4 @@
import { noop } from 'lodash'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructionsDialog } from './CreateTable/CreateTableInstructionsDialog'
import { FormattedWrapperTable } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
import {
ScaffoldHeader,
ScaffoldSectionDescription,
@@ -12,19 +7,9 @@ import {
interface BucketHeaderProps {
showActions?: boolean
namespaces?: {
namespace: string
schema: string
tables: FormattedWrapperTable[]
}[]
onSuccessConnectTables?: () => void
}
export const BucketHeader = ({
showActions = true,
namespaces = [],
onSuccessConnectTables = noop,
}: BucketHeaderProps) => {
export const BucketHeader = ({ showActions = true }: BucketHeaderProps) => {
return (
<ScaffoldHeader className="pt-0 flex flex-row justify-between items-end gap-x-8">
<div>
@@ -35,13 +20,7 @@ export const BucketHeader = ({
</div>
{showActions && (
<div className="flex items-center gap-x-2">
{HIDE_REPLICATION_USER_FLOW ? (
<CreateTableInstructionsDialog />
) : (
namespaces.length > 0 && (
<ConnectTablesDialog onSuccessConnectTables={onSuccessConnectTables} />
)
)}
<CreateTableInstructionsDialog />
</div>
)}
</ScaffoldHeader>
@@ -1,481 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Loader2, Plus } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Progress,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelector } from 'ui-patterns/multi-select'
import z from 'zod'
import {
getAnalyticsBucketPublicationName,
getAnalyticsBucketsDestinationName,
} from './AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities'
import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance'
import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha'
import { convertKVStringArrayToJson } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { useCreateDestinationPipelineMutation } from '@/data/replication/create-destination-pipeline-mutation'
import { useCreateTenantSourceMutation } from '@/data/replication/create-tenant-source-mutation'
import { useCreatePublicationMutation } from '@/data/replication/publication-create-mutation'
import { useUpdatePublicationMutation } from '@/data/replication/publication-update-mutation'
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
import { useReplicationTablesQuery } from '@/data/replication/tables-query'
import { getDecryptedValues } from '@/data/vault/vault-secret-decrypted-value-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
/**
* [Joshen] So far this is purely just setting up a "Connect from empty state" flow
* Doing it bit by bit as this is quite an unknown territory, will adjust as we figure out
* limitations, correctness, etc, etc. ETL is also only available on staging so its quite hard
* to test things locally (Local set up is technically available but quite high friction)
*
* What's missing afaict:
* - Deleting namespaces
* - Removing tables
* - Adding more tables
* - Error handling due to multiple async processes
*/
const formId = 'connect-tables-form'
const FormSchema = z.object({
tables: z.array(z.string()).min(1, 'Select at least one table'),
})
type ConnectTablesForm = z.infer<typeof FormSchema>
enum PROGRESS_STAGE {
CREATE_PUBLICATION = 'CREATE_PUBLICATION',
CREATE_PIPELINE = 'CREATE_PIPELINE',
START_PIPELINE = 'START_PIPELINE',
UPDATE_PUBLICATION = 'CREATE_REPLICATION',
}
const PROGRESS_INDICATORS = {
CREATE: [
{
step: PROGRESS_STAGE.CREATE_PUBLICATION,
getDescription: (numTables: number) =>
`Creating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.CREATE_PIPELINE, description: `Creating replication pipeline` },
{ step: PROGRESS_STAGE.START_PIPELINE, description: `Starting replication pipeline` },
],
UPDATE: [
{
step: PROGRESS_STAGE.UPDATE_PUBLICATION,
getDescription: (numTables: number) =>
`Updating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`,
},
{ step: PROGRESS_STAGE.START_PIPELINE, description: 'Restarting replication pipeline...' },
],
}
interface ConnectTablesDialogProps {
onSuccessConnectTables: () => void
}
/** [Joshen] This component is currently not user-facing atm, might opt to clean up as we're likely not going to use this UI flow */
export const ConnectTablesDialog = ({ onSuccessConnectTables }: ConnectTablesDialogProps) => {
const { ref: projectRef, bucketId } = useParams()
const [visible, setVisible] = useState(false)
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
return (
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger asChild>
<Button size="tiny" type="primary" icon={<Plus />} onClick={() => setVisible(true)}>
{isEditingExistingPublication ? 'Add tables' : 'Connect tables'}
</Button>
</DialogTrigger>
{!sourceId ? (
<EnableReplicationDialogContent onClose={() => setVisible(false)} />
) : (
<ConnectTablesDialogContent
visible={visible}
onClose={() => setVisible(false)}
onSuccessConnectTables={onSuccessConnectTables}
/>
)}
</Dialog>
)
}
export const ConnectTablesDialogContent = ({
visible,
onClose,
onSuccessConnectTables,
}: ConnectTablesDialogProps & { visible: boolean; onClose: () => void }) => {
const { ref: projectRef, bucketId } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isConnecting, setIsConnecting] = useState(false)
const [connectingStep, setConnectingStep] = useState<PROGRESS_STAGE>()
const form = useForm<ConnectTablesForm>({
resolver: zodResolver(FormSchema),
defaultValues: { tables: [] },
})
const { tables: selectedTables } = form.watch()
const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId })
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef, reveal: true },
{ enabled: canReadAPIKeys }
)
const { serviceKey } = getKeys(apiKeys)
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
projectRef,
bucketId,
})
const isEditingExistingPublication = !!publication && !!pipeline
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
const { mutateAsync: createPublication } = useCreatePublicationMutation()
const { mutateAsync: updatePublication } = useUpdatePublicationMutation()
const { mutateAsync: createDestinationPipeline } = useCreateDestinationPipelineMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const progressIndicator = useMemo(
() => (isEditingExistingPublication ? PROGRESS_INDICATORS.UPDATE : PROGRESS_INDICATORS.CREATE),
// [Joshen] This is to prevent the progressIndicator from flipping to UPDATE in the middle of CREATE
// since the publication and pipelines are getting created in the middle of CREATE
// eslint-disable-next-line react-hooks/exhaustive-deps
[isConnecting]
)
const totalProgressSteps = progressIndicator.length
const currentStep = progressIndicator.findIndex((x) => x.step === connectingStep) + 1
const progressDescription = progressIndicator.find((x) => x.step === connectingStep)
const onSubmitNewPublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!bucketId) return toast.error('Bucket ID is required')
if (!sourceId) return toast.error('Replication has not been enabled for your project')
try {
setIsConnecting(true)
// Step 1: Create publication
setConnectingStep(PROGRESS_STAGE.CREATE_PUBLICATION)
const publicationName = getAnalyticsBucketPublicationName(bucketId)
const publicationTables = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
await createPublication({
projectRef,
sourceId,
name: publicationName,
tables: publicationTables,
})
// Step 2: Create destination pipeline
setConnectingStep(PROGRESS_STAGE.CREATE_PIPELINE)
const keysToDecrypt = Object.entries(wrapperValues)
.filter(([name]) =>
['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name)
)
.map(([_, keyId]) => keyId)
const decryptedValues = await getDecryptedValues({
projectRef,
connectionString: project?.connectionString,
ids: keysToDecrypt,
})
const warehouseName = bucketId
const catalogToken = serviceKey?.api_key ?? ''
const s3AccessKeyId = decryptedValues[wrapperValues['vault_aws_access_key_id']]
const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']]
const s3Region = projectSettings?.region ?? ''
const icebergConfiguration = {
projectRef,
warehouseName,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
}
const destinationName = getAnalyticsBucketsDestinationName(bucketId)
const { pipeline_id: pipelineId } = await createDestinationPipeline({
projectRef,
destinationName,
destinationConfig: { iceberg: icebergConfiguration },
sourceId,
pipelineConfig: { publicationName },
})
// Step 3: Start the destination pipeline
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId })
onSuccessConnectTables?.()
toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`)
form.reset()
onClose()
} catch (error: any) {
// [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly
// e.g publication gets created, but namespace creation fails -> should the old publication get deleted?
// Another question is probably whether all of these step by step logic should be at the API level instead of client level
// Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA
toast.error(`Failed to connect tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const onSubmitUpdatePublication: SubmitHandler<ConnectTablesForm> = async (values) => {
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return toast.error('Replication has not been enabled on this project')
if (!bucketId) return toast.error('Bucket ID is required')
if (!publication) return toast.error('Unable to find existing publication')
if (!pipeline) return toast.error('Unable to find existing pipeline')
try {
setIsConnecting(true)
const tablesToBeAdded = values.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
})
setConnectingStep(PROGRESS_STAGE.UPDATE_PUBLICATION)
const publicationTables = publication.tables.concat(tablesToBeAdded)
await updatePublication({
projectRef,
sourceId,
publicationName: publication.name,
tables: publicationTables,
})
setConnectingStep(PROGRESS_STAGE.START_PIPELINE)
await startPipeline({ projectRef, pipelineId: pipeline.id })
onSuccessConnectTables?.()
toast.success('Successfully updated connected tables! Pipeline is being restarted')
onClose()
} catch (error: any) {
toast.error(`Failed to update tables: ${error.message}`)
} finally {
setIsConnecting(false)
setConnectingStep(undefined)
}
}
const onSubmit: SubmitHandler<ConnectTablesForm> = async (values) => {
if (isEditingExistingPublication) {
onSubmitUpdatePublication(values)
} else {
onSubmitNewPublication(values)
}
}
useEffect(() => {
form.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
return (
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'}
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
Select the database tables to send data from. A destination analytics table will be
created for each, and data will replicate automatically.
</p>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="overflow-visible">
<FormField_Shadcn_
control={form.control}
name="tables"
render={({ field }) => (
<FormItemLayout label="Tables">
<FormControl_Shadcn_>
<MultiSelector
values={field.value}
onValuesChange={field.onChange}
disabled={isConnecting}
>
<MultiSelector.Trigger
deletableBadge
badgeLimit="wrap"
mode="inline-combobox"
label="Select tables..."
/>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.map((table) => {
const alreadyConnected = (publication?.tables ?? []).some(
(x) => x.schema === table.schema && x.name === table.name
)
return (
<MultiSelector.Item
disabled={alreadyConnected}
className="[&>div]:flex [&>div]:items-center [&>div]:justify-between"
key={`${table.schema}.${table.name}`}
value={`${table.schema}.${table.name}`}
>
<span>{`${table.schema}.${table.name}`}</span>
{alreadyConnected && <span>Connected to analytics bucket</span>}
</MultiSelector.Item>
)
})}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</DialogSection>
</form>
</Form_Shadcn_>
<AnimatePresence mode="wait">
{isConnecting && !!progressDescription && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<DialogSectionSeparator />
<DialogSection>
<div className="flex items-center gap-x-2 mb-2">
<p className="text-sm text-foreground-light">
{'getDescription' in progressDescription
? progressDescription.getDescription?.(selectedTables.length)
: progressDescription.description}
</p>
<Loader2 size={14} className="animate-spin" />
</div>
<Progress value={(currentStep / (totalProgressSteps + 1)) * 100} />
</DialogSection>
</motion.div>
)}
</AnimatePresence>
<DialogFooter>
<Button type="default" disabled={isConnecting} onClick={onClose}>
Cancel
</Button>
<Button form={formId} htmlType="submit" disabled={isConnecting}>
Connect
</Button>
</DialogFooter>
</DialogContent>
)
}
const EnableReplicationDialogContent = ({ onClose }: { onClose: () => void }) => {
const { ref: projectRef } = useParams()
const enablePgReplicate = useIsETLPrivateAlpha()
const { error } = useReplicationSourcesQuery({ projectRef })
const noAccessToReplication =
!enablePgReplicate || error?.message.includes('feature flag is required')
const { mutateAsync: createTenantSource, isPending: creatingTenantSource } =
useCreateTenantSourceMutation()
const onEnableReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
await createTenantSource({ projectRef })
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Database replication needs to be enabled</DialogTitle>
<DialogDescription>
Replication is used to sync data from your Postgres tables
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2 !p-0">
<Admonition
type="warning"
className="rounded-none border-0"
title={
noAccessToReplication
? 'Replication is currently unavailable for your project'
: 'Replication is currently in Alpha'
}
>
{noAccessToReplication ? (
<p className="text-sm !leading-normal">
Access to database replication is currently not available yet for public use. If
you're interested, do reach out to us via support!
</p>
) : (
<>
<p className="text-sm !leading-normal">
This feature is in active development and may change as we gather feedback.
Availability and behavior can evolve while in Alpha.
</p>
<p className="text-sm !leading-normal">
Pricing has not been finalized yet. You can enable replication now; well announce
pricing later and notify you before any charges apply.
</p>
</>
)}
</Admonition>
</DialogSection>
<DialogFooter>
<Button type="default" disabled={creatingTenantSource} onClick={() => onClose()}>
{noAccessToReplication ? 'Understood' : 'Cancel'}
</Button>
{!noAccessToReplication && (
<Button type="primary" loading={creatingTenantSource} onClick={onEnableReplication}>
Enable replication
</Button>
)}
</DialogFooter>
</DialogContent>
)
}
@@ -1,6 +1,6 @@
import { useParams } from 'common'
import { uniq } from 'lodash'
import { Loader2, SquarePlus } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
@@ -14,7 +14,6 @@ import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal'
import { useSelectedAnalyticsBucket } from '../useSelectedAnalyticsBucket'
import { HIDE_REPLICATION_USER_FLOW } from './AnalyticsBucketDetails.constants'
import { BucketHeader } from './BucketHeader'
import { ConnectTablesDialog } from './ConnectTablesDialog'
import { CreateTableInstructions } from './CreateTable/CreateTableInstructions'
import { NamespaceWithTables } from './NamespaceWithTables'
import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
@@ -202,13 +201,7 @@ export const AnalyticBucketDetails = () => {
) : state === 'added' && wrapperInstance ? (
<>
<ScaffoldSection isFullWidth>
<BucketHeader
namespaces={namespaces}
onSuccessConnectTables={() => {
setPollIntervalNamespaces(4000)
setPollIntervalNamespaceTables(4000)
}}
/>
<BucketHeader />
{isLoadingNamespaces || isLoadingWrapperInstance ? (
<GenericTableLoader headers={['Name']} />
@@ -228,20 +221,7 @@ export const AnalyticBucketDetails = () => {
title="Connecting table(s) to bucket"
description="Tables will be shown here once the connection is complete"
/>
) : (
<EmptyStatePresentational
icon={SquarePlus}
title="Connect database tables"
description="Stream table data for continuous backups and analysis"
>
<ConnectTablesDialog
onSuccessConnectTables={() => {
setPollIntervalNamespaces(4000)
setPollIntervalNamespaceTables(4000)
}}
/>
</EmptyStatePresentational>
)}
) : null}
</>
) : (
<>
@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { replicationKeys } from './keys'
import { del, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type DeleteReplicationTenantParams = {
projectRef: string
}
export async function deleteReplicationTenant(
{ projectRef }: DeleteReplicationTenantParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await del('/platform/replication/{ref}/tenants', {
params: { path: { ref: projectRef } },
signal,
})
if (error) handleError(error)
return data
}
type DeleteReplicationTenantData = Awaited<ReturnType<typeof deleteReplicationTenant>>
export const useDeleteReplicationTenantMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
DeleteReplicationTenantData,
ResponseError,
DeleteReplicationTenantParams
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeleteReplicationTenantData, ResponseError, DeleteReplicationTenantParams>({
mutationFn: (vars) => deleteReplicationTenant(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([
queryClient.invalidateQueries({ queryKey: replicationKeys.sources(projectRef) }),
queryClient.invalidateQueries({ queryKey: replicationKeys.destinations(projectRef) }),
queryClient.invalidateQueries({ queryKey: replicationKeys.pipelines(projectRef) }),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to disable external replication: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+152 -1
View File
@@ -3835,6 +3835,26 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/replication/{ref}/tenants': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post?: never
/**
* Delete tenant
* @description Delete the replication tenant for the project. Requires bearer auth.
*/
delete: operations['ReplicationTenantsController_deleteTenant']
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/replication/{ref}/tenants-sources': {
parameters: {
query?: never
@@ -6127,6 +6147,23 @@ export interface components {
DeclineAuthorizationResponse: {
id: string
}
DeleteDestinationPipelineResponse: {
/**
* @description Whether the destination was deleted. True when no other pipelines remain attached to it.
* @example true
*/
destination_deleted: boolean
/**
* @description Destination id
* @example 2001
*/
destination_id: number
/**
* @description Pipeline id
* @example 1012
*/
pipeline_id: number
}
DeleteOAuthAppResponse: {
client_id: string
created_at: string
@@ -23703,7 +23740,9 @@ export interface operations {
headers: {
[name: string]: unknown
}
content?: never
content: {
'application/json': components['schemas']['DeleteDestinationPipelineResponse']
}
}
/** @description Unauthorized */
401: {
@@ -23719,6 +23758,20 @@ export interface operations {
}
content?: never
}
/** @description Pipeline or destination not found. */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Pipeline is still active. */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
@@ -23878,6 +23931,20 @@ export interface operations {
}
content?: never
}
/** @description Destination not found. */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Destination has an active or attached pipeline. */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
@@ -24198,6 +24265,20 @@ export interface operations {
}
content?: never
}
/** @description Pipeline not found. */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Pipeline is still active. */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
@@ -24946,6 +25027,13 @@ export interface operations {
}
content?: never
}
/** @description Source not found. */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
@@ -25015,6 +25103,69 @@ export interface operations {
}
}
}
ReplicationTenantsController_deleteTenant: {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description Tenant deleted. */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden action */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Tenant not found. */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Tenant has active pipelines. */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded */
429: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unexpected error while deleting tenant. */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
ReplicationTenantsSourcesController_createTenantSource: {
parameters: {
query?: never