DestinationRow.tsx242 lines · main
1import { useParams } from 'common'
2import { AnalyticsBucket, BigQuery, Database } from 'icons'
3import { Minus } from 'lucide-react'
4import Link from 'next/link'
5import { useEffect, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Button,
9 TableCell,
10 TableRow,
11 Tooltip,
12 TooltipContent,
13 TooltipTrigger,
14 WarningIcon,
15} from 'ui'
16import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
17
18import { DeleteDestination } from './DeleteDestination'
19import { PipelineStatus } from './PipelineStatus'
20import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
21import { RowMenu } from './RowMenu'
22import { UpdateVersionModal } from './UpdateVersionModal'
23import { useDestinationInformation } from './useDestinationInformation'
24import { AlertError } from '@/components/ui/AlertError'
25import { useDeleteDestinationPipelineMutation } from '@/data/replication/delete-destination-pipeline-mutation'
26import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication/pipeline-replication-status-query'
27import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
28import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query'
29import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
30import {
31 PipelineStatusRequestStatus,
32 usePipelineRequestStatus,
33} from '@/state/replication-pipeline-request-status'
34import { type ResponseError } from '@/types'
35
36interface DestinationRowProps {
37 destinationId: number
38}
39
40export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
41 const { ref: projectRef } = useParams()
42 const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
43 const [isDeleting, setIsDeleting] = useState(false)
44 const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false)
45
46 const { type, statusName, destination, pipeline, pipelineStatus, pipelineFetcher } =
47 useDestinationInformation({
48 id: destinationId,
49 })
50 const {
51 error: pipelineError,
52 isPending: isPipelineLoading,
53 isError: isPipelineError,
54 isSuccess: isPipelineSuccess,
55 } = pipelineFetcher
56 const destinationName = destination?.name ?? ''
57
58 const {
59 error: pipelineStatusError,
60 isPending: isPipelineStatusLoading,
61 isError: isPipelineStatusError,
62 isSuccess: isPipelineStatusSuccess,
63 } = useReplicationPipelineStatusQuery(
64 {
65 projectRef,
66 pipelineId: pipeline?.id,
67 },
68 { refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
69 )
70 const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
71 const requestStatus = pipeline?.id
72 ? getRequestStatus(pipeline.id)
73 : PipelineStatusRequestStatus.None
74
75 const { mutateAsync: stopPipeline } = useStopPipelineMutation()
76 const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({})
77
78 // Fetch table-level replication status to surface errors in list view
79 const { data: replicationStatusData } = useReplicationPipelineReplicationStatusQuery(
80 { projectRef, pipelineId: pipeline?.id },
81 { refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
82 )
83 const tableStatuses = replicationStatusData?.table_statuses ?? []
84 const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length
85 // Only show errors when pipeline is running (not when stopped or restarting)
86 const isPipelineStopped = statusName === PipelineStatusName.STOPPED
87 const isRestarting = requestStatus === PipelineStatusRequestStatus.RestartRequested
88 const hasTableErrors = errorCount > 0 && !isPipelineStopped && !isRestarting
89
90 // Check if a newer pipeline version is available (one-time check cached for session)
91 const { data: versionData } = useReplicationPipelineVersionQuery({
92 projectRef,
93 pipelineId: pipeline?.id,
94 })
95 const hasUpdate = Boolean(versionData?.new_version)
96
97 const onDeleteClick = async () => {
98 if (!projectRef) {
99 return console.error('Project ref is required')
100 }
101 if (!pipeline) {
102 return toast.error('No pipeline found')
103 }
104
105 try {
106 setIsDeleting(true)
107 await stopPipeline({ projectRef, pipelineId: pipeline.id })
108 await deleteDestinationPipeline({
109 projectRef,
110 destinationId: destinationId,
111 pipelineId: pipeline.id,
112 })
113 // Close dialog after successful deletion
114 setShowDeleteDestinationForm(false)
115 toast.success(`Deleted destination "${destinationName}"`)
116 } catch (error) {
117 toast.error(`Failed to delete destination: ${(error as ResponseError).message}`)
118 } finally {
119 setIsDeleting(false)
120 }
121 }
122
123 useEffect(() => {
124 if (pipeline?.id) {
125 updatePipelineStatus(pipeline.id, statusName)
126 }
127 }, [pipeline?.id, statusName, updatePipelineStatus])
128
129 return (
130 <>
131 {isPipelineError && (
132 <AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
133 )}
134 {isPipelineSuccess && (
135 <TableRow>
136 <TableCell>
137 {type === 'BigQuery' ? (
138 <BigQuery size={18} className="text-foreground-light" />
139 ) : type === 'Analytics Bucket' ? (
140 <AnalyticsBucket size={18} className="text-foreground-light" />
141 ) : type === 'DuckLake' ? (
142 <Database size={18} className="text-foreground-light" />
143 ) : (
144 <Database size={18} className="text-foreground-light" />
145 )}
146 </TableCell>
147
148 <TableCell className="max-w-[180px]">
149 {isPipelineLoading ? (
150 <ShimmeringLoader />
151 ) : (
152 <div>
153 <p>
154 {type} (ID: {pipeline?.id})
155 </p>
156 <p className="text-foreground-lighter">{destinationName}</p>
157 </div>
158 )}
159 </TableCell>
160
161 <TableCell>
162 {isPipelineLoading || !pipeline ? (
163 <ShimmeringLoader />
164 ) : (
165 <PipelineStatus
166 pipelineStatus={pipelineStatus?.status}
167 error={pipelineStatusError}
168 isLoading={isPipelineStatusLoading}
169 isError={isPipelineStatusError}
170 isSuccess={isPipelineStatusSuccess}
171 requestStatus={requestStatus}
172 pipelineId={pipeline?.id}
173 />
174 )}
175 </TableCell>
176
177 <TableCell>
178 <Minus size={18} className="text-foreground-lighter" />
179 </TableCell>
180
181 <TableCell>
182 {isPipelineLoading || !pipeline ? (
183 <ShimmeringLoader />
184 ) : (
185 pipeline.config.publication_name
186 )}
187 </TableCell>
188
189 <TableCell>
190 <div className="flex items-center justify-end gap-x-2">
191 {hasTableErrors && (
192 <Tooltip>
193 <TooltipTrigger>
194 <WarningIcon />
195 </TooltipTrigger>
196 <TooltipContent side="bottom">
197 {errorCount} table{errorCount === 1 ? '' : 's'} encountered replication errors
198 </TooltipContent>
199 </Tooltip>
200 )}
201 <Button asChild type="default" className="relative">
202 <Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
203 View replication
204 </Link>
205 </Button>
206 <RowMenu
207 destinationId={destinationId}
208 pipeline={pipeline}
209 pipelineStatus={pipelineStatus?.status}
210 error={pipelineStatusError}
211 isLoading={isPipelineStatusLoading}
212 isError={isPipelineStatusError}
213 onDeleteClick={() => setShowDeleteDestinationForm(true)}
214 hasUpdate={hasUpdate}
215 onUpdateClick={() => setShowUpdateVersionModal(true)}
216 />
217 </div>
218 </TableCell>
219 </TableRow>
220 )}
221
222 <DeleteDestination
223 visible={showDeleteDestinationForm}
224 setVisible={setShowDeleteDestinationForm}
225 onDelete={onDeleteClick}
226 isLoading={isDeleting}
227 name={destinationName}
228 />
229
230 <UpdateVersionModal
231 visible={showUpdateVersionModal}
232 pipeline={pipeline}
233 onClose={() => setShowUpdateVersionModal(false)}
234 confirmLabel={
235 statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
236 ? 'Update and restart'
237 : 'Update version'
238 }
239 />
240 </>
241 )
242}