feat(replication): Add new UI for new table state handling (#37662)

* feat(replication): Add new UI for new table state handling

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Improve

* Fix

* Fix

* Fix TS

* Some UI tweaks

* Tweaaakk

* Fix semantics

* Small optimization

* Some adjustments

* Bit more fixes

* Small UI tweak to simplify UI

* Fixy fix

* gahhh tiny tiny tiny

* Last bit of cleanup

* I swear one final tweak

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Riccardo BusettiandJoshen Lim authored and GitHub committed 2025-08-07 12:16:03 +02:00
1 parent e6e24aadad
commit 1ea8c656db
12 files changed
+785 -253

No files matched your search

@@ -133,15 +133,15 @@ export const Destinations = () => {
'flex flex-col px-10 rounded-lg justify-center items-center py-8 mt-4'
)}
>
<h4 className="text-lg">Send data to your first destination</h4>
<p className="prose text-sm text-center mt-2">
<h4>Send data to your first destination</h4>
<p className="prose text-sm text-center mt-1 max-w-full">
Use destinations to improve performance or run analysis on your data via
integrations like BigQuery
</p>
<Button
icon={<Plus />}
onClick={() => setShowNewDestinationPanel(true)}
className="mt-6"
className="mt-4"
>
Add destination
</Button>
@@ -0,0 +1,59 @@
import { useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { TableState } from './ReplicationPipelineStatus.types'
import { isValidRetryPolicy } from './ReplicationPipelineStatus.utils'
import { RetryCountdown } from './RetryCountdown'
import { RetryOptionsDropdown } from './RetryOptionsDropdown'
interface ErroredTableDetailsProps {
state: Extract<TableState['state'], { name: 'error' }>
tableName: string
tableId: number
}
export const ErroredTableDetails = ({ state, tableName, tableId }: ErroredTableDetailsProps) => {
const { ref: projectRef } = useParams()
const retryPolicy = state.retry_policy.policy
if (!isValidRetryPolicy(state.retry_policy)) {
return (
<div
role="region"
className="flex flex-col gap-y-3"
aria-label={`Error details for table ${tableName}`}
>
{state.solution && <div className="text-xs text-foreground-light">{state.solution}</div>}
<div className="text-xs text-foreground-lighter">Invalid retry policy configuration</div>
</div>
)
}
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>
) : 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>
) : retryPolicy === 'timed_retry' ? (
<div className="flex flex-col text-foreground-lighter">
<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} />
</div>
) : null}
</div>
)
}
@@ -1,11 +1,10 @@
import AlertError from 'components/ui/AlertError'
import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
import { ResponseError } from 'types'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { cn, Tooltip, TooltipContent, TooltipTrigger, WarningIcon } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { getPipelineStateMessages } from './Pipeline.utils'
export enum PipelineStatusName {
FAILED = 'failed',
@@ -15,9 +14,6 @@ export enum PipelineStatusName {
UNKNOWN = 'unknown',
}
// Type alias for better readability
type FailedStatus = Extract<ReplicationPipelineStatusData['status'], { name: 'failed' }>
interface PipelineStatusProps {
pipelineStatus: ReplicationPipelineStatusData['status'] | undefined
error: ResponseError | null
@@ -35,32 +31,6 @@ export const PipelineStatus = ({
isSuccess,
requestStatus,
}: PipelineStatusProps) => {
const isFailedStatus = (
status: ReplicationPipelineStatusData['status'] | undefined
): status is FailedStatus => {
return (
status !== null &&
status !== undefined &&
typeof status === 'object' &&
status.name === PipelineStatusName.FAILED
)
}
const renderFailedStatus = () => {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="w-3 h-3" />
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getPipelineStateMessages(requestStatus, 'failed').message}
</TooltipContent>
</Tooltip>
)
}
// Map backend statuses to UX-friendly display
const getStatusConfig = () => {
const statusName =
@@ -89,15 +59,15 @@ export const PipelineStatus = ({
}
}
// Handle Failed status object
if (isFailedStatus(pipelineStatus)) {
return {
isFailedStatus: true,
}
}
if (pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus) {
switch (pipelineStatus.name) {
case PipelineStatusName.FAILED:
return {
label: 'Failed',
dot: <AlertTriangle className="w-3 h-3 text-destructive-600" />,
color: 'text-destructive-600',
tooltip: stateMessages.message,
}
case PipelineStatusName.STARTING:
return {
label: 'Starting',
@@ -151,24 +121,25 @@ export const PipelineStatus = ({
<>
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
<Tooltip>
<TooltipTrigger>
<WarningIcon />
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64 text-center">
Unable to retrieve status: {error?.message}
</TooltipContent>
</Tooltip>
)}
{isSuccess && (
<>
{statusConfig.isFailedStatus ? (
<div className="relative">{renderFailedStatus()}</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
</Tooltip>
)}
</>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
</Tooltip>
)}
</>
)
@@ -1,4 +1,4 @@
import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react'
import { Activity, ChevronLeft, ExternalLink, Search, X } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -16,14 +16,20 @@ import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from 'state/replication-pipeline-request-status'
import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui'
import { Badge, Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { ErroredTableDetails } from './ErroredTableDetails'
import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
import { PipelineStatus } from './PipelineStatus'
import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
import { TableState } from './ReplicationPipelineStatus.types'
import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils'
/**
* Component for displaying replication pipeline status and table replication details.
* Supports both legacy 'error' state and new 'errored' state with retry policies.
*/
export const ReplicationPipelineStatus = () => {
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
const [filterString, setFilterString] = useState<string>('')
@@ -84,8 +90,6 @@ export const ReplicationPipelineStatus = () => {
table.table_name.toLowerCase().includes(filterString.toLowerCase())
)
const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error')
const hasErrors = errorTables.length > 0
const isPipelineRunning = statusName === 'started'
const hasTableData = tableStatuses.length > 0
const isEnablingDisabling =
@@ -93,19 +97,6 @@ export const ReplicationPipelineStatus = () => {
requestStatus === PipelineStatusRequestStatus.DisableRequested
const showDisabledState = !isPipelineRunning || isEnablingDisabling
const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => {
const statusText = `Table: ${tableName}\nStatus: ${state.name}${
'message' in state ? `\nError: ${state.message}` : ''
}${'lag' in state ? `\nLag: ${state.lag}ms` : ''}`
try {
await copyToClipboard(statusText)
toast.success('Table status copied to clipboard')
} catch {
toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS)
}
}
const onTogglePipeline = async () => {
if (!projectRef) {
return console.error('Project ref is required')
@@ -158,19 +149,32 @@ export const ReplicationPipelineStatus = () => {
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
<Input
className="pl-7 h-[26px] text-xs"
placeholder="Search for tables"
value={filterString}
disabled={isPipelineError}
onChange={(e) => setFilterString(e.target.value)}
actions={
filterString.length > 0
? [
<X
key="close"
className="mx-2 cursor-pointer text-foreground"
size={14}
strokeWidth={2}
onClick={() => setFilterString('')}
/>,
]
: undefined
}
/>
</div>
<Button
type={statusName === 'stopped' ? 'primary' : 'default'}
onClick={() => onTogglePipeline()}
loading={isPipelineError || isStartingPipeline || isStoppingPipeline}
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
disabled={!['failed', 'started', 'stopped', 'stopping'].includes(statusName ?? '')}
>
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
</Button>
@@ -190,38 +194,6 @@ export const ReplicationPipelineStatus = () => {
/>
)}
{hasErrors && (
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-destructive-900 mb-1">
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
</h4>
<p className="text-sm text-destructive-700 mb-3">
Some tables encountered replication errors. Check the logs for detailed error
information.
</p>
<Button
asChild
type="outline"
size="tiny"
icon={<ExternalLink className="w-3 h-3" />}
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
>
<Link
target="_blank"
rel="noreferrer noopener"
href={`/project/${projectRef}/logs/postgres-logs`}
>
View Logs
</Link>
</Button>
</div>
</div>
</div>
)}
{hasTableData && (
<div className="flex flex-col gap-y-4">
{showDisabledState && (
@@ -254,95 +226,88 @@ export const ReplicationPipelineStatus = () => {
<Table.th key="table">Table</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="details">Details</Table.th>,
<Table.th key="actions" className="w-16 text-center">
Actions
</Table.th>,
]}
body={filteredTableStatuses.map((table: TableState, index: number) => {
const statusConfig = getStatusConfig(table.state)
return (
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
<Table.td>
<div className="flex items-center gap-x-2">
<p>{table.table_name}</p>
<ButtonTooltip
asChild
type="text"
className="px-1.5"
icon={<ExternalLink />}
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
>
<a
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/editor/${table.table_id}`}
/>
</ButtonTooltip>
</div>
</Table.td>
<Table.td>
{showDisabledState ? (
<Badge variant="default">Not Available</Badge>
) : (
statusConfig.badge
)}
</Table.td>
<Table.td>
{showDisabledState ? (
<p className="text-sm text-foreground-lighter">
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
body={
<>
{filteredTableStatuses.length === 0 && hasTableData && (
<Table.tr>
<Table.td colSpan={3}>
<div className="space-y-1">
<div className="text-sm text-foreground">{statusConfig.description}</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</div>
</Table.td>
</Table.tr>
)}
{filteredTableStatuses.map((table: TableState, index: number) => {
const statusConfig = getStatusConfig(table.state)
return (
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
<Table.td className="align-top">
<div className="flex items-center gap-x-2">
<p>{table.table_name}</p>
<ButtonTooltip
asChild
type="text"
className="px-1.5"
icon={<ExternalLink />}
tooltip={{
content: { side: 'bottom', text: 'Open in Table Editor' },
}}
>
<a
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/editor/${table.table_id}`}
/>
</ButtonTooltip>
</div>
</Table.td>
<Table.td className="align-top">
{showDisabledState ? (
<Badge variant="default">Not Available</Badge>
) : (
statusConfig.badge
)}
</Table.td>
<Table.td className="align-top">
{showDisabledState ? (
<p className="text-sm text-foreground-lighter">
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-3">
<div className="text-sm text-foreground">
{statusConfig.description}
</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
</div>
)}
{table.state.name === 'error' && (
<ErroredTableDetails
state={table.state}
tableName={table.table_name}
tableId={table.table_id}
/>
)}
</div>
)}
</div>
)}
</Table.td>
<Table.td className="text-center">
<ButtonTooltip
type="text"
size="tiny"
icon={<Copy className="w-3 h-3" />}
className="px-1.5"
disabled={showDisabledState}
onClick={() => handleCopyTableStatus(table.table_name, table.state)}
tooltip={{
content: {
side: 'bottom',
text: showDisabledState
? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}`
: 'Copy status details',
},
}}
/>
</Table.td>
</Table.tr>
)
})}
</Table.td>
</Table.tr>
)
})}
</>
}
/>
</div>
</div>
)}
{filteredTableStatuses.length === 0 && hasTableData && (
<Table.tr>
<Table.td colSpan={4}>
<div className="space-y-1">
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</div>
</Table.td>
</Table.tr>
)}
{!isStatusLoading && tableStatuses.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-full max-w-sm mx-auto text-center space-y-4">
@@ -1,3 +1,8 @@
export type RetryPolicy =
| { policy: 'no_retry' }
| { policy: 'manual_retry' }
| { policy: 'timed_retry'; next_retry: string }
export type TableState = {
table_id: number
table_name: string
@@ -6,5 +11,5 @@ export type TableState = {
| { name: 'copying_table' }
| { name: 'copied_table' }
| { name: 'following_wal'; lag: number }
| { name: 'error'; message: string }
| { name: 'error'; reason: string; solution?: string; retry_policy: RetryPolicy }
}
@@ -3,7 +3,7 @@ import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react'
import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status'
import { Badge } from 'ui'
import { getPipelineStateMessages } from './Pipeline.utils'
import { TableState } from './ReplicationPipelineStatus.types'
import { RetryPolicy, TableState } from './ReplicationPipelineStatus.types'
export const getStatusConfig = (state: TableState['state']) => {
switch (state.name) {
@@ -34,7 +34,7 @@ export const getStatusConfig = (state: TableState['state']) => {
case 'error':
return {
badge: <Badge variant="destructive">Error</Badge>,
description: state.message,
description: <pre className="text-xs font-mono">{state.reason}</pre>,
color: 'text-destructive-600',
}
default:
@@ -106,3 +106,17 @@ export const getDisabledStateConfig = ({
return { title, message, badge, icon, colors }
}
export const isValidRetryPolicy = (policy: any): policy is RetryPolicy => {
if (!policy || typeof policy !== 'object' || !policy.policy) return false
switch (policy.policy) {
case 'no_retry':
case 'manual_retry':
return true
case 'timed_retry':
return typeof policy.next_retry === 'string'
default:
return false
}
}
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
interface RetryCountdownProps {
nextRetryTime: string // RFC3339 formatted date
}
interface TimeRemaining {
days: number
hours: number
minutes: number
seconds: number
isExpired: boolean
isInvalid: boolean
}
export const RetryCountdown = ({ nextRetryTime }: RetryCountdownProps) => {
const [timeRemaining, setTimeRemaining] = useState<TimeRemaining>({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
isExpired: false,
isInvalid: false,
})
const targetTimestamp = useMemo(() => {
try {
const date = new Date(nextRetryTime)
if (isNaN(date.getTime())) {
return null
}
return date.getTime()
} catch {
return null
}
}, [nextRetryTime])
const calculateTimeRemaining = useCallback((targetTime: number): TimeRemaining => {
const now = Date.now()
const difference = targetTime - now
if (difference <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, isExpired: true, isInvalid: false }
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24))
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((difference % (1000 * 60)) / 1000)
return { days, hours, minutes, seconds, isExpired: false, isInvalid: false }
}, [])
useEffect(() => {
if (targetTimestamp === null) return
const updateTimer = () => {
setTimeRemaining(calculateTimeRemaining(targetTimestamp))
}
updateTimer()
const interval = setInterval(updateTimer, 1000)
return () => clearInterval(interval)
}, [targetTimestamp, calculateTimeRemaining])
const { timeDisplay, statusMessage } = useMemo(() => {
if (targetTimestamp === null) {
return {
timeDisplay: 'Invalid retry time format',
statusMessage: '',
}
}
const formatTimeUnit = (value: number, unit: string) => {
if (value === 0) return null
return `${value}${unit.charAt(0)}`
}
let timeDisplay: string
let statusMessage: string
if (timeRemaining.isExpired) {
statusMessage = ''
timeDisplay = 'Retrying soon...'
} else {
const parts = [
formatTimeUnit(timeRemaining.days, 'day'),
formatTimeUnit(timeRemaining.hours, 'hour'),
formatTimeUnit(timeRemaining.minutes, 'minute'),
formatTimeUnit(timeRemaining.seconds, 'second'),
].filter(Boolean)
statusMessage = parts.length === 0 ? '' : 'Next retry in:'
timeDisplay = parts.length === 0 ? 'Retrying soon...' : parts.join(' ')
}
return { timeDisplay, statusMessage }
}, [targetTimestamp, timeRemaining])
return (
<div role="status" aria-live="polite" aria-label={`${statusMessage} ${timeDisplay}`}>
<span className="text-xs font-medium">{statusMessage}</span>{' '}
{/* [Joshen] It's a bit hard to debug without doing this locally, but we could use CountdownTimerSpan here perhaps */}
<span className="text-xs font-mono">{timeDisplay}</span>
</div>
)
}
@@ -0,0 +1,120 @@
import { ChevronDown, RotateCcw, Undo2 } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { RollbackType, useRollbackTableMutation } from 'data/replication/rollback-table-mutation'
import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
const RETRY_OPTIONS = [
{
type: 'individual' as RollbackType,
icon: <Undo2 className="w-3 h-3 mt-0.5" />,
title: 'Rollback to previous state',
description: 'Restart from the last working state (may not be enough in some cases)',
},
{
type: 'full' as RollbackType,
icon: <RotateCcw className="w-3 h-3 mt-0.5" />,
title: 'Reset from scratch',
description: 'Completely restart the table replication',
},
] as const
interface RetryOptionsDropdownProps {
tableId: number
tableName: string
}
export const RetryOptionsDropdown = ({ tableId, tableName }: RetryOptionsDropdownProps) => {
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
const [isOpen, setIsOpen] = useState(false)
const { mutate: rollbackTable, isLoading: isRollingBack } = useRollbackTableMutation({
onSuccess: (_, vars) => {
const { projectRef, pipelineId } = vars
toast.success(`Table "${tableName}" rolled back successfully`)
startPipeline({ projectRef, pipelineId })
},
onError: (error, vars) => {
const { rollbackType } = vars
toast.error(
`Failed to ${rollbackType === 'full' ? 'reset' : 'rollback'} table: ${error.message}`
)
},
})
const { mutate: startPipeline, isLoading: 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
const handleRollback = async (rollbackType: RollbackType) => {
if (!projectRef) return toast.error('Project ref is required')
if (!_pipelineId) return toast.error('Pipeline ID is required')
const pipelineId = Number(_pipelineId)
rollbackTable({
projectRef,
pipelineId,
tableId,
rollbackType,
})
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
size="tiny"
type="default"
loading={isLoading}
disabled={isLoading}
className="w-min"
iconRight={<ChevronDown />}
aria-label={`Rollback ${tableName}`}
>
Rollback
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72" aria-label="Rollback options">
{RETRY_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.type}
disabled={isLoading}
className="flex flex-col items-start px-3 py-2 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
aria-describedby={`rollback-${option.type}-description`}
onClick={() => handleRollback(option.type)}
>
<div className="flex items-start gap-x-2 w-full">
<div className="min-w-4">{option.icon}</div>
<div
id={`rollback-${option.type}-description`}
className="flex flex-col gap-y-1 text-xs"
>
<p>{option.title}</p>
<p className="text-foreground-light">{option.description}</p>
</div>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -111,7 +111,7 @@ const RestartServerButton = () => {
? 'Unable to restart project as project is not active'
: isAwsK8s
? 'Project restart is not supported for AWS (Revamped) projects'
: '',
: undefined,
},
}}
>
@@ -0,0 +1,84 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { replicationKeys } from './keys'
export type RollbackType = 'individual' | 'full'
type RollbackTableParams = {
projectRef: string
pipelineId: number
tableId: number
rollbackType: RollbackType
}
type RollbackTableResponse = {
pipeline_id: number
table_id: number
new_state: {
name: string
[key: string]: any
}
}
async function rollbackTableState(
{ projectRef, pipelineId, tableId, rollbackType }: RollbackTableParams,
signal?: AbortSignal
): Promise<RollbackTableResponse> {
if (!projectRef) throw new Error('Project reference is required')
if (!pipelineId) throw new Error('Pipeline ID is required')
if (!tableId) throw new Error('Table ID is required')
if (!rollbackType) throw new Error('Rollback type is required')
const { data, error } = await post(
'/platform/replication/{ref}/pipelines/{pipeline_id}/rollback-table-state',
{
params: { path: { ref: projectRef, pipeline_id: pipelineId } },
body: { table_id: tableId, rollback_type: rollbackType },
signal,
}
)
if (error) handleError(error)
return data
}
type RollbackTableData = Awaited<ReturnType<typeof rollbackTableState>>
export const useRollbackTableMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<RollbackTableData, ResponseError, RollbackTableParams>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<RollbackTableData, ResponseError, RollbackTableParams>(
(vars) => rollbackTableState(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, pipelineId } = variables
await Promise.all([
queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId)),
queryClient.invalidateQueries(
replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId)
),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to rollback table: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
+51 -2
View File
@@ -3318,6 +3318,49 @@ export interface components {
*/
template_url?: string
}
V1GetUsageApiCountResponse: {
error?:
| string
| {
code: number
errors: {
domain: string
location: string
locationType: string
message: string
reason: string
}[]
message: string
status: string
}
result?: {
/** Format: date-time */
timestamp: string
total_auth_requests: number
total_realtime_requests: number
total_rest_requests: number
total_storage_requests: number
}[]
}
V1GetUsageApiRequestsCountResponse: {
error?:
| string
| {
code: number
errors: {
domain: string
location: string
locationType: string
message: string
reason: string
}[]
message: string
status: string
}
result?: {
count: number
}[]
}
V1ListMigrationsResponse: {
name?: string
version: string
@@ -4281,7 +4324,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['AnalyticsResponse']
'application/json': components['schemas']['V1GetUsageApiCountResponse']
}
}
403: {
@@ -4316,9 +4359,15 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['AnalyticsResponse']
'application/json': components['schemas']['V1GetUsageApiRequestsCountResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Failed to get project's usage api requests count */
500: {
headers: {
+216 -58
View File
@@ -2504,6 +2504,23 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/projects/{ref}/api-keys/temporary': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Create a temporary API key */
post: operations['ApiKeysController_createTemporaryApiKey']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/projects/{ref}/api/graphql': {
parameters: {
query?: never
@@ -3449,10 +3466,10 @@ export interface paths {
path?: never
cookie?: never
}
/** Get all replication pipelines. */
/** Retrieves all replication pipelines. */
get: operations['ReplicationPipelinesController_getPipelines']
put?: never
/** Create a replication pipeline. */
/** Creates a replication pipeline. */
post: operations['ReplicationPipelinesController_createPipeline']
delete?: never
options?: never
@@ -3467,12 +3484,12 @@ export interface paths {
path?: never
cookie?: never
}
/** Get a replication pipeline by ID. */
/** Retrieves a replication pipeline by ID. */
get: operations['ReplicationPipelinesController_getPipeline']
put?: never
/** Update a replication pipeline. */
/** Updates a replication pipeline. */
post: operations['ReplicationPipelinesController_updatePipeline']
/** Delete a replication pipeline. */
/** Deletes a replication pipeline. */
delete: operations['ReplicationPipelinesController_deletePipeline']
options?: never
head?: never
@@ -3486,7 +3503,7 @@ export interface paths {
path?: never
cookie?: never
}
/** Get the status of a replication pipeline. */
/** Retrieves the replication status of a pipeline. */
get: operations['ReplicationPipelinesController_getPipelineReplicationStatus']
put?: never
post?: never
@@ -3496,6 +3513,23 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/replication/{ref}/pipelines/{pipeline_id}/rollback-table-state': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Rolls back the state of a table in the pipeline. */
post: operations['ReplicationPipelinesController_rollbackTableState']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/replication/{ref}/pipelines/{pipeline_id}/start': {
parameters: {
query?: never
@@ -3505,7 +3539,7 @@ export interface paths {
}
get?: never
put?: never
/** Start a replication pipeline. */
/** Starts a replication pipeline. */
post: operations['ReplicationPipelinesController_startPipeline']
delete?: never
options?: never
@@ -3520,7 +3554,7 @@ export interface paths {
path?: never
cookie?: never
}
/** Get the status of a replication pipeline. */
/** Retrieves the status of a replication pipeline. */
get: operations['ReplicationPipelinesController_getPipelineStatus']
put?: never
post?: never
@@ -3539,7 +3573,7 @@ export interface paths {
}
get?: never
put?: never
/** Stop a replication pipeline. */
/** Stops a replication pipeline. */
post: operations['ReplicationPipelinesController_stopPipeline']
delete?: never
options?: never
@@ -7592,9 +7626,25 @@ export interface components {
name: 'following_wal'
}
| {
message: string
/** @enum {string} */
name: 'error'
reason: string
retry_policy:
| {
/** @enum {string} */
policy: 'no_retry'
}
| {
/** @enum {string} */
policy: 'manual_retry'
}
| {
/** @description The time of the next retry (RFC3339 format) */
next_retry: string
/** @enum {string} */
policy: 'timed_retry'
}
solution?: string
}
/** @description Table id (internal Postgres OID) */
table_id: number
@@ -7665,34 +7715,10 @@ export interface components {
/** @description Pipeline id */
pipeline_id: number
/** @description Pipeline status */
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
}
status: {
/** @enum {string} */
name: 'stopped' | 'starting' | 'started' | 'stopping' | 'unknown' | 'failed'
}
}
ReplicationPublicationsResponse: {
/** @description List of publications */
@@ -7795,6 +7821,61 @@ export interface components {
/** @enum {string} */
privilege_type: 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'REFERENCES'
}[]
RollbackTableStateBody: {
/**
* @description Rollback type
* @enum {string}
*/
rollback_type: 'individual' | 'full'
/** @description Table id (internal Postgres OID) */
table_id: number
}
RollbackTableStateResponse: {
/** @description Table replication state */
new_state:
| {
/** @enum {string} */
name: 'queued'
}
| {
/** @enum {string} */
name: 'copying_table'
}
| {
/** @enum {string} */
name: 'copied_table'
}
| {
lag: number
/** @enum {string} */
name: 'following_wal'
}
| {
/** @enum {string} */
name: 'error'
reason: string
retry_policy:
| {
/** @enum {string} */
policy: 'no_retry'
}
| {
/** @enum {string} */
policy: 'manual_retry'
}
| {
/** @description The time of the next retry (RFC3339 format) */
next_retry: string
/** @enum {string} */
policy: 'timed_retry'
}
solution?: string
}
/** @description Pipeline id */
pipeline_id: number
/** @description Table id (internal Postgres OID) */
table_id: number
}
RunLintByNameResponse: {
lints: {
cache_key: string
@@ -8078,6 +8159,9 @@ export interface components {
page_url: string
pathname: string
}
TemporaryApiKeyResponse: {
api_key: string
}
TransferProjectBody: {
target_organization_slug: string
}
@@ -8825,9 +8909,9 @@ export interface components {
publication_name: string
}
/** @description Destination id */
destination_id?: number
destination_id: number
/** @description Source id */
source_id?: number
source_id: number
}
UpdateSchemaBody: {
name?: string
@@ -16463,6 +16547,38 @@ export interface operations {
}
}
}
ApiKeysController_createTemporaryApiKey: {
parameters: {
query: {
authorization_exp: string
claims: string
}
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description Temporary API key */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['TemporaryApiKeyResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
ProjectsApiController_projectGraphql: {
parameters: {
query?: never
@@ -19136,7 +19252,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returns all pipelines. */
/** @description Returns all replication pipelines. */
200: {
headers: {
[name: string]: unknown
@@ -19151,7 +19267,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to get replication pipelines. */
/** @description Fails to retrieve replication pipelines. */
500: {
headers: {
[name: string]: unknown
@@ -19176,7 +19292,7 @@ export interface operations {
}
}
responses: {
/** @description Returns the created replication pipeline ID. */
/** @description Returns the ID of the created replication pipeline. */
201: {
headers: {
[name: string]: unknown
@@ -19191,7 +19307,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to create pipeline. */
/** @description Fails to create replication pipeline. */
500: {
headers: {
[name: string]: unknown
@@ -19214,7 +19330,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returns the pipeline. */
/** @description Returns the details of the specified replication pipeline. */
200: {
headers: {
[name: string]: unknown
@@ -19229,7 +19345,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to get pipeline. */
/** @description Fails to retrieve replication pipeline. */
500: {
headers: {
[name: string]: unknown
@@ -19256,7 +19372,7 @@ export interface operations {
}
}
responses: {
/** @description Returned when the pipeline is updated. */
/** @description Returns when the replication pipeline is successfully updated. */
201: {
headers: {
[name: string]: unknown
@@ -19269,7 +19385,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to update pipeline. */
/** @description Fails to update replication pipeline. */
500: {
headers: {
[name: string]: unknown
@@ -19292,7 +19408,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returned when the pipeline is deleted. */
/** @description Returns when the replication pipeline is successfully deleted. */
200: {
headers: {
[name: string]: unknown
@@ -19305,7 +19421,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to delete pipeline. */
/** @description Fails to delete replication pipeline. */
500: {
headers: {
[name: string]: unknown
@@ -19328,7 +19444,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returns the pipeline replication status. */
/** @description Returns the replication status of the pipeline. */
200: {
headers: {
[name: string]: unknown
@@ -19343,7 +19459,49 @@ export interface operations {
}
content?: never
}
/** @description Failed to get pipeline replication status. */
/** @description Fails to retrieve pipeline replication status. */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
ReplicationPipelinesController_rollbackTableState: {
parameters: {
query?: never
header?: never
path: {
/** @description Pipeline id */
pipeline_id: number
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['RollbackTableStateBody']
}
}
responses: {
/** @description Returns the table state after the rollback. */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['RollbackTableStateResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Fails to roll back table state. */
500: {
headers: {
[name: string]: unknown
@@ -19366,7 +19524,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returned when the pipeline is started. */
/** @description Returns when the replication pipeline is successfully started. */
200: {
headers: {
[name: string]: unknown
@@ -19379,7 +19537,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to start pipeline. */
/** @description Fails to start replication pipeline. */
500: {
headers: {
[name: string]: unknown
@@ -19402,7 +19560,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returns the pipeline status. */
/** @description Returns the current status of the replication pipeline. */
200: {
headers: {
[name: string]: unknown
@@ -19417,7 +19575,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to get pipeline status. */
/** @description Fails to retrieve pipeline status. */
500: {
headers: {
[name: string]: unknown
@@ -19440,7 +19598,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Returned when the pipeline is stopped. */
/** @description Returns when the replication pipeline is successfully stopped. */
200: {
headers: {
[name: string]: unknown
@@ -19453,7 +19611,7 @@ export interface operations {
}
content?: never
}
/** @description Failed to stop pipeline. */
/** @description Fails to stop replication pipeline. */
500: {
headers: {
[name: string]: unknown