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 |
| --- | --- |
| <img width="1842" height="594" alt="CleanShot 2026-09-16 at 12 55
52@2x"
src="https://github.com/user-attachments/assets/fb6f6a3f-2e81-411e-9d49-ed4cf8cc66e7"
/> | <img width="1824" height="328" alt="CleanShot 2026-09-16 at 15 16
21@2x"
src="https://github.com/user-attachments/assets/bad558c4-2d4c-4d1b-bb68-32ad4d5b348f"
/> |
| _Not applicable._ | <img width="832" height="662" alt="CleanShot
2026-09-16 at 15 16 29@2x"
src="https://github.com/user-attachments/assets/540a5d55-f99f-4c1e-9a57-5ab2ac31e44e"
/> |

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/<ref>/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/<ref>/database/replication/<pipelineId>`.
3. Use the floating pipeline-state switcher in the bottom-right.
4. Select _Running, some tables errored_.
This commit is contained in:
Danny White authored and GitHub committed 2026-09-17 12:17:52 +10:00
1 parent 0b002892d7
commit e21e0c73bb
3 files changed
+61 -175

No files matched your search

@@ -4,6 +4,7 @@ import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
@@ -29,38 +30,32 @@ export const ErrorDetailsDialog = ({
}: ErrorDetailsDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="xlarge" aria-describedby={undefined}>
<DialogContent size="small">
<DialogHeader>
<DialogTitle>
Replication error on <code className="text-code-inline">{tableName}</code>
</DialogTitle>
<DialogTitle>Replication error</DialogTitle>
<DialogDescription>{tableName} stopped replicating</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="p-0!">
<div className="px-4 py-3">
<p className="text-sm text-foreground-light">
The following error occurred during replication:
</p>
</div>
<DialogSection className="flex flex-col gap-y-4">
{/*
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.
*/}
<CodeBlock
hideLineNumbers
wrapLines={false}
wrapperClassName={cn(
'[&_pre]:px-4 [&_pre]:py-3 [&>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 && (
<div className="px-4 py-3">
<p className="text-sm">{solution}</p>
</div>
)}
{solution && <p className="text-sm text-foreground-light">{solution}</p>}
</DialogSection>
<DialogFooter>
<DialogClose>
<Button>Close</Button>
<DialogClose asChild>
<Button variant="default">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
@@ -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<ReplicationPipelineTableStatus['state'], { name: 'error' }>
const tableName = `${table.schema}.${table.name}`
const retryPolicy = state.retry_policy.policy
if (!isValidRetryPolicy(state.retry_policy)) {
return (
<div
role="region"
className="flex flex-col gap-y-3"
aria-label={`Error details for table ${tableName}`}
>
{state.solution && <div className="text-xs text-foreground-light">{state.solution}</div>}
<div className="text-xs text-foreground-lighter">Invalid retry policy configuration</div>
</div>
)
if (!isValidRetryPolicy(state.retry_policy)) return <>Retry settings are invalid.</>
switch (state.retry_policy.policy) {
case 'timed_retry':
return <RetryCountdown nextRetryTime={state.retry_policy.next_retry} />
case 'manual_retry':
return <>Reset this table to resume.</>
case 'no_retry':
return (
<>
Needs{' '}
<InlineLink
className="text-foreground-lighter hover:text-foreground"
href={`/support?projectRef=${projectRef}&category=dashboard_bug&subject=Database%20replication%20error&error=${encodeURIComponent(state.reason ?? '')}`}
>
support
</InlineLink>
, or recreate the pipeline.
</>
)
}
return (
<div role="region" aria-label={`Error details for table ${tableName}`}>
{retryPolicy === 'no_retry' ? (
<div className="flex flex-col gap-y-3">
<p className="text-xs text-foreground-lighter">
This error requires manual intervention from our{' '}
<InlineLink
className="text-foreground-lighter hover:text-foreground"
href={`/support?projectRef=${projectRef}&category=dashboard_bug&subject=Database%20replication%20error&error=${encodeURIComponent(state.reason ?? '')}`}
>
support
</InlineLink>
. Alternatively, you may also recreate the pipeline. Use the table actions menu on the
right to view the full error details.
</p>
</div>
) : retryPolicy === 'manual_retry' ? (
<div className="flex flex-col gap-y-3">
<div className="rounded-md border border-destructive-400 bg-destructive-100 px-3 py-3 space-y-2">
<div className="flex items-start gap-x-2">
<CriticalIcon />
<div className="flex-1 text-xs text-destructive-900">
<p className="font-semibold mb-1">Action required to continue replication</p>
<p className="text-foreground-light">
{state.solution}
{state.solution && !/[.!?]$/.test(state.solution.trim()) && '.'}
</p>
<p className="text-foreground-light mt-2">
Restart table replication from the table actions menu on the right. The pipeline
will restart automatically.
</p>
</div>
</div>
</div>
</div>
) : retryPolicy === 'timed_retry' ? (
<div className="flex flex-col text-foreground-lighter gap-y-3">
<p className="text-xs">
Replication will retry automatically. The pipeline will restart to apply the retry.
</p>
<RetryCountdown nextRetryTime={state.retry_policy.next_retry} />
</div>
) : null}
</div>
)
}
@@ -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<TimeRemaining>({
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 (
<div role="status" aria-live="polite" aria-label={`${statusMessage} ${timeDisplay}`}>
<span className="text-xs font-medium">{statusMessage}</span>{' '}
{/* [Joshen] It's a bit hard to debug without doing this locally, but we could use CountdownTimerSpan here perhaps */}
<span className="text-xs font-mono">{timeDisplay}</span>
</div>
<span role="status" aria-live="polite">
{remaining <= 0 ? 'Retrying now…' : `Retrying in ${formatRemaining(remaining)}…`}
</span>
)
}