Add replication UI in Studio (#30090)

* add dummy sinks and pipelines pages

* update api types

* show empty sources state

* show empty replication state at /replication

* create source when enable replication button is clicked

* improve replication page when replication is enabled

* replace sources page with publications page

* publications table

* show publications in table

* create publication wip

* show toast error instead of throwing an exception

* user can now delete a publication

* show empty sinks page

* create and list sinks

* add ui to delete a sink

* show pipelines on the pipelines page

* add ui to create and delete pipelines

* get pipeline status wip

* show pipeline status wip

* show correct label on action buttons

* start and stop pipelines

* remove a couple of console.logs

* fix error when deleting a pipeline

* only consider replication enabled when a source with name = ref is present

* add source and sink names

* correct colspan for 'no pipelines' row

* hide 'supabase_realtime' publication on ui

* move filtering to fetch query

* show sink name in ui

* show source/sink names on pipelines page

* fix start/stop status shown on ui

* fix prettier formatting

* update api types

* extract pipeline action button as a separate component

* fix a crashing page

* fixed publications page crash

* update to match with changes in api

* add new replication page under database

* hide replication page behind feature flag

* update types

* update api types

* show destinations empty state

* add destinations table

* factor out components from Destinations table

* show status dot

* move pipeline fetch query to parent component

* add ability to enable/disable a pipeline

* show loader when starting or stopping a pipeline

* fix a bug in which loading & empty states were shown together

* fix a bug in which error & empty states were shown together

* wrap in default layout

* add new destination panel

* add type field

* fix a forwardRef error

* fix layout

* create destination

* delete destination

* add ability to create or delete destinations with pipelines

* create source if missing

* show only a single error

* add an enable switch

* new layout

* add subsections

* comment out unused code

* show enabled switch only in the header

* close panel when destination is created

* disable buttons when api requests in flight

* reduce panel size

* remove commented out code

* treat max size and max fill secs as numbers

* use drop down to show publications

* simpler vertical layout

* add separators

* add form validation

* remove publications drop down padding

* add new publication button

* hide advanced settings behind an accordion

* add some margin between icon and text

* show publications panel on clicking new publication button

* add header to new publication panel

* fix validation not running for publication drop down

* create publication in the new publication panel

* add table selector in new publication panel

* update api types

* remove old code

* update platform.d.ts

* update navigation bar utils

* remove a redirect from replication page to publications page

* ask user for confirmation before deleting destination

* edit destination panel

* edit destination panel values fixed

* bug fixes

* fix prettier formatting

* enable/disable pipeline after editing

* rename snake_case params to camelCase

* loading button when editing

* remove merge markers

* update api types

* add max_staleness parameter in sinks for bigquery

* add read replicas flow diagram to replication page

* remove an unused import

* Revert "add read replicas flow diagram to replication page"

This reverts commit 8852d7847b457885603dba786141a8aaf8e99350.

* add panel to warn users about additional cost before creating a destination

* hide replication page contents behind a feature flag

* fix merge conflicts

* styling changes

* revert static flag

* styling updates

* fixes

* fix switch

* copy

* fix layout

---------

Co-authored-by: Saxon Fletcher <saxonafletcher@gmail.com>
This commit is contained in:
Raminder SinghandSaxon Fletcher authored and GitHub committed 2025-04-23 10:52:46 +05:30
1 parent f504436af7
commit 23ceb9072d
32 files changed
+2466 -6

No files matched your search

@@ -0,0 +1,41 @@
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface DeleteDestinationProps {
visible: boolean
setVisible: (value: boolean) => void
onDelete: () => void
isLoading: boolean
name: string
}
const DeleteDestination = ({
visible,
setVisible,
onDelete,
isLoading,
name,
}: DeleteDestinationProps) => {
return (
<>
<TextConfirmModal
variant={'warning'}
visible={visible}
onCancel={() => setVisible(!visible)}
onConfirm={onDelete}
title="Delete this destination"
loading={isLoading}
confirmLabel={`Delete destination`}
confirmPlaceholder="Type in name of destination"
confirmString={name ?? 'Unknown'}
text={
<>
<span>This will delete the destination</span>{' '}
</>
}
alert={{ title: 'You cannot recover this destination once deleted.' }}
/>
</>
)
}
export default DeleteDestination
@@ -0,0 +1,519 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { useCreatePipelineMutation } from 'data/replication/create-pipeline-mutation'
import { useCreateSinkMutation } from 'data/replication/create-sink-mutation'
import { useCreateSourceMutation } from 'data/replication/create-source-mutation'
import { useReplicationPublicationsQuery } from 'data/replication/publications-query'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useUpdateSinkMutation } from 'data/replication/update-sink-mutation'
import { useUpdatePipelineMutation } from 'data/replication/update-pipeline-mutation'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Accordion_Shadcn_,
AccordionContent_Shadcn_,
AccordionItem_Shadcn_,
AccordionTrigger_Shadcn_,
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
TextArea_Shadcn_,
WarningIcon,
Label_Shadcn_ as Label,
} from 'ui'
import * as z from 'zod'
import PublicationsComboBox from './PublicationsComboBox'
import NewPublicationPanel from './NewPublicationPanel'
import { useState, useMemo, useEffect } from 'react'
import { useReplicationSinkByIdQuery } from 'data/replication/sink-by-id-query'
import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface DestinationPanelProps {
visible: boolean
sourceId: number | undefined
onClose: () => void
existingDestination?: {
sourceId?: number
sinkId: number
pipelineId?: number
enabled: boolean
}
}
const DestinationPanel = ({
visible,
sourceId,
onClose,
existingDestination,
}: DestinationPanelProps) => {
const { ref: projectRef } = useParams()
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
const { mutateAsync: createSource, isLoading: creatingSource } = useCreateSourceMutation()
const { mutateAsync: createSink, isLoading: creatingSink } = useCreateSinkMutation()
const { mutateAsync: createPipeline, isLoading: creatingPipeline } = useCreatePipelineMutation()
const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline, isLoading: stoppingPipeline } = useStopPipelineMutation()
const { mutateAsync: updateSink, isLoading: updatingSink } = useUpdateSinkMutation()
const { mutateAsync: updatePipeline, isLoading: updatingPipeline } = useUpdatePipelineMutation()
const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({
projectRef,
sourceId,
})
const { data: sinkData } = useReplicationSinkByIdQuery({
projectRef,
sinkId: existingDestination?.sinkId,
})
const { data: pipelineData } = useReplicationPipelineByIdQuery({
projectRef,
pipelineId: existingDestination?.pipelineId,
})
const isCreating = creatingSource || creatingSink || creatingPipeline || startingPipeline
const isUpdating = updatingSink || updatingPipeline || stoppingPipeline || startingPipeline
const isSubmitting = isCreating || isUpdating
const editMode = !!existingDestination
const formId = 'destination-editor'
const types = ['BigQuery'] as const
const TypeEnum = z.enum(types)
const FormSchema = z.object({
type: TypeEnum,
name: z.string().min(1, 'Name is required'),
projectId: z.string().min(1, 'Project id is required'),
datasetId: z.string().min(1, 'Dataset id is required'),
serviceAccountKey: z.string().min(1, 'Service account key is required'),
publicationName: z.string().min(1, 'Publication is required'),
maxSize: z.number().min(1, 'Max Size must be greater than 0').int(),
maxFillSecs: z.number().min(1, 'Max Fill seconds should be greater than 0').int(),
maxStalenessMins: z.number().nonnegative(),
enabled: z.boolean(),
})
const defaultValues = useMemo(
() => ({
type: TypeEnum.enum.BigQuery,
name: sinkData?.name ?? '',
projectId: sinkData?.config?.big_query?.project_id ?? '',
datasetId: sinkData?.config?.big_query?.dataset_id ?? '',
serviceAccountKey: sinkData?.config?.big_query?.service_account_key ?? '',
publicationName: pipelineData?.publication_name ?? '',
maxSize: pipelineData?.config?.config?.max_size ?? 1000,
maxFillSecs: pipelineData?.config?.config?.max_fill_secs ?? 10,
maxStalenessMins: sinkData?.config?.big_query?.max_staleness_mins ?? 5,
enabled: existingDestination?.enabled ?? true,
}),
[sinkData, pipelineData, existingDestination]
)
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues,
})
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
if (!projectRef) return console.error('Project ref is required')
try {
if (editMode && existingDestination) {
if (!sourceId) {
console.error('Source id is required')
return
}
if (!existingDestination.pipelineId) {
console.error('Pipeline id is required')
return
}
// Update existing destination
await updateSink({
projectRef,
sinkId: existingDestination.sinkId,
sinkName: data.name,
projectId: data.projectId,
datasetId: data.datasetId,
serviceAccountKey: data.serviceAccountKey,
maxStalenessMins: data.maxStalenessMins,
})
if (existingDestination.pipelineId) {
await updatePipeline({
projectRef,
pipelineId: existingDestination.pipelineId,
sourceId,
sinkId: existingDestination.sinkId,
publicationName: data.publicationName,
config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } },
})
}
if (data.enabled) {
await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
} else {
await stopPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
}
toast.success('Successfully updated destination')
} else {
// Create new destination
if (!sourceId) {
console.error('Source id is required')
return
}
const { id: sinkId } = await createSink({
projectRef,
sinkName: data.name,
projectId: data.projectId,
datasetId: data.datasetId,
serviceAccountKey: data.serviceAccountKey,
maxStalenessMins: data.maxStalenessMins,
})
const { id: pipelineId } = await createPipeline({
projectRef,
sourceId,
sinkId,
publicationName: data.publicationName,
config: { config: { maxSize: data.maxSize, maxFillSecs: data.maxFillSecs } },
})
if (data.enabled) {
await startPipeline({ projectRef, pipelineId })
}
toast.success('Successfully created destination')
}
onClose()
} catch (error) {
toast.error(`Failed to ${editMode ? 'update' : 'create'} destination`)
}
}
const onEnableReplication = async () => {
if (!projectRef) return console.error('Project ref is required')
await createSource({ projectRef })
}
const { enabled } = form.watch()
useEffect(() => {
if (editMode && sinkData && pipelineData) {
form.reset(defaultValues)
}
}, [sinkData, pipelineData, editMode, defaultValues, form])
return (
<>
{sourceId ? (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader className="flex justify-between items-center">
<div>
<SheetTitle>{editMode ? 'Edit Destination' : 'New Destination'}</SheetTitle>
<SheetDescription>
{editMode ? null : 'Send data to a new destination'}
</SheetDescription>
</div>
<div className="flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(checked) => {
form.setValue('enabled', checked)
}}
/>
<Label className="text-sm mx-2">Enable</Label>
</div>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
className="mb-8"
label="Name"
layout="vertical"
description="A name you will use to identify this destination"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4">What data to send</h3>
<FormField_Shadcn_
control={form.control}
name="publicationName"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Publication"
layout="vertical"
description="A publication is a collection of tables that you want to replicate "
>
<FormControl_Shadcn_>
<PublicationsComboBox
publications={publications?.map((pub) => pub.name) || []}
loading={loadingPublications}
field={field}
onNewPublicationClick={() => setPublicationPanelVisible(true)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4 mt-8">Where to send that data</h3>
<FormField_Shadcn_
name="type"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Type"
layout="vertical"
description="The type of destination to send the data to"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value}>
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="BigQuery">
BigQuery
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
></FormField_Shadcn_>
<FormField_Shadcn_
control={form.control}
name="projectId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project Id"
layout="vertical"
description="Which BigQuery project to send data to"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Project id" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project's Dataset Id"
layout="vertical"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Dataset id" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="serviceAccountKey"
render={({ field }) => (
<FormItemLayout
label="Service Account Key"
layout="vertical"
description="The service account key for BigQuery"
>
<FormControl_Shadcn_>
<TextArea_Shadcn_
{...field}
rows={4}
maxLength={5000}
placeholder="Service account key"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Accordion_Shadcn_ type="single" collapsible>
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-start mb-0 mt-8">
Advanced Settings
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<FormField_Shadcn_
control={form.control}
name="maxSize"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Size"
layout="vertical"
description="The maximum size of the data to send"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxSize', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max size"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxFillSecs"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Fill Seconds"
layout="vertical"
description="The maximum amount of time to fill the data"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxFillSecs', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max fill seconds"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxStalenessMins"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Staleness"
layout="vertical"
description="Maximum staleness time allowed"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxStalenessMins', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max staleness in minutes"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
<div className="hidden">
<FormField_Shadcn_
control={form.control}
name="enabled"
render={({ field }) => (
<FormItemLayout className="mb-4" layout="vertical" label="Enabled">
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={isSubmitting} type="default" onClick={onClose}>
Cancel
</Button>
<Button
disabled={isSubmitting}
loading={isSubmitting}
form={formId}
htmlType="submit"
>
{editMode ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
<NewPublicationPanel
visible={publicationPanelVisible}
sourceId={sourceId}
onClose={() => setPublicationPanelVisible(false)}
/>
</>
) : (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>New Destination</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
{/* Pricing to be decided yet */}
Enabling replication will cost additional $xx.xx
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span></span>
<div className="flex items-center gap-x-2 mt-3">
<Button type="default" onClick={onEnableReplication}>
Enable replication
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={isSubmitting} type="default" onClick={onClose}>
Cancel
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
</>
)}
</>
)
}
export default DestinationPanel
@@ -0,0 +1,206 @@
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
import { ResponseError } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import RowMenu from './RowMenu'
import PipelineStatus from './PipelineStatus'
import { useParams } from 'common'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
import { useDeleteSinkMutation } from 'data/replication/delete-sink-mutation'
import { useDeletePipelineMutation } from 'data/replication/delete-pipeline-mutation'
import DeleteDestination from './DeleteDestination'
import DestinationPanel from './DestinationPanel'
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
interface DestinationRowProps {
sourceId: number | undefined
sinkId: number
sinkName: string
type: string
pipeline: Pipeline | undefined
error: ResponseError | null
isLoading: boolean
isError: boolean
isSuccess: boolean
}
const DestinationRow = ({
sourceId,
sinkId,
sinkName,
type,
pipeline,
error: pipelineError,
isLoading: isPipelineLoading,
isError: isPipelineError,
isSuccess: isPipelineSuccess,
}: DestinationRowProps) => {
const { ref: projectRef } = useParams()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
const [showEditDestinationPanel, setShowEditDestinationPanel] = useState(false)
const {
data: pipelineStatusData,
error: pipelineStatusError,
isLoading: isPipelineStatusLoading,
isError: isPipelineStatusError,
isSuccess: isPipelineStatusSuccess,
} = useReplicationPipelineStatusQuery(
{
projectRef,
pipelineId: pipeline?.id,
},
{ refetchInterval }
)
const [requestStatus, setRequestStatus] = useState<
'None' | 'EnableRequested' | 'DisableRequested'
>('None')
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const pipelineStatus = pipelineStatusData?.status
if (
(requestStatus === 'EnableRequested' && pipelineStatus === 'Started') ||
(requestStatus === 'DisableRequested' && pipelineStatus === 'Stopped')
) {
setRefetchInterval(false)
setRequestStatus('None')
}
const onEnableClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error('No pipeline found')
return
}
try {
await startPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
toast.error('Failed to enable destination')
}
setRequestStatus('EnableRequested')
setRefetchInterval(5000)
}
const onDisableClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error('No pipeline found')
return
}
try {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
toast.error('Failed to disable destination')
}
setRequestStatus('DisableRequested')
setRefetchInterval(5000)
}
const { mutateAsync: deleteSink } = useDeleteSinkMutation({})
const { mutateAsync: deletePipeline } = useDeletePipelineMutation({
onSuccess: (_res: any) => {
toast.success('Successfully deleted destination')
},
})
const onDeleteClick = async () => {
if (!projectRef) {
console.error('Project ref is required')
return
}
if (!pipeline) {
toast.error('No pipeline found')
return
}
try {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
await deletePipeline({ projectRef, pipelineId: pipeline.id })
await deleteSink({ projectRef, sinkId })
} catch (error) {
toast.error('Failed to delete destination')
}
}
return (
<>
{isPipelineError && (
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
)}
{isPipelineSuccess && (
<Table.tr>
<Table.td>
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : sinkName}
</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
) : (
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
></PipelineStatus>
)}
</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
) : (
pipeline.publication_name
)}
</Table.td>
<Table.td>
<RowMenu
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onEnableClick={onEnableClick}
onDisableClick={onDisableClick}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
></RowMenu>
</Table.td>
</Table.tr>
)}
<DeleteDestination
visible={showDeleteDestinationForm}
setVisible={setShowDeleteDestinationForm}
onDelete={onDeleteClick}
isLoading={isPipelineStatusLoading}
name={sinkName}
/>
<DestinationPanel
visible={showEditDestinationPanel}
onClose={() => setShowEditDestinationPanel(false)}
sourceId={sourceId}
existingDestination={{
sourceId,
sinkId,
pipelineId: pipeline?.id,
enabled: pipelineStatusData?.status === 'Started',
}}
/>
</>
)
}
export default DestinationRow
@@ -0,0 +1,138 @@
import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useReplicationSinksQuery } from 'data/replication/sinks-query'
import { Plus } from 'lucide-react'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import DestinationRow from './DestinationRow'
import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { useState } from 'react'
import NewDestinationPanel from './DestinationPanel'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
const Destinations = () => {
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
const { ref: projectRef } = useParams()
const {
data: sourcesData,
error: sourcesError,
isLoading: isSourcesLoading,
isError: isSourcesError,
isSuccess: isSourcesSuccess,
} = useReplicationSourcesQuery({
projectRef,
})
let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const {
data: sinksData,
error: sinksError,
isLoading: isSinksLoading,
isError: isSinksError,
isSuccess: isSinksSuccess,
} = useReplicationSinksQuery({
projectRef,
})
const {
data: pipelinesData,
error: pipelinesError,
isLoading: isPipelinesLoading,
isError: isPipelinesError,
isSuccess: isPipelinesSuccess,
} = useReplicationPipelinesQuery({
projectRef,
})
const anySinks = isSinksSuccess && sinksData.sinks.length > 0
return (
<>
<ScaffoldSection isFullWidth>
<div className="flex justify-between items-center mb-4">
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
</Button>
</div>
{(isSourcesLoading || isSinksLoading) && <GenericSkeletonLoader />}
{(isSourcesError || isSinksError) && (
<AlertError
error={sourcesError || sinksError}
subject="Failed to retrieve destinations"
/>
)}
{anySinks ? (
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="type">Type</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="actions"></Table.th>,
]}
body={sinksData.sinks.map((sink) => {
const pipeline = pipelinesData?.pipelines.find((p) => p.sink_id === sink.id)
return (
<DestinationRow
key={sink.id}
sourceId={sourceId}
sinkId={sink.id}
sinkName={sink.name}
type={sink.config.big_query ? 'BigQuery' : 'Other'}
pipeline={pipeline}
error={pipelinesError}
isLoading={isPipelinesLoading}
isError={isPipelinesError}
isSuccess={isPipelinesSuccess}
></DestinationRow>
)
})}
></Table>
) : (
!isSourcesLoading &&
!isSinksLoading &&
!isSourcesError &&
!isSinksError && (
<div
className={cn(
'w-full',
'border border-dashed bg-surface-100 border-overlay',
'flex flex-col px-10 rounded-lg justify-center items-center py-8 mt-4'
)}
>
<h4 className="text-lg">Send data to your first destination</h4>
<p className="prose text-sm text-center mt-2">
Use destinations to improve performance or run analysis on your data via
integrations like BigQuery
</p>
<Button
icon={<Plus />}
onClick={() => setShowNewDestinationPanel(true)}
className="mt-6"
>
Add destination
</Button>
</div>
)
)}
</ScaffoldSection>
<NewDestinationPanel
visible={showNewDestinationPanel}
sourceId={sourceId}
onClose={() => setShowNewDestinationPanel(false)}
></NewDestinationPanel>
</>
)
}
export default Destinations
@@ -0,0 +1,179 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { useCreatePublicationMutation } from 'data/replication/create-publication-mutation'
import { useReplicationTablesQuery } from 'data/replication/tables-query'
import { X } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
cn,
Button,
SheetFooter,
SheetSection,
Form_Shadcn_,
FormLabel_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormControl_Shadcn_,
Input_Shadcn_,
FormMessage_Shadcn_,
Card,
CardContent,
SheetDescription,
} from 'ui'
import { MultiSelector } from 'ui-patterns/multi-select'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
interface NewPublicationPanelProps {
visible: boolean
sourceId?: number
onClose: () => void
}
const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => {
const { ref: projectRef } = useParams()
const { mutateAsync: createPublication, isLoading: creatingPublication } =
useCreatePublicationMutation()
const { data: tables } = useReplicationTablesQuery({
projectRef,
sourceId,
})
const formId = 'publication-editor'
const FormSchema = z.object({
name: z.string().min(1, 'Name is required'),
tables: z.array(z.string()).min(1, 'At least one table is required'),
})
const defaultValues = {
name: '',
tables: [],
}
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues,
})
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
if (!projectRef) return console.error('Project ref is required')
if (!sourceId) return console.error('Source id is required')
try {
await createPublication({
projectRef,
sourceId,
name: data.name,
tables: data.tables.map((table) => {
const [schema, name] = table.split('.')
return { schema, name }
}),
})
toast.success('Successfully created publication')
onClose()
} catch (error) {
toast.error('Failed to create publication')
}
form.reset(defaultValues)
}
return (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full">
<SheetHeader>
<div className="flex flex-row justify-between items-center">
<div>
<SheetTitle>New Publication</SheetTitle>
<SheetDescription>
Create a new publication to share table changes for replication
</SheetDescription>
</div>
<SheetClose
className={cn(
'text-muted hover:opacity-100',
'focus:outline-none focus:ring-2',
'disabled:pointer-events-none'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
</div>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Form_Shadcn_ {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-y-4"
>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="tables"
render={({ field }) => (
<FormItemLayout
label="Tables"
description="Which tables to make available for replication"
>
<FormControl_Shadcn_>
<MultiSelector
values={field.value}
onValuesChange={field.onChange}
disabled={creatingPublication}
>
<MultiSelector.Trigger>
<MultiSelector.Input placeholder="Select tables" />
</MultiSelector.Trigger>
<MultiSelector.Content>
<MultiSelector.List>
{tables?.tables.map((table) => (
<MultiSelector.Item
key={`${table.schema}.${table.name}`}
value={`${table.schema}.${table.name}`}
>
{`${table.schema}.${table.name}`}
</MultiSelector.Item>
))}
</MultiSelector.List>
</MultiSelector.Content>
</MultiSelector>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button type="default" disabled={creatingPublication} onClick={onClose}>
Cancel
</Button>
<Button type="primary" disabled={creatingPublication} form={formId} htmlType="submit">
Create publication
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
</>
)
}
export default NewPublicationPanel
@@ -0,0 +1,57 @@
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { cn } from 'ui'
import { ResponseError } from 'types'
import { Loader2 } from 'lucide-react'
interface PipelineStatusProps {
pipelineStatus: string | undefined
error: ResponseError | null
isLoading: boolean
isError: boolean
isSuccess: boolean
requestStatus: 'None' | 'EnableRequested' | 'DisableRequested'
}
const PipelineStatus = ({
pipelineStatus,
error,
isLoading,
isError,
isSuccess,
requestStatus,
}: PipelineStatusProps) => {
const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true
const requestInFlight = requestStatus !== 'None'
const status =
requestStatus === 'EnableRequested'
? 'Enabling'
: requestStatus === 'DisableRequested'
? 'Disabling'
: pipelineStatus === 'Stopped'
? 'Disabled'
: 'Enabled'
return (
<>
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isSuccess && (
<div className="flex flex-row items-center gap-2">
{requestInFlight ? (
<Loader2 className="animate-spin" />
) : (
<div
className={cn(
'w-2 h-2 rounded-full',
pipelineEnabled ? 'bg-brand' : 'bg-warning-600'
)}
></div>
)}
{status}
</div>
)}
</>
)
}
export default PipelineStatus
@@ -0,0 +1,132 @@
import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
} from 'ui'
import { ControllerRenderProps } from 'react-hook-form'
interface PublicationsComboBoxProps {
publications: string[]
loading: boolean
onNewPublicationClick: () => void
field: ControllerRenderProps<any, 'publicationName'>
}
const PublicationsComboBox = ({
publications,
loading,
onNewPublicationClick,
field,
}: PublicationsComboBoxProps) => {
const [dropdownOpen, setDropdownOpen] = useState(false)
const [selectedPublication, setSelectedPublication] = useState<string>(field?.value || '')
const [searchTerm, setSearchTerm] = useState('')
function handleSearchChange(value: string) {
setSearchTerm(value)
}
function handlePublicationSelect(pub: string) {
setSelectedPublication(pub)
setDropdownOpen(false)
field.onChange(pub)
}
return (
<Popover_Shadcn_
modal={false}
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open)
if (!open && field?.onBlur) {
field.onBlur()
}
}}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="default"
size="medium"
className={`w-full [&>span]:w-full text-left`}
iconRight={
<ChevronsUpDown
className="text-foreground-muted"
strokeWidth={2}
size={14}
></ChevronsUpDown>
}
name={field.name}
onBlur={field.onBlur}
>
{selectedPublication || 'Select publication'}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0" sameWidthAsTrigger>
<Command_Shadcn_>
<CommandInput_Shadcn_
placeholder="Find publication..."
value={searchTerm}
onValueChange={handleSearchChange}
></CommandInput_Shadcn_>
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>
{loading ? (
<div className="flex items-center gap-2 text-center justify-center">
<Loader2 size={12} className="animate-spin" />
Loading...
</div>
) : (
'No publications found'
)}
</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={publications.length > 7 ? 'h-[210px]' : ''}>
{publications.map((pub) => (
<CommandItem_Shadcn_
key={pub}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
onSelect={() => {
handlePublicationSelect(pub)
}}
onClick={() => {
handlePublicationSelect(pub)
}}
>
<span>{pub}</span>
{selectedPublication === pub && (
<Check className="text-brand" strokeWidth={2} size={13} />
)}
</CommandItem_Shadcn_>
))}
</ScrollArea>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="cursor-pointer w-full"
onSelect={onNewPublicationClick}
onClick={onNewPublicationClick}
>
<Plus size={14} strokeWidth={1.5} className="mr-2" />
<p>New publication</p>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default PublicationsComboBox
@@ -0,0 +1,72 @@
import AlertError from 'components/ui/AlertError'
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
import { ResponseError } from 'types'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
interface RowMenuProps {
pipelineStatus: string | undefined
error: ResponseError | null
isLoading: boolean
isError: boolean
onEnableClick: () => void
onDisableClick: () => void
onEditClick: () => void
onDeleteClick: () => void
}
const RowMenu = ({
pipelineStatus,
error,
isLoading,
isError,
onEnableClick,
onDisableClick,
onEditClick,
onDeleteClick,
}: RowMenuProps) => {
const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true
return (
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
{pipelineEnabled ? (
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
<Pause size={14} />
<p>Disable</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
<Play size={14} />
<p>Enable</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
<Edit size={14} />
<p>Edit destination</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
<Trash stroke="red" size={14} />
<p>Delete destination</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default RowMenu
@@ -9,6 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
import { generateDatabaseMenu } from './DatabaseMenu.utils'
import { useFlag } from 'hooks/ui/useFlag'
export interface DatabaseLayoutProps {
title?: string
@@ -29,6 +30,7 @@ const DatabaseProductMenu = () => {
const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined
const pitrEnabled = addons?.selected_addons.find((addon) => addon.type === 'pitr') !== undefined
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
const enablePgReplicate = useFlag('enablePgReplicate')
return (
<>
@@ -38,6 +40,7 @@ const DatabaseProductMenu = () => {
pgNetExtensionExists,
pitrEnabled,
columnLevelPrivileges,
enablePgReplicate,
})}
/>
</>
@@ -9,10 +9,12 @@ export const generateDatabaseMenu = (
pgNetExtensionExists: boolean
pitrEnabled: boolean
columnLevelPrivileges: boolean
enablePgReplicate: boolean
}
): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
const { pgNetExtensionExists, pitrEnabled, columnLevelPrivileges } = flags || {}
const { pgNetExtensionExists, pitrEnabled, columnLevelPrivileges, enablePgReplicate } =
flags || {}
return [
{
@@ -62,6 +64,16 @@ export const generateDatabaseMenu = (
url: `/project/${ref}/database/publications`,
items: [],
},
...(enablePgReplicate
? [
{
name: 'Replication',
key: 'replication',
url: `/project/${ref}/database/replication`,
items: [],
},
]
: []),
],
},
{
@@ -0,0 +1,77 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type CreatePipelineParams = {
projectRef: string
sourceId: number
sinkId: number
publicationName: string
config: { config: { maxSize: number; maxFillSecs: number } }
}
async function createPipeline(
{
projectRef,
sourceId,
sinkId,
publicationName,
config: {
config: { maxSize, maxFillSecs },
},
}: CreatePipelineParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/pipelines', {
params: { path: { ref: projectRef } },
body: {
source_id: sourceId,
sink_id: sinkId,
publication_name: publicationName,
config: { config: { max_size: maxSize, max_fill_secs: maxFillSecs } },
},
signal,
})
if (error) {
handleError(error)
}
return data
}
type CreatePipelineData = Awaited<ReturnType<typeof createPipeline>>
export const useCreatePipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<CreatePipelineData, ResponseError, CreatePipelineParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreatePipelineData, ResponseError, CreatePipelineParams>(
(vars) => createPipeline(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,66 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type CreatePublicationParams = {
projectRef: string
sourceId: number
name: string
tables: { schema: string; name: string }[]
}
async function createPublication(
{ projectRef, sourceId, name, tables }: CreatePublicationParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post(
'/platform/replication/{ref}/sources/{source_id}/publications',
{
params: { path: { ref: projectRef, source_id: sourceId } },
body: { name, tables },
signal,
}
)
if (error) {
handleError(error)
}
return data
}
type CreatePublicationData = Awaited<ReturnType<typeof createPublication>>
export const useCreatePublicationMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<CreatePublicationData, ResponseError, CreatePublicationParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreatePublicationData, ResponseError, CreatePublicationParams>(
(vars) => createPublication(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, sourceId } = variables
await queryClient.invalidateQueries(replicationKeys.publications(projectRef, sourceId))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create publication: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,75 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type CreateSinkParams = {
projectRef: string
sinkName: string
projectId: string
datasetId: string
serviceAccountKey: string
maxStalenessMins: number
}
async function createSink(
{
projectRef,
sinkName,
projectId,
datasetId,
serviceAccountKey,
maxStalenessMins,
}: CreateSinkParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/sinks', {
params: { path: { ref: projectRef } },
body: {
project_id: projectId,
dataset_id: datasetId,
service_account_key: serviceAccountKey,
sink_name: sinkName,
max_staleness_mins: maxStalenessMins,
},
signal,
})
if (error) {
handleError(error)
}
return data
}
type CreateSinkData = Awaited<ReturnType<typeof createSink>>
export const useCreateSinkMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<CreateSinkData, ResponseError, CreateSinkParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreateSinkData, ResponseError, CreateSinkParams>((vars) => createSink(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(replicationKeys.sinks(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create sink: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,56 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type CreateSourceParams = {
projectRef: string
}
async function createSource({ projectRef }: CreateSourceParams, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/sources', {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type CreateSourceData = Awaited<ReturnType<typeof createSource>>
export const useCreateSourceMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<CreateSourceData, ResponseError, CreateSourceParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<CreateSourceData, ResponseError, CreateSourceParams>(
(vars) => createSource(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(replicationKeys.sources(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to create source: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,60 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, del } from 'data/fetchers'
export type DeletePipelineParams = {
projectRef: string
pipelineId: number
}
async function deletePipeline(
{ projectRef, pipelineId }: DeletePipelineParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await del('/platform/replication/{ref}/pipelines/{pipeline_id}', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type DeletePipelineData = Awaited<ReturnType<typeof deletePipeline>>
export const useDeletePipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<DeletePipelineData, ResponseError, DeletePipelineParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeletePipelineData, ResponseError, DeletePipelineParams>(
(vars) => deletePipeline(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, pipelineId } = variables
await queryClient.removeQueries(replicationKeys.pipelines(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,64 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, del } from 'data/fetchers'
export type DeletePublicationParams = {
projectRef: string
sourceId: number
publicationName: string
}
async function deletePublication(
{ projectRef, sourceId, publicationName: publication_name }: DeletePublicationParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await del(
'/platform/replication/{ref}/sources/{source_id}/publications/{publication_name}',
{
params: { path: { ref: projectRef, source_id: sourceId, publication_name } },
signal,
}
)
if (error) {
handleError(error)
}
return data
}
type DeletePublicationData = Awaited<ReturnType<typeof deletePublication>>
export const useDeletePublicationMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<DeletePublicationData, ResponseError, DeletePublicationParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeletePublicationData, ResponseError, DeletePublicationParams>(
(vars) => deletePublication(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, sourceId } = variables
await queryClient.invalidateQueries(replicationKeys.publications(projectRef, sourceId))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete publication: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,54 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, del } from 'data/fetchers'
export type DeleteSinkParams = {
projectRef: string
sinkId: number
}
async function deleteSink({ projectRef, sinkId }: DeleteSinkParams, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await del('/platform/replication/{ref}/sinks/{sink_id}', {
params: { path: { ref: projectRef, sink_id: sinkId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type DeleteSinkData = Awaited<ReturnType<typeof deleteSink>>
export const useDeleteSinkMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<DeleteSinkData, ResponseError, DeleteSinkParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<DeleteSinkData, ResponseError, DeleteSinkParams>((vars) => deleteSink(vars), {
async onSuccess(data, variables, context) {
const { projectRef, sinkId } = variables
await queryClient.invalidateQueries(replicationKeys.sinks(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete sink: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+15
View File
@@ -0,0 +1,15 @@
export const replicationKeys = {
sources: (projectRef: string | undefined) => ['projects', projectRef, 'sources'] as const,
sinks: (projectRef: string | undefined) => ['projects', projectRef, 'sinks'] as const,
sinkById: (projectRef: string | undefined, sinkId: number | undefined) =>
['projects', projectRef, 'sinks', sinkId] as const,
publications: (projectRef: string | undefined, source_id: number | undefined) =>
['projects', projectRef, 'sources', source_id, 'publications'] as const,
tables: (projectRef: string | undefined, source_id: number | undefined) =>
['projects', projectRef, 'sources', source_id, 'tables'] as const,
pipelines: (projectRef: string | undefined) => ['projects', projectRef, 'pipelines'] as const,
pipelineById: (projectRef: string | undefined, pipelineId: number | undefined) =>
['projects', projectRef, 'pipelines', pipelineId] as const,
pipelinesStatus: (projectRef: string | undefined, pipelineId: number | undefined) =>
['projects', projectRef, 'pipelines', pipelineId, 'status'] as const,
}
@@ -0,0 +1,42 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationPipelineByIdParams = { projectRef?: string; pipelineId?: number }
async function fetchReplicationPipelineById(
{ projectRef, pipelineId }: ReplicationPipelineByIdParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!pipelineId) throw new Error('pipelineId is required')
const { data, error } = await get('/platform/replication/{ref}/pipelines/{pipeline_id}', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationPipelineByIdData = Awaited<ReturnType<typeof fetchReplicationPipelineById>>
export const useReplicationPipelineByIdQuery = <TData = ReplicationPipelineByIdData>(
{ projectRef, pipelineId }: ReplicationPipelineByIdParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationPipelineByIdData, ResponseError, TData> = {}
) =>
useQuery<ReplicationPipelineByIdData, ResponseError, TData>(
replicationKeys.pipelineById(projectRef, pipelineId),
({ signal }) => fetchReplicationPipelineById({ projectRef, pipelineId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined',
...options,
}
)
@@ -0,0 +1,45 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationPipelinesStatusParams = { projectRef?: string; pipelineId?: number }
async function fetchReplicationPipelineStatus(
{ projectRef, pipelineId }: ReplicationPipelinesStatusParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!pipelineId) throw new Error('pipelineId is required')
const { data, error } = await get('/platform/replication/{ref}/pipelines/{pipeline_id}/status', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationPipelineStatusData = Awaited<
ReturnType<typeof fetchReplicationPipelineStatus>
>
export const useReplicationPipelineStatusQuery = <TData = ReplicationPipelineStatusData>(
{ projectRef, pipelineId }: ReplicationPipelinesStatusParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationPipelineStatusData, ResponseError, TData> = {}
) =>
useQuery<ReplicationPipelineStatusData, ResponseError, TData>(
replicationKeys.pipelinesStatus(projectRef, pipelineId),
({ signal }) => fetchReplicationPipelineStatus({ projectRef, pipelineId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined',
...options,
}
)
@@ -0,0 +1,39 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationPipelinesParams = { projectRef?: string }
async function fetchReplicationPipelines(
{ projectRef }: ReplicationPipelinesParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/platform/replication/{ref}/pipelines', {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationPipelinesData = Awaited<ReturnType<typeof fetchReplicationPipelines>>
export const useReplicationPipelinesQuery = <TData = ReplicationPipelinesData>(
{ projectRef }: ReplicationPipelinesParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationPipelinesData, ResponseError, TData> = {}
) =>
useQuery<ReplicationPipelinesData, ResponseError, TData>(
replicationKeys.pipelines(projectRef),
({ signal }) => fetchReplicationPipelines({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
@@ -0,0 +1,47 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationPublicationsParams = { projectRef?: string; sourceId?: number }
async function fetchReplicationPublications(
{ projectRef, sourceId }: ReplicationPublicationsParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!sourceId) throw new Error('sourceId is required')
const { data, error } = await get(
'/platform/replication/{ref}/sources/{source_id}/publications',
{
params: { path: { ref: projectRef, source_id: sourceId } },
signal,
}
)
if (error) {
handleError(error)
}
return data.publications.filter((pub) => pub.name !== 'supabase_realtime')
}
export type ReplicationPublicationsData = Awaited<ReturnType<typeof fetchReplicationPublications>>
export const useReplicationPublicationsQuery = <TData = ReplicationPublicationsData>(
{ projectRef, sourceId }: ReplicationPublicationsParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationPublicationsData, ResponseError, TData> = {}
) =>
useQuery<ReplicationPublicationsData, ResponseError, TData>(
replicationKeys.publications(projectRef, sourceId),
({ signal }) => fetchReplicationPublications({ projectRef, sourceId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof sourceId !== 'undefined',
...options,
}
)
@@ -0,0 +1,42 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationSinkByIdParams = { projectRef?: string; sinkId?: number }
async function fetchReplicationSinkById(
{ projectRef, sinkId }: ReplicationSinkByIdParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!sinkId) throw new Error('sinkId is required')
const { data, error } = await get('/platform/replication/{ref}/sinks/{sink_id}', {
params: { path: { ref: projectRef, sink_id: sinkId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationSinkByIdData = Awaited<ReturnType<typeof fetchReplicationSinkById>>
export const useReplicationSinkByIdQuery = <TData = ReplicationSinkByIdData>(
{ projectRef, sinkId }: ReplicationSinkByIdParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationSinkByIdData, ResponseError, TData> = {}
) =>
useQuery<ReplicationSinkByIdData, ResponseError, TData>(
replicationKeys.sinkById(projectRef, sinkId),
({ signal }) => fetchReplicationSinkById({ projectRef, sinkId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof sinkId !== 'undefined',
...options,
}
)
@@ -0,0 +1,33 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationSinksParams = { projectRef?: string }
async function fetchReplicationSinks({ projectRef }: ReplicationSinksParams, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/platform/replication/{ref}/sinks', {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationSinksData = Awaited<ReturnType<typeof fetchReplicationSinks>>
export const useReplicationSinksQuery = <TData = ReplicationSinksData>(
{ projectRef }: ReplicationSinksParams,
{ enabled = true, ...options }: UseQueryOptions<ReplicationSinksData, ResponseError, TData> = {}
) =>
useQuery<ReplicationSinksData, ResponseError, TData>(
replicationKeys.sinks(projectRef),
({ signal }) => fetchReplicationSinks({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
@@ -0,0 +1,36 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationSourcesParams = { projectRef?: string }
async function fetchReplicationSources(
{ projectRef }: ReplicationSourcesParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/platform/replication/{ref}/sources', {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationSourcesData = Awaited<ReturnType<typeof fetchReplicationSources>>
export const useReplicationSourcesQuery = <TData = ReplicationSourcesData>(
{ projectRef }: ReplicationSourcesParams,
{ enabled = true, ...options }: UseQueryOptions<ReplicationSourcesData, ResponseError, TData> = {}
) =>
useQuery<ReplicationSourcesData, ResponseError, TData>(
replicationKeys.sources(projectRef),
({ signal }) => fetchReplicationSources({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
@@ -0,0 +1,60 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type StartPipelineParams = {
projectRef: string
pipelineId: number
}
async function startPipeline(
{ projectRef, pipelineId }: StartPipelineParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}/start', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type StartPipelineData = Awaited<ReturnType<typeof startPipeline>>
export const useStartPipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<StartPipelineData, ResponseError, StartPipelineParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<StartPipelineData, ResponseError, StartPipelineParams>(
(vars) => startPipeline(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, pipelineId } = variables
await queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to start pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,57 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type StopPipelineParams = {
projectRef: string
pipelineId: number
}
async function stopPipeline({ projectRef, pipelineId }: StopPipelineParams, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}/stop', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type StartPipelineData = Awaited<ReturnType<typeof stopPipeline>>
export const useStopPipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<StartPipelineData, ResponseError, StopPipelineParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<StartPipelineData, ResponseError, StopPipelineParams>(
(vars) => stopPipeline(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, pipelineId } = variables
await queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to stop pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,40 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { replicationKeys } from './keys'
type ReplicationTablesParams = { projectRef?: string; sourceId?: number }
async function fetchReplicationTables(
{ projectRef, sourceId }: ReplicationTablesParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (!sourceId) throw new Error('sourceId is required')
const { data, error } = await get('/platform/replication/{ref}/sources/{source_id}/tables', {
params: { path: { ref: projectRef, source_id: sourceId } },
signal,
})
if (error) {
handleError(error)
}
return data
}
export type ReplicationTablesData = Awaited<ReturnType<typeof fetchReplicationTables>>
export const useReplicationTablesQuery = <TData = ReplicationTablesData>(
{ projectRef, sourceId }: ReplicationTablesParams,
{ enabled = true, ...options }: UseQueryOptions<ReplicationTablesData, ResponseError, TData> = {}
) =>
useQuery<ReplicationTablesData, ResponseError, TData>(
replicationKeys.tables(projectRef, sourceId),
({ signal }) => fetchReplicationTables({ projectRef, sourceId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof sourceId !== 'undefined',
...options,
}
)
@@ -0,0 +1,79 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type UpdatePipelineParams = {
pipelineId: number
projectRef: string
sourceId: number
sinkId: number
publicationName: string
config: { config: { maxSize: number; maxFillSecs: number } }
}
async function updatePipeline(
{
pipelineId,
projectRef,
sourceId,
sinkId,
publicationName,
config: {
config: { maxSize, maxFillSecs },
},
}: UpdatePipelineParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/pipelines/{pipeline_id}', {
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
body: {
source_id: sourceId,
sink_id: sinkId,
publication_name: publicationName,
config: { config: { max_size: maxSize, max_fill_secs: maxFillSecs } },
},
signal,
})
if (error) {
handleError(error)
}
return data
}
type UpdatePipelineData = Awaited<ReturnType<typeof updatePipeline>>
export const useUpdatePipelineMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<UpdatePipelineData, ResponseError, UpdatePipelineParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdatePipelineData, ResponseError, UpdatePipelineParams>(
(vars) => updatePipeline(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update pipeline: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,77 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
import { handleError, post } from 'data/fetchers'
export type UpdateSinkParams = {
sinkId: number
projectRef: string
sinkName: string
projectId: string
datasetId: string
serviceAccountKey: string
maxStalenessMins: number
}
async function updateSink(
{
sinkId,
projectRef,
sinkName,
projectId,
datasetId,
serviceAccountKey,
maxStalenessMins,
}: UpdateSinkParams,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await post('/platform/replication/{ref}/sinks/{sink_id}', {
params: { path: { ref: projectRef, sink_id: sinkId } },
body: {
project_id: projectId,
dataset_id: datasetId,
service_account_key: serviceAccountKey,
sink_name: sinkName,
max_staleness_mins: maxStalenessMins,
},
signal,
})
if (error) {
handleError(error)
}
return data
}
type UpdateSinkData = Awaited<ReturnType<typeof updateSink>>
export const useUpdateSinkMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<UpdateSinkData, ResponseError, UpdateSinkParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdateSinkData, ResponseError, UpdateSinkParams>((vars) => updateSink(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(replicationKeys.sinks(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update sink: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
-5
View File
@@ -222,11 +222,6 @@ const nextConfig = {
destination: '/project/:ref/database/tables',
permanent: true,
},
{
source: '/project/:ref/database/replication',
destination: '/project/:ref/database/publications',
permanent: true,
},
{
source: '/project/:ref/database/graphiql',
destination: '/project/:ref/api/graphiql',
@@ -0,0 +1,42 @@
import type { NextPageWithLayout } from 'types'
import Destinations from 'components/interfaces/Database/Replication/Destinations'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useFlag } from 'hooks/ui/useFlag'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { Admonition } from 'ui-patterns'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
const DatabaseReplicationPage: NextPageWithLayout = () => {
const enablePgReplicate = useFlag('enablePgReplicate')
return (
<>
{enablePgReplicate ? (
<ScaffoldContainer>
<Destinations />
</ScaffoldContainer>
) : (
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<Admonition type="default" title="Coming soon">
<p className="!mb-0">Replication is not yet available for your project</p>
</Admonition>
</ScaffoldSection>
</ScaffoldContainer>
)}
</>
)
}
DatabaseReplicationPage.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Database">
<PageLayout title="Database Replication" subtitle="Send data to other destinations">
{page}
</PageLayout>
</DatabaseLayout>
</DefaultLayout>
)
export default DatabaseReplicationPage