mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user