diff --git a/apps/studio/components/interfaces/Database/ETL/EnableReplicationModal.tsx b/apps/studio/components/interfaces/Database/ETL/EnableReplicationModal.tsx index 3dbb98700a6..a92ae3396cd 100644 --- a/apps/studio/components/interfaces/Database/ETL/EnableReplicationModal.tsx +++ b/apps/studio/components/interfaces/Database/ETL/EnableReplicationModal.tsx @@ -20,7 +20,7 @@ export const EnableReplicationModal = () => { const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) - const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } = + const { mutate: createTenantSource, isLoading: creatingTenantSource } = useCreateTenantSourceMutation({ onSuccess: () => { toast.success('Replication has been successfully enabled!') @@ -33,7 +33,7 @@ export const EnableReplicationModal = () => { const onEnableReplication = async () => { if (!projectRef) return console.error('Project ref is required') - await createTenantSource({ projectRef }) + createTenantSource({ projectRef }) } return ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/NamespaceWithTables.utils.ts b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/NamespaceWithTables.utils.ts new file mode 100644 index 00000000000..f36458a801e --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/NamespaceWithTables.utils.ts @@ -0,0 +1,13 @@ +import { snakeCase } from 'lodash' + +import { ReplicationPublication } from 'data/etl/publications-query' + +export const inferPostgresTableFromNamespaceTable = ({ + publication, + tableName, +}: { + publication?: ReplicationPublication + tableName: string +}) => { + return publication?.tables.find((x) => tableName === snakeCase(`${x.schema}.${x.name}_changelog`)) +} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx index 0240c74ade5..b5a45921ad9 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -1,7 +1,7 @@ import { snakeCase, uniq } from 'lodash' -import { MoreVertical, Pause, Play, Trash } from 'lucide-react' +import { Loader2, MoreVertical, Pause, Play, Trash } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -10,7 +10,9 @@ import { formatWrapperTables, } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' import { getDecryptedParameters } from 'components/interfaces/Storage/ImportForeignSchemaDialog.utils' +import { DotPing } from 'components/ui/DotPing' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' +import { useReplicationPipelineStatusQuery } from 'data/etl/pipeline-status-query' import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' import { useReplicationTablesQuery } from 'data/etl/tables-query' @@ -27,14 +29,17 @@ import { DropdownMenuTrigger, TableCell, TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { getAnalyticsBucketFDWServerName } from '../AnalyticsBucketDetails.utils' import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities' import { useAnalyticsBucketWrapperInstance } from '../useAnalyticsBucketWrapperInstance' +import { inferPostgresTableFromNamespaceTable } from './NamespaceWithTables.utils' interface TableRowComponentProps { - index: number table: { id: number; name: string; isConnected: boolean } schema: string namespace: string @@ -43,7 +48,6 @@ interface TableRowComponentProps { } export const TableRowComponent = ({ - index, table, schema, namespace, @@ -63,6 +67,8 @@ export const TableRowComponent = ({ projectRef, bucketId, }) + const { data } = useReplicationPipelineStatusQuery({ projectRef, pipelineId: pipeline?.id }) + const pipelineStatus = data?.status.name const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) const { data: wrapperInstance, meta: wrapperMeta } = useAnalyticsBucketWrapperInstance({ @@ -74,9 +80,29 @@ export const TableRowComponent = ({ const { mutateAsync: updatePublication } = useUpdatePublicationMutation() const { mutateAsync: startPipeline } = useStartPipelineMutation() - const isReplicating = !!publication?.tables.find( - (x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`) - ) + const inferredPostgresTable = inferPostgresTableFromNamespaceTable({ + publication, + tableName: table.name, + }) + const isTableUnderReplicationPublication = !!inferredPostgresTable + const hasReplication = !!pipeline && !!publication + const isPipelineRunning = pipelineStatus === 'started' + const isReplicating = isTableUnderReplicationPublication && isPipelineRunning + + // [Joshen] Considers both the replication pipeline status + if the table is in the replication publication + const replicationStatusLabel = useMemo(() => { + if (isLoading) return 'Checking' + + if (hasReplication) { + if (!isPipelineRunning) { + return '-' + } else if (isTableUnderReplicationPublication) { + return 'Running' + } else { + return 'Disabled' + } + } + }, [hasReplication, isLoading, isPipelineRunning, isTableUnderReplicationPublication]) const onConfirmStopReplication = async () => { if (!projectRef) return console.error('Project ref is required') @@ -100,9 +126,9 @@ export const TableRowComponent = ({ }) await startPipeline({ projectRef, pipelineId: pipeline.id }) setShowStopReplicationModal(false) - toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + toast.success('Successfully disabled replication for table! Pipeline is being restarted.') } catch (error: any) { - toast.error(`Failed to stop replication for table: ${error.message}`) + toast.error(`Failed to disable replication for table: ${error.message}`) } finally { setIsUpdatingReplication(false) } @@ -132,9 +158,9 @@ export const TableRowComponent = ({ }) await startPipeline({ projectRef, pipelineId: pipeline.id }) setShowStartReplicationModal(false) - toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + toast.success('Successfully enabled replication for table! Pipeline is being restarted.') } catch (error: any) { - toast.error(`Failed to stop replication for table: ${error.message}`) + toast.error(`Failed to enable replication for table: ${error.message}`) } finally { setIsUpdatingReplication(false) } @@ -209,38 +235,35 @@ export const TableRowComponent = ({ <> {table.name} - {!!publication && ( + {!!hasReplication && ( -
-
- - -
- - {isLoading && !isReplicating - ? '-' - : isReplicating - ? 'Replicating' - : 'Not replicating'} - +
+ + +
+ {isLoading ? ( + + ) : isPipelineRunning ? ( + + ) : null} + + {replicationStatusLabel} + +
+
+ {isPipelineRunning && ( + + {isReplicating + ? `Table data is currently replicating${!!inferredPostgresTable ? ` from ${inferredPostgresTable.schema}.${inferredPostgresTable.name}` : ''}` + : !isTableUnderReplicationPublication + ? 'Replication is disabled for this table' + : undefined} + + )} +
)} @@ -270,13 +293,13 @@ export const TableRowComponent = ({ {!!publication && ( <> - {isReplicating ? ( + {isTableUnderReplicationPublication ? ( setShowStopReplicationModal(true)} > -

Stop replication

+

Disable replication

) : ( setShowStartReplicationModal(true)} > -

Start replication

+

Enable replication

)} @@ -317,14 +340,14 @@ export const TableRowComponent = ({ variant="warning" visible={showStopReplicationModal} loading={isUpdatingReplication} - title="Confirm to stop replication for table" - confirmLabel="Stop replication" + title="Confirm to disable replication for table" + confirmLabel="Disable replication" onCancel={() => setShowStopReplicationModal(false)} onConfirm={() => onConfirmStopReplication()} >

Data within the "{table.name}" table will stop replicating. However do note that, - restarting replication on the table will clear and re-sync all data in it. Are you sure? + re-enabling replication on this table will clear and re-sync all data in it. Are you sure?

@@ -333,13 +356,13 @@ export const TableRowComponent = ({ variant="warning" visible={showStartReplicationModal} loading={isUpdatingReplication} - title="Confirm to start replication for table" - confirmLabel="Start replication" + title="Confirm to enable replication for table" + confirmLabel="Enable replication" onCancel={() => setShowStartReplicationModal(false)} onConfirm={() => onConfirmStartReplication()} >

- Restarting replication on the "{table.name}" table will clear and re-sync all data in it. + Re-enabling replication on the "{table.name}" table will clear and re-sync all data in it. Are you sure?

diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx index 574d7ffa380..896493b348e 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/index.tsx @@ -234,7 +234,7 @@ export const NamespaceWithTables = ({ ) : ( - allTables.map((table, index) => ( + allTables.map((table) => ( )) )} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx index a9efb065f22..8191cfae13a 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx @@ -25,6 +25,8 @@ import { DatabaseExtension, useDatabaseExtensionsQuery, } from 'data/database-extensions/database-extensions-query' +import { useReplicationPipelineStatusQuery } from 'data/etl/pipeline-status-query' +import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' import { AnalyticsBucket } from 'data/storage/analytics-buckets-query' import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' @@ -64,6 +66,8 @@ export const AnalyticBucketDetails = () => { const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) + const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation() + /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */ const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({ bucketId: bucket?.id, @@ -72,6 +76,18 @@ export const AnalyticBucketDetails = () => { projectRef, bucketId: bucket?.id, }) + const { data } = useReplicationPipelineStatusQuery( + { projectRef, pipelineId: pipeline?.id }, + { + refetchInterval: (data) => { + if (data?.status.name !== 'started') return 2000 + else return false + }, + } + ) + const pipelineStatus = data?.status.name + const isPipelineRunning = pipelineStatus === 'started' + const isPipelineStopped = ['failed', 'stopped'].includes(pipelineStatus ?? '') const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') @@ -193,7 +209,7 @@ export const AnalyticBucketDetails = () => {
- {!!pipeline && ( + {!!pipeline && isPipelineRunning && ( + {isPipelineStopped && ( + + )} +
+ } + > + {!isPipelineStopped && ( + + )} + + )} +
+ {namespaces.map(({ namespace, schema, tables }) => ( + + ))} +
+ )} diff --git a/apps/studio/components/ui/DotPing.tsx b/apps/studio/components/ui/DotPing.tsx new file mode 100644 index 00000000000..7bdca2e7024 --- /dev/null +++ b/apps/studio/components/ui/DotPing.tsx @@ -0,0 +1,34 @@ +import { cn } from 'ui' + +interface DotPingProps { + animate?: boolean + variant?: 'primary' | 'default' | 'warning' +} + +export const DotPing = ({ animate = true, variant = 'primary' }: DotPingProps) => { + return ( +
+ + +
+ ) +} diff --git a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx index 8dbfcac0f0a..7db9f803e4f 100644 --- a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx +++ b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx @@ -34,7 +34,7 @@ export interface ConfirmationModalProps { } } -const ConfirmationModal = forwardRef< +export const ConfirmationModal = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & ConfirmationModalProps >( diff --git a/packages/ui-patterns/src/admonition.tsx b/packages/ui-patterns/src/admonition.tsx index 22cd48c6adc..5e37142b8db 100644 --- a/packages/ui-patterns/src/admonition.tsx +++ b/packages/ui-patterns/src/admonition.tsx @@ -1,5 +1,5 @@ import { cva } from 'class-variance-authority' -import { forwardRef, ReactNode } from 'react' +import { ComponentProps, forwardRef, ReactNode } from 'react' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, cn } from 'ui' export interface AdmonitionProps { @@ -14,11 +14,11 @@ export interface AdmonitionProps { | 'warning' label?: string title?: string - description?: string | React.ReactNode + description?: string | ReactNode showIcon?: boolean childProps?: { - title?: React.ComponentProps - description?: React.ComponentProps + title?: ComponentProps + description?: ComponentProps } layout?: 'horizontal' | 'vertical' actions?: ReactNode