ReplicationPipelineStatus.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import { useParams } from 'common'
  2. import {
  3. Activity,
  4. ArrowUpCircle,
  5. Ban,
  6. ChevronDown,
  7. ChevronLeft,
  8. Info,
  9. Pause,
  10. Play,
  11. RotateCcw,
  12. Search,
  13. WifiOff,
  14. X,
  15. } from 'lucide-react'
  16. import Link from 'next/link'
  17. import { parseAsString, useQueryState } from 'nuqs'
  18. import { useEffect, useMemo, useState } from 'react'
  19. import { toast } from 'sonner'
  20. import {
  21. Button,
  22. Card,
  23. CardContent,
  24. DropdownMenu,
  25. DropdownMenuContent,
  26. DropdownMenuTrigger,
  27. Table,
  28. TableBody,
  29. TableHead,
  30. TableHeader,
  31. TableRow,
  32. } from 'ui'
  33. import { GenericSkeletonLoader } from 'ui-patterns'
  34. import { Input } from 'ui-patterns/DataInputs/Input'
  35. import { BatchRestartDialog } from '../BatchRestartDialog'
  36. import { ErrorDetailsDialog } from '../ErrorDetailsDialog'
  37. import {
  38. getPipelineDisplayState,
  39. getStatusName,
  40. PIPELINE_ACTIONABLE_STATES,
  41. } from '../Pipeline.utils'
  42. import { PipelineStatus } from '../PipelineStatus'
  43. import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
  44. import { RestartTableDialog } from '../RestartTableDialog'
  45. import { UpdateVersionModal } from '../UpdateVersionModal'
  46. import { SlotLagMetrics } from './ReplicationPipelineStatus.types'
  47. import { getDisabledStateConfig } from './ReplicationPipelineStatus.utils'
  48. import { SlotLagMetricsInline, SlotLagMetricsList } from './SlotLagMetrics'
  49. import { TableReplicationRow } from './TableReplicationRow'
  50. import { AlertError } from '@/components/ui/AlertError'
  51. import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
  52. import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query'
  53. import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication/pipeline-replication-status-query'
  54. import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
  55. import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query'
  56. import { useRestartPipelineHelper } from '@/data/replication/restart-pipeline-helper'
  57. import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
  58. import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
  59. import {
  60. PipelineStatusRequestStatus,
  61. usePipelineRequestStatus,
  62. } from '@/state/replication-pipeline-request-status'
  63. import { type ResponseError } from '@/types'
  64. /**
  65. * Component for displaying replication pipeline status and table replication details.
  66. * Supports both legacy 'error' state and new 'errored' state with retry policies.
  67. */
  68. export const ReplicationPipelineStatus = () => {
  69. const { ref: projectRef, pipelineId: _pipelineId } = useParams()
  70. const [searchString, setSearchString] = useQueryState('search', parseAsString.withDefault(''))
  71. const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false)
  72. const [showErrorDialog, setShowErrorDialog] = useState(false)
  73. const [selectedTableError, setSelectedTableError] = useState<{
  74. tableName: string
  75. reason: string
  76. solution?: string
  77. } | null>(null)
  78. const [showRestartDialog, setShowRestartDialog] = useState(false)
  79. const [selectedTableForRestart, setSelectedTableForRestart] = useState<{
  80. tableId: number
  81. tableName: string
  82. } | null>(null)
  83. const [showBatchRestartDialog, setShowBatchRestartDialog] = useState(false)
  84. const [batchRestartMode, setBatchRestartMode] = useState<'all' | 'errored' | null>(null)
  85. const [restartingTableIds, setRestartingTableIds] = useState<Set<number>>(new Set())
  86. const pipelineId = Number(_pipelineId)
  87. const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
  88. const requestStatus = getRequestStatus(pipelineId)
  89. const {
  90. data: pipeline,
  91. error: pipelineError,
  92. isPending: isPipelineLoading,
  93. isError: isPipelineError,
  94. } = useReplicationPipelineByIdQuery({
  95. projectRef,
  96. pipelineId,
  97. })
  98. const {
  99. data: pipelineStatusData,
  100. error: pipelineStatusError,
  101. isLoading: isPipelineStatusLoading,
  102. isError: isPipelineStatusError,
  103. isSuccess: isPipelineStatusSuccess,
  104. } = useReplicationPipelineStatusQuery(
  105. { projectRef, pipelineId },
  106. {
  107. enabled: !!pipelineId,
  108. refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
  109. }
  110. )
  111. const {
  112. data: replicationStatusData,
  113. isPending: isStatusLoading,
  114. isError: isStatusError,
  115. } = useReplicationPipelineReplicationStatusQuery(
  116. { projectRef, pipelineId },
  117. {
  118. enabled: !!pipelineId,
  119. refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
  120. }
  121. )
  122. const { data: versionData } = useReplicationPipelineVersionQuery({
  123. projectRef,
  124. pipelineId: pipeline?.id,
  125. })
  126. const hasUpdate = Boolean(versionData?.new_version)
  127. const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation()
  128. const { mutateAsync: stopPipeline, isPending: isStoppingPipeline } = useStopPipelineMutation()
  129. const { restartPipeline } = useRestartPipelineHelper()
  130. const destinationName = pipeline?.destination_name
  131. const statusName = getStatusName(pipelineStatusData?.status)
  132. const displayState = getPipelineDisplayState(requestStatus, statusName)
  133. const config = getDisabledStateConfig({ requestStatus, statusName })
  134. // Sort tables by name for consistent ordering (memoized)
  135. const tableStatuses = useMemo(
  136. () =>
  137. (replicationStatusData?.table_statuses || []).sort((a, b) =>
  138. a.table_name.localeCompare(b.table_name)
  139. ),
  140. [replicationStatusData?.table_statuses]
  141. )
  142. const applyLagMetrics = replicationStatusData?.apply_lag
  143. // Filter tables based on search (memoized)
  144. const filteredTableStatuses = useMemo(
  145. () =>
  146. searchString.length === 0
  147. ? tableStatuses
  148. : tableStatuses.filter((table) =>
  149. table.table_name.toLowerCase().includes(searchString.toLowerCase())
  150. ),
  151. [tableStatuses, searchString]
  152. )
  153. const tablesWithLag = useMemo(
  154. () => tableStatuses.filter((table) => Boolean(table.table_sync_lag)),
  155. [tableStatuses]
  156. )
  157. const erroredTables = useMemo(
  158. () =>
  159. tableStatuses.filter(
  160. (table) =>
  161. table.state.name === 'error' &&
  162. 'retry_policy' in table.state &&
  163. table.state.retry_policy?.policy === 'manual_retry'
  164. ),
  165. [tableStatuses]
  166. )
  167. const hasErroredTables = erroredTables.length > 0
  168. const isAnyRestartInProgress = restartingTableIds.size > 0
  169. const hasTableData = tableStatuses.length > 0
  170. const isPipelineActionable =
  171. statusName === PipelineStatusName.STARTED ||
  172. statusName === PipelineStatusName.STOPPED ||
  173. statusName === PipelineStatusName.FAILED
  174. const isEnablingDisabling =
  175. requestStatus === PipelineStatusRequestStatus.StartRequested ||
  176. requestStatus === PipelineStatusRequestStatus.StopRequested ||
  177. requestStatus === PipelineStatusRequestStatus.RestartRequested
  178. const isPipelineBusy = isEnablingDisabling || isAnyRestartInProgress
  179. const showDisabledState = isPipelineBusy || !isPipelineActionable
  180. const lastKnownStateMessage =
  181. statusName === PipelineStatusName.STOPPED
  182. ? 'Showing the last known table state before the pipeline was stopped.'
  183. : statusName === PipelineStatusName.FAILED
  184. ? 'Showing the last reported table state before the pipeline failed.'
  185. : null
  186. const refreshIntervalLabel =
  187. STATUS_REFRESH_FREQUENCY_MS >= 1000
  188. ? `${Math.round(STATUS_REFRESH_FREQUENCY_MS / 1000)}s`
  189. : `${STATUS_REFRESH_FREQUENCY_MS}ms`
  190. const logsUrl = `/project/${projectRef}/logs/replication-logs${
  191. pipelineId ? `?f=${encodeURIComponent(JSON.stringify({ pipeline_id: pipelineId }))}` : ''
  192. }`
  193. const label = isEnablingDisabling
  194. ? displayState.label
  195. : statusName === PipelineStatusName.STOPPED
  196. ? 'Start'
  197. : statusName === PipelineStatusName.STARTED
  198. ? 'Stop'
  199. : statusName === PipelineStatusName.FAILED
  200. ? 'Restart'
  201. : displayState.label
  202. const icon =
  203. statusName === PipelineStatusName.STOPPED ? (
  204. <Play />
  205. ) : statusName === PipelineStatusName.STARTED ? (
  206. <Pause />
  207. ) : statusName === PipelineStatusName.FAILED ? (
  208. <RotateCcw />
  209. ) : (
  210. <Ban />
  211. )
  212. const onPrimaryAction = async () => {
  213. if (!projectRef) return console.error('Project ref is required')
  214. if (!pipeline) return toast.error('No pipeline found')
  215. const action =
  216. statusName === PipelineStatusName.STOPPED
  217. ? 'start'
  218. : statusName === PipelineStatusName.STARTED
  219. ? 'stop'
  220. : 'restart'
  221. try {
  222. if (statusName === PipelineStatusName.STOPPED) {
  223. setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
  224. await startPipeline({ projectRef, pipelineId: pipeline.id })
  225. } else if (statusName === PipelineStatusName.STARTED) {
  226. setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
  227. await stopPipeline({ projectRef, pipelineId: pipeline.id })
  228. } else if (statusName === PipelineStatusName.FAILED) {
  229. setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
  230. await restartPipeline({ projectRef, pipelineId: pipeline.id })
  231. }
  232. } catch (error) {
  233. setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
  234. toast.error(`Failed to ${action} pipeline: ${(error as ResponseError).message}`)
  235. }
  236. }
  237. useEffect(() => {
  238. updatePipelineStatus(pipelineId, statusName)
  239. }, [pipelineId, statusName, updatePipelineStatus])
  240. return (
  241. <>
  242. <div className="flex flex-col gap-y-4">
  243. <div className="flex items-center justify-between">
  244. <div className="flex items-center gap-x-3">
  245. <Button asChild type="outline" icon={<ChevronLeft />} style={{ padding: '5px' }}>
  246. <Link href={`/project/${projectRef}/database/replication`} />
  247. </Button>
  248. <div className="flex items-center gap-x-3">
  249. <h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
  250. <PipelineStatus
  251. pipelineStatus={pipelineStatusData?.status}
  252. error={pipelineStatusError}
  253. isLoading={isPipelineStatusLoading}
  254. isError={isPipelineStatusError}
  255. isSuccess={isPipelineStatusSuccess}
  256. requestStatus={requestStatus}
  257. pipelineId={pipelineId}
  258. />
  259. </div>
  260. </div>
  261. <div className="flex items-center gap-x-2">
  262. {hasUpdate && (
  263. <Button
  264. type="primary"
  265. icon={<ArrowUpCircle />}
  266. onClick={() => setShowUpdateVersionModal(true)}
  267. >
  268. Update available
  269. </Button>
  270. )}
  271. <Button asChild type="default">
  272. <Link href={logsUrl}>View logs</Link>
  273. </Button>
  274. <Button
  275. type={statusName === PipelineStatusName.STOPPED ? 'primary' : 'default'}
  276. onClick={onPrimaryAction}
  277. loading={
  278. isPipelineError ||
  279. displayState.type === 'loading' ||
  280. isEnablingDisabling ||
  281. isStartingPipeline ||
  282. isStoppingPipeline ||
  283. isAnyRestartInProgress
  284. }
  285. disabled={
  286. isPipelineBusy ||
  287. !PIPELINE_ACTIONABLE_STATES.includes(statusName as PipelineStatusName)
  288. }
  289. icon={icon}
  290. className="capitalize"
  291. >
  292. {label}
  293. </Button>
  294. </div>
  295. </div>
  296. {isPipelineError && (
  297. <AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
  298. )}
  299. {isStatusError && (
  300. <div className="flex items-center gap-2 rounded-lg border border-warning-400 bg-warning-50 px-3 py-2 text-xs text-warning-800">
  301. <WifiOff size={14} />
  302. <span className="font-medium">Live updates paused</span>
  303. <span className="text-warning-700">Retrying automatically</span>
  304. </div>
  305. )}
  306. {(isPipelineLoading || isStatusLoading) && (
  307. <div className="space-y-3">
  308. <div className="flex items-center gap-x-3">
  309. <div className="h-6 w-40 rounded-sm bg-surface-200" />
  310. <div className="h-5 w-24 rounded-sm bg-surface-200" />
  311. </div>
  312. <GenericSkeletonLoader />
  313. </div>
  314. )}
  315. {applyLagMetrics && (
  316. <div className="border border-default rounded-lg bg-surface-100 px-4 py-4 space-y-3">
  317. <div className="flex flex-wrap items-baseline justify-between gap-y-1">
  318. <div>
  319. <h4 className="text-sm font-semibold text-foreground">Replication lag</h4>
  320. <p className="text-xs text-foreground-light">
  321. Snapshot of how far this pipeline is trailing behind right now.
  322. </p>
  323. </div>
  324. <p className="text-xs text-foreground-lighter">
  325. Updates every {refreshIntervalLabel}
  326. </p>
  327. </div>
  328. {isStatusError && (
  329. <p className="text-xs text-warning-700">
  330. Unable to refresh data. Showing the last values we received.
  331. </p>
  332. )}
  333. <SlotLagMetricsList metrics={applyLagMetrics} />
  334. {tablesWithLag.length > 0 && (
  335. <>
  336. <div className="border-t border-default/40" />
  337. <div className="space-y-3 text-xs text-foreground">
  338. <div className="flex items-start gap-2 rounded-md border border-default/50 bg-surface-200/60 px-3 py-2 text-foreground-light">
  339. <Info size={14} className="mt-0.5" />
  340. <span>
  341. During initial sync, tables can copy and stream independently before
  342. reconciling with the overall pipeline.
  343. </span>
  344. </div>
  345. <div className="rounded-sm border border-default/50 bg-surface-200/40">
  346. <ul className="divide-y divide-default/40">
  347. {tablesWithLag.map((table) => (
  348. <li key={`${table.table_id}-${table.table_name}`} className="px-3 py-2">
  349. <SlotLagMetricsInline
  350. tableName={table.table_name}
  351. metrics={table.table_sync_lag as SlotLagMetrics}
  352. />
  353. </li>
  354. ))}
  355. </ul>
  356. </div>
  357. </div>
  358. </>
  359. )}
  360. </div>
  361. )}
  362. {!isPipelineLoading && !isStatusLoading && hasTableData && (
  363. <div className="flex flex-col gap-y-3">
  364. <div className="flex items-center justify-between">
  365. <Input
  366. icon={<Search />}
  367. size="tiny"
  368. className="text-xs w-52"
  369. placeholder="Search for tables"
  370. value={searchString}
  371. disabled={isPipelineError}
  372. onChange={(e) => setSearchString(e.target.value)}
  373. actions={
  374. searchString.length > 0 && [
  375. <X
  376. key="close"
  377. className="mx-2 cursor-pointer text-foreground"
  378. size={14}
  379. strokeWidth={1.5}
  380. onClick={() => setSearchString('')}
  381. />,
  382. ]
  383. }
  384. />
  385. <div className="flex items-center">
  386. <Button
  387. size="tiny"
  388. type="default"
  389. className="rounded-r-none hover:z-2"
  390. icon={<RotateCcw />}
  391. disabled={isAnyRestartInProgress || showDisabledState || isPipelineError}
  392. loading={isAnyRestartInProgress}
  393. onClick={() => {
  394. setBatchRestartMode('all')
  395. setShowBatchRestartDialog(true)
  396. }}
  397. >
  398. Restart all tables
  399. </Button>
  400. <DropdownMenu>
  401. <DropdownMenuTrigger asChild>
  402. <Button
  403. type="default"
  404. icon={<ChevronDown />}
  405. className="w-7 rounded-l-none -ml-px"
  406. disabled={showDisabledState || isPipelineError}
  407. />
  408. </DropdownMenuTrigger>
  409. <DropdownMenuContent align="end" className="w-44">
  410. <DropdownMenuItemTooltip
  411. disabled={!hasErroredTables || isAnyRestartInProgress || showDisabledState}
  412. onClick={() => {
  413. setBatchRestartMode('errored')
  414. setShowBatchRestartDialog(true)
  415. }}
  416. tooltip={{
  417. content: {
  418. side: 'left',
  419. text: !hasErroredTables ? 'No tables require manual retry' : undefined,
  420. },
  421. }}
  422. >
  423. Restart failed tables only
  424. </DropdownMenuItemTooltip>
  425. </DropdownMenuContent>
  426. </DropdownMenu>
  427. </div>
  428. </div>
  429. {lastKnownStateMessage !== null && !showDisabledState && (
  430. <div className="flex items-start gap-2 rounded-md border border-default/50 bg-surface-200/60 px-3 py-2 text-xs text-foreground-light">
  431. <Info size={14} className="mt-0.5" />
  432. <span>{lastKnownStateMessage}</span>
  433. </div>
  434. )}
  435. <Card>
  436. <CardContent className="p-0">
  437. <Table>
  438. <TableHeader>
  439. <TableRow>
  440. <TableHead key="table">Table</TableHead>
  441. <TableHead key="status">Status</TableHead>
  442. <TableHead key="details">Details</TableHead>
  443. <TableHead key="actions" />
  444. </TableRow>
  445. </TableHeader>
  446. <TableBody>
  447. {filteredTableStatuses.map((table) => {
  448. const isRestarting = restartingTableIds.has(table.table_id)
  449. const isErrorState = table.state.name === 'error'
  450. const errorReason =
  451. isErrorState && 'reason' in table.state ? table.state.reason : undefined
  452. const errorSolution =
  453. isErrorState && 'solution' in table.state ? table.state.solution : undefined
  454. return (
  455. <TableReplicationRow
  456. key={table.table_id}
  457. table={table}
  458. isRestarting={isRestarting}
  459. showDisabledState={showDisabledState}
  460. disabledStateMessage={config.message}
  461. isAnyRestartInProgress={isAnyRestartInProgress}
  462. isPipelineStopped={statusName === PipelineStatusName.STOPPED}
  463. onSelectRestart={() => {
  464. setSelectedTableForRestart({
  465. tableId: table.table_id,
  466. tableName: table.table_name,
  467. })
  468. setShowRestartDialog(true)
  469. }}
  470. onSelectShowError={
  471. isErrorState && errorReason
  472. ? () => {
  473. setSelectedTableError({
  474. tableName: table.table_name,
  475. reason: errorReason,
  476. solution: errorSolution,
  477. })
  478. setShowErrorDialog(true)
  479. }
  480. : () => {}
  481. }
  482. />
  483. )
  484. })}
  485. </TableBody>
  486. </Table>
  487. </CardContent>
  488. </Card>
  489. </div>
  490. )}
  491. {!isPipelineLoading && !isStatusLoading && tableStatuses.length === 0 && (
  492. <div className="flex flex-col items-center justify-center py-16 px-4 border rounded-lg border-dashed">
  493. <div className="w-full max-w-sm mx-auto text-center space-y-4">
  494. <div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
  495. <Activity className="w-8 h-8 text-foreground-lighter" />
  496. </div>
  497. <div className="space-y-2">
  498. <h4 className="text-lg font-semibold text-foreground">
  499. {showDisabledState
  500. ? config.title
  501. : statusName === PipelineStatusName.STOPPED
  502. ? 'Pipeline stopped'
  503. : statusName === PipelineStatusName.FAILED
  504. ? 'Pipeline failed'
  505. : 'No table data yet'}
  506. </h4>
  507. <p className="text-sm text-foreground-light leading-relaxed">
  508. {showDisabledState
  509. ? config.message
  510. : statusName === PipelineStatusName.STOPPED
  511. ? 'Start the pipeline to begin replication.'
  512. : statusName === PipelineStatusName.FAILED
  513. ? 'The pipeline encountered an error. Restart it or reset your tables to recover.'
  514. : 'Table status will appear here once replication begins.'}
  515. </p>
  516. </div>
  517. {statusName !== PipelineStatusName.STOPPED && (
  518. <p className="text-xs text-foreground-lighter">
  519. Data refreshes every {refreshIntervalLabel}
  520. </p>
  521. )}
  522. </div>
  523. </div>
  524. )}
  525. </div>
  526. <UpdateVersionModal
  527. visible={showUpdateVersionModal}
  528. pipeline={pipeline}
  529. onClose={() => setShowUpdateVersionModal(false)}
  530. confirmLabel={
  531. statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
  532. ? 'Update and restart'
  533. : 'Update version'
  534. }
  535. />
  536. {/* Restart Table Confirmation Dialog */}
  537. {selectedTableForRestart && (
  538. <RestartTableDialog
  539. open={showRestartDialog}
  540. onOpenChange={setShowRestartDialog}
  541. tableId={selectedTableForRestart.tableId}
  542. tableName={selectedTableForRestart.tableName}
  543. pipelineStatusName={statusName}
  544. onRestartStart={() => {
  545. setRestartingTableIds((prev) => new Set(prev).add(selectedTableForRestart.tableId))
  546. }}
  547. onRestartComplete={() => {
  548. setRestartingTableIds((prev) => {
  549. const next = new Set(prev)
  550. next.delete(selectedTableForRestart.tableId)
  551. return next
  552. })
  553. }}
  554. />
  555. )}
  556. {/* Error Details Dialog */}
  557. {selectedTableError && (
  558. <ErrorDetailsDialog
  559. open={showErrorDialog}
  560. onOpenChange={setShowErrorDialog}
  561. tableName={selectedTableError.tableName}
  562. reason={selectedTableError.reason}
  563. solution={selectedTableError.solution}
  564. />
  565. )}
  566. {/* Batch Restart Dialog */}
  567. {batchRestartMode && (
  568. <BatchRestartDialog
  569. open={showBatchRestartDialog}
  570. onOpenChange={setShowBatchRestartDialog}
  571. mode={batchRestartMode}
  572. totalTables={tableStatuses.length}
  573. erroredTablesCount={erroredTables.length}
  574. tables={tableStatuses}
  575. pipelineStatusName={statusName}
  576. onRestartStart={(tableIds) => {
  577. setRestartingTableIds((prev) => new Set([...prev, ...tableIds]))
  578. }}
  579. onRestartComplete={(tableIds) => {
  580. setRestartingTableIds((prev) => {
  581. const next = new Set(prev)
  582. tableIds.forEach((id) => next.delete(id))
  583. return next
  584. })
  585. }}
  586. />
  587. )}
  588. </>
  589. )
  590. }