mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## What kind of change does this PR introduce? Studio UI polish. This is the first PR in the Pipelines review stack and targets `master`. Resolves DEPR-674. ## What is the current behaviour? Replication destinations use generic line icons across the destination picker, pipelines list, and replication diagram. The existing shared ClickHouse and Snowflake assets also use older artwork. ## What is the new behaviour? Adds a reusable `DestinationLogo` treatment and uses it consistently across Replication surfaces. BigQuery, ClickHouse, DuckLake, and Snowflake now use their colour brand marks, while destinations without a dedicated asset retain their existing line icon in the same frame. The refreshed ClickHouse and Snowflake artwork replaces the canonical shared assets, so existing consumers such as the Wrappers catalogue receive the updated marks too. | Light | Dark | | --- | --- | | <img width="572" height="804" alt="CleanShot 2026-09-11 at 16 48 42@2x" src="https://github.com/user-attachments/assets/5c2eaef3-8714-4c0a-8bcc-7c8618abd677" /> | <img width="552" height="788" alt="CleanShot 2026-09-11 at 16 47 30@2x" src="https://github.com/user-attachments/assets/2aff8775-cdc1-4d6b-9f78-7b40d42e102a" /> | | Add Pipeline form | | --- | | <img width="1252" height="730" alt="CleanShot 2026-09-11 at 16 48 57@2x" src="https://github.com/user-attachments/assets/025a158c-57ee-495e-8356-00ed1717d09d" /> | ## To test 1. Open `/project/<ref>/database/replication` and start adding a pipeline. 2. Confirm the BigQuery, ClickHouse, DuckLake, and Snowflake marks appear consistently in the destination picker, pipelines list, and replication diagram. 3. Open the ClickHouse and Snowflake entries in the Wrappers catalogue and confirm they use the refreshed artwork. 4. Confirm Analytics Bucket retains its existing fallback icon within the same frame. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Visual Updates** * Destination logos now display dedicated brand marks for ClickHouse, DuckLake, Snowflake, and BigQuery. * Updated replication destination selectors, rows, and diagrams to use consistent destination logos. * Adjusted the destination type column width for improved layout. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
263 lines
9.5 KiB
TypeScript
263 lines
9.5 KiB
TypeScript
import { useParams } from 'common'
|
|
import { Minus } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
TableCell,
|
|
TableRow,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
WarningIcon,
|
|
} from 'ui'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { DeleteDestination } from './DeleteDestination'
|
|
import { DestinationLogo } from './DestinationLogo'
|
|
import { PipelineStatus } from './PipelineStatus'
|
|
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
|
import { getFormattedLagValue } 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 {
|
|
PipelineStatusRequestStatus,
|
|
usePipelineRequestStatus,
|
|
} from '@/state/replication-pipeline-request-status'
|
|
import { type ResponseError } from '@/types'
|
|
|
|
interface DestinationRowProps {
|
|
destinationId: number
|
|
}
|
|
|
|
export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
|
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,
|
|
},
|
|
{ refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
|
|
)
|
|
const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus()
|
|
const requestStatus = pipeline?.id
|
|
? getRequestStatus(pipeline.id)
|
|
: PipelineStatusRequestStatus.None
|
|
|
|
const { mutateAsync: stopPipeline } = useStopPipelineMutation()
|
|
const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({})
|
|
|
|
// Fetch table-level replication status to surface errors in list view
|
|
const {
|
|
data: replicationStatusData,
|
|
isPending: isReplicationStatusLoading,
|
|
isError: isReplicationStatusError,
|
|
} = useReplicationPipelineReplicationStatusQuery(
|
|
{ projectRef, pipelineId: pipeline?.id },
|
|
{ refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
|
|
)
|
|
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)
|
|
const isCaughtUp = lagBytes === 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({
|
|
projectRef,
|
|
pipelineId: pipeline?.id,
|
|
})
|
|
const hasUpdate = Boolean(versionData?.new_version)
|
|
|
|
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 })
|
|
await deleteDestinationPipeline({
|
|
projectRef,
|
|
destinationId: destinationId,
|
|
pipelineId: pipeline.id,
|
|
})
|
|
// Close dialog after successful deletion
|
|
setShowDeleteDestinationForm(false)
|
|
toast.success(`Deleted destination "${destinationName}"`)
|
|
} catch (error) {
|
|
toast.error(`Failed to delete destination: ${(error as ResponseError).message}`)
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (pipeline?.id) {
|
|
updatePipelineStatus(pipeline.id, statusName)
|
|
}
|
|
}, [pipeline?.id, statusName, updatePipelineStatus])
|
|
|
|
return (
|
|
<>
|
|
{isPipelineError && (
|
|
<TableRow>
|
|
<TableCell colSpan={6}>
|
|
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{isPipelineSuccess && (
|
|
<TableRow>
|
|
<TableCell>{type ? <DestinationLogo type={type} /> : 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>
|
|
<div className="flex items-center gap-x-1.5 text-xs text-foreground-lighter">
|
|
<span className="font-mono">#{pipeline?.id}</span>
|
|
<span aria-hidden>·</span>
|
|
<span>{type}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
{isPipelineLoading || !pipeline ? (
|
|
<ShimmeringLoader />
|
|
) : (
|
|
<PipelineStatus
|
|
pipelineStatus={pipelineStatus?.status}
|
|
error={pipelineStatusError}
|
|
isLoading={isPipelineStatusLoading}
|
|
isError={isPipelineStatusError}
|
|
isSuccess={isPipelineStatusSuccess}
|
|
requestStatus={requestStatus}
|
|
pipelineId={pipeline?.id}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
{!pipeline ? (
|
|
<Minus size={18} className="text-foreground-lighter" />
|
|
) : isReplicationStatusLoading ? (
|
|
<ShimmeringLoader />
|
|
) : isReplicationStatusError || !applyLag ? (
|
|
<Minus size={18} className="text-foreground-lighter" />
|
|
) : isCaughtUp ? (
|
|
<span className="text-foreground-light whitespace-nowrap">Caught up</span>
|
|
) : (
|
|
<span className="text-foreground whitespace-nowrap">{lag.display}</span>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
{isPipelineLoading || !pipeline ? (
|
|
<ShimmeringLoader />
|
|
) : (
|
|
pipeline.config.publication_name
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<div className="flex items-center justify-end gap-x-2">
|
|
{hasTableErrors && (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<WarningIcon />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
{errorCount} table{errorCount === 1 ? '' : 's'} encountered replication errors.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Button asChild className="relative">
|
|
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
|
|
View pipeline
|
|
</Link>
|
|
</Button>
|
|
<RowMenu
|
|
destinationId={destinationId}
|
|
pipeline={pipeline}
|
|
pipelineStatus={pipelineStatus?.status}
|
|
error={pipelineStatusError}
|
|
isLoading={isPipelineStatusLoading}
|
|
isError={isPipelineStatusError}
|
|
onDeleteClick={() => setShowDeleteDestinationForm(true)}
|
|
hasUpdate={hasUpdate}
|
|
onUpdateClick={() => setShowUpdateVersionModal(true)}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
|
|
<DeleteDestination
|
|
visible={showDeleteDestinationForm}
|
|
setVisible={setShowDeleteDestinationForm}
|
|
onDelete={onDeleteClick}
|
|
isLoading={isDeleting}
|
|
name={destinationName}
|
|
/>
|
|
|
|
<UpdateVersionModal
|
|
visible={showUpdateVersionModal}
|
|
pipeline={pipeline}
|
|
onClose={() => setShowUpdateVersionModal(false)}
|
|
confirmLabel={
|
|
statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
|
|
? 'Update and restart'
|
|
: 'Update version'
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
}
|