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)}…`} + ) }