From e21e0c73bb8ea9148d573dcad619c0d9dad1a38c Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:17:52 +1000 Subject: [PATCH] refactor(studio): simplify pipeline error details (#50444) ## What kind of change does this PR introduce? Studio UI refactor. ## What is the current behavior? Failed replicated tables spread retry timing and error details across several visually heavy blocks. ## What is the new behavior? Condenses retry timing and failure details into a clearer table-level presentation without changing retry behaviour or pipeline mutations. | Before | After | | --- | --- | | CleanShot 2026-09-16 at 12 55
52@2x | CleanShot 2026-09-16 at 15 16
21@2x | | _Not applicable._ | CleanShot
2026-09-16 at 15 16 29@2x | This is an independent slice extracted from #49630. The related review series is #50443, this PR, #50445, #50446, then #49630. ## To test 1. Open `/project//database/replication` and select a pipeline with a failed table. 2. Confirm the table row presents its failure and retry timing without expanding the row unnecessarily. 3. Open the error details dialog and confirm the underlying error remains available. This is difficult to test unless you have a properly-failing table. You can instead do the following locally: 1. Check out `dnywh/tmp/pipelines-running-fixture`. 2. Open `/project//database/replication/`. 3. Use the floating pipeline-state switcher in the bottom-right. 4. Select _Running, some tables errored_. --- .../Replication/ErrorDetailsDialog.tsx | 39 +++--- .../Replication/ErroredTableDetails.tsx | 85 ++++--------- .../Database/Replication/RetryCountdown.tsx | 112 ++++-------------- 3 files changed, 61 insertions(+), 175 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/ErrorDetailsDialog.tsx b/apps/studio/components/interfaces/Database/Replication/ErrorDetailsDialog.tsx index 22f6f7a2782..d27cf3553be 100644 --- a/apps/studio/components/interfaces/Database/Replication/ErrorDetailsDialog.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ErrorDetailsDialog.tsx @@ -4,6 +4,7 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogSection, @@ -29,38 +30,32 @@ export const ErrorDetailsDialog = ({ }: ErrorDetailsDialogProps) => { return ( - + - - Replication error on {tableName} - + Replication error + {tableName} stopped replicating - -
-

- The following error occurred during replication: -

-
+ + {/* + No `language`: this is an error message reported by the destination, not code, so + syntax highlighting would colour it at random. The code block is still the right + frame, since it marks the text as machine output and carries a copy button for + pasting into a support request. + */} pre]:border-x-0 [&>pre]:rounded-none' - )} - language="bash" + wrapLines + wrapLongLines value={reason} + wrapperClassName={cn('[&_pre]:px-3 [&_pre]:py-3')} className="[&_code]:text-xs [&_code]:text-foreground [&_span]:text-foreground!" /> - {solution && ( -
-

{solution}

