mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## 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>
282 lines
11 KiB
TypeScript
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>·</span>
|
|
<span>{type}</span>
|
|
{hasTableErrors && (
|
|
<>
|
|
<span aria-hidden>·</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)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|