Files
supabase/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx
Danny WhiteandJoshen Lim 05a45dd1ed feat(studio): rename Replication to Pipelines (#50637)
## What kind of change does this PR introduce?

Feature and docs update.

## What is the current behavior?

The Dashboard lists Pipelines destinations under Database > Replication.
Read replicas have moved to Infrastructure, but the temporary notices
remain on the destinations page and new destination sheet.

Closes PIPE-1021.

## What is the new behavior?

The canonical Dashboard routes are Database > Pipelines, while legacy
Replication list and detail URLs permanently redirect to the equivalent
Pipelines routes. Navigation, command palette, shortcuts, pipeline
links, docs, and current marketing copy use Pipelines. Read-replica
notices and their obsolete dismissal state are removed.

| Before | After |
| --- | --- |
| <img width="1024" height="759" alt="Replication Database Agua Basket
Supabase"
src="https://github.com/user-attachments/assets/53f9f565-1ed1-43e9-a7d9-b66b2a47e948"
/> | <img width="1024" height="759" alt="2540"
src="https://github.com/user-attachments/assets/14ab2d61-d01c-483f-9d4f-0ac286dae159"
/> |

The Management API, pipeline behaviour, replication logs, and Postgres
replication terminology remain unchanged.

## To test

- Open `/project/<ref>/database/pipelines` and confirm the Database
navigation, page header, and pipeline breadcrumb say Pipelines.
- Open
`/project/<ref>/database/replication?source=bookmark#destinations` and a
legacy pipeline detail URL. Confirm each redirects to the matching
Pipelines URL while preserving parameters and fragments.
- From the Pipelines page, open Add destination. Confirm no read-replica
migration notice appears.
- Open the Pipelines guide and confirm its Dashboard steps lead to
Database > Pipelines.

## Before merge

- [ ] Get changelog entry reviewed
https://github.com/supabase/changelog/pull/262 and prepare to merge
simultaneously

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added dedicated **Database > Pipelines** pages for pipeline lists and
details.
- Added permanent redirects from legacy Replication URLs to their
corresponding Pipelines pages.
- Read replica management links now open **Settings > Infrastructure**.

- **Documentation**
- Updated Pipelines setup, monitoring, troubleshooting, and usage
guidance to reference the current dashboard locations.
  - Updated Realtime guidance to use **Database > Publications**.

- **Updates**
- Renamed dashboard navigation, breadcrumbs, commands, and keyboard
shortcuts from **Replication** to **Pipelines**.
  - Removed the “Read replicas have moved” notification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-23 08:52:07 +10:00

282 lines
11 KiB
TypeScript

import { useParams } from 'common'
import { ChevronRight, Minus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { TableCell, TableRow } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { DeleteDestination } from './DeleteDestination'
import { DestinationLogo } from './DestinationLogo'
import { DetailSubtext } from './DetailSubtext'
import { PipelineStatePill } from './PipelineStatePill'
import { PipelineStatusName } from './Replication.constants'
import {
getFormattedLagValue,
getInitialSyncProgress,
} from './ReplicationPipelineStatus/ReplicationPipelineStatus.utils'
import { RowMenu } from './RowMenu'
import { UpdateVersionModal } from './UpdateVersionModal'
import { useDestinationInformation } from './useDestinationInformation'
import { AlertError } from '@/components/ui/AlertError'
import { useDeleteDestinationPipelineMutation } from '@/data/replication/delete-destination-pipeline-mutation'
import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication/pipeline-replication-status-query'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query'
import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
import { createNavigationHandler } from '@/lib/navigation'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
import { type ResponseError } from '@/types'
interface DestinationRowProps {
destinationId: number
}
export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false)
const { type, statusName, destination, pipeline, pipelineStatus, pipelineFetcher } =
useDestinationInformation({
id: destinationId,
})
const {
error: pipelineError,
isPending: isPipelineLoading,
isError: isPipelineError,
isSuccess: isPipelineSuccess,
} = pipelineFetcher
const destinationName = destination?.name ?? ''
const {
error: pipelineStatusError,
isPending: isPipelineStatusLoading,
isError: isPipelineStatusError,
isSuccess: isPipelineStatusSuccess,
} = useReplicationPipelineStatusQuery({
projectRef,
pipelineId: pipeline?.id,
})
const { getRequestStatus } = usePipelineRequestStatus()
const requestStatus = pipeline?.id
? getRequestStatus(pipeline.id)
: PipelineStatusRequestStatus.None
const { mutateAsync: stopPipeline } = useStopPipelineMutation({ onError: () => {} })
const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({
onError: () => {},
})
// Fetch table-level replication status to surface errors in list view
const {
data: replicationStatusData,
isPending: isReplicationStatusLoading,
isError: isReplicationStatusError,
} = useReplicationPipelineReplicationStatusQuery({ projectRef, pipelineId: pipeline?.id }, {})
const tableStatuses = replicationStatusData?.table_statuses ?? []
const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length
const applyLag = replicationStatusData?.apply_lag
// Show the byte-based slot lag (WAL the destination hasn't confirmed flushing yet). The
// time-based flush_lag from pg_stat_replication is routinely NULL for logical slots that are
// idle or don't report timed feedback, whereas confirmed_flush_lsn_bytes is always populated.
const lagBytes = applyLag?.confirmed_flush_lsn_bytes
const lag = getFormattedLagValue('bytes', lagBytes)
// The lag figure only covers ongoing changes, so it reads as "Caught up" while an initial copy
// is still running. Say what's actually happening instead.
const { syncingCount } = getInitialSyncProgress(tableStatuses)
const isInitialSyncRunning = syncingCount > 0
const isCaughtUp = lagBytes === 0
// Hide old table errors while an optimistic lifecycle action is displayed.
const isPipelineStopped = statusName === PipelineStatusName.STOPPED
const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None
const hasTableErrors = errorCount > 0 && !isPipelineStopped && !isTransitioning
// Check if a newer pipeline version is available (one-time check cached for session)
const { data: versionData } = useReplicationPipelineVersionQuery({
projectRef,
pipelineId: pipeline?.id,
})
const hasUpdate = Boolean(versionData?.new_version)
const handleNavigation = pipeline
? createNavigationHandler(`/project/${projectRef}/database/pipelines/${pipeline.id}`, router)
: undefined
const onDeleteClick = async () => {
if (!projectRef) {
return console.error('Project ref is required')
}
if (!pipeline) {
return toast.error('No pipeline found')
}
try {
setIsDeleting(true)
await stopPipeline({ projectRef, pipelineId: pipeline.id, waitUntilStopped: true })
await deleteDestinationPipeline({
projectRef,
destinationId: destinationId,
pipelineId: pipeline.id,
})
// Close dialog after successful deletion
setShowDeleteDestinationForm(false)
toast.success(`Deleted pipeline "${destinationName}"`)
} catch (error) {
toast.error(`Failed to delete pipeline: ${(error as ResponseError).message}`)
} finally {
setIsDeleting(false)
}
}
// Five distinct states, so early returns rather than a ternary chain. The row only renders once
// a pipeline exists, so there is no "no pipeline" case to handle here.
const renderLag = () => {
if (isReplicationStatusLoading) return <ShimmeringLoader />
if (isInitialSyncRunning)
return <span className="text-foreground-light whitespace-nowrap">Initial sync</span>
if (isReplicationStatusError || !applyLag)
return (
<>
<Minus size={18} className="text-foreground-lighter" aria-hidden />
<span className="sr-only">Lag unavailable</span>
</>
)
if (isCaughtUp)
return <span className="text-foreground-light whitespace-nowrap">Caught up</span>
return <span className="text-foreground-light whitespace-nowrap">{lag.display}</span>
}
return (
<>
{isPipelineError && (
<TableRow>
<TableCell colSpan={6}>
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
</TableCell>
</TableRow>
)}
{isPipelineSuccess && pipeline && (
<TableRow
className="relative cursor-pointer focus-inset"
onClick={handleNavigation}
onAuxClick={handleNavigation}
onKeyDown={handleNavigation}
tabIndex={0}
>
<TableCell className="!pr-1">
{type ? <DestinationLogo type={type} hasErrors={hasTableErrors} /> : null}
</TableCell>
<TableCell className="max-w-[180px]">
{isPipelineLoading ? (
<ShimmeringLoader />
) : (
<div className="flex flex-col gap-y-0.5">
<p className="text-sm font-medium text-foreground truncate">
{destinationName || type}
</p>
<DetailSubtext className="flex items-center gap-x-1.5">
<span>#{pipeline?.id}</span>
<span aria-hidden>&middot;</span>
<span>{type}</span>
{hasTableErrors && (
<>
<span aria-hidden>&middot;</span>
<span className="text-destructive">
{errorCount} table error{errorCount === 1 ? '' : 's'}
</span>
</>
)}
</DetailSubtext>
</div>
)}
</TableCell>
<TableCell>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader />
) : (
<PipelineStatePill
pipelineStatus={pipelineStatus?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
projectRef={projectRef}
pipelineId={pipeline?.id}
/>
)}
</TableCell>
<TableCell>
<div aria-live="polite" aria-atomic="true">
{isReplicationStatusLoading && <span className="sr-only">Loading lag</span>}
{renderLag()}
</div>
</TableCell>
<TableCell>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader />
) : (
pipeline.config.publication_name
)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-x-2">
<div
onClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<RowMenu
destinationId={destinationId}
pipeline={pipeline}
pipelineStatus={pipelineStatus?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
hasUpdate={hasUpdate}
onUpdateClick={() => setShowUpdateVersionModal(true)}
/>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
className="text-foreground-lighter"
aria-hidden
/>
<button tabIndex={-1} className="sr-only">
Go to pipeline details
</button>
</div>
</TableCell>
</TableRow>
)}
<DeleteDestination
visible={showDeleteDestinationForm}
setVisible={setShowDeleteDestinationForm}
onDelete={onDeleteClick}
isLoading={isDeleting}
name={destinationName}
/>
<UpdateVersionModal
visible={showUpdateVersionModal}
pipeline={pipeline}
onClose={() => setShowUpdateVersionModal(false)}
/>
</>
)
}