diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx
index a23bf75eb81ed..67e66aae6a906 100644
--- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx
@@ -182,6 +182,7 @@ export const CreateBranchModal = () => {
createBranch({
projectRef,
branchName: data.branchName,
+ is_default: false,
...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
})
}
diff --git a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx b/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx
index d51bdf2f5d46c..d2830c5468e78 100644
--- a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx
@@ -1,13 +1,15 @@
-import Table from 'components/to-be-cleaned/Table'
import { motion } from 'framer-motion'
-import { BASE_PATH } from 'lib/constants'
import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useMemo } from 'react'
import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow'
import 'reactflow/dist/style.css'
-import { Button, Input } from 'ui'
+
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import Table from 'components/to-be-cleaned/Table'
+import { BASE_PATH } from 'lib/constants'
+import { Button, Input_Shadcn_ } from 'ui'
import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
const STATIC_NODES = [
@@ -62,6 +64,14 @@ const STATIC_EDGES = [
{ id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true },
]
+export const ReplicationComingSoon = () => {
+ return (
+
+
+
+ )
+}
+
const ReplicationStaticMockup = () => {
const nodes = useMemo(() => STATIC_NODES, [])
const edges = useMemo(() => STATIC_EDGES, [])
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
)
}
-const ReplicationComingSoon = () => {
- return (
-
-
-
- )
-}
-
-export default ReplicationComingSoon
-
const PrimaryNode = ({
data,
}: {
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
return (
<>
-
-
-
- }
- placeholder="Search..."
- />
- }
- type="primary"
- className="flex items-center"
- onClick={() => {}}
- >
- New destination
-
-
-
Name,
- Publication ,
- Lag ,
- Status ,
- ,
- ]}
- className="mt-4"
- body={mockRows.map((row, i) => (
-
- {row.name}
-
-
- All
- {row.tables} tables
-
-
- {row.lag}
-
-
-
- {row.status}
-
-
-
-
-
-
-
-
- ))}
- />
+
+
+
+
+
+
+
+
+
+
+
+
}
+ className="flex items-center pointer-events-none"
+ >
+ New destination
+
+
+
Name,
+ Publication ,
+ Lag ,
+ Status ,
+ ,
+ ]}
+ className="mt-4"
+ body={mockRows.map((row, i) => (
+
+ {row.name}
+
+
+ All
+ {row.tables} tables
+
+
+ {row.lag}
+
+
+
+ {row.status}
+
+
+
+
+
+
+
+
+ ))}
+ />
+
+
+
>
)
diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
index edfab6d923992..04386674eeb7f 100644
--- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
@@ -1,26 +1,28 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
+import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
+import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
+import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
+import {
+ PipelineStatusRequestStatus,
+ usePipelineRequestStatus,
+} from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
-import RowMenu from './RowMenu'
-import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus'
-import { useParams } from 'common'
-import {
- ReplicationPipelineStatusData,
- 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 { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation'
import DeleteDestination from './DeleteDestination'
import DestinationPanel from './DestinationPanel'
+import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
+import { PipelineStatus, PipelineStatusName } from './PipelineStatus'
+import { RowMenu } from './RowMenu'
export type Pipeline = ReplicationPipelinesData['pipelines'][0]
-const refreshFrequencyMs: number = 5000
+const refreshFrequencyMs: number = 2000
interface DestinationRowProps {
sourceId: number | undefined
@@ -32,9 +34,10 @@ interface DestinationRowProps {
isLoading: boolean
isError: boolean
isSuccess: boolean
+ onSelectPipeline?: (pipelineId: number, destinationName: string) => void
}
-const DestinationRow = ({
+export const DestinationRow = ({
sourceId,
destinationId,
destinationName,
@@ -44,6 +47,7 @@ const DestinationRow = ({
isLoading: isPipelineLoading,
isError: isPipelineError,
isSuccess: isPipelineSuccess,
+ onSelectPipeline,
}: DestinationRowProps) => {
const { ref: projectRef } = useParams()
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
@@ -62,76 +66,23 @@ const DestinationRow = ({
},
{ refetchInterval: refreshFrequencyMs }
)
- const [requestStatus, setRequestStatus] = useState(
- PipelineStatusRequestStatus.None
- )
- const { mutateAsync: startPipeline } = useStartPipelineMutation()
- const { mutateAsync: stopPipeline } = useStopPipelineMutation()
- const pipelineStatus = pipelineStatusData?.status
- const getStatusName = (
- status: ReplicationPipelineStatusData['status'] | undefined
- ): string | undefined => {
- if (status && typeof status === 'object' && 'name' in status) {
- return status.name
- }
+ const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
+ const requestStatus = pipeline?.id
+ ? getRequestStatus(pipeline.id)
+ : PipelineStatusRequestStatus.None
- return undefined
- }
+ const { mutateAsync: stopPipeline } = useStopPipelineMutation()
+ const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({})
+ const pipelineStatus = pipelineStatusData?.status
const statusName = getStatusName(pipelineStatus)
- if (
- (requestStatus === PipelineStatusRequestStatus.EnableRequested &&
- (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) ||
- (requestStatus === PipelineStatusRequestStatus.DisableRequested &&
- (statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED))
- ) {
- setRequestStatus(PipelineStatusRequestStatus.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(PipelineStatusRequestStatus.EnableRequested)
- }
- 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(PipelineStatusRequestStatus.DisableRequested)
- }
- const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({})
const onDeleteClick = async () => {
if (!projectRef) {
- console.error('Project ref is required')
- return
+ return console.error('Project ref is required')
}
if (!pipeline) {
- toast.error('No pipeline found')
- return
+ return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
}
try {
@@ -140,24 +91,33 @@ const DestinationRow = ({
// so we don't need to call deletePipeline explicitly
await deleteDestination({ projectRef, destinationId: destinationId })
} catch (error) {
- toast.error('Failed to delete destination')
+ toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
}
}
+ useEffect(() => {
+ if (pipeline?.id) {
+ updatePipelineStatus(pipeline.id, statusName)
+ }
+ }, [pipeline?.id, statusName, updatePipelineStatus])
+
return (
<>
{isPipelineError && (
-
+
)}
{isPipelineSuccess && (
-
-
- {isPipelineLoading ? : destinationName}
-
- {isPipelineLoading ? : type}
+ {
+ if (pipeline) onSelectPipeline?.(pipeline.id, destinationName)
+ }}
+ >
+ {isPipelineLoading ? : destinationName}
+ {isPipelineLoading ? : type}
{isPipelineLoading || !pipeline ? (
-
+
) : (
+ />
)}
{isPipelineLoading || !pipeline ? (
-
+
) : (
pipeline.config.publication_name
)}
- setShowDeleteDestinationForm(true)}
- onEditClick={() => setShowEditDestinationPanel(true)}
- >
+
+ setShowDeleteDestinationForm(true)}
+ onEditClick={() => setShowEditDestinationPanel(true)}
+ />
+
)}
@@ -211,5 +172,3 @@ const DestinationRow = ({
>
)
}
-
-export default DestinationRow
diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
index d1bbfc71b5db9..8d384e7840de9 100644
--- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx
@@ -1,19 +1,26 @@
+import { noop } from 'lodash'
+import { Plus, Search } from 'lucide-react'
+import { useState } from 'react'
+
import { useParams } from 'common'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { useReplicationDestinationsQuery } from 'data/replication/destinations-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 { useState } from 'react'
-import NewDestinationPanel from './DestinationPanel'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
-import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
+import { Button, cn, Input_Shadcn_ } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns'
+import NewDestinationPanel from './DestinationPanel'
+import { DestinationRow } from './DestinationRow'
+import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
-const Destinations = () => {
+interface DestinationsProps {
+ onSelectPipeline?: (pipelineId: number, destinationName: string) => void
+}
+
+export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => {
const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false)
+ const [filterString, setFilterString] = useState('')
const { ref: projectRef } = useParams()
const {
@@ -21,12 +28,11 @@ const Destinations = () => {
error: sourcesError,
isLoading: isSourcesLoading,
isError: isSourcesError,
- isSuccess: isSourcesSuccess,
} = useReplicationSourcesQuery({
projectRef,
})
- let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
+ const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id
const {
data: destinationsData,
@@ -50,21 +56,44 @@ const Destinations = () => {
const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0
+ const filteredDestinations =
+ filterString.length === 0
+ ? destinationsData?.destinations ?? []
+ : (destinationsData?.destinations ?? []).filter((destination) =>
+ destination.name.toLowerCase().includes(filterString.toLowerCase())
+ )
+
return (
<>
-
-
-
Destinations
+
+
+
+
+
+ setFilterString(e.target.value)}
+ />
+
+
} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
+
+
+
{(isSourcesLoading || isDestinationsLoading) &&
}
{(isSourcesError || isDestinationsError) && (
)}
@@ -77,7 +106,7 @@ const Destinations = () => {
Publication ,
,
]}
- body={destinationsData.destinations.map((destination) => {
+ body={filteredDestinations.map((destination) => {
const pipeline = pipelinesData?.pipelines.find(
(p) => p.destination_id === destination.id
)
@@ -93,7 +122,8 @@ const Destinations = () => {
isLoading={isPipelinesLoading}
isError={isPipelinesError}
isSuccess={isPipelinesSuccess}
- >
+ onSelectPipeline={onSelectPipeline}
+ />
)
})}
>
@@ -124,15 +154,22 @@ const Destinations = () => {
)
)}
-
+
+
+ {!isSourcesLoading &&
+ !isDestinationsLoading &&
+ filteredDestinations.length === 0 &&
+ anyDestinations && (
+
+
No destinations match "{filterString}"
+
+ )}
setShowNewDestinationPanel(false)}
- >
+ />
>
)
}
-
-export default Destinations
diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts
new file mode 100644
index 0000000000000..d15fe8040cc2a
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts
@@ -0,0 +1,96 @@
+import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
+import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
+
+export const PIPELINE_ERROR_MESSAGES = {
+ RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
+ RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
+ RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
+ RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
+ ENABLE_DESTINATION: 'Failed to enable destination',
+ DISABLE_DESTINATION: 'Failed to disable destination',
+ DELETE_DESTINATION: 'Failed to delete destination',
+ NO_PIPELINE_FOUND: 'No pipeline found',
+ COPY_TABLE_STATUS: 'Failed to copy table status',
+} as const
+
+export const getStatusName = (
+ status: ReplicationPipelineStatusData['status'] | undefined
+): ReplicationPipelineStatusData['status']['name'] | undefined => {
+ if (status && typeof status === 'object' && 'name' in status) {
+ return status.name
+ }
+ return undefined
+}
+
+export const PIPELINE_STATE_MESSAGES = {
+ enabling: {
+ title: 'Pipeline Enabling',
+ message: 'Starting the pipeline. Table replication will resume once enabled.',
+ badge: 'Enabling',
+ },
+ disabling: {
+ title: 'Pipeline Disabling',
+ message: 'Stopping the pipeline. Table replication will be paused once disabled.',
+ badge: 'Disabling',
+ },
+ failed: {
+ title: 'Pipeline Failed',
+ message: 'Replication has encountered an error. Check the logs for details.',
+ badge: 'Failed',
+ },
+ stopped: {
+ title: 'Pipeline Stopped',
+ message: 'Replication is paused. Enable the pipeline to resume data synchronization.',
+ badge: 'Stopped',
+ },
+ starting: {
+ title: 'Pipeline Starting',
+ message: 'Initializing replication. Table status will be available once running.',
+ badge: 'Starting',
+ },
+ running: {
+ title: 'Pipeline Running',
+ message: 'Replication is active and processing data',
+ badge: 'Running',
+ },
+ unknown: {
+ title: 'Pipeline Status Unknown',
+ message: 'Unable to determine replication status. Check the logs for more information.',
+ badge: 'Unknown',
+ },
+ notRunning: {
+ title: 'Pipeline Not Running',
+ message: 'Replication is not active. Enable the pipeline to start data synchronization.',
+ badge: 'Disabled',
+ },
+} as const
+
+export const getPipelineStateMessages = (
+ requestStatus: PipelineStatusRequestStatus | undefined,
+ statusName: string | undefined
+) => {
+ // Always prioritize request status (enabling/disabling) over pipeline status
+ if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
+ return PIPELINE_STATE_MESSAGES.enabling
+ }
+
+ if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
+ return PIPELINE_STATE_MESSAGES.disabling
+ }
+
+ // Only check pipeline status if no request is in progress
+ switch (statusName) {
+ case 'failed':
+ return PIPELINE_STATE_MESSAGES.failed
+ case 'stopped':
+ return PIPELINE_STATE_MESSAGES.stopped
+ case 'starting':
+ return PIPELINE_STATE_MESSAGES.starting
+ case 'started':
+ return PIPELINE_STATE_MESSAGES.running
+ case 'unknown':
+ return PIPELINE_STATE_MESSAGES.unknown
+ default:
+ return PIPELINE_STATE_MESSAGES.notRunning
+ }
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
index 6fb360e6d972e..f81a8df536ad1 100644
--- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
@@ -1,28 +1,11 @@
import AlertError from 'components/ui/AlertError'
-import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
-import { cn } from 'ui'
-import { ResponseError } from 'types'
-import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react'
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
- Button,
- Collapsible_Shadcn_ as Collapsible,
- CollapsibleContent_Shadcn_ as CollapsibleContent,
- CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
-} from 'ui'
-import { useState, useEffect, useRef } from 'react'
-import { copyToClipboard } from 'ui'
-import { toast } from 'sonner'
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
-
-export enum PipelineStatusRequestStatus {
- None = 'None',
- EnableRequested = 'EnableRequested',
- DisableRequested = 'DisableRequested',
-}
+import { AlertTriangle, Loader2 } from 'lucide-react'
+import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
+import { ResponseError } from 'types'
+import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
export enum PipelineStatusName {
FAILED = 'failed',
@@ -41,10 +24,10 @@ interface PipelineStatusProps {
isLoading: boolean
isError: boolean
isSuccess: boolean
- requestStatus: PipelineStatusRequestStatus
+ requestStatus?: PipelineStatusRequestStatus
}
-const PipelineStatus = ({
+export const PipelineStatus = ({
pipelineStatus,
error,
isLoading,
@@ -52,34 +35,6 @@ const PipelineStatus = ({
isSuccess,
requestStatus,
}: PipelineStatusProps) => {
- const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false)
- const errorDetailsRef = useRef(null)
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) {
- setIsErrorDetailsOpen(false)
- }
- }
-
- if (isErrorDetailsOpen) {
- document.addEventListener('mousedown', handleClickOutside)
- }
-
- return () => {
- document.removeEventListener('mousedown', handleClickOutside)
- }
- }, [isErrorDetailsOpen])
-
- const handleCopyToClipboard = async (text: string) => {
- try {
- await copyToClipboard(text)
- toast.success('Error details copied to clipboard')
- } catch (error) {
- toast.error('Failed to copy error details')
- }
- }
-
const isFailedStatus = (
status: ReplicationPipelineStatusData['status'] | undefined
): status is FailedStatus => {
@@ -91,144 +46,37 @@ const PipelineStatus = ({
)
}
- const isLogLoadingState = (failedStatus: FailedStatus): boolean => {
- // Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems
- // to be a transient error. In case we find a way to properly handle this in the backend, this hack will not
- // be needed anymore.
+ const renderFailedStatus = () => {
return (
- failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') ===
- true
- )
- }
-
- const renderFailedStatus = (failedStatus: FailedStatus) => {
- const hasDetails =
- failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined
- const isLoadingLogs = isLogLoadingState(failedStatus)
-
- return (
-
-
-
-
-
- {isLoadingLogs ? (
-
- ) : (
-
- )}
-
Failed
-
-
-
-
- {isLoadingLogs
- ? 'Pipeline failed - logs are being retrieved from container'
- : 'Pipeline has failed - expand for error details'}
-
-
-
-
- {hasDetails && (
-
-
-
- {isErrorDetailsOpen ? : }
-
-
-
-
- {isLoadingLogs ? (
-
-
-
-
- Pipeline Failed - Loading logs...
-
-
- Error logs are being retrieved from the container. This may take a few
- moments.
-
-
-
- ) : (
- <>
-
-
- Pipeline Error Details
-
- {(failedStatus.message || failedStatus.reason) && (
- {
- await handleCopyToClipboard(
- [
- failedStatus.exit_code !== undefined
- ? `Exit Code: ${failedStatus.exit_code}`
- : null,
- failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
- failedStatus.message ? `Message: ${failedStatus.message}` : null,
- ]
- .filter(Boolean)
- .join('\n')
- )
- }}
- >
-
-
- )}
-
-
- {failedStatus.exit_code !== undefined && (
-
-
- Exit Code:
-
-
- {failedStatus.exit_code}
-
-
- )}
- {failedStatus.reason && (
-
-
Reason:
-
- {failedStatus.reason}
-
-
- )}
- {failedStatus.message && (
-
-
- Message:
-
-
- {failedStatus.message}
-
-
- )}
-
- >
- )}
-
-
-
- )}
-
+
+
+
+
+
+ {getPipelineStateMessages(requestStatus, 'failed').message}
+
+
)
}
// Map backend statuses to UX-friendly display
const getStatusConfig = () => {
+ const statusName =
+ pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus
+ ? pipelineStatus.name
+ : undefined
+
+ // Get consistent tooltip message using the same logic as other components
+ const stateMessages = getPipelineStateMessages(requestStatus, statusName)
+
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
return {
label: 'Enabling...',
dot: ,
color: 'text-brand-600',
- tooltip: 'Pipeline is being enabled and will start shortly',
+ tooltip: stateMessages.message,
}
}
@@ -237,7 +85,7 @@ const PipelineStatus = ({
label: 'Disabling...',
dot: ,
color: 'text-warning-600',
- tooltip: 'Pipeline is being disabled and will stop shortly',
+ tooltip: stateMessages.message,
}
}
@@ -255,35 +103,35 @@ const PipelineStatus = ({
label: 'Starting',
dot: ,
color: 'text-warning-600',
- tooltip: 'Pipeline is initializing and will be ready soon',
+ tooltip: stateMessages.message,
}
case PipelineStatusName.STARTED:
return {
label: 'Running',
dot:
,
color: 'text-brand-600',
- tooltip: 'Pipeline is active and processing data',
+ tooltip: stateMessages.message,
}
case PipelineStatusName.STOPPED:
return {
label: 'Stopped',
dot:
,
color: 'text-foreground-light',
- tooltip: 'Pipeline is not running - enable to start processing',
+ tooltip: stateMessages.message,
}
case PipelineStatusName.UNKNOWN:
return {
label: 'Unknown',
dot:
,
color: 'text-warning-600',
- tooltip: 'Pipeline status could not be determined',
+ tooltip: stateMessages.message,
}
default:
return {
label: 'Unknown',
dot:
,
color: 'text-destructive-600',
- tooltip: 'Pipeline status is unclear - check logs for details',
+ tooltip: stateMessages.message,
}
}
}
@@ -301,31 +149,27 @@ const PipelineStatus = ({
return (
<>
- {isLoading && }
- {isError && }
+ {isLoading && }
+ {isError && (
+
+ )}
{isSuccess && (
<>
{statusConfig.isFailedStatus ? (
- {renderFailedStatus(pipelineStatus as FailedStatus)}
+ {renderFailedStatus()}
) : (
-
-
-
-
- {statusConfig.dot}
- {statusConfig.label}
-
-
-
- {statusConfig.tooltip}
-
-
-
+
+
+
+ {statusConfig.dot}
+ {statusConfig.label}
+
+
+ {statusConfig.tooltip}
+
)}
>
)}
>
)
}
-
-export default PipelineStatus
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx
new file mode 100644
index 0000000000000..24d41862f06be
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx
@@ -0,0 +1,396 @@
+import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react'
+import Link from 'next/link'
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+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 { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query'
+import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query'
+import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
+import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
+import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
+import {
+ PipelineStatusRequestStatus,
+ usePipelineRequestStatus,
+} from 'state/replication-pipeline-request-status'
+import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns'
+import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
+import { PipelineStatus } from './PipelineStatus'
+import { TableState } from './ReplicationPipelineStatus.types'
+import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
+
+interface ReplicationPipelineStatusProps {
+ pipelineId: number
+ destinationName?: string
+ onSelectBack: () => void
+}
+
+export const ReplicationPipelineStatus = ({
+ pipelineId,
+ destinationName,
+ onSelectBack,
+}: ReplicationPipelineStatusProps) => {
+ const { ref: projectRef } = useParams()
+ const [filterString, setFilterString] = useState('')
+
+ const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
+ const requestStatus = getRequestStatus(pipelineId)
+
+ const {
+ data: pipeline,
+ error: pipelineError,
+ isLoading: isPipelineLoading,
+ isError: isPipelineError,
+ } = useReplicationPipelineByIdQuery({
+ projectRef,
+ pipelineId,
+ })
+
+ const {
+ data: pipelineStatusData,
+ error: pipelineStatusError,
+ isLoading: isPipelineStatusLoading,
+ isError: isPipelineStatusError,
+ isSuccess: isPipelineStatusSuccess,
+ } = useReplicationPipelineStatusQuery(
+ { projectRef, pipelineId },
+ {
+ enabled: !!pipelineId,
+ refetchInterval: 2000, // Poll every 2 seconds
+ }
+ )
+
+ const {
+ data: replicationStatusData,
+ error: statusError,
+ isLoading: isStatusLoading,
+ isError: isStatusError,
+ } = useReplicationPipelineReplicationStatusQuery(
+ { projectRef, pipelineId },
+ {
+ enabled: !!pipelineId,
+ refetchInterval: 2000, // Poll every 2 seconds
+ }
+ )
+
+ const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation()
+ const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation()
+
+ const statusName = getStatusName(pipelineStatusData?.status)
+ const config = getDisabledStateConfig({ requestStatus, statusName })
+
+ const tableStatuses = replicationStatusData?.table_statuses || []
+ const filteredTableStatuses =
+ filterString.length === 0
+ ? tableStatuses
+ : tableStatuses.filter((table: TableState) =>
+ table.table_name.toLowerCase().includes(filterString.toLowerCase())
+ )
+
+ const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error')
+ const hasErrors = errorTables.length > 0
+ const isPipelineRunning = statusName === 'started'
+ const hasTableData = tableStatuses.length > 0
+ const isEnablingDisabling =
+ requestStatus === PipelineStatusRequestStatus.EnableRequested ||
+ requestStatus === PipelineStatusRequestStatus.DisableRequested
+ const showDisabledState = !isPipelineRunning || isEnablingDisabling
+
+ const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => {
+ const statusText = `Table: ${tableName}\nStatus: ${state.name}${
+ 'message' in state ? `\nError: ${state.message}` : ''
+ }${'lag' in state ? `\nLag: ${state.lag}ms` : ''}`
+
+ try {
+ await copyToClipboard(statusText)
+ toast.success('Table status copied to clipboard')
+ } catch {
+ toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS)
+ }
+ }
+
+ const onTogglePipeline = async () => {
+ if (!projectRef) {
+ return console.error('Project ref is required')
+ }
+ if (!pipeline) {
+ return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
+ }
+
+ try {
+ if (statusName === 'stopped') {
+ await startPipeline({ projectRef, pipelineId: pipeline.id })
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
+ } else if (statusName === 'started') {
+ await stopPipeline({ projectRef, pipelineId: pipeline.id })
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
+ }
+ } catch (error) {
+ toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
+ }
+ }
+
+ useEffect(() => {
+ updatePipelineStatus(pipelineId, statusName)
+ }, [pipelineId, statusName, updatePipelineStatus])
+
+ if (isPipelineError) {
+ return (
+
+
+
+ }
+ style={{ padding: '5px' }}
+ />
+
Pipeline Status
+
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header with back button and filters */}
+
+
+
}
+ style={{ padding: '5px' }}
+ />
+
+
+
{destinationName || 'Pipeline'}
+
+
+
+
+
+
+
+ setFilterString(e.target.value)}
+ />
+
+
onTogglePipeline()}
+ loading={isStartingPipeline || isStoppingPipeline}
+ disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
+ >
+ {statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
+
+
+
+
+ {(isPipelineLoading || isStatusLoading) &&
}
+
+ {isStatusError && (
+
+ )}
+
+ {hasErrors && (
+
+
+
+
+
+ {errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
+
+
+ Some tables encountered replication errors. Check the logs for detailed error
+ information.
+
+
}
+ className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
+ >
+
+ View Logs
+
+
+
+
+
+ )}
+
+ {hasTableData && (
+
+ {showDisabledState && (
+
+
+
+
+
{config.title}
+
{config.message}
+
+
+
+ )}
+
+
+
Table,
+ Status ,
+ Details ,
+
+ Actions
+ ,
+ ]}
+ body={filteredTableStatuses.map((table: TableState, index: number) => {
+ const statusConfig = getStatusConfig(table.state)
+ return (
+
+
+
+
{table.table_name}
+
+
}
+ tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
+ >
+
+
+
+
+
+ {showDisabledState ? (
+ Not Available
+ ) : (
+ statusConfig.badge
+ )}
+
+
+ {showDisabledState ? (
+
+ Status unavailable while pipeline is {config.badge.toLowerCase()}
+
+ ) : (
+
+
{statusConfig.description}
+ {'lag' in table.state && (
+
+ Lag: {table.state.lag}ms
+
+ )}
+
+ )}
+
+
+ }
+ className="px-1.5"
+ disabled={showDisabledState}
+ onClick={() => handleCopyTableStatus(table.table_name, table.state)}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: showDisabledState
+ ? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}`
+ : 'Copy status details',
+ },
+ }}
+ />
+
+
+ )
+ })}
+ />
+
+
+ )}
+
+ {filteredTableStatuses.length === 0 && hasTableData && (
+
+
+
+
No results found
+
+ Your search for "{filterString}" did not return any results
+
+
+
+
+ )}
+
+ {!isStatusLoading && tableStatuses.length === 0 && (
+
+
+
+
+
+ {showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
+
+
+ {showDisabledState
+ ? `The replication pipeline is currently ${statusName || 'not active'}. Table status
+ information is not available while the pipeline is in this state.`
+ : `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`}
+
+
+
+ Data refreshes automatically every 2 seconds
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts
new file mode 100644
index 0000000000000..c5ab523ec1ead
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts
@@ -0,0 +1,10 @@
+export type TableState = {
+ table_id: number
+ table_name: string
+ state:
+ | { name: 'queued' }
+ | { name: 'copying_table' }
+ | { name: 'copied_table' }
+ | { name: 'following_wal'; lag: number }
+ | { name: 'error'; message: string }
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx
new file mode 100644
index 0000000000000..f9ff22a9e9a0a
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx
@@ -0,0 +1,108 @@
+import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
+import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react'
+import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
+import { Badge } from 'ui'
+import { getPipelineStateMessages } from './Pipeline.utils'
+import { TableState } from './ReplicationPipelineStatus.types'
+
+export const getStatusConfig = (state: TableState['state']) => {
+ switch (state.name) {
+ case 'queued':
+ return {
+ badge: Queued ,
+ description: 'Waiting to start replication',
+ color: 'text-warning-600',
+ }
+ case 'copying_table':
+ return {
+ badge: Copying ,
+ description: 'Initial data copy in progress',
+ color: 'text-brand-600',
+ }
+ case 'copied_table':
+ return {
+ badge: Copied ,
+ description: 'Initial copy completed',
+ color: 'text-success-600',
+ }
+ case 'following_wal':
+ return {
+ badge: Live ,
+ description: `Replicating live changes`,
+ color: 'text-success-600',
+ }
+ case 'error':
+ return {
+ badge: Error ,
+ description: state.message,
+ color: 'text-destructive-600',
+ }
+ default:
+ return {
+ badge: Unknown ,
+ description: 'Unknown status',
+ color: 'text-warning-600',
+ }
+ }
+}
+
+export const getDisabledStateConfig = ({
+ requestStatus,
+ statusName,
+}: {
+ requestStatus: PipelineStatusRequestStatus
+ statusName?: ReplicationPipelineStatusData['status']['name']
+}) => {
+ const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName)
+
+ // Get icon and colors based on current state
+ const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested
+ const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested
+ const isTransitioning = isEnabling || isDisabling
+
+ const icon = isTransitioning ? (
+
+ ) : statusName === 'failed' ? (
+
+ ) : statusName === 'starting' ? (
+
+ ) : statusName === 'unknown' ? (
+
+ ) : (
+
+ )
+
+ const colors = isEnabling
+ ? {
+ bg: 'bg-brand-50',
+ text: 'text-brand-900',
+ subtext: 'text-brand-700',
+ iconBg: 'bg-brand-600',
+ icon: 'text-white dark:text-black',
+ }
+ : isDisabling || statusName === 'starting' || statusName === 'unknown'
+ ? {
+ bg: 'bg-warning-50',
+ text: 'text-warning-900',
+ subtext: 'text-warning-700',
+ iconBg: 'bg-warning-600',
+ icon: 'text-white dark:text-black',
+ }
+ : statusName === 'failed'
+ ? {
+ bg: 'bg-destructive-50',
+ text: 'text-destructive-900',
+ subtext: 'text-destructive-700',
+ iconBg: 'bg-destructive-600',
+ icon: 'text-white dark:text-black',
+ }
+ : {
+ bg: 'bg-surface-100',
+ text: 'text-foreground',
+ subtext: 'text-foreground-light',
+ iconBg: 'bg-foreground-lighter',
+ icon: 'text-white dark:text-black',
+ }
+
+ return { title, message, badge, icon, colors }
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
index f09cc703093c2..ccd49c4b520ac 100644
--- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx
@@ -1,5 +1,14 @@
-import AlertError from 'components/ui/AlertError'
import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
+import AlertError from 'components/ui/AlertError'
+import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
+import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation'
+import {
+ PipelineStatusRequestStatus,
+ usePipelineRequestStatus,
+} from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import {
Button,
@@ -10,29 +19,31 @@ import {
DropdownMenuTrigger,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+import { Pipeline } from './DestinationRow'
+import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatusName } from './PipelineStatus'
interface RowMenuProps {
+ pipeline: Pipeline | undefined
pipelineStatus: any
error: ResponseError | null
isLoading: boolean
isError: boolean
- onEnableClick: () => void
- onDisableClick: () => void
onEditClick: () => void
onDeleteClick: () => void
}
-const RowMenu = ({
+export const RowMenu = ({
+ pipeline,
pipelineStatus,
error,
isLoading,
isError,
- onEnableClick,
- onDisableClick,
onEditClick,
onDeleteClick,
}: RowMenuProps) => {
+ const { ref: projectRef } = useParams()
+
const getStatusName = (status: any) => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
@@ -43,33 +54,104 @@ const RowMenu = ({
const statusName = getStatusName(pipelineStatus)
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
+ const { mutateAsync: startPipeline } = useStartPipelineMutation()
+ const { mutateAsync: stopPipeline } = useStopPipelineMutation()
+ const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
+
+ const onEnablePipeline = async () => {
+ if (!projectRef) {
+ return console.error('Project ref is required')
+ }
+ if (!pipeline) {
+ return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
+ }
+
+ try {
+ await startPipeline({ projectRef, pipelineId: pipeline.id })
+ toast(`Enabling pipeline ${pipeline.destination_name}`)
+ setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested)
+ } catch (error) {
+ toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
+ }
+ }
+
+ const onDisablePipeline = async () => {
+ if (!projectRef) {
+ console.error('Project ref is required')
+ return
+ }
+ if (!pipeline) {
+ toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
+ return
+ }
+
+ try {
+ await stopPipeline({ projectRef, pipelineId: pipeline.id })
+ toast(`Disabling pipeline ${pipeline.destination_name}`)
+ setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested)
+ } catch (error) {
+ toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
+ }
+ }
+
return (
- {isLoading &&
}
- {isError &&
}
+ {isLoading &&
}
+ {isError && (
+
+ )}
- } />
+ }
+ onClick={(e) => e.stopPropagation()}
+ />
{pipelineEnabled ? (
-
+ {
+ e.stopPropagation()
+ onDisablePipeline()
+ }}
+ >
- Disable
+ Disable pipeline
) : (
-
+ {
+ e.stopPropagation()
+ onEnablePipeline()
+ }}
+ >
- Enable
+ Enable pipeline
)}
-
+ {
+ e.stopPropagation()
+ onEditClick()
+ }}
+ >
Edit destination
-
+ {
+ e.stopPropagation()
+ onDeleteClick()
+ }}
+ >
Delete destination
@@ -78,5 +160,3 @@ const RowMenu = ({
)
}
-
-export default RowMenu
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
index 711deddee3e2f..7df159728937b 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx
@@ -9,6 +9,7 @@ import {
ScaffoldSectionContent,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
+import { DocsButton } from 'components/ui/DocsButton'
import { Input } from 'ui-patterns/DataInputs/Input'
import { InviteMemberButton } from './InviteMemberButton'
import MembersView from './MembersView'
@@ -32,6 +33,7 @@ export const TeamSettings = () => {
placeholder="Filter members"
/>
+
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx
index 835d77cc54e3f..0267de60693d0 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx
@@ -15,7 +15,6 @@ import {
DrawerTrigger,
Tooltip,
TooltipContent,
- TooltipProvider,
TooltipTrigger,
} from 'ui'
import { useMediaQuery } from '../hooks/useMediaQuery'
@@ -32,26 +31,24 @@ export function DataTableFilterControlsDrawer() {
return (
-
-
-
-
-
-
-
-
-
-
-
- Toggle controls with{' '}
-
- ⌘
- B
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ Toggle controls with{' '}
+
+ ⌘
+ B
+
+
+
+
diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx
index 0e9c18c8911a0..3e373ff2ee52a 100644
--- a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx
@@ -1,7 +1,7 @@
import { X } from 'lucide-react'
import { useHotKey } from 'hooks/ui/useHotKey'
-import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
+import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Kbd } from './primitives/Kbd'
import { useDataTable } from './providers/DataTableProvider'
@@ -10,28 +10,21 @@ export function DataTableResetButton() {
useHotKey(table.resetColumnFilters, 'Escape')
return (
-
-
-
- table.resetColumnFilters()}
- icon={ }
- >
- Reset
-
-
-
-
- Reset filters with{' '}
-
- ⌘
- Esc
-
-
-
-
-
+
+
+ table.resetColumnFilters()} icon={ }>
+ Reset
+
+
+
+
+ Reset filters with{' '}
+
+ ⌘
+ Esc
+
+
+
+
)
}
diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx
index 4a5ed2200fe86..29f7a7f0495b9 100644
--- a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx
@@ -1,16 +1,7 @@
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
-import {
- Button,
- cn,
- Separator,
- Skeleton,
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from 'ui'
+import { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Kbd } from './primitives/Kbd'
import { useDataTable } from './providers/DataTableProvider'
@@ -79,44 +70,41 @@ export function DataTableSheetDetails({
{isLoading && !selectedRowKey ? : title}
-
-
-
- }
- />
-
-
-
- Navigate ↑
-
-
-
-
-
-
-
- }
- />
-
-
-
- Navigate ↓
-
-
-
-
+
+
+ }
+ />
+
+
+
+ Navigate ↑
+
+
+
+
+
+
+ }
+ />
+
+
+
+ Navigate ↓
+
+
+
-
-
-
- : }
- onClick={() => setOpen((prev) => !prev)}
- className="hidden sm:flex"
- >
- {open ? 'Hide' : 'Show'} Controls
-
-
-
-
- Toggle controls with{' '}
-
- ⌘
- B
-
-
-
-
-
+
+
+ : }
+ onClick={() => setOpen((prev) => !prev)}
+ className="hidden sm:flex"
+ >
+ {open ? 'Hide' : 'Show'} Controls
+
+
+
+
+ Toggle controls with{' '}
+
+ ⌘
+ B
+
+
+
+
diff --git a/apps/studio/data/replication/keys.ts b/apps/studio/data/replication/keys.ts
index ce20ee79d4a7b..30ae4cd85b7de 100644
--- a/apps/studio/data/replication/keys.ts
+++ b/apps/studio/data/replication/keys.ts
@@ -13,4 +13,6 @@ export const replicationKeys = {
['projects', projectRef, 'pipelines', pipelineId] as const,
pipelinesStatus: (projectRef: string | undefined, pipelineId: number | undefined) =>
['projects', projectRef, 'pipelines', pipelineId, 'status'] as const,
+ pipelinesReplicationStatus: (projectRef: string | undefined, pipelineId: number | undefined) =>
+ ['projects', projectRef, 'pipelines', pipelineId, 'replication-status'] as const,
}
diff --git a/apps/studio/data/replication/pipeline-replication-status-query.ts b/apps/studio/data/replication/pipeline-replication-status-query.ts
new file mode 100644
index 0000000000000..952fa97fa4412
--- /dev/null
+++ b/apps/studio/data/replication/pipeline-replication-status-query.ts
@@ -0,0 +1,50 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
+
+import { get, handleError } from 'data/fetchers'
+import { ResponseError } from 'types'
+import { replicationKeys } from './keys'
+
+type ReplicationPipelineReplicationStatusParams = { projectRef?: string; pipelineId?: number }
+
+async function fetchReplicationPipelineReplicationStatus(
+ { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams,
+ 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}/replication-status',
+ {
+ params: { path: { ref: projectRef, pipeline_id: pipelineId } },
+ signal,
+ }
+ )
+ if (error) {
+ handleError(error)
+ }
+
+ return data
+}
+
+export type ReplicationPipelineReplicationStatusData = Awaited<
+ ReturnType
+>
+
+export const useReplicationPipelineReplicationStatusQuery = <
+ TData = ReplicationPipelineReplicationStatusData,
+>(
+ { projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId),
+ ({ signal }) => fetchReplicationPipelineReplicationStatus({ projectRef, pipelineId }, signal),
+ {
+ enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined',
+ ...options,
+ }
+ )
diff --git a/apps/studio/pages/project/[ref]/database/replication.tsx b/apps/studio/pages/project/[ref]/database/replication.tsx
deleted file mode 100644
index 20242cbeb779a..0000000000000
--- a/apps/studio/pages/project/[ref]/database/replication.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import ReplicationComingSoon from 'components/interfaces/Database/Replication/ComingSoon'
-import Destinations from 'components/interfaces/Database/Replication/Destinations'
-import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import {
- ScaffoldContainer,
- ScaffoldDescription,
- ScaffoldHeader,
- ScaffoldTitle,
-} from 'components/layouts/Scaffold'
-import { useFlag } from 'hooks/ui/useFlag'
-import type { NextPageWithLayout } from 'types'
-
-const DatabaseReplicationPage: NextPageWithLayout = () => {
- const enablePgReplicate = useFlag('enablePgReplicate')
-
- return (
- <>
- {enablePgReplicate ? (
- <>
-
-
- Replication
-
-
-
- >
- ) : (
- <>
-
-
- Replication
- Send data to other destinations
-
-
-
- >
- )}
- >
- )
-}
-
-DatabaseReplicationPage.getLayout = (page) => (
-
- {page}
-
-)
-
-export default DatabaseReplicationPage
diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx
new file mode 100644
index 0000000000000..bc1a2a5445ac4
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx
@@ -0,0 +1,76 @@
+import { useState } from 'react'
+
+import { ReplicationComingSoon } from 'components/interfaces/Database/Replication/ComingSoon'
+import { Destinations } from 'components/interfaces/Database/Replication/Destinations'
+import { ReplicationPipelineStatus } from 'components/interfaces/Database/Replication/ReplicationPipelineStatus'
+import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import { FormHeader } from 'components/ui/Forms/FormHeader'
+import { useFlag } from 'hooks/ui/useFlag'
+import { PipelineRequestStatusProvider } from 'state/replication-pipeline-request-status'
+import type { NextPageWithLayout } from 'types'
+
+const DatabaseReplicationPage: NextPageWithLayout = () => {
+ const enablePgReplicate = useFlag('enablePgReplicate')
+ const [selectedPipelineId, setSelectedPipelineId] = useState()
+ const [selectedDestinationName, setSelectedDestinationName] = useState()
+
+ // [Joshen] Ideally selecting a pipeline should be a route on its own with pipelineId as the param
+ // e.g /project/ref/database/replication/[pipelineId]
+ // Can destinationName be derived from pipeline ID or something?
+
+ const handleSelectPipeline = (pipelineId: number, destinationName: string) => {
+ setSelectedPipelineId(pipelineId)
+ setSelectedDestinationName(destinationName)
+ }
+
+ const handleSelectBack = () => {
+ setSelectedPipelineId(undefined)
+ setSelectedDestinationName(undefined)
+ }
+
+ return (
+ <>
+ {enablePgReplicate ? (
+
+
+
+
+
+ {selectedPipelineId === undefined ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ )
+}
+
+DatabaseReplicationPage.getLayout = (page) => (
+
+ {page}
+
+)
+
+export default DatabaseReplicationPage
diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx
new file mode 100644
index 0000000000000..722c4de9120b5
--- /dev/null
+++ b/apps/studio/state/replication-pipeline-request-status.tsx
@@ -0,0 +1,76 @@
+import { createContext, useContext, useState, ReactNode, useCallback } from 'react'
+
+export enum PipelineStatusRequestStatus {
+ None = 'None',
+ EnableRequested = 'EnableRequested',
+ DisableRequested = 'DisableRequested',
+}
+
+interface PipelineRequestStatusContextType {
+ requestStatus: Record
+ setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void
+ getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus
+ updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void
+}
+
+interface PipelineRequestStatusProviderProps {
+ children: ReactNode
+}
+
+const PipelineRequestStatusContext = createContext(
+ undefined
+)
+
+export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatusProviderProps) => {
+ const [requestStatus, setRequestStatusState] = useState<
+ Record
+ >({})
+
+ const setRequestStatus = (pipelineId: number, status: PipelineStatusRequestStatus) => {
+ setRequestStatusState((prev) => ({
+ ...prev,
+ [pipelineId]: status,
+ }))
+ }
+
+ const getRequestStatus = (pipelineId: number): PipelineStatusRequestStatus => {
+ return requestStatus[pipelineId] || PipelineStatusRequestStatus.None
+ }
+
+ const updatePipelineStatus = useCallback(
+ (pipelineId: number, backendStatus: string | undefined) => {
+ const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None
+
+ if (
+ (currentRequestStatus === PipelineStatusRequestStatus.EnableRequested &&
+ (backendStatus === 'started' || backendStatus === 'failed')) ||
+ (currentRequestStatus === PipelineStatusRequestStatus.DisableRequested &&
+ (backendStatus === 'stopped' || backendStatus === 'failed'))
+ ) {
+ setRequestStatus(pipelineId, PipelineStatusRequestStatus.None)
+ }
+ },
+ [requestStatus, setRequestStatus]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const usePipelineRequestStatus = () => {
+ const context = useContext(PipelineRequestStatusContext)
+ if (context === undefined) {
+ throw new Error('usePipelineRequestStatus must be used within a PipelineRequestStatusProvider')
+ }
+ return context
+}
diff --git a/apps/studio/styles/ui.scss b/apps/studio/styles/ui.scss
index 886f96393d435..f0f37298fec5f 100644
--- a/apps/studio/styles/ui.scss
+++ b/apps/studio/styles/ui.scss
@@ -119,7 +119,7 @@
}
.table-container thead th:first-child {
- @apply pl-6 rounded rounded-r-none rounded-b-none;
+ @apply rounded rounded-r-none rounded-b-none;
@apply border-l;
}
diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts
index b34c046fe0963..23e0737d942da 100644
--- a/packages/api-types/types/platform.d.ts
+++ b/packages/api-types/types/platform.d.ts
@@ -3391,6 +3391,23 @@ export interface paths {
patch?: never
trace?: never
}
+ '/platform/replication/{ref}/pipelines/{pipeline_id}/replication-status': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Get the status of a replication pipeline. */
+ get: operations['ReplicationPipelinesController_getPipelineReplicationStatus']
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/platform/replication/{ref}/pipelines/{pipeline_id}/start': {
parameters: {
query?: never
@@ -7353,6 +7370,41 @@ export interface components {
tenant_id: string
}[]
}
+ ReplicationPipelineReplicationStatusResponse: {
+ /** @description Pipeline id */
+ pipeline_id: number
+ /** @description Table statuses */
+ table_statuses: {
+ /** @description Table replication state */
+ state:
+ | {
+ /** @enum {string} */
+ name: 'queued'
+ }
+ | {
+ /** @enum {string} */
+ name: 'copying_table'
+ }
+ | {
+ /** @enum {string} */
+ name: 'copied_table'
+ }
+ | {
+ lag: number
+ /** @enum {string} */
+ name: 'following_wal'
+ }
+ | {
+ message: string
+ /** @enum {string} */
+ name: 'error'
+ }
+ /** @description Table id (internal Postgres OID) */
+ table_id: number
+ /** @description Table name */
+ table_name: string
+ }[]
+ }
ReplicationPipelineResponse: {
/** @description Pipeline config */
config: {
@@ -16607,6 +16659,12 @@ export interface operations {
'application/json': components['schemas']['GetUserContentResponse']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to retrieve project's content */
500: {
headers: {
@@ -16638,6 +16696,12 @@ export interface operations {
}
content?: never
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to update project's content */
500: {
headers: {
@@ -16671,6 +16735,12 @@ export interface operations {
'application/json': components['schemas']['UserContentObject']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to create project's content */
500: {
headers: {
@@ -16702,6 +16772,12 @@ export interface operations {
'application/json': components['schemas']['BulkDeleteUserContentResponse'][]
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to delete project's contents */
500: {
headers: {
@@ -16734,6 +16810,12 @@ export interface operations {
'application/json': components['schemas']['GetContentCountV2Response']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to retrieve user's content counts */
500: {
headers: {
@@ -16771,6 +16853,12 @@ export interface operations {
'application/json': components['schemas']['GetUserContentFolderResponse']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to retrieve project's content root folder */
500: {
headers: {
@@ -16804,6 +16892,12 @@ export interface operations {
'application/json': components['schemas']['CreateUserContentFolderResponse']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to create project's content folder */
500: {
headers: {
@@ -16830,6 +16924,12 @@ export interface operations {
}
content?: never
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to delete project's content folders */
500: {
headers: {
@@ -16867,6 +16967,12 @@ export interface operations {
'application/json': components['schemas']['GetUserContentFolderResponse']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to retrieve project's content folder */
500: {
headers: {
@@ -16900,6 +17006,12 @@ export interface operations {
}
content?: never
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to update project's content folder */
500: {
headers: {
@@ -16930,6 +17042,12 @@ export interface operations {
'application/json': components['schemas']['GetUserContentByIdResponse']
}
}
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
/** @description Failed to retrieve project's content by the given id */
500: {
headers: {
@@ -18639,6 +18757,44 @@ export interface operations {
}
}
}
+ ReplicationPipelinesController_getPipelineReplicationStatus: {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Pipeline id */
+ pipeline_id: number
+ /** @description Project ref */
+ ref: string
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Returns the pipeline replication status. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['ReplicationPipelineReplicationStatusResponse']
+ }
+ }
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Failed to get pipeline replication status. */
+ 500: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ }
+ }
ReplicationPipelinesController_startPipeline: {
parameters: {
query?: never