From e3b646fea60a3be8795e21e2834425e17f59dbfb Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Tue, 15 Jul 2025 12:10:37 +0200 Subject: [PATCH] feat(replication): Implement new error UI when the pipeline fails (#37105) --- .../Database/Replication/DestinationRow.tsx | 25 +- .../Database/Replication/PipelineStatus.tsx | 320 +++++++++++++++--- .../Database/Replication/RowMenu.tsx | 14 +- packages/api-types/types/api.d.ts | 60 ++++ packages/api-types/types/platform.d.ts | 73 +++- 5 files changed, 424 insertions(+), 68 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 442540ebe09..edfab6d9239 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -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, }} /> diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 6e8be284823..6fb360e6d97 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -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 + 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(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 ( +
+ + + +
+ {isLoadingLogs ? ( + + ) : ( + + )} + Failed +
+
+ +

+ {isLoadingLogs + ? 'Pipeline failed - logs are being retrieved from container' + : 'Pipeline has failed - expand for error details'} +

+
+
+
+ {hasDetails && ( + + + + + +
+ {isLoadingLogs ? ( +
+ +
+
+ Pipeline Failed - Loading logs... +
+
+ Error logs are being retrieved from the container. This may take a few + moments. +
+
+
+ ) : ( + <> +
+ + Pipeline Error Details + + {(failedStatus.message || failedStatus.reason) && ( + + )} +
+
+ {failedStatus.exit_code !== undefined && ( +
+ + Exit Code: + +
+ {failedStatus.exit_code} +
+
+ )} + {failedStatus.reason && ( +
+ Reason: +
+                            {failedStatus.reason}
+                          
+
+ )} + {failedStatus.message && ( +
+ + Message: + +
+                            {failedStatus.message}
+                          
+
+ )} +
+ + )} +
+
+
+ )} +
+ ) + } // 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: , - color: 'text-warning-600', - tooltip: 'Pipeline is initializing and will be ready soon', - } - case 'Started': - return { - label: 'Running', - dot:
, - color: 'text-brand-600', - tooltip: 'Pipeline is active and processing data', - } - case 'Stopped': - return { - label: 'Stopped', - dot:
, - color: 'text-foreground-light', - tooltip: 'Pipeline is not running - enable to start processing', - } - case 'Unknown': - return { - label: 'Unknown', - dot:
, - color: 'text-warning-600', - tooltip: 'Pipeline status could not be determined', - } - default: - return { - label: 'Unknown', - dot:
, - 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: , + color: 'text-warning-600', + tooltip: 'Pipeline is initializing and will be ready soon', + } + case PipelineStatusName.STARTED: + return { + label: 'Running', + dot:
, + color: 'text-brand-600', + tooltip: 'Pipeline is active and processing data', + } + case PipelineStatusName.STOPPED: + return { + label: 'Stopped', + dot:
, + color: 'text-foreground-light', + tooltip: 'Pipeline is not running - enable to start processing', + } + case PipelineStatusName.UNKNOWN: + return { + label: 'Unknown', + dot:
, + color: 'text-warning-600', + tooltip: 'Pipeline status could not be determined', + } + default: + return { + label: 'Unknown', + dot:
, + color: 'text-destructive-600', + tooltip: 'Pipeline status is unclear - check logs for details', + } + } + } + + // Fallback for undefined or invalid status + return { + label: 'Unknown', + dot:
, + color: 'text-destructive-600', + tooltip: 'Pipeline status is unclear - check logs for details', } } @@ -94,19 +304,25 @@ const PipelineStatus = ({ {isLoading && } {isError && } {isSuccess && ( - - - -
- {statusConfig.dot} - {statusConfig.label} -
-
- -

{statusConfig.tooltip}

-
-
-
+ <> + {statusConfig.isFailedStatus ? ( +
{renderFailedStatus(pipelineStatus as FailedStatus)}
+ ) : ( + + + +
+ {statusConfig.dot} + {statusConfig.label} +
+
+ +

{statusConfig.tooltip}

+
+
+
+ )} + )} ) diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index 2852aea3577..f09cc703093 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -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 (
{isLoading && } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 76b8ed8d853..0f93564023b 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -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 diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index be06c9dfb0d..090d53fbbd8 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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: {