feat(replication): Implement UI to render pipeline replication status (#37376)

This commit is contained in:
Riccardo Busetti authored and GitHub committed 2025-07-24 13:04:07 +02:00
1 parent cfb22045d0
commit 0ecddf1901
20 files changed
+1401 -574

No files matched your search

@@ -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 (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
const ReplicationStaticMockup = () => {
const nodes = useMemo(() => STATIC_NODES, [])
const edges = useMemo(() => STATIC_EDGES, [])
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
)
}
const ReplicationComingSoon = () => {
return (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
export default ReplicationComingSoon
const PrimaryNode = ({
data,
}: {
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
return (
<>
<div className="flex flex-col bg-surface-100 px-6 pt-6 border-t relative h-full">
<div className="bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30"></div>
<div className="flex items-center justify-between">
<Input
size="small"
className="w-52 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search..."
/>
<Button
icon={<Plus size={16} />}
type="primary"
className="flex items-center"
onClick={() => {}}
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
<div className="flex flex-col bg-surface-100 px-6 py-6 border-t relative ">
<div className="z-10 bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30" />
<ScaffoldContainer>
<ScaffoldSection className="!py-0">
<div className="col-span-12">
<div className="flex items-center justify-between">
<div className="relative w-52">
<Search
size={14}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
/>
<Input_Shadcn_
className="pl-9 bg-transparent h-8 pointer-events-none"
placeholder="Search..."
/>
</div>
<Button
disabled
type="primary"
icon={<Plus size={16} />}
className="flex items-center pointer-events-none"
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
</div>
</ScaffoldSection>
</ScaffoldContainer>
</div>
</>
)
@@ -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>(
PipelineStatusRequestStatus.None
)
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
const requestStatus = pipeline?.id
? getRequestStatus(pipeline.id)
: PipelineStatusRequestStatus.None
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
}
return undefined
}
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 pipelineStatus = pipelineStatusData?.status
const statusName = getStatusName(pipelineStatus)
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 && (
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
)}
{isPipelineSuccess && (
<Table.tr>
<Table.td>
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : destinationName}
</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
<Table.tr
className="hover:!bg-surface-200 transition"
onClick={() => {
if (pipeline) onSelectPipeline?.(pipeline.id, destinationName)
}}
>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : destinationName}</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : type}</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
@@ -166,27 +126,28 @@ const DestinationRow = ({
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
></PipelineStatus>
/>
)}
</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
pipeline.config.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>
<div className="flex items-center justify-end gap-x-2">
<RowMenu
pipeline={pipeline}
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
/>
</div>
</Table.td>
</Table.tr>
)}
@@ -211,5 +172,3 @@ const DestinationRow = ({
</>
)
}
export default DestinationRow
@@ -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<string>('')
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 (
<>
<ScaffoldSection isFullWidth>
<div className="flex justify-between items-center mb-4">
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="relative">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-9 h-7"
placeholder={'Filter destinations'}
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
</div>
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
</Button>
</div>
</div>
<div className="w-full overflow-hidden overflow-x-auto">
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
{(isSourcesError || isDestinationsError) && (
<AlertError
error={sourcesError || destinationsError}
subject="Failed to retrieve destinations"
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
/>
)}
@@ -77,7 +106,7 @@ const Destinations = () => {
<Table.th key="publication">Publication</Table.th>,
<Table.th key="actions"></Table.th>,
]}
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}
></DestinationRow>
onSelectPipeline={onSelectPipeline}
/>
)
})}
></Table>
@@ -124,15 +154,22 @@ const Destinations = () => {
</div>
)
)}
</ScaffoldSection>
</div>
{!isSourcesLoading &&
!isDestinationsLoading &&
filteredDestinations.length === 0 &&
anyDestinations && (
<div className="text-center py-8 text-foreground-light">
<p>No destinations match "{filterString}"</p>
</div>
)}
<NewDestinationPanel
visible={showNewDestinationPanel}
sourceId={sourceId}
onClose={() => setShowNewDestinationPanel(false)}
></NewDestinationPanel>
/>
</>
)
}
export default Destinations
@@ -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
}
}
@@ -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<HTMLDivElement>(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 (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
{isLoadingLogs ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isLoadingLogs
? 'Pipeline failed - logs are being retrieved from container'
: 'Pipeline has failed - expand for error details'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{hasDetails && (
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
<CollapsibleTrigger asChild>
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
<div
ref={errorDetailsRef}
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
>
{isLoadingLogs ? (
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
<div>
<div className="text-sm font-medium text-foreground mb-1">
Pipeline Failed - Loading logs...
</div>
<div className="text-xs text-foreground-light">
Error logs are being retrieved from the container. This may take a few
moments.
</div>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Pipeline Error Details
</span>
{(failedStatus.message || failedStatus.reason) && (
<Button
type="text"
size="tiny"
onClick={async () => {
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')
)
}}
>
<Copy className="w-3 h-3" />
</Button>
)}
</div>
<div className="space-y-2">
{failedStatus.exit_code !== undefined && (
<div>
<span className="text-xs font-medium text-foreground-light">
Exit Code:
</span>
<div className="font-mono text-sm text-foreground mt-1">
{failedStatus.exit_code}
</div>
</div>
)}
{failedStatus.reason && (
<div>
<span className="text-xs font-medium text-foreground-light">Reason:</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.reason}
</pre>
</div>
)}
{failedStatus.message && (
<div>
<span className="text-xs font-medium text-foreground-light">
Message:
</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.message}
</pre>
</div>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="w-3 h-3" />
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getPipelineStateMessages(requestStatus, 'failed').message}
</TooltipContent>
</Tooltip>
)
}
// 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: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
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: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
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: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is initializing and will be ready soon',
tooltip: stateMessages.message,
}
case PipelineStatusName.STARTED:
return {
label: 'Running',
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
color: 'text-brand-600',
tooltip: 'Pipeline is active and processing data',
tooltip: stateMessages.message,
}
case PipelineStatusName.STOPPED:
return {
label: 'Stopped',
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
color: 'text-foreground-light',
tooltip: 'Pipeline is not running - enable to start processing',
tooltip: stateMessages.message,
}
case PipelineStatusName.UNKNOWN:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
color: 'text-warning-600',
tooltip: 'Pipeline status could not be determined',
tooltip: stateMessages.message,
}
default:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
tooltip: stateMessages.message,
}
}
}
@@ -301,31 +149,27 @@ const PipelineStatus = ({
return (
<>
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
{isSuccess && (
<>
{statusConfig.isFailedStatus ? (
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
<div className="relative">{renderFailedStatus()}</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{statusConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
</Tooltip>
)}
</>
)}
</>
)
}
export default PipelineStatus
@@ -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<string>('')
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 (
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<h3 className="text-xl font-semibold">Pipeline Status</h3>
</div>
</div>
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
</div>
)
}
return (
<div className="space-y-4">
{/* Header with back button and filters */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<div>
<div className="flex items-center gap-x-3">
<h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
/>
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<div className="relative">
<Search
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-7 h-[26px] text-xs"
placeholder="Search for tables"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
<Button
type={statusName === 'stopped' ? 'primary' : 'default'}
onClick={() => onTogglePipeline()}
loading={isStartingPipeline || isStoppingPipeline}
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
>
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
</Button>
</div>
</div>
{(isPipelineLoading || isStatusLoading) && <GenericSkeletonLoader />}
{isStatusError && (
<AlertError
error={statusError}
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_REPLICATION_STATUS}
/>
)}
{hasErrors && (
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-destructive-900 mb-1">
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
</h4>
<p className="text-sm text-destructive-700 mb-3">
Some tables encountered replication errors. Check the logs for detailed error
information.
</p>
<Button
asChild
type="outline"
size="tiny"
icon={<ExternalLink className="w-3 h-3" />}
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
>
<Link
target="_blank"
rel="noreferrer noopener"
href={`/project/${projectRef}/logs/postgres-logs`}
>
View Logs
</Link>
</Button>
</div>
</div>
</div>
)}
{hasTableData && (
<div className="flex flex-col gap-y-4">
{showDisabledState && (
<div
className={cn(
'p-4 border border-default rounded-lg flex items-center justify-between',
config.colors.bg
)}
>
<div className="flex items-center gap-x-3">
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
config.colors.iconBg
)}
>
<div className={config.colors.icon}>{config.icon}</div>
</div>
<div className="flex-1">
<h4 className={`text-sm font-medium ${config.colors.text}`}>{config.title}</h4>
<p className={`text-sm ${config.colors.subtext}`}>{config.message}</p>
</div>
</div>
</div>
)}
<div className="w-full overflow-hidden overflow-x-auto">
<Table
head={[
<Table.th key="table">Table</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="details">Details</Table.th>,
<Table.th key="actions" className="w-16 text-center">
Actions
</Table.th>,
]}
body={filteredTableStatuses.map((table: TableState, index: number) => {
const statusConfig = getStatusConfig(table.state)
return (
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
<Table.td>
<div className="flex items-center gap-x-2">
<p>{table.table_name}</p>
<ButtonTooltip
asChild
type="text"
className="px-1.5"
icon={<ExternalLink />}
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
>
<a
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/editor/${table.table_id}`}
/>
</ButtonTooltip>
</div>
</Table.td>
<Table.td>
{showDisabledState ? (
<Badge variant="default">Not Available</Badge>
) : (
statusConfig.badge
)}
</Table.td>
<Table.td>
{showDisabledState ? (
<p className="text-sm text-foreground-lighter">
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-1">
<div className="text-sm text-foreground">{statusConfig.description}</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
</div>
)}
</div>
)}
</Table.td>
<Table.td className="text-center">
<ButtonTooltip
type="text"
size="tiny"
icon={<Copy className="w-3 h-3" />}
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',
},
}}
/>
</Table.td>
</Table.tr>
)
})}
/>
</div>
</div>
)}
{filteredTableStatuses.length === 0 && hasTableData && (
<Table.tr>
<Table.td colSpan={4}>
<div className="space-y-1">
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</div>
</Table.td>
</Table.tr>
)}
{!isStatusLoading && tableStatuses.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-full max-w-sm mx-auto text-center space-y-4">
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
<Activity className="w-8 h-8 text-foreground-lighter" />
</div>
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">
{showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
</h4>
<p className="text-sm text-foreground-light leading-relaxed">
{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.`}
</p>
</div>
<p className="text-xs text-foreground-lighter">
Data refreshes automatically every 2 seconds
</p>
</div>
</div>
)}
</div>
)
}
@@ -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 }
}
@@ -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: <Badge variant="warning">Queued</Badge>,
description: 'Waiting to start replication',
color: 'text-warning-600',
}
case 'copying_table':
return {
badge: <Badge variant="brand">Copying</Badge>,
description: 'Initial data copy in progress',
color: 'text-brand-600',
}
case 'copied_table':
return {
badge: <Badge variant="success">Copied</Badge>,
description: 'Initial copy completed',
color: 'text-success-600',
}
case 'following_wal':
return {
badge: <Badge variant="success">Live</Badge>,
description: `Replicating live changes`,
color: 'text-success-600',
}
case 'error':
return {
badge: <Badge variant="destructive">Error</Badge>,
description: state.message,
color: 'text-destructive-600',
}
default:
return {
badge: <Badge variant="warning">Unknown</Badge>,
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 ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : statusName === 'failed' ? (
<XCircle className="w-6 h-6" />
) : statusName === 'starting' ? (
<Clock className="w-6 h-6" />
) : statusName === 'unknown' ? (
<HelpCircle className="w-6 h-6" />
) : (
<Activity className="w-6 h-6" />
)
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 }
}
@@ -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 (
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
<Button
type="default"
className="px-1.5"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
{pipelineEnabled ? (
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDisablePipeline()
}}
>
<Pause size={14} />
<p>Disable</p>
<p>Disable pipeline</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEnablePipeline()
}}
>
<Play size={14} />
<p>Enable</p>
<p>Enable pipeline</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEditClick()
}}
>
<Edit size={14} />
<p>Edit destination</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDeleteClick()
}}
>
<Trash stroke="red" size={14} />
<p>Delete destination</p>
</DropdownMenuItem>
@@ -78,5 +160,3 @@ const RowMenu = ({
</div>
)
}
export default RowMenu
@@ -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 (
<Drawer>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DrawerTrigger asChild>
<Button className="h-9 w-9" ref={isMobile ? triggerButtonRef : null}>
<FilterIcon className="w-4 h-4" />
</Button>
</DrawerTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>
Toggle controls with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DrawerTrigger asChild>
<Button className="h-9 w-9" ref={isMobile ? triggerButtonRef : null}>
<FilterIcon className="w-4 h-4" />
</Button>
</DrawerTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>
Toggle controls with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
<DrawerContent className="max-h-[calc(100dvh-4rem)]">
<VisuallyHidden>
<DrawerHeader>
@@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="default"
size="tiny"
onClick={() => table.resetColumnFilters()}
icon={<X />}
>
Reset
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>
Reset filters with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>Esc</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button type="default" size="tiny" onClick={() => table.resetColumnFilters()} icon={<X />}>
Reset
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>
Reset filters with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>Esc</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
)
}
@@ -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 ? <Skeleton className="h-7 w-36" /> : title}
</h5>
<div className="flex h-7 items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="text"
disabled={!prevId}
onClick={onPrev}
className="px-1"
icon={<ChevronUp />}
/>
</TooltipTrigger>
<TooltipContent>
<p>
Navigate <Kbd>↑</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="text"
disabled={!nextId}
onClick={onNext}
className="px-1"
icon={<ChevronDown />}
/>
</TooltipTrigger>
<TooltipContent>
<p>
Navigate <Kbd>↓</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="text"
disabled={!prevId}
onClick={onPrev}
className="px-1"
icon={<ChevronUp />}
/>
</TooltipTrigger>
<TooltipContent>
<p>
Navigate <Kbd>↑</Kbd>
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="text"
disabled={!nextId}
onClick={onNext}
className="px-1"
icon={<ChevronDown />}
/>
</TooltipTrigger>
<TooltipContent>
<p>
Navigate <Kbd>↓</Kbd>
</p>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-1" />
<Button
@@ -2,7 +2,7 @@ import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { ReactNode, useMemo } from 'react'
import { useHotKey } from 'hooks/ui/useHotKey'
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { formatCompactNumber } from './DataTable.utils'
import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer'
import { DataTableResetButton } from './DataTableResetButton'
@@ -33,30 +33,28 @@ export function DataTableToolbar({ renderActions }: DataTableToolbarProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="outline"
icon={open ? <PanelLeftClose /> : <PanelLeftOpen />}
onClick={() => setOpen((prev) => !prev)}
className="hidden sm:flex"
>
<span className="hidden md:block">{open ? 'Hide' : 'Show'} Controls</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>
Toggle controls with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
type="outline"
icon={open ? <PanelLeftClose /> : <PanelLeftOpen />}
onClick={() => setOpen((prev) => !prev)}
className="hidden sm:flex"
>
<span className="hidden md:block">{open ? 'Hide' : 'Show'} Controls</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>
Toggle controls with{' '}
<Kbd className="ml-1 text-muted-foreground group-hover:text-accent-foreground">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
<div className="block sm:hidden">
<DataTableFilterControlsDrawer />
</div>
+2
View File
@@ -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,
}
@@ -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<typeof fetchReplicationPipelineReplicationStatus>
>
export const useReplicationPipelineReplicationStatusQuery = <
TData = ReplicationPipelineReplicationStatusData,
>(
{ projectRef, pipelineId }: ReplicationPipelineReplicationStatusParams,
{
enabled = true,
...options
}: UseQueryOptions<ReplicationPipelineReplicationStatusData, ResponseError, TData> = {}
) =>
useQuery<ReplicationPipelineReplicationStatusData, ResponseError, TData>(
replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId),
({ signal }) => fetchReplicationPipelineReplicationStatus({ projectRef, pipelineId }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined',
...options,
}
)
@@ -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 ? (
<>
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Replication</ScaffoldTitle>
<Destinations />
</ScaffoldHeader>
</ScaffoldContainer>
</>
) : (
<>
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Replication</ScaffoldTitle>
<ScaffoldDescription>Send data to other destinations</ScaffoldDescription>
</ScaffoldHeader>
</ScaffoldContainer>
<ReplicationComingSoon />
</>
)}
</>
)
}
DatabaseReplicationPage.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Database Replication">{page}</DatabaseLayout>
</DefaultLayout>
)
export default DatabaseReplicationPage
@@ -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<number>()
const [selectedDestinationName, setSelectedDestinationName] = useState<string>()
// [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 ? (
<PipelineRequestStatusProvider>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<FormHeader title="Replication" />
{selectedPipelineId === undefined ? (
<Destinations onSelectPipeline={handleSelectPipeline} />
) : (
<ReplicationPipelineStatus
pipelineId={selectedPipelineId}
destinationName={selectedDestinationName}
onSelectBack={handleSelectBack}
/>
)}
</div>
</ScaffoldSection>
</ScaffoldContainer>
</PipelineRequestStatusProvider>
) : (
<>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<FormHeader title="Replication" description="Send data to other destinations" />
</div>
</ScaffoldSection>
</ScaffoldContainer>
<ReplicationComingSoon />
</>
)}
</>
)
}
DatabaseReplicationPage.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Database Replication">{page}</DatabaseLayout>
</DefaultLayout>
)
export default DatabaseReplicationPage
@@ -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<number, PipelineStatusRequestStatus>
setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void
getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus
updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void
}
interface PipelineRequestStatusProviderProps {
children: ReactNode
}
const PipelineRequestStatusContext = createContext<PipelineRequestStatusContextType | undefined>(
undefined
)
export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatusProviderProps) => {
const [requestStatus, setRequestStatusState] = useState<
Record<number, PipelineStatusRequestStatus>
>({})
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 (
<PipelineRequestStatusContext.Provider
value={{
requestStatus,
setRequestStatus,
getRequestStatus,
updatePipelineStatus,
}}
>
{children}
</PipelineRequestStatusContext.Provider>
)
}
export const usePipelineRequestStatus = () => {
const context = useContext(PipelineRequestStatusContext)
if (context === undefined) {
throw new Error('usePipelineRequestStatus must be used within a PipelineRequestStatusProvider')
}
return context
}
+1 -1
View File
@@ -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;
}
+156
View File
@@ -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