mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
ref(etl): Improve updates, rollbacks and errors UI (#40644)
This commit is contained in:
1 parent
169e3d9c90
commit
9fdc3dd8de
15 files changed
+491
-89
No files matched your search
+24
-4
@@ -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}
|
||||
|
||||
+9
-12
@@ -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>
|
||||
|
||||
+5
-2
@@ -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 }
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user