DestinationRow.tsx 8.5 KB

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