feat(replication): Implement new error UI when the pipeline fails (#37105)

This commit is contained in:
Riccardo Busetti authored and GitHub committed 2025-07-15 13:10:37 +03:00
1 parent 0666d5c03e
commit e3b646fea6
5 files changed
+424 -68

No files matched your search

@@ -4,9 +4,12 @@ import { ReplicationPipelinesData } from 'data/replication/pipelines-query'
import { ResponseError } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import RowMenu from './RowMenu'
import PipelineStatus, { PipelineStatusRequestStatus } from './PipelineStatus'
import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus'
import { useParams } from 'common'
import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query'
import {
ReplicationPipelineStatusData,
useReplicationPipelineStatusQuery,
} from 'data/replication/pipeline-status-query'
import { useState } from 'react'
import { toast } from 'sonner'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
@@ -65,10 +68,22 @@ const DestinationRow = ({
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const pipelineStatus = pipelineStatusData?.status
const getStatusName = (
status: ReplicationPipelineStatusData['status'] | undefined
): string | undefined => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
}
return undefined
}
const statusName = getStatusName(pipelineStatus)
if (
(requestStatus === PipelineStatusRequestStatus.EnableRequested &&
pipelineStatus === 'Started') ||
(requestStatus === PipelineStatusRequestStatus.DisableRequested && pipelineStatus === 'Stopped')
(statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) ||
(requestStatus === PipelineStatusRequestStatus.DisableRequested &&
(statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED))
) {
setRequestStatus(PipelineStatusRequestStatus.None)
}
@@ -190,7 +205,7 @@ const DestinationRow = ({
sourceId,
destinationId: destinationId,
pipelineId: pipeline?.id,
enabled: pipelineStatusData?.status === 'Started',
enabled: statusName === PipelineStatusName.STARTED,
}}
/>
</>
@@ -2,8 +2,21 @@ import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { cn } from 'ui'
import { ResponseError } from 'types'
import { Loader2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Button,
Collapsible_Shadcn_ as Collapsible,
CollapsibleContent_Shadcn_ as CollapsibleContent,
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
} from 'ui'
import { useState, useEffect, useRef } from 'react'
import { copyToClipboard } from 'ui'
import { toast } from 'sonner'
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
export enum PipelineStatusRequestStatus {
None = 'None',
@@ -11,8 +24,19 @@ export enum PipelineStatusRequestStatus {
DisableRequested = 'DisableRequested',
}
export enum PipelineStatusName {
FAILED = 'failed',
STARTING = 'starting',
STARTED = 'started',
STOPPED = 'stopped',
UNKNOWN = 'unknown',
}
// Type alias for better readability
type FailedStatus = Extract<ReplicationPipelineStatusData['status'], { name: 'failed' }>
interface PipelineStatusProps {
pipelineStatus: string | undefined
pipelineStatus: ReplicationPipelineStatusData['status'] | undefined
error: ResponseError | null
isLoading: boolean
isError: boolean
@@ -28,6 +52,175 @@ const PipelineStatus = ({
isSuccess,
requestStatus,
}: PipelineStatusProps) => {
const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false)
const errorDetailsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) {
setIsErrorDetailsOpen(false)
}
}
if (isErrorDetailsOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isErrorDetailsOpen])
const handleCopyToClipboard = async (text: string) => {
try {
await copyToClipboard(text)
toast.success('Error details copied to clipboard')
} catch (error) {
toast.error('Failed to copy error details')
}
}
const isFailedStatus = (
status: ReplicationPipelineStatusData['status'] | undefined
): status is FailedStatus => {
return (
status !== null &&
status !== undefined &&
typeof status === 'object' &&
status.name === PipelineStatusName.FAILED
)
}
const isLogLoadingState = (failedStatus: FailedStatus): boolean => {
// Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems
// to be a transient error. In case we find a way to properly handle this in the backend, this hack will not
// be needed anymore.
return (
failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') ===
true
)
}
const renderFailedStatus = (failedStatus: FailedStatus) => {
const hasDetails =
failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined
const isLoadingLogs = isLogLoadingState(failedStatus)
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
{isLoadingLogs ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isLoadingLogs
? 'Pipeline failed - logs are being retrieved from container'
: 'Pipeline has failed - expand for error details'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{hasDetails && (
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
<CollapsibleTrigger asChild>
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
<div
ref={errorDetailsRef}
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
>
{isLoadingLogs ? (
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
<div>
<div className="text-sm font-medium text-foreground mb-1">
Pipeline Failed - Loading logs...
</div>
<div className="text-xs text-foreground-light">
Error logs are being retrieved from the container. This may take a few
moments.
</div>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Pipeline Error Details
</span>
{(failedStatus.message || failedStatus.reason) && (
<Button
type="text"
size="tiny"
onClick={async () => {
await handleCopyToClipboard(
[
failedStatus.exit_code !== undefined
? `Exit Code: ${failedStatus.exit_code}`
: null,
failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
failedStatus.message ? `Message: ${failedStatus.message}` : null,
]
.filter(Boolean)
.join('\n')
)
}}
>
<Copy className="w-3 h-3" />
</Button>
)}
</div>
<div className="space-y-2">
{failedStatus.exit_code !== undefined && (
<div>
<span className="text-xs font-medium text-foreground-light">
Exit Code:
</span>
<div className="font-mono text-sm text-foreground mt-1">
{failedStatus.exit_code}
</div>
</div>
)}
{failedStatus.reason && (
<div>
<span className="text-xs font-medium text-foreground-light">Reason:</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.reason}
</pre>
</div>
)}
{failedStatus.message && (
<div>
<span className="text-xs font-medium text-foreground-light">
Message:
</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.message}
</pre>
</div>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)
}
// Map backend statuses to UX-friendly display
const getStatusConfig = () => {
if (requestStatus === PipelineStatusRequestStatus.EnableRequested) {
@@ -48,42 +241,59 @@ const PipelineStatus = ({
}
}
switch (pipelineStatus) {
case 'Starting':
return {
label: 'Starting',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is initializing and will be ready soon',
}
case 'Started':
return {
label: 'Running',
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
color: 'text-brand-600',
tooltip: 'Pipeline is active and processing data',
}
case 'Stopped':
return {
label: 'Stopped',
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
color: 'text-foreground-light',
tooltip: 'Pipeline is not running - enable to start processing',
}
case 'Unknown':
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
color: 'text-warning-600',
tooltip: 'Pipeline status could not be determined',
}
default:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
}
// Handle Failed status object
if (isFailedStatus(pipelineStatus)) {
return {
isFailedStatus: true,
}
}
if (pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus) {
switch (pipelineStatus.name) {
case PipelineStatusName.STARTING:
return {
label: 'Starting',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is initializing and will be ready soon',
}
case PipelineStatusName.STARTED:
return {
label: 'Running',
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
color: 'text-brand-600',
tooltip: 'Pipeline is active and processing data',
}
case PipelineStatusName.STOPPED:
return {
label: 'Stopped',
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
color: 'text-foreground-light',
tooltip: 'Pipeline is not running - enable to start processing',
}
case PipelineStatusName.UNKNOWN:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
color: 'text-warning-600',
tooltip: 'Pipeline status could not be determined',
}
default:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
}
}
}
// Fallback for undefined or invalid status
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
}
}
@@ -94,19 +304,25 @@ const PipelineStatus = ({
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isSuccess && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{statusConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<>
{statusConfig.isFailedStatus ? (
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{statusConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
)}
</>
)
@@ -10,9 +10,10 @@ import {
DropdownMenuTrigger,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { PipelineStatusName } from './PipelineStatus'
interface RowMenuProps {
pipelineStatus: string | undefined
pipelineStatus: any
error: ResponseError | null
isLoading: boolean
isError: boolean
@@ -32,7 +33,16 @@ const RowMenu = ({
onEditClick,
onDeleteClick,
}: RowMenuProps) => {
const pipelineEnabled = pipelineStatus === 'Stopped' ? false : true
const getStatusName = (status: any) => {
if (status && typeof status === 'object' && 'name' in status) {
return status.name
}
return status
}
const statusName = getStatusName(pipelineStatus)
const pipelineEnabled = statusName !== PipelineStatusName.STOPPED
return (
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
+60
View File
@@ -514,6 +514,26 @@ export interface paths {
patch?: never
trace?: never
}
'/v1/projects/{ref}/branches/{name}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Get a database branch
* @description Fetches the specified database branch by its name.
*/
get: operations['v1-get-a-branch']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/v1/projects/{ref}/claim-token': {
parameters: {
query?: never
@@ -3220,6 +3240,10 @@ export interface components {
max_client_conn?: number
/** @enum {string} */
pool_mode?: 'transaction' | 'session' | 'statement'
query_wait_timeout?: number
reserve_pool_size?: number
server_idle_timeout?: number
server_lifetime?: number
}
V1PostgrestConfigResponse: {
db_extra_search_path: string
@@ -4633,6 +4657,42 @@ export interface operations {
}
}
}
'v1-get-a-branch': {
parameters: {
query?: never
header?: never
path: {
name: string
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BranchResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Failed to fetch database branch */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-get-project-claim-token': {
parameters: {
query?: never
+64 -9
View File
@@ -4896,7 +4896,7 @@ export interface components {
id: string
public: boolean
/** @enum {string} */
type?: 'STANDARD' | 'ICEBERG'
type?: 'STANDARD' | 'ANALYTICS'
}
CreateStorageCredentialBody: {
description: string
@@ -6550,7 +6550,7 @@ export interface components {
db_port: number
db_user: string
default_pool_size?: number
ignore_startup_parameters: string
ignore_startup_parameters?: string
inserted_at: string
max_client_conn?: number
pgbouncer_enabled: boolean
@@ -6558,6 +6558,10 @@ export interface components {
pgbouncer_status: 'COMING_UP' | 'COMING_DOWN' | 'RELOADING' | 'ENABLED' | 'DISABLED'
/** @enum {string} */
pool_mode: 'transaction' | 'session' | 'statement'
query_wait_timeout?: number
reserve_pool_size?: number
server_idle_timeout?: number
server_lifetime?: number
ssl_enforced: boolean
}
PgbouncerStatusResponse: {
@@ -7281,6 +7285,14 @@ export interface components {
}[]
}
}
RemoveProjectResponse: {
id: number
name: string
ref: string
vercel?: {
resourceUninstallFailure: boolean
}
}
ReplicationDestinationResponse: {
/** @description Destination config */
config: {
@@ -7385,11 +7397,38 @@ export interface components {
tenant_id: string
}[]
}
ReplicationPipelinesStatusResponse: {
ReplicationPipelineStatusResponse: {
/** @description Pipeline id */
pipeline_id: number
/** @description Pipeline status */
status: string
status:
| {
/** @enum {string} */
name: 'stopped'
}
| {
/** @enum {string} */
name: 'starting'
}
| {
/** @enum {string} */
name: 'started'
}
| {
/** @enum {string} */
name: 'stopping'
}
| {
/** @enum {string} */
name: 'unknown'
}
| {
exit_code?: number | null
message?: string | null
/** @enum {string} */
name: 'failed'
reason?: string | null
}
}
ReplicationPublicationsResponse: {
/** @description List of publications */
@@ -7631,7 +7670,7 @@ export interface components {
owner: string
public: boolean
/** @enum {string} */
type?: 'STANDARD' | 'ICEBERG'
type?: 'STANDARD' | 'ANALYTICS'
updated_at: string
}
StorageConfigResponse: {
@@ -7660,7 +7699,7 @@ export interface components {
owner: string
public: boolean
/** @enum {string} */
type?: 'STANDARD' | 'ICEBERG'
type?: 'STANDARD' | 'ANALYTICS'
updated_at: string
}
created_at: string
@@ -8329,7 +8368,11 @@ export interface components {
}
UpdatePgbouncerConfigBody: {
default_pool_size?: number
ignore_startup_parameters: string
/**
* @deprecated
* @default options,extra_float_digits
*/
ignore_startup_parameters?: string
max_client_conn?: number
/** @deprecated */
pgbouncer_enabled?: boolean
@@ -8338,6 +8381,10 @@ export interface components {
* @enum {string}
*/
pool_mode?: 'transaction' | 'session' | 'statement'
query_wait_timeout?: number
reserve_pool_size?: number
server_idle_timeout?: number
server_lifetime?: number
}
UpdatePolicyBody: {
check?: string
@@ -8359,6 +8406,8 @@ export interface components {
* @enum {string}
*/
pool_mode?: 'transaction' | 'session' | 'statement'
server_idle_timeout?: number
server_lifetime?: number
}
UpdatePostgresConfigBody: {
effective_cache_size?: string
@@ -14647,6 +14696,12 @@ export interface operations {
'application/json': components['schemas']['ProjectDetailResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
ProjectsRefController_deleteProject: {
@@ -14666,7 +14721,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ProjectRefResponse']
'application/json': components['schemas']['RemoveProjectResponse']
}
}
403: {
@@ -18498,7 +18553,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ReplicationPipelinesStatusResponse']
'application/json': components['schemas']['ReplicationPipelineStatusResponse']
}
}
403: {