-
- )} + {solution &&

{solution}

}
- - + +
diff --git a/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx b/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx index 1bc5ff7cdd3..4f3426c3761 100644 --- a/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ErroredTableDetails.tsx @@ -1,5 +1,4 @@ import { useParams } from 'common' -import { CriticalIcon } from 'ui' import { isValidRetryPolicy } from './ReplicationPipelineStatus/ReplicationPipelineStatus.utils' import { RetryCountdown } from './RetryCountdown' @@ -10,69 +9,33 @@ interface ErroredTableDetailsProps { table: ReplicationPipelineTableStatus } +/** + * What happens next for a table that failed, as the second sentence of the row's status line, so + * it ends in a period. The error and how to fix it live in ErrorDetailsDialog, via View error. + */ export const ErroredTableDetails = ({ table }: ErroredTableDetailsProps) => { const { ref: projectRef } = useParams() - const state = table.state as Extract - const tableName = `${table.schema}.${table.name}` - const retryPolicy = state.retry_policy.policy - if (!isValidRetryPolicy(state.retry_policy)) { - return ( -
- {state.solution &&
{state.solution}
} -
Invalid retry policy configuration
-
- ) + if (!isValidRetryPolicy(state.retry_policy)) return <>Retry settings are invalid. + + switch (state.retry_policy.policy) { + case 'timed_retry': + return + case 'manual_retry': + return <>Reset this table to resume. + case 'no_retry': + return ( + <> + Needs{' '} + + support + + , or recreate the pipeline. + + ) } - - return ( -
- {retryPolicy === 'no_retry' ? ( -
-

- This error requires manual intervention from our{' '} - - support - - . Alternatively, you may also recreate the pipeline. Use the table actions menu on the - right to view the full error details. -

-
- ) : retryPolicy === 'manual_retry' ? ( -
-
-
- -
-

Action required to continue replication

-

- {state.solution} - {state.solution && !/[.!?]$/.test(state.solution.trim()) && '.'} -

-

- Restart table replication from the table actions menu on the right. The pipeline - will restart automatically. -

-
-
-
-
- ) : retryPolicy === 'timed_retry' ? ( -
-

- Replication will retry automatically. The pipeline will restart to apply the retry. -

- -
- ) : null} -
- ) } diff --git a/apps/studio/components/interfaces/Database/Replication/RetryCountdown.tsx b/apps/studio/components/interfaces/Database/Replication/RetryCountdown.tsx index 6b5b1edf650..a4e23a28f58 100644 --- a/apps/studio/components/interfaces/Database/Replication/RetryCountdown.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RetryCountdown.tsx @@ -1,107 +1,35 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import dayjs from 'dayjs' +import { useEffect, useState } from 'react' interface RetryCountdownProps { - nextRetryTime: string // RFC3339 formatted date + /** RFC 3339 timestamp of the next automatic retry */ + nextRetryTime: string } -interface TimeRemaining { - days: number - hours: number - minutes: number - seconds: number - isExpired: boolean - isInvalid: boolean +const formatRemaining = (milliseconds: number) => { + const duration = dayjs.duration(milliseconds) + if (duration.asHours() >= 1) return `${Math.floor(duration.asHours())}h ${duration.minutes()}m` + if (duration.asMinutes() >= 1) return `${duration.minutes()}m ${duration.seconds()}s` + return `${duration.seconds()}s` } export const RetryCountdown = ({ nextRetryTime }: RetryCountdownProps) => { - const [timeRemaining, setTimeRemaining] = useState({ - days: 0, - hours: 0, - minutes: 0, - seconds: 0, - isExpired: false, - isInvalid: false, - }) - - const targetTimestamp = useMemo(() => { - try { - const date = new Date(nextRetryTime) - if (isNaN(date.getTime())) { - return null - } - return date.getTime() - } catch { - return null - } - }, [nextRetryTime]) - - const calculateTimeRemaining = useCallback((targetTime: number): TimeRemaining => { - const now = Date.now() - const difference = targetTime - now - - if (difference <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, isExpired: true, isInvalid: false } - } - - const days = Math.floor(difference / (1000 * 60 * 60 * 24)) - const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)) - const seconds = Math.floor((difference % (1000 * 60)) / 1000) - - return { days, hours, minutes, seconds, isExpired: false, isInvalid: false } - }, []) + const target = new Date(nextRetryTime).getTime() + const [remaining, setRemaining] = useState(() => target - Date.now()) useEffect(() => { - if (targetTimestamp === null) return - - const updateTimer = () => { - setTimeRemaining(calculateTimeRemaining(targetTimestamp)) - } - - updateTimer() - const interval = setInterval(updateTimer, 1000) - + if (Number.isNaN(target)) return + const tick = () => setRemaining(target - Date.now()) + tick() + const interval = setInterval(tick, 1000) return () => clearInterval(interval) - }, [targetTimestamp, calculateTimeRemaining]) + }, [target]) - const { timeDisplay, statusMessage } = useMemo(() => { - if (targetTimestamp === null) { - return { - timeDisplay: 'Invalid retry time format', - statusMessage: '', - } - } - - const formatTimeUnit = (value: number, unit: string) => { - if (value === 0) return null - return `${value}${unit.charAt(0)}` - } - - let timeDisplay: string - let statusMessage: string - - if (timeRemaining.isExpired) { - statusMessage = '' - timeDisplay = 'Retrying soon...' - } else { - const parts = [ - formatTimeUnit(timeRemaining.days, 'day'), - formatTimeUnit(timeRemaining.hours, 'hour'), - formatTimeUnit(timeRemaining.minutes, 'minute'), - formatTimeUnit(timeRemaining.seconds, 'second'), - ].filter(Boolean) - statusMessage = parts.length === 0 ? '' : 'Next retry in:' - timeDisplay = parts.length === 0 ? 'Retrying soon...' : parts.join(' ') - } - - return { timeDisplay, statusMessage } - }, [targetTimestamp, timeRemaining]) + if (Number.isNaN(target)) return <>Retry time is invalid. return ( -
- {statusMessage}{' '} - {/* [Joshen] It's a bit hard to debug without doing this locally, but we could use CountdownTimerSpan here perhaps */} - {timeDisplay} -
+ + {remaining <= 0 ? 'Retrying now…' : `Retrying in ${formatRemaining(remaining)}…`} + ) }