RowMenu.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { useParams } from 'common'
  2. import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react'
  3. import { parseAsInteger, useQueryState } from 'nuqs'
  4. import { toast } from 'sonner'
  5. import {
  6. Button,
  7. DropdownMenu,
  8. DropdownMenuContent,
  9. DropdownMenuItem,
  10. DropdownMenuSeparator,
  11. DropdownMenuTrigger,
  12. } from 'ui'
  13. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  14. import {
  15. getStatusName,
  16. PIPELINE_DISABLE_ALLOWED_FROM,
  17. PIPELINE_ENABLE_ALLOWED_FROM,
  18. } from './Pipeline.utils'
  19. import { PipelineStatusName } from './Replication.constants'
  20. import AlertError from '@/components/ui/AlertError'
  21. import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
  22. import { Pipeline } from '@/data/replication/pipelines-query'
  23. import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper'
  24. import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
  25. import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
  26. import {
  27. PipelineStatusRequestStatus,
  28. usePipelineRequestStatus,
  29. } from '@/state/replication-pipeline-request-status'
  30. import type { ResponseError } from '@/types'
  31. interface RowMenuProps {
  32. destinationId: number
  33. pipeline: Pipeline | undefined
  34. pipelineStatus?: ReplicationPipelineStatusData['status']
  35. error: ResponseError | null
  36. isLoading: boolean
  37. isError: boolean
  38. hasUpdate?: boolean
  39. onDeleteClick: () => void
  40. onUpdateClick?: () => void
  41. }
  42. export const RowMenu = ({
  43. destinationId,
  44. pipeline,
  45. pipelineStatus,
  46. error,
  47. isLoading,
  48. isError,
  49. hasUpdate = false,
  50. onDeleteClick,
  51. onUpdateClick,
  52. }: RowMenuProps) => {
  53. const { ref: projectRef } = useParams()
  54. const statusName = getStatusName(pipelineStatus)
  55. const [, setEdit] = useQueryState(
  56. 'edit',
  57. parseAsInteger.withOptions({ history: 'push', clearOnDefault: true })
  58. )
  59. const { mutateAsync: startPipeline } = useStartPipelineMutation()
  60. const { mutateAsync: stopPipeline } = useStopPipelineMutation()
  61. const { restartPipeline } = useRestartPipelineHelper()
  62. const { getRequestStatus, setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
  63. const requestStatus = pipeline?.id
  64. ? getRequestStatus(pipeline.id)
  65. : PipelineStatusRequestStatus.None
  66. // Show actions when not in a transitional state
  67. const canPerformActions =
  68. requestStatus === PipelineStatusRequestStatus.None &&
  69. statusName !== PipelineStatusName.STARTING &&
  70. [PipelineStatusName.STOPPED, PipelineStatusName.STARTED, PipelineStatusName.FAILED].includes(
  71. statusName as PipelineStatusName
  72. )
  73. // Show both stop and restart for started/failed states
  74. const showStopAndRestart =
  75. canPerformActions &&
  76. (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)
  77. // Show only start for stopped state
  78. const showStart = canPerformActions && statusName === PipelineStatusName.STOPPED
  79. const onEnablePipeline = async () => {
  80. if (!projectRef) return console.error('Project ref is required')
  81. if (!pipeline) return toast.error('No pipeline found')
  82. try {
  83. // Only show 'enabling' when transitioning from allowed states
  84. if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
  85. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
  86. }
  87. await startPipeline({ projectRef, pipelineId: pipeline.id })
  88. } catch (error) {
  89. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
  90. toast.error(`Failed to start pipeline: ${(error as ResponseError).message}`)
  91. }
  92. }
  93. const onDisablePipeline = async () => {
  94. if (!projectRef) return console.error('Project ref is required')
  95. if (!pipeline) return toast.error('No pipeline found')
  96. try {
  97. // Only show 'disabling' when transitioning from allowed states
  98. if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
  99. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
  100. }
  101. await stopPipeline({ projectRef, pipelineId: pipeline.id })
  102. } catch (error) {
  103. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
  104. toast.error(`Failed to stop pipeline: ${(error as ResponseError).message}`)
  105. }
  106. }
  107. const onRestartPipeline = async () => {
  108. if (!projectRef) return console.error('Project ref is required')
  109. if (!pipeline) return toast.error('No pipeline found')
  110. try {
  111. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
  112. await restartPipeline({ projectRef, pipelineId: pipeline.id })
  113. } catch (error) {
  114. setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
  115. toast.error(`Failed to restart pipeline: ${(error as ResponseError).message}`)
  116. }
  117. }
  118. return (
  119. <div className="flex justify-end items-center space-x-2">
  120. {isLoading && <ShimmeringLoader />}
  121. {isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
  122. <DropdownMenu>
  123. <DropdownMenuTrigger asChild>
  124. <div className="relative">
  125. <Button type="default" className="px-1.5" icon={<MoreVertical />} />
  126. {hasUpdate && (
  127. <span className="absolute -top-0.5 -right-0.5 h-2 w-2 bg-brand rounded-full" />
  128. )}
  129. </div>
  130. </DropdownMenuTrigger>
  131. <DropdownMenuContent side="bottom" align="end" className="w-52">
  132. {hasUpdate && (
  133. <>
  134. <DropdownMenuItem className="space-x-2" onClick={() => onUpdateClick?.()}>
  135. <ArrowUpCircle size={14} />
  136. <p>Update available</p>
  137. </DropdownMenuItem>
  138. <DropdownMenuSeparator />
  139. </>
  140. )}
  141. {showStart && (
  142. <>
  143. <DropdownMenuItem className="space-x-2" onClick={onEnablePipeline}>
  144. <Play size={14} />
  145. <p>Start pipeline</p>
  146. </DropdownMenuItem>
  147. <DropdownMenuSeparator />
  148. </>
  149. )}
  150. {showStopAndRestart && (
  151. <>
  152. <DropdownMenuItem className="space-x-2" onClick={onRestartPipeline}>
  153. <RotateCcw size={14} />
  154. <p>Restart pipeline</p>
  155. </DropdownMenuItem>
  156. <DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}>
  157. <Pause size={14} />
  158. <p>Stop pipeline</p>
  159. </DropdownMenuItem>
  160. <DropdownMenuSeparator />
  161. </>
  162. )}
  163. <DropdownMenuItem className="space-x-2" onClick={() => setEdit(destinationId)}>
  164. <Edit size={14} />
  165. <p>Edit destination</p>
  166. </DropdownMenuItem>
  167. <DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
  168. <Trash size={14} />
  169. <p>Delete destination</p>
  170. </DropdownMenuItem>
  171. </DropdownMenuContent>
  172. </DropdownMenu>
  173. </div>
  174. )
  175. }