ref(etl): Improve updates, rollbacks and errors UI (#40644)

This commit is contained in:
Riccardo Busetti authored and GitHub committed 2025-11-21 10:30:39 +00:00
1 parent 169e3d9c90
commit 9fdc3dd8de
15 files changed
+491 -89

No files matched your search

@@ -15,6 +15,7 @@ import { useCreateDestinationPipelineMutation } from 'data/etl/create-destinatio
import { useReplicationDestinationByIdQuery } from 'data/etl/destination-by-id-query'
import { useReplicationPipelineByIdQuery } from 'data/etl/pipeline-by-id-query'
import { useReplicationPublicationsQuery } from 'data/etl/publications-query'
import { useRestartPipelineHelper } from 'data/etl/restart-pipeline-helper'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useUpdateDestinationPipelineMutation } from 'data/etl/update-destination-pipeline-mutation'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
@@ -36,6 +37,7 @@ import {
SheetSection,
SheetTitle,
} from 'ui'
import { NewPublicationPanel } from '../NewPublicationPanel'
import { ReplicationDisclaimerDialog } from '../ReplicationDisclaimerDialog'
import { AdvancedSettings } from './AdvancedSettings'
import { DestinationNameInput } from './DestinationNameInput'
@@ -88,6 +90,7 @@ export const DestinationPanel = ({
const editMode = !!existingDestination
const [showDisclaimerDialog, setShowDisclaimerDialog] = useState(false)
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
const [pendingFormValues, setPendingFormValues] = useState<z.infer<typeof FormSchema> | null>(
null
)
@@ -104,12 +107,13 @@ export const DestinationPanel = ({
})
const { mutateAsync: startPipeline, isPending: startingPipeline } = useStartPipelineMutation()
const { restartPipeline } = useRestartPipelineHelper()
const { mutateAsync: createS3AccessKey, isPending: isCreatingS3AccessKey } =
useS3AccessKeyCreateMutation()
const { mutateAsync: createNamespace, isPending: isCreatingNamespace } =
useIcebergNamespaceCreateMutation()
useIcebergNamespaceCreateMutation({ projectRef })
const {
data: publications = [],
@@ -317,6 +321,7 @@ export const DestinationPanel = ({
snapshot
)
toast.success('Settings applied. Restarting the pipeline...')
restartPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
} else {
setRequestStatus(
existingDestination.pipelineId,
@@ -324,8 +329,8 @@ export const DestinationPanel = ({
snapshot
)
toast.success('Settings applied. Starting the pipeline...')
startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
}
startPipeline({ projectRef, pipelineId: existingDestination.pipelineId })
onClose()
} else {
let destinationConfig: any = {}
@@ -440,7 +445,11 @@ export const DestinationPanel = ({
return (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<SheetContent
showClose={false}
size="default"
className={publicationPanelVisible ? 'right-32' : 'right-0'}
>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>{editMode ? 'Edit destination' : 'Create a new destination'}</SheetTitle>
@@ -467,7 +476,12 @@ export const DestinationPanel = ({
<div className="space-y-4">
<DestinationNameInput form={form} />
<PublicationSelection form={form} sourceId={sourceId} visible={visible} />
<PublicationSelection
form={form}
sourceId={sourceId}
visible={visible}
onSelectNewPublication={() => setPublicationPanelVisible(true)}
/>
</div>
</div>
<DialogSectionSeparator />
@@ -514,6 +528,12 @@ export const DestinationPanel = ({
</SheetContent>
</Sheet>
<NewPublicationPanel
sourceId={sourceId}
visible={publicationPanelVisible}
onClose={() => setPublicationPanelVisible(false)}
/>
<ReplicationDisclaimerDialog
open={showDisclaimerDialog}
onOpenChange={handleDisclaimerDialogChange}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
@@ -9,7 +9,6 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { FormControl_Shadcn_, FormField_Shadcn_ } from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { NewPublicationPanel } from '../NewPublicationPanel'
import { PublicationsComboBox } from '../PublicationsComboBox'
import type { DestinationPanelSchemaType } from './DestinationPanel.schema'
@@ -17,15 +16,19 @@ type PublicationSelectionProps = {
form: UseFormReturn<DestinationPanelSchemaType>
sourceId?: number
visible: boolean
onSelectNewPublication: () => void
}
export const PublicationSelection = ({ form, sourceId, visible }: PublicationSelectionProps) => {
export const PublicationSelection = ({
form,
sourceId,
visible,
onSelectNewPublication,
}: PublicationSelectionProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { publicationName } = form.watch()
const [publicationPanelVisible, setPublicationPanelVisible] = useState(false)
const {
data: publications = [],
isLoading: isLoadingPublications,
@@ -64,7 +67,7 @@ export const PublicationSelection = ({ form, sourceId, visible }: PublicationSel
isLoadingPublications={isLoadingPublications}
isLoadingCheck={!!selectedPublication && isLoadingCheck}
field={field}
onNewPublicationClick={() => setPublicationPanelVisible(true)}
onNewPublicationClick={() => onSelectNewPublication()}
/>
</FormControl_Shadcn_>
{isSelectedPublicationMissing ? (
@@ -98,12 +101,6 @@ export const PublicationSelection = ({ form, sourceId, visible }: PublicationSel
</FormItemLayout>
)}
/>
<NewPublicationPanel
sourceId={sourceId}
visible={publicationPanelVisible}
onClose={() => setPublicationPanelVisible(false)}
/>
</>
)
}
@@ -88,7 +88,10 @@ export const DestinationRow = ({
)
const tableStatuses = replicationStatusData?.table_statuses ?? []
const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length
const hasTableErrors = errorCount > 0
// Only show errors when pipeline is running (not when stopped or restarting)
const isPipelineStopped = statusName === PipelineStatusName.STOPPED
const isRestarting = requestStatus === PipelineStatusRequestStatus.RestartRequested
const hasTableErrors = errorCount > 0 && !isPipelineStopped && !isRestarting
// Check if a newer pipeline version is available (one-time check cached for session)
const { data: versionData } = useReplicationPipelineVersionQuery({
@@ -113,11 +113,18 @@ export const Destinations = () => {
/>
</div>
</div>
{!!sourceId && (
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
</Button>
)}
<div className="flex items-center gap-x-2">
{!!sourceId && (
<Button
type="default"
icon={<Plus />}
onClick={() => setShowNewDestinationPanel(true)}
>
Add destination
</Button>
)}
<DocsButton href="https://supabase.com/docs/guides/database/replication" />
</div>
</div>
</div>
@@ -0,0 +1,109 @@
import { AlertCircle } from 'lucide-react'
import {
Button,
cn,
CodeBlock,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
interface ErrorDetailsButtonProps {
tableName: string
reason: string
solution?: string
}
export const ErrorDetailsButton = ({ tableName, reason, solution }: ErrorDetailsButtonProps) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button size="tiny" type="default" className="w-min" aria-label="Show error details">
Show error
</Button>
</DialogTrigger>
<DialogContent size="xlarge" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Replication error on "{tableName}"</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="!p-0">
<div className="px-4 py-3">
<p className="text-sm text-foreground-light">
The following error occured during replication:
</p>
</div>
<CodeBlock
hideLineNumbers
wrapLines={false}
wrapperClassName={cn(
'[&_pre]:px-4 [&_pre]:py-3 [&>pre]:border-x-0 [&>pre]:rounded-none'
)}
language="bash"
value={reason}
className="[&_code]:text-xs [&_code]:text-foreground [&_span]:!text-foreground"
/>
{/* Solution if available */}
{solution && (
<div className="px-4 py-3">
<p className="text-sm">{solution}</p>
</div>
)}
</DialogSection>
<DialogFooter>
<DialogClose>
<Button type="default">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
size="tiny"
type="default"
className="w-min"
icon={<AlertCircle size={14} />}
aria-label="Show error details"
>
Show Error
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="w-[500px] max-w-[90vw] max-h-[400px] p-0 overflow-hidden"
>
<div className="flex flex-col gap-y-3 p-4 max-h-[400px] overflow-y-auto">
{/* Error message */}
<div>
<div className="text-xs font-medium mb-2">Error</div>
<div className="bg-surface-100 rounded p-2 max-h-[250px] overflow-y-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">{reason}</pre>
</div>
</div>
{/* Solution if available */}
{solution && (
<div>
<div className="text-xs font-medium mb-2">Solution</div>
<p className="text-xs">{solution}</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -1,9 +1,10 @@
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { ErrorDetailsButton } from './ErrorDetailsButton'
import { TableState } from './ReplicationPipelineStatus/ReplicationPipelineStatus.types'
import { isValidRetryPolicy } from './ReplicationPipelineStatus/ReplicationPipelineStatus.utils'
import { ResetTableButton } from './ResetTableButton'
import { RetryCountdown } from './RetryCountdown'
import { RetryOptionsDropdown } from './RetryOptionsDropdown'
interface ErroredTableDetailsProps {
state: Extract<TableState['state'], { name: 'error' }>
@@ -31,27 +32,52 @@ export const ErroredTableDetails = ({ state, tableName, tableId }: ErroredTableD
return (
<div role="region" aria-label={`Error details for table ${tableName}`}>
{retryPolicy === 'no_retry' ? (
<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=${state.reason}`}
>
support
</InlineLink>
. Alternatively, you may also recreate the pipeline.
</p>
<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=${state.reason}`}
>
support
</InlineLink>
. Alternatively, you may also recreate the pipeline.
</p>
<ErrorDetailsButton
tableName={tableName}
reason={state.reason}
solution={state.solution}
/>
</div>
) : retryPolicy === 'manual_retry' ? (
<div className="flex flex-col gap-y-2 text-foreground-lighter">
<p className="text-xs">{state.solution}. You may thereafter rollback the pipeline.</p>
<RetryOptionsDropdown tableId={tableId} tableName={tableName} />
<div className="flex flex-col gap-y-3 text-foreground-lighter">
<div>
<p className="text-xs">
{state.solution}
{state.solution && !/[.!?]$/.test(state.solution.trim()) && '.'}
</p>
<p className="text-xs">You can reset the table to start replication from scratch.</p>
</div>
<div className="flex items-center gap-x-2">
<ResetTableButton tableId={tableId} tableName={tableName} />
<ErrorDetailsButton
tableName={tableName}
reason={state.reason}
solution={state.solution}
/>
</div>
</div>
) : retryPolicy === 'timed_retry' ? (
<div className="flex flex-col text-foreground-lighter">
<div className="flex flex-col text-foreground-lighter gap-y-3">
<p className="text-xs">
A retry will be triggered automatically by restarting the pipeline on this table.
</p>
<RetryCountdown nextRetryTime={state.retry_policy.next_retry} />
<ErrorDetailsButton
tableName={tableName}
reason={state.reason}
solution={state.solution}
/>
</div>
) : null}
</div>
@@ -25,6 +25,7 @@ import { useReplicationPipelineByIdQuery } from 'data/etl/pipeline-by-id-query'
import { useReplicationPipelineReplicationStatusQuery } from 'data/etl/pipeline-replication-status-query'
import { useReplicationPipelineStatusQuery } from 'data/etl/pipeline-status-query'
import { useReplicationPipelineVersionQuery } from 'data/etl/pipeline-version-query'
import { useRestartPipelineHelper } from 'data/etl/restart-pipeline-helper'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/etl/stop-pipeline-mutation'
import {
@@ -105,6 +106,7 @@ export const ReplicationPipelineStatus = () => {
const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline, isPending: isStoppingPipeline } = useStopPipelineMutation()
const { restartPipeline } = useRestartPipelineHelper()
const destinationName = pipeline?.destination_name
const statusName = getStatusName(pipelineStatusData?.status)
@@ -169,7 +171,7 @@ export const ReplicationPipelineStatus = () => {
await stopPipeline({ projectRef, pipelineId: pipeline.id })
} else if (statusName === 'failed') {
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
await startPipeline({ projectRef, pipelineId: pipeline.id })
await restartPipeline({ projectRef, pipelineId: pipeline.id })
}
} catch (error) {
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
@@ -411,7 +413,7 @@ export const ReplicationPipelineStatus = () => {
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-3">
<div className="flex flex-col gap-y-2">
<div className="text-sm text-foreground">
{statusConfig.description}
</div>
@@ -459,6 +461,7 @@ export const ReplicationPipelineStatus = () => {
</div>
)}
</div>
<UpdateVersionModal
visible={showUpdateVersionModal}
pipeline={pipeline}
@@ -0,0 +1,97 @@
import { RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useTableReset } from 'data/etl/use-table-reset'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
} from 'ui'
interface ResetTableButtonProps {
tableId: number
tableName: string
}
export const ResetTableButton = ({ tableId, tableName }: ResetTableButtonProps) => {
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
const [isOpen, setIsOpen] = useState(false)
const { resetTable, isRollingBack, isRestartingPipeline, isResetting } = useTableReset({
tableName,
onSuccess: () => setIsOpen(false),
onError: () => setIsOpen(false),
})
const handleReset = () => {
if (!projectRef) return toast.error('Project ref is required')
if (!_pipelineId) return toast.error('Pipeline ID is required')
const pipelineId = Number(_pipelineId)
resetTable({
projectRef,
pipelineId,
tableId,
rollbackType: 'full',
})
}
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Button
size="tiny"
type="default"
loading={isResetting}
disabled={isResetting}
className="w-min"
icon={<RotateCcw />}
aria-label={`Reset and restart table ${tableName}`}
onClick={() => setIsOpen(true)}
>
Reset table and restart
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset and restart table "{tableName}"?</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-y-3 py-4 !mt-0">
<p>
This will reset and restart replication for this table only. The table will start
copying from scratch, and any existing data for this table downstream will be deleted.
</p>
<p className="text-foreground-light">
Other tables in the pipeline will not be affected. Only this table will be restarted
and go through the full replication process again, starting with the initial copy
phase.
</p>
<p className="text-foreground-light">
The pipeline will be restarted to apply the table reset.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResetting}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isResetting}
onClick={handleReset}
className="bg-destructive hover:bg-destructive/90"
>
{isRollingBack
? 'Resetting table...'
: isRestartingPipeline
? 'Restarting pipeline...'
: 'Confirm reset and restart'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -3,8 +3,8 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useRestartPipelineHelper } from 'data/etl/restart-pipeline-helper'
import { RollbackType, useRollbackTableMutation } from 'data/etl/rollback-table-mutation'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import {
Button,
DropdownMenu,
@@ -36,12 +36,27 @@ interface RetryOptionsDropdownProps {
export const RetryOptionsDropdown = ({ tableId, tableName }: RetryOptionsDropdownProps) => {
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
const [isOpen, setIsOpen] = useState(false)
const [isRestartingPipeline, setIsRestartingPipeline] = useState(false)
const { restartPipeline } = useRestartPipelineHelper()
const { mutate: rollbackTable, isPending: isRollingBack } = useRollbackTableMutation({
onSuccess: (_, vars) => {
const { projectRef, pipelineId } = vars
toast.success(`Table "${tableName}" rolled back successfully and pipeline is being restarted`)
startPipeline({ projectRef, pipelineId })
onSuccess: async (_, vars) => {
const { projectRef, pipelineId, rollbackType } = vars
toast.success(
`Table "${tableName}" ${rollbackType === 'full' ? 'reset' : 'rolled back'} successfully and pipeline is being restarted`
)
setIsRestartingPipeline(true)
try {
await restartPipeline({ projectRef, pipelineId })
toast.success('Pipeline restarted successfully')
} catch (error: any) {
toast.error(`Failed to restart pipeline: ${error.message}`)
} finally {
setIsRestartingPipeline(false)
setIsOpen(false)
}
},
onError: (error, vars) => {
const { rollbackType } = vars
@@ -50,16 +65,6 @@ export const RetryOptionsDropdown = ({ tableId, tableName }: RetryOptionsDropdow
)
},
})
const { mutate: startPipeline, isPending: isRestartingPipeline } = useStartPipelineMutation({
onSuccess: () => {
toast.success('Pipeline restarted successfully')
setIsOpen(false)
},
onError: (error) => {
toast.error(`Failed to restart pipeline: ${error.message}`)
setIsOpen(false)
},
})
const isLoading = isRollingBack || isRestartingPipeline
@@ -5,6 +5,7 @@ import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { ReplicationPipelineStatusData } from 'data/etl/pipeline-status-query'
import { Pipeline } from 'data/etl/pipelines-query'
import { useRestartPipelineHelper } from 'data/etl/restart-pipeline-helper'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useStopPipelineMutation } from 'data/etl/stop-pipeline-mutation'
import {
@@ -58,6 +59,7 @@ export const RowMenu = ({
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const { restartPipeline } = useRestartPipelineHelper()
const { getRequestStatus, setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus()
const requestStatus = pipeline?.id
? getRequestStatus(pipeline.id)
@@ -117,7 +119,7 @@ export const RowMenu = ({
try {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
await startPipeline({ projectRef, pipelineId: pipeline.id })
await restartPipeline({ projectRef, pipelineId: pipeline.id })
} catch (error) {
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
@@ -4,12 +4,19 @@ import { useParams } from 'common'
import { useReplicationPipelineStatusQuery } from 'data/etl/pipeline-status-query'
import { useReplicationPipelineVersionQuery } from 'data/etl/pipeline-version-query'
import { Pipeline } from 'data/etl/pipelines-query'
import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation'
import { useRestartPipelineHelper } from 'data/etl/restart-pipeline-helper'
import { useUpdatePipelineVersionMutation } from 'data/etl/update-pipeline-version-mutation'
import { ChevronDown } from 'lucide-react'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from 'state/replication-pipeline-request-status'
import {
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
DialogSectionSeparator,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { getStatusName } from './Pipeline.utils'
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
@@ -25,7 +32,7 @@ interface UpdateVersionModalProps {
export const UpdateVersionModal = ({
visible,
pipeline,
confirmLabel = 'Update and restart',
confirmLabel,
confirmLabelLoading = 'Updating',
onClose,
}: UpdateVersionModalProps) => {
@@ -38,6 +45,7 @@ export const UpdateVersionModal = ({
)
const pipelineStatus = pipelineStatusData?.status
const statusName = getStatusName(pipelineStatus)
const isStopped = statusName === PipelineStatusName.STOPPED
const { data: versionData } = useReplicationPipelineVersionQuery({
projectRef,
@@ -47,7 +55,7 @@ export const UpdateVersionModal = ({
const newVersionName = versionData?.new_version?.name
const { mutateAsync: updatePipelineVersion } = useUpdatePipelineVersionMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const { restartPipeline } = useRestartPipelineHelper()
const onConfirmUpdate = async () => {
if (!projectRef || !pipeline?.id) return
@@ -64,16 +72,13 @@ export const UpdateVersionModal = ({
return
}
// Step 2: Reflect optimistic restart (only if currently active) and close any panels
const isActive =
statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
if (isActive) {
// Step 2: Reflect optimistic restart (only if not stopped) and close any panels
if (!isStopped) {
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
// Step 3: Restart the pipeline
// Step 3: Restart the pipeline (stop + start)
try {
await startPipeline({ projectRef, pipelineId: pipeline.id })
await restartPipeline({ projectRef, pipelineId: pipeline.id })
toast.success('Pipeline successfully updated and is currently restarting')
} catch (e: any) {
// Clear optimistic state and surface a single concise error
@@ -89,34 +94,46 @@ export const UpdateVersionModal = ({
return (
<ConfirmationModal
size="medium"
size="small"
visible={visible}
title="Update pipeline version"
confirmLabel={confirmLabel}
title="Update pipeline image"
className="!p-0"
confirmLabel={confirmLabel ?? (isStopped ? 'Update image' : 'Update and restart')}
confirmLabelLoading={confirmLabelLoading}
onCancel={onClose}
onConfirm={onConfirmUpdate}
alert={{
base: { variant: 'warning' },
title: 'Pipeline will be restarted briefly to complete the change',
description: (
<div className="flex flex-col gap-y-1">
<p className="!leading-normal">
During the update process, the replication pauses and resumes.
</p>
<p className="!leading-normal">
If a long‑running transaction is in progress, some records may be reprocessed due to
PostgreSQL logical replication limitations.
</p>
</div>
),
}}
>
<p className="text-sm text-foreground prose max-w-full mb-1">
Pipeline will be updated from <code>{currentVersionName ?? 'Current version'}</code> to{' '}
<code>{newVersionName ?? 'New version'}</code>.
</p>
<p className="text-sm">Confirm to update pipeline? This action cannot be undone.</p>
<div className="flex flex-col gap-y-3 py-4 px-5">
<p className="text-sm text-foreground">
A new pipeline image is available with improvements and bug fixes. Proceed to update?
</p>
{!isStopped && (
<p className="text-sm text-foreground-light">
The pipeline will automatically restart when updating. Replication will continue from
where it left off.
</p>
)}
</div>
<DialogSectionSeparator />
<Collapsible_Shadcn_ className="px-5 py-3 group">
<CollapsibleTrigger_Shadcn_ className="w-full flex items-center justify-between text-sm text-foreground-light">
<p>View version update details</p>
<ChevronDown size={14} className="group-data-[state=open]:-rotate-180 transition" />
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_>
<div className="flex flex-col gap-y-2 mt-2 pb-2">
<div className="text-sm text-foreground prose max-w-full">
<p className="text-foreground-light mb-1">Current version:</p>{' '}
<code className="text-xs">{currentVersionName ?? 'Unknown'}</code>
</div>
<div className="text-sm text-foreground prose max-w-full">
<p className="text-foreground-light mb-1">New version:</p>{' '}
<code className="text-xs">{newVersionName ?? 'Unknown'}</code>
</div>
</div>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</ConfirmationModal>
)
}
@@ -430,6 +430,8 @@ const OrganizationLinks = () => {
},
]
if (!organizationSlug) return null
return (
<SidebarMenu className="flex flex-col gap-1 items-start">
<SidebarGroup className="gap-0.5">
@@ -0,0 +1,40 @@
import { useStopPipelineMutation } from './stop-pipeline-mutation'
import { useStartPipelineMutation } from './start-pipeline-mutation'
export interface RestartPipelineParams {
projectRef: string
pipelineId: number
}
/**
* Helper hook that provides a restart function which properly stops and then starts a pipeline.
*
* ## Why Stop + Start?
*
* This explicit two-step restart process is necessary to work around edge cases where Kubernetes
* doesn't properly recreate pods when using the start endpoint alone on a running pipeline. This happens
* because crash looping pods are not restarted if the resource is patched. We will try to find a better
* solution for this in the future.
*/
export const useRestartPipelineHelper = () => {
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
const { mutateAsync: startPipeline } = useStartPipelineMutation()
const restartPipeline = async ({ projectRef, pipelineId }: RestartPipelineParams) => {
// Step 1: Stop the pipeline to ensure pods are fully terminated
try {
await stopPipeline({ projectRef, pipelineId })
} catch (error: any) {
throw new Error(`Failed to stop pipeline: ${error.message}`)
}
// Step 2: Start the pipeline to create fresh pods with clean state
try {
await startPipeline({ projectRef, pipelineId })
} catch (error: any) {
throw new Error(`Failed to start pipeline: ${error.message}`)
}
}
return { restartPipeline }
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { RollbackType, useRollbackTableMutation } from './rollback-table-mutation'
import { useRestartPipelineHelper } from './restart-pipeline-helper'
interface UseTableResetParams {
tableName: string
onSuccess?: () => void
onError?: (error: Error) => void
}
/**
* Custom hook that encapsulates the logic for resetting a table and restarting the pipeline.
* Provides unified error handling and loading states for table reset operations.
*/
export const useTableReset = ({ tableName, onSuccess, onError }: UseTableResetParams) => {
const [isRestartingPipeline, setIsRestartingPipeline] = useState(false)
const { restartPipeline } = useRestartPipelineHelper()
const { mutate: rollbackTable, isLoading: isRollingBack } = useRollbackTableMutation({
onSuccess: async (_, vars) => {
const { projectRef, pipelineId } = vars
toast.success(`Table "${tableName}" reset successfully and pipeline is being restarted`)
setIsRestartingPipeline(true)
try {
await restartPipeline({ projectRef, pipelineId })
toast.success('Pipeline restarted successfully')
onSuccess?.()
} catch (error: any) {
const errorMessage = `Failed to restart pipeline: ${error.message}`
toast.error(errorMessage)
onError?.(new Error(errorMessage))
} finally {
setIsRestartingPipeline(false)
}
},
onError: (error) => {
const errorMessage = `Failed to reset table: ${error.message}`
toast.error(errorMessage)
onError?.(new Error(errorMessage))
},
})
const resetTable = ({
projectRef,
pipelineId,
tableId,
rollbackType = 'full' as RollbackType,
}: {
projectRef: string
pipelineId: number
tableId: number
rollbackType?: RollbackType
}) => {
rollbackTable({
projectRef,
pipelineId,
tableId,
rollbackType,
})
}
return {
resetTable,
isRollingBack,
isRestartingPipeline,
isResetting: isRollingBack || isRestartingPipeline,
}
}
@@ -32,6 +32,7 @@ export interface ConfirmationModalProps {
title?: string
description?: string | React.ReactNode
}
className?: string
}
export const ConfirmationModal = forwardRef<
@@ -54,6 +55,7 @@ export const ConfirmationModal = forwardRef<
children,
variant = 'default',
disabled,
className,
...props
},
ref
@@ -105,7 +107,9 @@ export const ConfirmationModal = forwardRef<
)}
{children && (
<>
<DialogSection padding={'small'}>{children}</DialogSection>
<DialogSection padding="small" className={className}>
{children}
</DialogSection>
<DialogSectionSeparator />
</>
)}