feat(studio): rebuild the pipeline overview (#49630)

## What kind of change does this PR introduce?

Studio UI improvement.

## What is the current behavior?

The pipeline Overview uses bespoke loading, metrics, table-state and
empty-state layouts that shift while data resolves and repeat status
information from the detail header.

## What is the new behavior?

Rebuilds the Overview around stable **Pipeline health** and **Replicated
tables** sections. It adds layout-matched loading geometry, prioritised
pipeline notices, initial-sync progress, clearer empty states, and
accessible loading announcements. Complete pipeline configuration
remains deferred to #49631.

| Before | After |
| --- | --- |
| <img width="1024" height="759" alt="54861"
src="https://github.com/user-attachments/assets/56e5cc5a-5d49-44c8-94d7-e1f1e0c827d5"
/> | <img width="1024" height="759" alt="Replication Database Agua
Basket Supabase"
src="https://github.com/user-attachments/assets/43b6d0f6-6fd5-47f9-b3e5-788a33511304"
/> |

This is the final independent slice in the review series: #50443,
#50444, #50445, #50446, then this PR. Each PR targets `master` and can
merge on its own. Rebase this PR as earlier slices merge.

## To test

1. Open `/project/<ref>/database/replication` and select a pipeline.
2. Throttle the initial requests and confirm **Pipeline health** and
**Replicated tables** keep their final geometry while loading.
3. Check running, initial-sync, stopped, failed, disconnected and
unavailable states.
4. Confirm the Overview contains Pipeline health and Replicated tables
only, without a Configuration section.

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

- **New Features**
- Reorganized replication pipeline status into Pipeline health and
Replicated tables sections.
  - Added loading skeletons with accessible status announcements.
- Added clearer notices for pipeline health, failed or disconnected
pipelines, paused updates, lag, and synchronization progress.
- Improved empty states when table data is unavailable or the pipeline
is inactive.
  - Added options to view logs and reset failed tables.

- **Tests**
- Added coverage for loading behavior, pipeline notices, table counts,
synchronization progress, and empty states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White authored and GitHub committed 2026-09-17 16:31:04 +10:00
1 parent 0043e6f53b
commit 7ab3f32625
6 files changed
+630 -240

No files matched your search

@@ -0,0 +1,94 @@
import { describe, expect, test } from 'vitest'
import {
getInitialSyncSummary,
getPipelineStateNotice,
getTableStatusEmptyState,
} from './PipelineOverview.utils'
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
const disabledStateConfig = { title: 'Starting pipeline', message: 'This can take a moment.' }
const liveTables = (count: number) =>
Array.from({ length: count }, () => ({ state: { name: 'following_wal' as const } }))
describe('getTableStatusEmptyState', () => {
test.each([
[true, 'stopped' as const, 'Starting pipeline'],
[false, 'stopped' as const, 'Pipeline stopped'],
[false, 'failed' as const, 'Pipeline failed'],
[false, 'started' as const, 'No table data yet'],
])('returns the appropriate empty state', (isDisabled, statusName, title) => {
expect(getTableStatusEmptyState({ isDisabled, disabledStateConfig, statusName }).title).toBe(
title
)
})
})
describe('getPipelineStateNotice', () => {
test('omits a notice for a healthy running pipeline', () => {
expect(
getPipelineStateNotice({
requestStatus: PipelineStatusRequestStatus.None,
statusName: 'started',
tableStatuses: liveTables(3),
})
).toBeUndefined()
})
test.each([
['failed' as const, 'destructive', true],
['stopped' as const, 'note', false],
])('explains a %s pipeline', (statusName, type, showLogsLink) => {
expect(
getPipelineStateNotice({
requestStatus: PipelineStatusRequestStatus.None,
statusName,
tableStatuses: liveTables(3),
})
).toMatchObject({ type, showLogsLink })
})
test('reports a requested transition ahead of the API status', () => {
expect(
getPipelineStateNotice({
requestStatus: PipelineStatusRequestStatus.StartRequested,
statusName: 'stopped',
tableStatuses: liveTables(3),
})?.title
).toBe('Starting pipeline')
})
test('distinguishes copying tables from queued tables', () => {
expect(
getPipelineStateNotice({
requestStatus: PipelineStatusRequestStatus.None,
statusName: 'started',
tableStatuses: [
...liveTables(2),
{ state: { name: 'copying_table' as const } },
{ state: { name: 'queued' as const } },
],
})?.description
).toContain('1 of 4 tables is copying and 1 is waiting.')
})
})
describe('getInitialSyncSummary', () => {
test.each([
[4, 3, 8, '4 of 8 tables are copying and 3 are waiting.'],
[1, 1, 4, '1 of 4 tables is copying and 1 is waiting.'],
[2, 0, 8, '2 of 8 tables are copying.'],
[0, 3, 3, '3 tables are waiting to copy.'],
[1, 0, 1, '1 of 1 table is copying.'],
[0, 0, 2, 'The last tables are finishing their copy.'],
])('summarises initial sync progress', (copyingCount, queuedCount, totalCount, expected) => {
expect(
getInitialSyncSummary({
syncingCount: copyingCount + queuedCount,
copyingCount,
queuedCount,
totalCount,
})
).toBe(expected)
})
})
@@ -0,0 +1,126 @@
import { getPipelineDisplayState, normalizePipelineStatusName } from '../Pipeline.utils'
import { PipelineStatusName } from '../Replication.constants'
import { TableState } from './ReplicationPipelineStatus.types'
import { getInitialSyncProgress } from './ReplicationPipelineStatus.utils'
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
export const getTableStatusEmptyState = ({
isDisabled,
disabledStateConfig,
statusName,
}: {
isDisabled: boolean
disabledStateConfig: { title: string; message: string }
statusName?: ReplicationPipelineStatusData['status']['name']
}) => {
if (isDisabled) {
return { title: disabledStateConfig.title, description: disabledStateConfig.message }
}
if (statusName === PipelineStatusName.STOPPED) {
return { title: 'Pipeline stopped', description: 'Start the pipeline to begin replication' }
}
if (statusName === PipelineStatusName.FAILED) {
return {
title: 'Pipeline failed',
description: 'Restart the pipeline or reset your tables to recover',
}
}
return {
title: 'No table data yet',
description: 'Table status appears here once replication begins',
}
}
export interface PipelineStateNotice {
type: 'note' | 'warning' | 'destructive'
title: string
description: string
showLogsLink: boolean
}
const plural = (count: number, singular: string, pluralForm = `${singular}s`) =>
`${count} ${count === 1 ? singular : pluralForm}`
export const getInitialSyncSummary = ({
copyingCount,
queuedCount,
totalCount,
}: ReturnType<typeof getInitialSyncProgress>) => {
if (copyingCount > 0 && queuedCount > 0) {
return `${copyingCount} of ${plural(totalCount, 'table')} ${copyingCount === 1 ? 'is' : 'are'} copying and ${queuedCount} ${queuedCount === 1 ? 'is' : 'are'} waiting.`
}
if (copyingCount > 0) {
return `${copyingCount} of ${plural(totalCount, 'table')} ${copyingCount === 1 ? 'is' : 'are'} copying.`
}
if (queuedCount > 0) {
return `${plural(queuedCount, 'table')} ${queuedCount === 1 ? 'is' : 'are'} waiting to copy.`
}
return 'The last tables are finishing their copy.'
}
export const getPipelineStateNotice = ({
requestStatus,
statusName,
tableStatuses,
}: {
requestStatus: PipelineStatusRequestStatus
statusName?: ReplicationPipelineStatusData['status']['name']
tableStatuses: { state: { name: TableState['state']['name'] } }[]
}): PipelineStateNotice | undefined => {
const displayState = getPipelineDisplayState(
requestStatus,
normalizePipelineStatusName(statusName)
)
if (displayState.type === 'loading') {
return {
type: 'note',
title: displayState.title,
description: displayState.message,
showLogsLink: false,
}
}
if (displayState.key === 'failed') {
return {
type: 'destructive',
title: displayState.title,
description:
'Replication has stopped. Restart the pipeline to resume from its last checkpoint. Table states below are from before it failed.',
showLogsLink: true,
}
}
if (displayState.key === 'stopped') {
return {
type: 'note',
title: displayState.title,
description:
'Changes to your source tables wait in Postgres until you start the pipeline again. Table states below are from before it stopped.',
showLogsLink: false,
}
}
if (displayState.key === 'unknown') {
return {
type: 'warning',
title: displayState.title,
description: 'We can’t tell whether replication is running',
showLogsLink: true,
}
}
const progress = getInitialSyncProgress(tableStatuses)
if (progress.syncingCount === 0) return undefined
return {
type: 'note',
title: 'Initial sync is running',
description: `${getInitialSyncSummary(progress)} Each table starts streaming as its copy finishes.`,
showLogsLink: false,
}
}
@@ -0,0 +1,95 @@
import { screen } from '@testing-library/react'
import type { components } from 'api-types'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { ReplicationPipelineStatus } from './ReplicationPipelineStatus'
import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
vi.mock('common', async (importOriginal) => ({
...(await importOriginal<typeof import('common')>()),
useParams: () => ({ ref: 'default', pipelineId: '42' }),
}))
type PipelineResponse = components['schemas']['PipelineResponse_Output']
type PipelineStatusResponse = components['schemas']['PipelineStatusResponse_Output']
type PipelineReplicationStatusResponse =
components['schemas']['PipelineReplicationStatusResponse_Output']
const pipeline: PipelineResponse = {
id: 42,
config: { publication_name: 'analytics_publication' },
destination_id: 7,
destination_name: 'Analytics warehouse',
replicator_id: 1,
source_id: 2,
source_name: 'main-db',
tenant_id: 'default',
}
describe('ReplicationPipelineStatus', () => {
test('preserves the overview structure while pipeline details load', async () => {
let resolvePipeline: (value: PipelineResponse) => void = () => {}
let resolvePipelineStatus: (value: PipelineStatusResponse) => void = () => {}
const pipelineResponse = new Promise<PipelineResponse>((resolve) => {
resolvePipeline = resolve
})
const pipelineStatusResponse = new Promise<PipelineStatusResponse>((resolve) => {
resolvePipelineStatus = resolve
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id',
response: async () => HttpResponse.json<PipelineResponse>(await pipelineResponse),
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
response: async () => HttpResponse.json<PipelineStatusResponse>(await pipelineStatusResponse),
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
response: () =>
HttpResponse.json<PipelineReplicationStatusResponse>({
pipeline_id: 42,
apply_lag: {
active: true,
wal_status: 'reserved',
restart_lsn_bytes: 0,
confirmed_flush_lsn_bytes: 0,
safe_wal_size_bytes: null,
},
table_statuses: [],
}),
})
customRender(
<PipelineRequestStatusProvider>
<ReplicationPipelineStatus />
</PipelineRequestStatusProvider>
)
expect(screen.getByRole('status')).toHaveTextContent('Loading pipeline details')
expect(screen.getByRole('heading', { name: 'Pipeline health' })).toBeVisible()
expect(screen.getByRole('heading', { name: 'Replicated tables' })).toBeVisible()
expect(screen.getByRole('columnheader', { name: 'Table' })).toBeVisible()
expect(screen.getByRole('columnheader', { name: 'Status' })).toBeVisible()
expect(screen.getByRole('columnheader', { name: 'Details' })).toBeVisible()
resolvePipeline(pipeline)
expect(screen.getByRole('status')).toHaveTextContent('Loading pipeline details')
resolvePipelineStatus({
pipeline_id: 42,
status: { name: 'started' },
})
expect(await screen.findByText('No table data yet')).toBeVisible()
expect(screen.getByRole('status')).toHaveTextContent('')
})
})
@@ -1,5 +1,6 @@
import { useParams } from 'common'
import { Activity, ChevronDown, Info, RotateCcw, Search, WifiOff, X } from 'lucide-react'
import { Activity, ChevronDown, RotateCcw, Search, X } from 'lucide-react'
import Link from 'next/link'
import { parseAsString, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import {
@@ -17,9 +18,18 @@ import {
TableHeadSort,
TableRow,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
import { PageContainer } from 'ui-patterns/PageContainer'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericTableLoader, ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { BatchRestartDialog } from '../BatchRestartDialog'
import { ErrorDetailsDialog } from '../ErrorDetailsDialog'
@@ -27,6 +37,7 @@ import { getStatusName } from '../Pipeline.utils'
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { RestartTableDialog } from '../RestartTableDialog'
import { PipelineHealthSection } from './PipelineHealthSection'
import { getPipelineStateNotice, getTableStatusEmptyState } from './PipelineOverview.utils'
import { getDisabledStateConfig } from './ReplicationPipelineStatus.utils'
import { TableReplicationRow } from './TableReplicationRow'
import { AlertError } from '@/components/ui/AlertError'
@@ -59,6 +70,43 @@ const compareTableStates = (
b: ReplicationPipelineTableStatus['state']
) => TABLE_STATE_SORT_ORDER.indexOf(a.name) - TABLE_STATE_SORT_ORDER.indexOf(b.name)
const PipelineOverviewSkeleton = () => (
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Pipeline health</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent className="pb-5">
<div className="grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<div key={index} className="space-y-2">
<ShimmeringLoader className="h-3 w-24 py-0" delayIndex={index} />
<ShimmeringLoader className="h-4 w-32 py-0" delayIndex={index} />
</div>
))}
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Replicated tables</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<GenericTableLoader headers={['Table', 'Status', 'Details', null]} />
</PageSectionContent>
</PageSection>
</>
)
/**
* Component for displaying replication pipeline status and table replication details.
* Supports both legacy 'error' state and new 'errored' state with retry policies.
@@ -97,13 +145,14 @@ export const ReplicationPipelineStatus = () => {
pipelineId,
})
const { data: pipelineStatusData } = useReplicationPipelineStatusQuery(
{ projectRef, pipelineId },
{
enabled: !!pipelineId,
refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
}
)
const { data: pipelineStatusData, isPending: isPipelineStatusLoading } =
useReplicationPipelineStatusQuery(
{ projectRef, pipelineId },
{
enabled: !!pipelineId,
refetchInterval: STATUS_REFRESH_FREQUENCY_MS,
}
)
const {
data: replicationStatusData,
@@ -166,6 +215,7 @@ export const ReplicationPipelineStatus = () => {
const hasErroredTables = erroredTables.length > 0
const isAnyRestartInProgress = restartingTableIds.size > 0
const isLoading = isPipelineLoading || isPipelineStatusLoading || isStatusLoading
const hasTableData = tableStatuses.length > 0
const isPipelineActionable =
@@ -179,253 +229,278 @@ export const ReplicationPipelineStatus = () => {
const isPipelineBusy = isEnablingDisabling || isAnyRestartInProgress
const showDisabledState = isPipelineBusy || !isPipelineActionable
const canResetErroredTables = hasErroredTables && !isAnyRestartInProgress && !showDisabledState
const lastKnownStateMessage =
statusName === PipelineStatusName.STOPPED
? 'Showing the last known table state before the pipeline was stopped.'
: statusName === PipelineStatusName.FAILED
? 'Showing the last reported table state before the pipeline failed.'
: null
const refreshIntervalLabel =
STATUS_REFRESH_FREQUENCY_MS >= 1000
? `${Math.round(STATUS_REFRESH_FREQUENCY_MS / 1000)}s`
: `${STATUS_REFRESH_FREQUENCY_MS}ms`
const stateNotice = getPipelineStateNotice({ requestStatus, statusName, tableStatuses })
const isSlotDisconnected =
!isStatusError && statusName === PipelineStatusName.STARTED && applyLagMetrics?.active === false
const logsUrl = `/project/${projectRef}/logs/replication-logs?f=${encodeURIComponent(
JSON.stringify({ pipeline_id: pipelineId })
)}`
const emptyState = getTableStatusEmptyState({
isDisabled: showDisabledState,
disabledStateConfig: config,
statusName,
})
return (
<>
<PageContainer size="large" className="flex flex-col gap-y-4 py-6">
<PageContainer size="large">
<p className="sr-only" role="status" aria-live="polite">
{isLoading ? 'Loading pipeline details' : ''}
</p>
{isPipelineError && (
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
<PageSection>
<PageSectionContent>
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
</PageSectionContent>
</PageSection>
)}
{isStatusError && (
<div className="flex items-center gap-2 rounded-lg border border-warning-400 bg-warning-50 px-3 py-2 text-xs text-warning-800">
<WifiOff size={14} />
<span className="font-medium">Live updates paused</span>
<span className="text-warning-700">Retrying automatically</span>
</div>
)}
{isLoading && <PipelineOverviewSkeleton />}
{(isPipelineLoading || isStatusLoading) && (
<div className="space-y-3">
<div className="flex items-center gap-x-3">
<div className="h-6 w-40 rounded-sm bg-surface-200" />
<div className="h-5 w-24 rounded-sm bg-surface-200" />
</div>
<GenericSkeletonLoader />
</div>
)}
{!isLoading && (
<PipelineHealthSection metrics={applyLagMetrics ?? undefined}>
{stateNotice !== undefined && (
<Admonition
type={stateNotice.type}
layout="responsive"
title={stateNotice.title}
description={stateNotice.description}
actions={
stateNotice.showLogsLink ? (
<Button asChild variant="default">
<Link href={logsUrl}>View logs</Link>
</Button>
) : undefined
}
/>
)}
{hasErroredTables && !showDisabledState && (
<Admonition
type="destructive"
layout="responsive"
title={
erroredTables.length === 1
? '1 table stopped replicating'
: `${erroredTables.length} tables stopped replicating`
}
description="The rest of the pipeline keeps running. Open a table’s error to see what went wrong, then reset it to resume."
actions={
<Button
variant="default"
icon={<RotateCcw />}
disabled={isAnyRestartInProgress || isPipelineError}
loading={isAnyRestartInProgress}
onClick={() => {
setBatchRestartMode('errored')
setShowBatchRestartDialog(true)
}}
>
Reset failed tables
</Button>
}
/>
)}
{isSlotDisconnected && (
<Admonition
type="warning"
title="Pipeline disconnected"
description="The pipeline is running but isn’t connected to your database right now. It reconnects on its own; if this persists, check the logs."
/>
)}
{applyLagMetrics && (
<PipelineHealthSection metrics={applyLagMetrics}>
{isStatusError && (
<p className="text-xs text-warning-700">
Unable to refresh data. Showing the last values we received.
</p>
<Admonition
type="warning"
title="Live updates paused"
description="We can’t reach this pipeline right now. Health below is the last we received, and we’re retrying automatically."
/>
)}
</PipelineHealthSection>
)}
{!isPipelineLoading && !isStatusLoading && hasTableData && (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<Input
icon={<Search />}
size="tiny"
className="text-xs w-52"
placeholder="Search tables"
value={searchString}
disabled={isPipelineError}
onChange={(e) => setSearchString(e.target.value)}
onKeyDown={onSearchInputEscape(searchString, setSearchString)}
actions={
searchString.length > 0 && (
<Button
aria-label="Clear search"
variant="text"
icon={<X />}
className="p-0 h-5 w-5"
onClick={() => setSearchString('')}
{!isLoading && !(isStatusError && !hasTableData) && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Replicated tables</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent className="flex flex-col gap-y-4">
{hasTableData && (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<Input
icon={<Search />}
size="tiny"
className="text-xs w-52"
placeholder="Search tables"
value={searchString}
disabled={isPipelineError}
onChange={(e) => setSearchString(e.target.value)}
onKeyDown={onSearchInputEscape(searchString, setSearchString)}
actions={
searchString.length > 0 && (
<Button
aria-label="Clear search"
variant="text"
icon={<X />}
className="p-0 h-5 w-5"
onClick={() => setSearchString('')}
/>
)
}
/>
)
}
/>
<div className="flex items-center">
<Button
size="tiny"
variant="default"
className="rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm"
icon={<RotateCcw />}
disabled={isAnyRestartInProgress || showDisabledState || isPipelineError}
loading={isAnyRestartInProgress}
onClick={() => {
setBatchRestartMode('all')
setShowBatchRestartDialog(true)
}}
>
Reset all tables
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
aria-label="More reset options"
icon={<ChevronDown />}
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm"
disabled={showDisabledState || isPipelineError}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
className="data-disabled:pointer-events-auto data-disabled:cursor-not-allowed"
disabled={!canResetErroredTables}
onClick={() => {
if (!canResetErroredTables) return
setBatchRestartMode('errored')
setShowBatchRestartDialog(true)
}}
>
<div className="flex flex-col gap-y-0.5">
<p>Reset failed tables only</p>
{!hasErroredTables && (
<p className="text-foreground-lighter">No failed tables</p>
)}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center">
<Button
size="tiny"
variant="default"
className="rounded-r-none hover:z-10 focus-visible:z-10 focus-visible:rounded-r-sm"
icon={<RotateCcw />}
disabled={isAnyRestartInProgress || showDisabledState || isPipelineError}
loading={isAnyRestartInProgress}
onClick={() => {
setBatchRestartMode('all')
setShowBatchRestartDialog(true)
}}
>
Reset all tables
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
aria-label="More reset options"
icon={<ChevronDown />}
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm"
disabled={showDisabledState || isPipelineError}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
className="data-disabled:pointer-events-auto data-disabled:cursor-not-allowed"
disabled={!canResetErroredTables}
onClick={() => {
if (!canResetErroredTables) return
setBatchRestartMode('errored')
setShowBatchRestartDialog(true)
}}
>
<div className="flex flex-col gap-y-0.5">
<p>Reset failed tables only</p>
{!hasErroredTables && (
<p className="text-foreground-lighter">No failed tables</p>
)}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{lastKnownStateMessage !== null && !showDisabledState && (
<div className="flex items-start gap-2 rounded-md border border-default/50 bg-surface-200/60 px-3 py-2 text-xs text-foreground-light">
<Info size={14} className="mt-0.5" />
<span>{lastKnownStateMessage}</span>
</div>
)}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead key="table" aria-sort={getAriaSort('table')}>
<TableHeadSort
column="table"
currentSort={sort}
onSortChange={handleSortChange}
>
Table
</TableHeadSort>
</TableHead>
<TableHead key="status" aria-sort={getAriaSort('status')}>
<TableHeadSort
column="status"
currentSort={sort}
onSortChange={handleSortChange}
>
Status
</TableHeadSort>
</TableHead>
<TableHead key="details">Details</TableHead>
<TableHead key="actions" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="sr-only" aria-live="polite" role="status">
<td colSpan={4}>
{filteredTableStatuses.length === 0 && searchString.length > 0
? `No results found for “${searchString}”`
: ''}
</td>
</TableRow>
{filteredTableStatuses.length === 0 && (
<TableRowNoResults
className="[&>td]:hover:bg-inherit"
colSpan={4}
search={searchString}
/>
)}
{filteredTableStatuses.map((table) => {
const isRestarting = restartingTableIds.has(table.id)
const isErrorState = table.state.name === 'error'
const errorReason =
isErrorState && 'reason' in table.state ? table.state.reason : undefined
const errorSolution =
isErrorState && 'solution' in table.state
? (table.state.solution ?? undefined)
: undefined
return (
<TableReplicationRow
key={table.id}
table={table}
isRestarting={isRestarting}
showDisabledState={showDisabledState}
disabledStateMessage={config.message}
isAnyRestartInProgress={isAnyRestartInProgress}
isPipelineStopped={statusName === PipelineStatusName.STOPPED}
onSelectRestart={() => {
setSelectedTableForRestart({
id: table.id,
schema: table.schema,
name: table.name,
})
setShowRestartDialog(true)
}}
onSelectShowError={
isErrorState && errorReason
? () => {
setSelectedTableError({
tableName: `${table.schema}.${table.name}`,
reason: errorReason,
solution: errorSolution,
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead key="table" aria-sort={getAriaSort('table')}>
<TableHeadSort
column="table"
currentSort={sort}
onSortChange={handleSortChange}
>
Table
</TableHeadSort>
</TableHead>
<TableHead key="status" aria-sort={getAriaSort('status')}>
<TableHeadSort
column="status"
currentSort={sort}
onSortChange={handleSortChange}
>
Status
</TableHeadSort>
</TableHead>
<TableHead key="details">Details</TableHead>
<TableHead key="actions" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="sr-only" aria-live="polite" role="status">
<td colSpan={4}>
{filteredTableStatuses.length === 0 && searchString.length > 0
? `No results found for “${searchString}”`
: ''}
</td>
</TableRow>
{filteredTableStatuses.length === 0 && (
<TableRowNoResults
className="[&>td]:hover:bg-inherit"
colSpan={4}
search={searchString}
/>
)}
{filteredTableStatuses.map((table) => {
const isRestarting = restartingTableIds.has(table.id)
const isErrorState = table.state.name === 'error'
const errorReason =
isErrorState && 'reason' in table.state
? table.state.reason
: undefined
const errorSolution =
isErrorState && 'solution' in table.state
? (table.state.solution ?? undefined)
: undefined
return (
<TableReplicationRow
key={table.id}
table={table}
isRestarting={isRestarting}
showDisabledState={showDisabledState}
disabledStateMessage={config.message}
isAnyRestartInProgress={isAnyRestartInProgress}
isPipelineStopped={statusName === PipelineStatusName.STOPPED}
onSelectRestart={() => {
setSelectedTableForRestart({
id: table.id,
schema: table.schema,
name: table.name,
})
setShowErrorDialog(true)
setShowRestartDialog(true)
}}
onSelectShowError={
isErrorState && errorReason
? () => {
setSelectedTableError({
tableName: `${table.schema}.${table.name}`,
reason: errorReason,
solution: errorSolution,
})
setShowErrorDialog(true)
}
: () => {}
}
: () => {}
}
/>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
{!isPipelineLoading && !isStatusLoading && tableStatuses.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-4 border rounded-lg border-dashed">
<div className="w-full max-w-sm mx-auto text-center space-y-4">
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
<Activity className="w-8 h-8 text-foreground-lighter" />
</div>
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">
{showDisabledState
? config.title
: statusName === PipelineStatusName.STOPPED
? 'Pipeline stopped'
: statusName === PipelineStatusName.FAILED
? 'Pipeline failed'
: 'No table data yet'}
</h4>
<p className="text-sm text-foreground-light leading-relaxed">
{showDisabledState
? config.message
: statusName === PipelineStatusName.STOPPED
? 'Start the pipeline to begin replication.'
: statusName === PipelineStatusName.FAILED
? 'The pipeline encountered an error. Restart it or reset your tables to recover.'
: 'Table status will appear here once replication begins.'}
</p>
</div>
{statusName !== PipelineStatusName.STOPPED && (
<p className="text-xs text-foreground-lighter">
Data refreshes every {refreshIntervalLabel}
</p>
/>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
</div>
</div>
{!hasTableData && (
<EmptyStatePresentational
icon={Activity}
title={emptyState.title}
description={emptyState.description}
/>
)}
</PageSectionContent>
</PageSection>
)}
</PageContainer>
@@ -115,7 +115,7 @@ export const TableReplicationRow = ({
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-64">
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canRestart}
@@ -178,7 +178,7 @@ export const RowMenu = ({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItem className="space-x-2" asChild disabled={!pipeline}>
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
<Eye size={14} />