mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
feat(studio): polish the Replication pipelines list (#50252)
## What kind of change does this PR introduce? Studio UI and interaction polish. This is the second PR in the Pipelines review stack and depends on #50251. ## What is the current behaviour? Pipeline rows require a separate view action, cannot be sorted, and present lifecycle, lag, and destination terminology inconsistently. ## What is the new behaviour? Makes rows navigable with link-like mouse and keyboard behaviour, adds Name and Status sorting, reuses cached status queries, and moves row actions into the overflow menu. It also clarifies pipeline terminology, adds Docs and feedback actions, and standardises state, error, lag, and loading presentation with accessible announcements. | Before | After | | --- | --- | | <img width="1280" height="1323" alt="Replication Database ETL BigTable ETL Team Supabase" src="https://github.com/user-attachments/assets/53a62283-0e58-4408-8409-2b87a38af159" /> | <img width="1280" height="1323" alt="Replication Database Agua Basket Supabase" src="https://github.com/user-attachments/assets/ba4de1e0-4a84-43b4-8975-ca05a3056bcf" /> | ## To test 1. Open `/project/<ref>/database/replication`. 2. Sort by Name and Status, then confirm failed and stopped pipelines surface first when Status is ascending. 3. Click a row, use Enter or Space, and modifier-click or middle-click to verify link behaviour. 4. Open the row overflow menu and confirm it does not navigate. 5. Check loading, initial sync, caught up, numeric lag, unavailable lag, and table-error states where available. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added sortable pipeline lists with clearer loading, empty, and error states. - Pipeline rows now support direct navigation, detail viewing, status indicators, lag progress, and table error summaries. - Added initial-sync progress indicators and accessible status announcements. - Added documentation and feedback links. - Improved pipeline version update and enable/disable dialogs. - **Bug Fixes** - Prevented right-clicks from triggering navigation. - Improved unavailable lag and initial-sync handling. - **Style** - Standardized replication terminology and confirmation messaging around pipelines. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
74c116de74
commit
2e435986c9
20 files changed
+750
-240
No files matched your search
@@ -20,12 +20,12 @@ export const DeleteDestination = ({
|
||||
variant="destructive"
|
||||
visible={visible}
|
||||
loading={isLoading}
|
||||
title="Delete this destination"
|
||||
confirmLabel={isLoading ? 'Deleting...' : `Delete destination`}
|
||||
confirmPlaceholder="Type in name of destination"
|
||||
title="Delete this pipeline"
|
||||
confirmLabel={isLoading ? 'Deleting…' : 'Delete pipeline'}
|
||||
confirmPlaceholder="Type the pipeline name"
|
||||
confirmString={name ?? 'Unknown'}
|
||||
text={`This will delete the destination "${name}" and stop its replication pipeline. Already replicated data remains at the destination, and pipeline-hour billing ends when deletion completes.`}
|
||||
alert={{ title: 'You cannot recover this destination or pipeline after deletion.' }}
|
||||
text={`This will delete the pipeline "${name}" and stop replication. Already replicated data stays at the destination, and pipeline-hour billing ends when deletion completes.`}
|
||||
alert={{ description: 'You cannot recover this pipeline after deletion.' }}
|
||||
onCancel={() => setVisible(!visible)}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ export const PipelineCostDialog = ({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="large">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm to create and start pipeline</DialogTitle>
|
||||
<DialogTitle>Create and start pipeline</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the estimated costs before you create and start the pipeline.
|
||||
</DialogDescription>
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ export const ValidationWarningsDialog = ({
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{hasWarnings
|
||||
? 'Replication can start, but the warnings listed above may affect how some changes are applied downstream. Review them before proceeding.'
|
||||
: 'No validation issues were found. Confirm to create the pipeline and start replication to the destination.'}
|
||||
? 'Replication can start, but the warnings listed above may affect how some changes are applied downstream.'
|
||||
: 'No validation issues were found. Create the pipeline and start replication to the destination.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
+4
-4
@@ -124,7 +124,7 @@ export const DestinationPanel = () => {
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="Replication unavailable locally"
|
||||
description="Configure the replication API to manage Pipelines destinations in local development."
|
||||
description="Configure the replication API to manage pipelines in local development."
|
||||
/>
|
||||
</SheetSection>
|
||||
)}
|
||||
@@ -138,11 +138,11 @@ export const DestinationPanel = () => {
|
||||
<div className="flex flex-col h-full min-h-0" tabIndex={-1}>
|
||||
<SheetHeader className="flex items-center justify-between">
|
||||
<div>
|
||||
<SheetTitle>{editMode ? 'Edit destination' : 'Add destination'}</SheetTitle>
|
||||
<SheetTitle>{editMode ? 'Edit pipeline' : 'Add pipeline'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editMode
|
||||
? 'Update the configuration for this destination.'
|
||||
: 'Connect an external destination for analytics workloads.'}
|
||||
? 'Update how this pipeline sends data to its destination.'
|
||||
: 'Send tables to an external destination for analytics workloads.'}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<DocsButton
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { HttpResponse } from 'msw'
|
||||
@@ -7,6 +8,7 @@ import { describe, expect, test, vi } from 'vitest'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
||||
import { routerMock } from '@/tests/lib/route-mock'
|
||||
|
||||
type ReplicationPipelinesResponse = components['schemas']['PipelinesResponse_Output']
|
||||
type ReplicationDestinationResponse = components['schemas']['DestinationResponse_Output']
|
||||
@@ -148,6 +150,40 @@ const addVersionMock = () =>
|
||||
})
|
||||
|
||||
describe('DestinationRow', () => {
|
||||
const addAllMocks = () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
addPipelineStatusMock('started')
|
||||
addReplicationStatusMock(0)
|
||||
addVersionMock()
|
||||
}
|
||||
|
||||
test('navigates to the pipeline when the row is clicked', async () => {
|
||||
addAllMocks()
|
||||
routerMock.setCurrentUrl('/project/default/database/replication')
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
const row = (await screen.findByText('supabase_realtime')).closest('tr')
|
||||
expect(row).not.toBeNull()
|
||||
await userEvent.click(row!)
|
||||
|
||||
expect(routerMock.asPath).toBe(`/project/default/database/replication/${PIPELINE_ID}`)
|
||||
})
|
||||
|
||||
test('does not navigate when the row overflow menu is opened', async () => {
|
||||
addAllMocks()
|
||||
routerMock.setCurrentUrl('/project/default/database/replication')
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
await screen.findByText('supabase_realtime')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
|
||||
|
||||
expect(routerMock.asPath).toBe('/project/default/database/replication')
|
||||
})
|
||||
|
||||
test('shows "Caught up" when confirmed_flush_lsn_bytes is 0', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
@@ -158,7 +194,9 @@ describe('DestinationRow', () => {
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Caught up')).toBeInTheDocument()
|
||||
const lag = await screen.findByText('Caught up')
|
||||
expect(lag).toHaveClass('text-foreground-light')
|
||||
expect(lag.closest('[aria-live="polite"]')).toHaveAttribute('aria-atomic', 'true')
|
||||
})
|
||||
|
||||
test('shows formatted lag value when confirmed_flush_lsn_bytes is non-zero', async () => {
|
||||
@@ -171,7 +209,123 @@ describe('DestinationRow', () => {
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('2 KB')).toBeInTheDocument()
|
||||
expect(await screen.findByText('2 KB')).toHaveClass('text-foreground-light')
|
||||
})
|
||||
|
||||
test('shows initial sync with the same lag text treatment', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
addPipelineStatusMock('started')
|
||||
addReplicationStatusMock(0, [
|
||||
{
|
||||
id: 1,
|
||||
schema: 'public',
|
||||
name: 'orders',
|
||||
table_id: 1,
|
||||
table_name: 'public.orders',
|
||||
state: { name: 'copying_table' },
|
||||
},
|
||||
])
|
||||
addVersionMock()
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Initial sync')).toHaveClass('text-foreground-light')
|
||||
})
|
||||
|
||||
test('shows initial sync when apply lag is unavailable', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
addPipelineStatusMock('started')
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
|
||||
response: () =>
|
||||
HttpResponse.json<ReplicationPipelineReplicationStatusResponse>({
|
||||
pipeline_id: PIPELINE_ID,
|
||||
apply_lag: null,
|
||||
table_statuses: [
|
||||
{
|
||||
id: 1,
|
||||
schema: 'public',
|
||||
name: 'orders',
|
||||
table_id: 1,
|
||||
table_name: 'public.orders',
|
||||
state: { name: 'queued' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
addVersionMock()
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Initial sync')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Lag unavailable')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('announces lag loading and replaces it with the resolved value', async () => {
|
||||
let resolveReplicationStatus: (
|
||||
response: ReplicationPipelineReplicationStatusResponse
|
||||
) => void = () => {}
|
||||
const replicationStatusResponse = new Promise<ReplicationPipelineReplicationStatusResponse>(
|
||||
(resolve) => {
|
||||
resolveReplicationStatus = resolve
|
||||
}
|
||||
)
|
||||
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
addPipelineStatusMock('started')
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
|
||||
response: async () =>
|
||||
HttpResponse.json<ReplicationPipelineReplicationStatusResponse>(
|
||||
await replicationStatusResponse
|
||||
),
|
||||
})
|
||||
addVersionMock()
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Loading lag')).toBeInTheDocument()
|
||||
|
||||
resolveReplicationStatus({
|
||||
pipeline_id: PIPELINE_ID,
|
||||
apply_lag: {
|
||||
active: true,
|
||||
wal_status: 'reserved',
|
||||
restart_lsn_bytes: 0,
|
||||
confirmed_flush_lsn_bytes: 0,
|
||||
safe_wal_size_bytes: null,
|
||||
},
|
||||
table_statuses: [],
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Caught up')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading lag')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('announces when lag is unavailable', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
addPipelineStatusMock('started')
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
|
||||
response: () =>
|
||||
HttpResponse.json<APIErrorBody>({ message: 'Pipeline unavailable' }, { status: 500 }),
|
||||
})
|
||||
addVersionMock()
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Lag unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows publication name from pipeline config', async () => {
|
||||
@@ -197,7 +351,11 @@ describe('DestinationRow', () => {
|
||||
|
||||
customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
expect(await screen.findByText('Running')).toBeInTheDocument()
|
||||
const status = await screen.findByText('Running')
|
||||
const liveRegion = status.closest('[aria-live="polite"]')
|
||||
|
||||
expect(status).toBeInTheDocument()
|
||||
expect(liveRegion).toHaveAttribute('aria-atomic', 'true')
|
||||
})
|
||||
|
||||
test('shows Failed badge when pipeline has failed', async () => {
|
||||
@@ -226,7 +384,7 @@ describe('DestinationRow', () => {
|
||||
expect(await screen.findByText('Stopped')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows warning icon when tables have replication errors', async () => {
|
||||
test('shows table errors in the subtext and on the logo when tables have replication errors', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
@@ -245,13 +403,12 @@ describe('DestinationRow', () => {
|
||||
|
||||
const { container } = customRender(<DestinationRow destinationId={DESTINATION_ID} />)
|
||||
|
||||
await screen.findByText('Caught up')
|
||||
|
||||
// WarningIcon renders with bg-warning-600 (from packages/ui/src/components/StatusIcon.tsx)
|
||||
expect(container.querySelector('.bg-warning-600')).toBeInTheDocument()
|
||||
expect(await screen.findByText('1 table error')).toBeInTheDocument()
|
||||
// StatusIcon destructive on the logo corner (packages/ui/src/components/StatusIcon.tsx)
|
||||
expect(container.querySelector('.bg-destructive-600')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('suppresses warning icon when pipeline is stopped even with table errors', async () => {
|
||||
test('suppresses table error signals when pipeline is stopped even with table errors', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
addPipelinesMock()
|
||||
@@ -272,9 +429,9 @@ describe('DestinationRow', () => {
|
||||
|
||||
await screen.findByText('Stopped')
|
||||
|
||||
expect(container.querySelector('.bg-warning-600')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/table error/)).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-destructive-600')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows error when the pipelines API fails', async () => {
|
||||
addSourcesMock()
|
||||
addDestinationMock()
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { useParams } from 'common'
|
||||
import { Minus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ChevronRight, Minus } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { TableCell, TableRow } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { DeleteDestination } from './DeleteDestination'
|
||||
import { DestinationLogo } from './DestinationLogo'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { DetailSubtext } from './DetailSubtext'
|
||||
import { PipelineStatePill } from './PipelineStatePill'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { getFormattedLagValue } from './ReplicationPipelineStatus/ReplicationPipelineStatus.utils'
|
||||
import {
|
||||
getFormattedLagValue,
|
||||
getInitialSyncProgress,
|
||||
} from './ReplicationPipelineStatus/ReplicationPipelineStatus.utils'
|
||||
import { RowMenu } from './RowMenu'
|
||||
import { UpdateVersionModal } from './UpdateVersionModal'
|
||||
import { useDestinationInformation } from './useDestinationInformation'
|
||||
@@ -28,6 +24,7 @@ import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication
|
||||
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,
|
||||
@@ -39,6 +36,7 @@ interface DestinationRowProps {
|
||||
}
|
||||
|
||||
export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -93,6 +91,10 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
// 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
|
||||
// Only show errors when pipeline is running (not when stopped or restarting)
|
||||
const isPipelineStopped = statusName === PipelineStatusName.STOPPED
|
||||
@@ -106,6 +108,10 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
})
|
||||
const hasUpdate = Boolean(versionData?.new_version)
|
||||
|
||||
const handleNavigation = pipeline
|
||||
? createNavigationHandler(`/project/${projectRef}/database/replication/${pipeline.id}`, router)
|
||||
: undefined
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
if (!projectRef) {
|
||||
return console.error('Project ref is required')
|
||||
@@ -124,9 +130,9 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
})
|
||||
// Close dialog after successful deletion
|
||||
setShowDeleteDestinationForm(false)
|
||||
toast.success(`Deleted destination "${destinationName}"`)
|
||||
toast.success(`Deleted pipeline "${destinationName}"`)
|
||||
} catch (error) {
|
||||
toast.error(`Failed to delete destination: ${(error as ResponseError).message}`)
|
||||
toast.error(`Failed to delete pipeline: ${(error as ResponseError).message}`)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
@@ -138,6 +144,24 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
}
|
||||
}, [pipeline?.id, statusName, updatePipelineStatus])
|
||||
|
||||
// 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 && (
|
||||
@@ -147,9 +171,17 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{isPipelineSuccess && (
|
||||
<TableRow>
|
||||
<TableCell>{type ? <DestinationLogo type={type} /> : null}</TableCell>
|
||||
{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 ? (
|
||||
@@ -159,11 +191,19 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
<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>
|
||||
<DetailSubtext className="flex items-center gap-x-1.5">
|
||||
<span>#{pipeline?.id}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{type}</span>
|
||||
</div>
|
||||
{hasTableErrors && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="text-destructive">
|
||||
{errorCount} table error{errorCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DetailSubtext>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -172,30 +212,24 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
{isPipelineLoading || !pipeline ? (
|
||||
<ShimmeringLoader />
|
||||
) : (
|
||||
<PipelineStatus
|
||||
<PipelineStatePill
|
||||
pipelineStatus={pipelineStatus?.status}
|
||||
error={pipelineStatusError}
|
||||
isLoading={isPipelineStatusLoading}
|
||||
isError={isPipelineStatusError}
|
||||
isSuccess={isPipelineStatusSuccess}
|
||||
requestStatus={requestStatus}
|
||||
projectRef={projectRef}
|
||||
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>
|
||||
)}
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{isReplicationStatusLoading && <span className="sr-only">Loading lag</span>}
|
||||
{renderLag()}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
@@ -208,32 +242,32 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { MessageSquare, MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
|
||||
import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableHeadSort,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
@@ -29,7 +30,8 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { DisablePipelinesDialog } from './DisablePipelinesDialog'
|
||||
import { EnablePipelinesModal } from './EnablePipelinesCallout'
|
||||
import { PIPELINES_FEEDBACK_URL } from './Replication.constants'
|
||||
import { getStatusName } from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
import {
|
||||
useIsETLBigQueryPrivateAlpha,
|
||||
useIsETLClickHousePrivateAlpha,
|
||||
@@ -39,21 +41,58 @@ import {
|
||||
} from './useIsETLPrivateAlpha'
|
||||
import { useRedirectLegacyReadReplicaDestination } from './useRedirectLegacyReadReplicaDestination'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query'
|
||||
import { replicationKeys } from '@/data/replication/keys'
|
||||
import {
|
||||
replicationPipelineStatusQueryOptions,
|
||||
type ReplicationPipelineStatusData,
|
||||
} from '@/data/replication/pipeline-status-query'
|
||||
import { fetchReplicationPipelineVersion } from '@/data/replication/pipeline-version-query'
|
||||
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
|
||||
import { useReplicationSourcesQuery } from '@/data/replication/sources-query'
|
||||
import { checkLocalETLNotSetUp } from '@/data/replication/utils'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
type DestinationSortColumn = 'name' | 'status'
|
||||
type DestinationSort = `${DestinationSortColumn}:${'asc' | 'desc'}`
|
||||
|
||||
// Worst first, so sorting ascending by status surfaces the pipelines that need attention.
|
||||
const STATUS_SORT_ORDER: PipelineStatusName[] = [
|
||||
PipelineStatusName.FAILED,
|
||||
PipelineStatusName.STOPPED,
|
||||
PipelineStatusName.STOPPING,
|
||||
PipelineStatusName.STARTING,
|
||||
PipelineStatusName.STARTED,
|
||||
PipelineStatusName.UNKNOWN,
|
||||
]
|
||||
|
||||
// Keyed by pipeline id from the responses themselves, so this never closes over component state.
|
||||
const combinePipelineStatuses = (
|
||||
results: { data?: ReplicationPipelineStatusData }[]
|
||||
): Map<number, PipelineStatusName | undefined> =>
|
||||
new Map(
|
||||
results
|
||||
.map((result) => result.data)
|
||||
.filter((data): data is ReplicationPipelineStatusData => data !== undefined)
|
||||
.map((data) => [data.pipeline_id, getStatusName(data.status)])
|
||||
)
|
||||
|
||||
const compareStatusNames = (
|
||||
a: PipelineStatusName | undefined,
|
||||
b: PipelineStatusName | undefined,
|
||||
direction: 'asc' | 'desc'
|
||||
) => {
|
||||
if (a === undefined) return b === undefined ? 0 : 1
|
||||
if (b === undefined) return -1
|
||||
|
||||
const comparison = STATUS_SORT_ORDER.indexOf(a) - STATUS_SORT_ORDER.indexOf(b)
|
||||
return direction === 'asc' ? comparison : -comparison
|
||||
}
|
||||
|
||||
export const Destinations = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -108,19 +147,68 @@ export const Destinations = () => {
|
||||
} = useReplicationDestinationsQuery({
|
||||
projectRef,
|
||||
})
|
||||
const destinations = destinationsData?.destinations ?? []
|
||||
const destinations = useMemo(
|
||||
() => destinationsData?.destinations ?? [],
|
||||
[destinationsData?.destinations]
|
||||
)
|
||||
const hasDestinations = isDestinationsSuccess && destinationsData?.destinations.length > 0
|
||||
const filteredDestinations =
|
||||
filterString.length === 0
|
||||
? (destinations ?? [])
|
||||
: (destinations ?? []).filter((destination) =>
|
||||
destination.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
)
|
||||
const filteredDestinations = useMemo(
|
||||
() =>
|
||||
filterString.length === 0
|
||||
? destinations
|
||||
: destinations.filter((destination) =>
|
||||
destination.name.toLowerCase().includes(filterString.toLowerCase())
|
||||
),
|
||||
[destinations, filterString]
|
||||
)
|
||||
|
||||
const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({
|
||||
projectRef,
|
||||
})
|
||||
const pipelines = pipelinesData?.pipelines ?? []
|
||||
const pipelines = useMemo(() => pipelinesData?.pipelines ?? [], [pipelinesData?.pipelines])
|
||||
|
||||
// Sorting by status needs every pipeline's status up here, not just inside each row. These share
|
||||
// the rows' query keys, so each status is still only fetched once.
|
||||
const statusByPipelineId = useQueries({
|
||||
queries: pipelines.map((pipeline) =>
|
||||
replicationPipelineStatusQueryOptions({ projectRef, pipelineId: pipeline.id })
|
||||
),
|
||||
combine: combinePipelineStatuses,
|
||||
})
|
||||
|
||||
const getDestinationStatus = (destinationId: number) => {
|
||||
const pipeline = pipelines.find((p) => p.destination_id === destinationId)
|
||||
return pipeline === undefined ? undefined : statusByPipelineId.get(pipeline.id)
|
||||
}
|
||||
|
||||
const [sort, setSort] = useState<DestinationSort>('name:asc')
|
||||
const [sortColumn, sortDirection] = sort.split(':') as [DestinationSortColumn, 'asc' | 'desc']
|
||||
|
||||
const getAriaSort = (column: DestinationSortColumn) => {
|
||||
if (sortColumn !== column) return 'none'
|
||||
return sortDirection === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
const handleSortChange = (column: DestinationSortColumn) => {
|
||||
if (sortColumn !== column) return setSort(`${column}:asc`)
|
||||
setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`)
|
||||
}
|
||||
|
||||
// Not memoized: the status map is rebuilt whenever a pipeline status refetches, so a useMemo
|
||||
// here would never hit. Sorting a handful of destinations per render costs nothing.
|
||||
const sortedDestinations = [...filteredDestinations].sort((a, b) => {
|
||||
if (sortColumn === 'status') {
|
||||
const nameComparison = a.name.localeCompare(b.name)
|
||||
|
||||
return (
|
||||
compareStatusNames(getDestinationStatus(a.id), getDestinationStatus(b.id), sortDirection) ||
|
||||
(sortDirection === 'asc' ? nameComparison : -nameComparison)
|
||||
)
|
||||
}
|
||||
|
||||
const comparison = a.name.localeCompare(b.name)
|
||||
return sortDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
const { data: sourcesData, isSuccess: isSourcesSuccess } = useReplicationSourcesQuery({
|
||||
projectRef,
|
||||
@@ -153,7 +241,7 @@ export const Destinations = () => {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
},
|
||||
{ label: 'Search destinations' }
|
||||
{ label: 'Search pipelines' }
|
||||
)
|
||||
|
||||
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setFilterString(''))
|
||||
@@ -185,7 +273,7 @@ export const Destinations = () => {
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Filter destinations"
|
||||
placeholder="Search pipelines"
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
@@ -195,7 +283,7 @@ export const Destinations = () => {
|
||||
actions={
|
||||
filterString.length > 0 && (
|
||||
<Button
|
||||
aria-label="Clear filter"
|
||||
aria-label="Clear search"
|
||||
variant="text"
|
||||
icon={<X />}
|
||||
className="p-0 h-5 w-5"
|
||||
@@ -208,9 +296,14 @@ export const Destinations = () => {
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label="More actions" icon={<MoreVertical />} className="px-1" />
|
||||
<Button
|
||||
aria-label="More actions"
|
||||
variant="default"
|
||||
icon={<MoreVertical />}
|
||||
className="px-1.25"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/org/${organization?.slug}/usage#pipeline-initial-sync-data`}>
|
||||
View Pipelines usage
|
||||
@@ -222,32 +315,28 @@ export const Destinations = () => {
|
||||
Enable Pipelines
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItemTooltip
|
||||
<DropdownMenuItem
|
||||
className="data-disabled:pointer-events-auto data-disabled:cursor-not-allowed"
|
||||
disabled={!canDisablePipelines}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'Remove all existing destinations before disabling Pipelines',
|
||||
},
|
||||
onClick={() => {
|
||||
if (!canDisablePipelines) return
|
||||
setShowDisablePipelinesDialog(true)
|
||||
}}
|
||||
onClick={() => setShowDisablePipelinesDialog(true)}
|
||||
>
|
||||
Disable Pipelines
|
||||
</DropdownMenuItemTooltip>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<p>Disable Pipelines</p>
|
||||
{!canDisablePipelines && (
|
||||
<p className="text-foreground-lighter">Delete all pipelines first</p>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button asChild icon={<MessageSquare />}>
|
||||
<a href={PIPELINES_FEEDBACK_URL} target="_blank" rel="noreferrer noopener">
|
||||
Leave feedback
|
||||
</a>
|
||||
</Button>
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />
|
||||
|
||||
<Shortcut
|
||||
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
||||
label="Add destination"
|
||||
label="Add pipeline"
|
||||
onTrigger={openDestinationPanel}
|
||||
options={{ enabled: !!newDestinationDefaultType }}
|
||||
side="bottom"
|
||||
@@ -258,31 +347,48 @@ export const Destinations = () => {
|
||||
disabled={!newDestinationDefaultType}
|
||||
onClick={openDestinationPanel}
|
||||
>
|
||||
Add destination
|
||||
Add pipeline
|
||||
</Button>
|
||||
</Shortcut>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto flex flex-col gap-y-4">
|
||||
{/* Mounted whether or not it has anything to say, so the update is announced */}
|
||||
<p role="status" aria-live="polite" className="sr-only">
|
||||
{isDestinationsLoading ? 'Loading pipelines' : ''}
|
||||
</p>
|
||||
|
||||
{hasErrorsFetchingData && (
|
||||
<AlertError error={destinationsError} subject="Failed to retrieve destinations" />
|
||||
<AlertError error={destinationsError} subject="Failed to retrieve pipelines" />
|
||||
)}
|
||||
|
||||
{isDestinationsLoading ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : hasDestinations ? (
|
||||
{isDestinationsLoading && <GenericSkeletonLoader />}
|
||||
|
||||
{!isDestinationsLoading && hasDestinations && (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="type" className="w-[40px]" />
|
||||
<TableHead key="name" className="w-[250px]">
|
||||
Name
|
||||
<TableHead key="name" className="w-[250px]" aria-sort={getAriaSort('name')}>
|
||||
<TableHeadSort
|
||||
column="name"
|
||||
currentSort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
Name
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead key="status" className="w-[150px]">
|
||||
Status
|
||||
<TableHead key="status" className="w-[150px]" aria-sort={getAriaSort('status')}>
|
||||
<TableHeadSort
|
||||
column="status"
|
||||
currentSort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
>
|
||||
Status
|
||||
</TableHeadSort>
|
||||
</TableHead>
|
||||
<TableHead key="lag" className="w-[150px]">
|
||||
Lag
|
||||
@@ -292,7 +398,7 @@ export const Destinations = () => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDestinations.map((destination) => (
|
||||
{sortedDestinations.map((destination) => (
|
||||
<DestinationRow key={destination.id} destinationId={destination.id} />
|
||||
))}
|
||||
|
||||
@@ -303,7 +409,7 @@ export const Destinations = () => {
|
||||
<TableCell colSpan={6}>
|
||||
<p>No results found</p>
|
||||
<p className="text-foreground-light">
|
||||
Your search for "{filterString}" did not return any results.
|
||||
Your search for “{filterString}” did not return any results.
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -312,23 +418,23 @@ export const Destinations = () => {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
!isDestinationsLoading &&
|
||||
!hasErrorsFetchingData && (
|
||||
<EmptyStatePresentational
|
||||
icon={Workflow}
|
||||
title="Add a destination"
|
||||
description="Connect an external destination for analytics workloads."
|
||||
)}
|
||||
|
||||
{!isDestinationsLoading && !hasDestinations && !hasErrorsFetchingData && (
|
||||
<EmptyStatePresentational
|
||||
icon={Workflow}
|
||||
title="Add a pipeline"
|
||||
description="Send tables to an external destination for analytics workloads."
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Plus />}
|
||||
disabled={!newDestinationDefaultType}
|
||||
onClick={openDestinationPanel}
|
||||
>
|
||||
<Button
|
||||
icon={<Plus />}
|
||||
disabled={!newDestinationDefaultType}
|
||||
onClick={openDestinationPanel}
|
||||
>
|
||||
Add destination
|
||||
</Button>
|
||||
</EmptyStatePresentational>
|
||||
)
|
||||
Add pipeline
|
||||
</Button>
|
||||
</EmptyStatePresentational>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
/**
|
||||
* The muted line that sits under a primary value: a destination's dataset, a region's AWS code, a
|
||||
* pipeline's id and type. One place so the treatment stays identical across the list and the
|
||||
* pipeline page.
|
||||
*/
|
||||
export const DetailSubtext = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) => <p className={cn('text-xs text-foreground-lighter text-balance', className)}>{children}</p>
|
||||
|
||||
/**
|
||||
* Marks text whose full meaning lives in a tooltip, so it doesn't look like inert copy. Use on the
|
||||
* trigger itself. Values that carry their explanation in an InfoTooltip don't need it, because the
|
||||
* icon is already the affordance.
|
||||
*/
|
||||
export const TOOLTIP_UNDERLINE_CLASS_NAME =
|
||||
'underline decoration-dotted decoration-foreground-muted/50 hover:decoration-foreground-muted transition-colors underline-offset-4'
|
||||
@@ -55,13 +55,9 @@ export const DisablePipelinesDialog = ({ open, setOpen }: DisablePipelinesDialog
|
||||
<AlertDialogContent size="small">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disable Pipelines</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-y-2 text-sm">
|
||||
<span>
|
||||
This will remove the <code className="text-code-inline">etl</code> schema and all
|
||||
Pipelines-managed resources from your database. Data already written to destination
|
||||
systems is not deleted.
|
||||
</span>
|
||||
<span>Read replicas are not affected.</span>
|
||||
<AlertDialogDescription>
|
||||
This removes the etl schema and all Pipelines-managed resources from your database. Data
|
||||
already at destinations is kept. Read replicas are not affected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && (
|
||||
@@ -76,7 +72,7 @@ export const DisablePipelinesDialog = ({ open, setOpen }: DisablePipelinesDialog
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="danger" loading={isSubmitting} onClick={onConfirm}>
|
||||
Disable
|
||||
Disable Pipelines
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
cn,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
@@ -65,41 +65,33 @@ export const EnablePipelinesModal = ({
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogContent size="small">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Pipelines</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasAccess
|
||||
? 'Enabling creates Pipelines resources for this project.'
|
||||
: 'Upgrade to the Pro plan to replicate database changes to data warehouses and analytics platforms.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-y-2 p-0!">
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-0"
|
||||
title="Pipelines is currently in public alpha"
|
||||
>
|
||||
{hasAccess ? (
|
||||
<>
|
||||
<p className="text-sm leading-normal!">
|
||||
Public alpha features may change as we refine the product and incorporate customer
|
||||
feedback.
|
||||
</p>
|
||||
<p className="text-sm leading-normal!">
|
||||
Pipelines is billed for configured pipeline hours and Postgres row data processed
|
||||
during initial sync and ongoing replication. Review the{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/platform/manage-your-usage/pipelines`}>
|
||||
Pipelines pricing
|
||||
</InlineLink>{' '}
|
||||
before enabling it.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
{hasAccess && (
|
||||
<>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Supabase Pipelines replicates database changes to supported destination systems.{' '}
|
||||
{hasAccess ? 'Enable Pipelines for your project' : 'Upgrade to the Pro plan'} to
|
||||
replicate database changes to data warehouses and analytics platforms.
|
||||
Pipelines is in public alpha and may change as we refine it.
|
||||
</p>
|
||||
)}
|
||||
</Admonition>
|
||||
</DialogSection>
|
||||
<p className="text-sm text-foreground-light">
|
||||
You’ll be billed for configured pipeline hours and for Postgres row data processed
|
||||
during initial sync and ongoing replication. Review{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/platform/manage-your-usage/pipelines`}>
|
||||
Pipelines pricing
|
||||
</InlineLink>{' '}
|
||||
before enabling.
|
||||
</p>
|
||||
</DialogSection>
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button disabled={creatingTenantSource} onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -67,7 +67,7 @@ const PIPELINE_DISPLAY_STATES: Record<PipelineDisplayStateKey, PipelineDisplaySt
|
||||
key: 'restarting',
|
||||
label: 'Restarting',
|
||||
title: 'Restarting pipeline',
|
||||
message: 'Applying settings and restarting the pipeline.',
|
||||
message: 'Applying settings and restarting the pipeline',
|
||||
badge: 'Restarting',
|
||||
type: 'loading',
|
||||
},
|
||||
@@ -75,7 +75,7 @@ const PIPELINE_DISPLAY_STATES: Record<PipelineDisplayStateKey, PipelineDisplaySt
|
||||
key: 'failed',
|
||||
label: 'Failed',
|
||||
title: 'Pipeline failed',
|
||||
message: 'Replication has encountered an error.',
|
||||
message: 'Replication has encountered an error',
|
||||
badge: 'Failed',
|
||||
type: 'failure',
|
||||
},
|
||||
@@ -91,7 +91,7 @@ const PIPELINE_DISPLAY_STATES: Record<PipelineDisplayStateKey, PipelineDisplaySt
|
||||
key: 'running',
|
||||
label: 'Running',
|
||||
title: 'Pipeline running',
|
||||
message: 'Replication is active and processing changes.',
|
||||
message: 'Replication is active and processing changes',
|
||||
badge: 'Running',
|
||||
type: 'success',
|
||||
},
|
||||
@@ -99,7 +99,7 @@ const PIPELINE_DISPLAY_STATES: Record<PipelineDisplayStateKey, PipelineDisplaySt
|
||||
key: 'unknown',
|
||||
label: 'Unknown',
|
||||
title: 'Pipeline status unknown',
|
||||
message: 'Unable to determine pipeline status.',
|
||||
message: 'Unable to determine pipeline status',
|
||||
badge: 'Unknown',
|
||||
type: 'idle',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { TOOLTIP_UNDERLINE_CLASS_NAME } from './DetailSubtext'
|
||||
import { getPipelineDisplayState, getStatusName } from './Pipeline.utils'
|
||||
import type { PipelineDisplayType } from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
import { StateDot, type StateDotVariant } from './StateDot'
|
||||
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
|
||||
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
const VARIANT_BY_TYPE: Record<PipelineDisplayType, StateDotVariant> = {
|
||||
success: 'success',
|
||||
failure: 'destructive',
|
||||
loading: 'warning',
|
||||
idle: 'default',
|
||||
}
|
||||
|
||||
interface PipelineStatePillProps {
|
||||
pipelineStatus: ReplicationPipelineStatusData['status'] | undefined
|
||||
error: ResponseError | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
requestStatus?: PipelineStatusRequestStatus
|
||||
projectRef?: string
|
||||
pipelineId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a pipeline as a dot and label. The label carries a dotted underline because its
|
||||
* explanation lives in the tooltip. Tooltips stay plain text (no links).
|
||||
*/
|
||||
export const PipelineStatePill = ({
|
||||
pipelineStatus,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
requestStatus,
|
||||
}: PipelineStatePillProps) => {
|
||||
const statusName = getStatusName(pipelineStatus)
|
||||
const { type, message, label } = getPipelineDisplayState(requestStatus, statusName)
|
||||
|
||||
const showLogsHint =
|
||||
isSuccess &&
|
||||
[PipelineStatusName.UNKNOWN, PipelineStatusName.FAILED].includes(
|
||||
statusName as PipelineStatusName
|
||||
)
|
||||
|
||||
return (
|
||||
<span className="inline-flex" aria-live="polite" aria-atomic="true">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="sr-only">Loading pipeline status</span>
|
||||
<ShimmeringLoader className="w-20" />
|
||||
</>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<StateDot
|
||||
tabIndex={0}
|
||||
variant={isError ? 'default' : VARIANT_BY_TYPE[type]}
|
||||
isPulsing={!isError && type === 'loading'}
|
||||
labelClassName={cn('text-foreground-light', TOOLTIP_UNDERLINE_CLASS_NAME)}
|
||||
>
|
||||
{isError ? 'Unknown' : label}
|
||||
</StateDot>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{isError
|
||||
? `Unable to retrieve status: ${error?.message}`
|
||||
: showLogsHint
|
||||
? `${message}. Check the logs for more information.`
|
||||
: message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+21
@@ -124,6 +124,27 @@ const formatLagDurationValue = (value?: number) => {
|
||||
export const getFormattedLagValue = (type: 'bytes' | 'duration', value?: number) =>
|
||||
type === 'bytes' ? formatLagBytesValue(value) : formatLagDurationValue(value)
|
||||
|
||||
const COPYING_STATES: TableState['state']['name'][] = ['queued', 'copying_table', 'copied_table']
|
||||
|
||||
/**
|
||||
* How much of the initial copy is left. A pipeline can be caught up on its ongoing change stream
|
||||
* while tables are still copying, so several surfaces need to know this to stay honest.
|
||||
*/
|
||||
export const getInitialSyncProgress = (
|
||||
tableStatuses: { state: { name: TableState['state']['name'] } }[]
|
||||
) => {
|
||||
const count = (name: TableState['state']['name']) =>
|
||||
tableStatuses.filter((table) => table.state.name === name).length
|
||||
|
||||
return {
|
||||
// Everything not yet streaming, whatever stage of the initial sync it is at
|
||||
syncingCount: tableStatuses.filter((table) => COPYING_STATES.includes(table.state.name)).length,
|
||||
copyingCount: count('copying_table'),
|
||||
queuedCount: count('queued'),
|
||||
totalCount: tableStatuses.length,
|
||||
}
|
||||
}
|
||||
|
||||
export type LagSeverity = 'normal' | 'warning' | 'critical'
|
||||
|
||||
type SlotStatusBadgeVariant = 'success' | 'warning' | 'destructive' | 'default'
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useParams } from 'common'
|
||||
import { ArrowUpCircle, Edit, MoreVertical, Pause, Play, RotateCcw, Trash } from 'lucide-react'
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CircleStop,
|
||||
Edit,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Trash,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsInteger, useQueryState } from 'nuqs'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -153,14 +163,29 @@ export const RowMenu = ({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="relative">
|
||||
<Button className="px-1.5" icon={<MoreVertical />} />
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-1.25 hit-area-2"
|
||||
aria-label={hasUpdate ? 'Pipeline options, update available' : 'Pipeline options'}
|
||||
icon={<MoreVertical />}
|
||||
/>
|
||||
{hasUpdate && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 bg-brand rounded-full" />
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 h-2 w-2 bg-brand rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
<DropdownMenuItem className="space-x-2" asChild disabled={!pipeline}>
|
||||
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
|
||||
<Eye size={14} />
|
||||
<p>View details</p>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{hasUpdate && (
|
||||
<>
|
||||
<DropdownMenuItem className="space-x-2" onClick={() => onUpdateClick?.()}>
|
||||
@@ -186,7 +211,7 @@ export const RowMenu = ({
|
||||
<p>Restart pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}>
|
||||
<Pause size={14} />
|
||||
<CircleStop size={14} />
|
||||
<p>Stop pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -195,11 +220,11 @@ export const RowMenu = ({
|
||||
|
||||
<DropdownMenuItem className="space-x-2" onClick={() => setEdit(destinationId)}>
|
||||
<Edit size={14} />
|
||||
<p>Edit destination</p>
|
||||
<p>Edit pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
|
||||
<Trash size={14} />
|
||||
<p>Delete destination</p>
|
||||
<p>Delete pipeline</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export type StateDotVariant = 'success' | 'warning' | 'destructive' | 'default'
|
||||
|
||||
const DOT_CLASS_NAME: Record<StateDotVariant, string> = {
|
||||
success: 'bg-brand',
|
||||
warning: 'bg-warning',
|
||||
destructive: 'bg-destructive',
|
||||
default: 'bg-foreground-muted',
|
||||
}
|
||||
|
||||
interface StateDotProps extends ComponentPropsWithoutRef<'span'> {
|
||||
variant: StateDotVariant
|
||||
children: ReactNode
|
||||
/** Pulse for states that are in motion, such as starting or restarting. */
|
||||
isPulsing?: boolean
|
||||
/** Offsets `animate-ping` so neighbouring pulsing dots don't flash in lockstep. */
|
||||
pulseDelayMs?: number
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A coloured dot with a label: the one way a state is shown across Replication. Green means
|
||||
* streaming or healthy, red means broken, amber means in motion or at risk, grey means waiting.
|
||||
*
|
||||
* Forwards its ref and spreads props so it can be the child of a Radix `asChild` trigger.
|
||||
*/
|
||||
export const StateDot = forwardRef<HTMLSpanElement, StateDotProps>(
|
||||
(
|
||||
{ variant, children, isPulsing = false, pulseDelayMs = 0, className, labelClassName, ...props },
|
||||
ref
|
||||
) => (
|
||||
<span ref={ref} className={cn('inline-flex items-center gap-x-2', className)} {...props}>
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
{isPulsing && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping motion-reduce:animate-none rounded-full opacity-75',
|
||||
DOT_CLASS_NAME[variant]
|
||||
)}
|
||||
style={pulseDelayMs > 0 ? { animationDelay: `${pulseDelayMs}ms` } : undefined}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('relative inline-flex h-2 w-2 rounded-full', DOT_CLASS_NAME[variant])}
|
||||
/>
|
||||
</span>
|
||||
<span className={labelClassName}>{children}</span>
|
||||
</span>
|
||||
)
|
||||
)
|
||||
StateDot.displayName = 'StateDot'
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useParams } from 'common'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger, DialogSectionSeparator } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { getStatusName } from './Pipeline.utils'
|
||||
@@ -28,7 +26,7 @@ export const UpdateVersionModal = ({
|
||||
visible,
|
||||
pipeline,
|
||||
confirmLabel,
|
||||
confirmLabelLoading = 'Updating',
|
||||
confirmLabelLoading = 'Updating…',
|
||||
onClose,
|
||||
}: UpdateVersionModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -51,24 +49,22 @@ export const UpdateVersionModal = ({
|
||||
const currentVersionName = versionData?.version?.name
|
||||
const newVersionName = versionData?.new_version?.name
|
||||
|
||||
const { mutateAsync: updatePipelineVersion } = useUpdatePipelineVersionMutation()
|
||||
const { mutateAsync: updatePipelineVersion, isPending: isUpdating } =
|
||||
useUpdatePipelineVersionMutation()
|
||||
|
||||
const onConfirmUpdate = async () => {
|
||||
if (!projectRef || !pipeline?.id) return
|
||||
const versionId = versionData?.new_version?.id
|
||||
if (!versionId) return
|
||||
|
||||
// Step 1: Update to the new version
|
||||
try {
|
||||
await updatePipelineVersion({ projectRef, pipelineId: pipeline.id, versionId })
|
||||
} catch (e) {
|
||||
// 404: default changed; version cache will refresh via mutation onError. Keep dialog open.
|
||||
if ((e as ResponseError)?.code === 404) return
|
||||
// Other errors are already toasted by the mutation; do not double-toast here.
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Reflect optimistic restart status (the pipeline restarts automatically on the backend)
|
||||
if (!isStopped) {
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
|
||||
toast.success('Pipeline successfully updated and is currently restarting')
|
||||
@@ -79,52 +75,45 @@ export const UpdateVersionModal = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const resolvedConfirmLabel = confirmLabel ?? (isStopped ? 'Update version' : 'Update and restart')
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
size="small"
|
||||
variant={isStopped ? 'default' : 'warning'}
|
||||
visible={visible}
|
||||
title="Update pipeline image"
|
||||
className="p-0!"
|
||||
confirmLabel={confirmLabel ?? (isStopped ? 'Update image' : 'Update and restart')}
|
||||
title="Update available"
|
||||
confirmLabel={resolvedConfirmLabel}
|
||||
confirmLabelLoading={confirmLabelLoading}
|
||||
loading={isUpdating}
|
||||
onCancel={onClose}
|
||||
onConfirm={onConfirmUpdate}
|
||||
>
|
||||
<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?
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{isStopped
|
||||
? 'A newer pipeline version is available with improvements and bug fixes.'
|
||||
: 'A newer pipeline version is available with improvements and bug fixes. The pipeline will restart and continue from where it left off.'}
|
||||
</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 className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody aria-live="polite" aria-atomic="true">
|
||||
<tr className="border-b">
|
||||
<td className="px-3 py-2 text-foreground-lighter">Current</td>
|
||||
<td className="px-3 py-2 text-right text-foreground" translate="no">
|
||||
{isLoadingVersion ? 'Loading…' : (currentVersionName ?? 'Unknown')}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-foreground-lighter">New</td>
|
||||
<td className="px-3 py-2 text-right text-foreground" translate="no">
|
||||
{isLoadingVersion ? 'Loading…' : (newVersionName ?? 'Unknown')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<Collapsible className="px-5 py-3 group">
|
||||
<CollapsibleTrigger 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-open:-rotate-180 transition" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<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-code-inline">
|
||||
{isLoadingVersion ? 'Loading...' : (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-code-inline">
|
||||
{isLoadingVersion ? 'Loading...' : (newVersionName ?? 'Unknown')}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</ConfirmationModal>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { components } from 'api-types'
|
||||
|
||||
import { replicationKeys } from './keys'
|
||||
@@ -32,6 +32,20 @@ export type ReplicationPipelineStatusData = Awaited<
|
||||
ReturnType<typeof fetchReplicationPipelineStatus>
|
||||
>
|
||||
|
||||
/**
|
||||
* Shared definition so callers that need many pipeline statuses at once (`useQueries`) hit the
|
||||
* same cache entries as the per-pipeline hook below, rather than fetching each status twice.
|
||||
*/
|
||||
export const replicationPipelineStatusQueryOptions = ({
|
||||
projectRef,
|
||||
pipelineId,
|
||||
}: ReplicationPipelinesStatusParams) =>
|
||||
queryOptions<ReplicationPipelineStatusData, ResponseError>({
|
||||
queryKey: replicationKeys.pipelinesStatus(projectRef, pipelineId),
|
||||
queryFn: ({ signal }) => fetchReplicationPipelineStatus({ projectRef, pipelineId }, signal),
|
||||
enabled: typeof projectRef !== 'undefined' && typeof pipelineId !== 'undefined',
|
||||
})
|
||||
|
||||
export const useReplicationPipelineStatusQuery = <TData = ReplicationPipelineStatusData>(
|
||||
{ projectRef, pipelineId }: ReplicationPipelinesStatusParams,
|
||||
{
|
||||
|
||||
@@ -190,8 +190,9 @@ describe('createNavigationHandler', () => {
|
||||
|
||||
handler(event)
|
||||
|
||||
// Right click should trigger router.push (falls through to default case)
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/test-url')
|
||||
// Right click opens the context menu, so it must not navigate.
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ export const createNavigationHandler = (url: string, router: Router) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle regular left click
|
||||
// Regular left click only. Other aux buttons (e.g. right-click) must not navigate.
|
||||
if ('button' in event && event.button !== 0) return
|
||||
|
||||
router.push(url)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderAside,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
@@ -11,14 +14,17 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ReadReplicasMovedCallout } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout'
|
||||
import { Destinations } from '@/components/interfaces/Database/Replication/Destinations'
|
||||
import { PIPELINES_FEEDBACK_URL } from '@/components/interfaces/Database/Replication/Replication.constants'
|
||||
import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram'
|
||||
import { InstanceConfiguration } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration'
|
||||
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { UnknownInterface } from '@/components/ui/UnknownInterface'
|
||||
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -64,6 +70,15 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
|
||||
<PageHeaderTitle>Replication</PageHeaderTitle>
|
||||
<PageHeaderDescription>Send data to external destinations</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
|
||||
<PageHeaderAside>
|
||||
<Button asChild variant="default" icon={<MessageSquare />}>
|
||||
<a href={PIPELINES_FEEDBACK_URL} target="_blank" rel="noreferrer noopener">
|
||||
Leave feedback
|
||||
</a>
|
||||
</Button>
|
||||
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />
|
||||
</PageHeaderAside>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
Reference in new issue
Block a user