feat(studio): add cost estimates and validation feedback to pipeline review (#51641)

## Problem

The pipeline wizard in #49243 needs estimated costs and validation
feedback in review before creation.

## Solution

Add the megabranch’s inline cost estimate and validation feedback to
preview-enabled creation. Initial-sync costs follow the selected tables;
ongoing pipeline and streaming rates remain visible. Missing estimates
do not block creation. Validation feedback distinguishes critical issues
from warnings.

Preview off and editing keep the existing layout. The Start action,
warning confirmation and final cost dialog stay unchanged; no wizard
routing is added. Stacked on #51635.

## Review instructions

1. Open the [Pipelines
preview](https://studio-staging-git-dnywh-featpipeline-review-costs-supabase.vercel.app/dashboard/project/_/database/pipelines),
select a test project with Pipelines enabled, enable **Pipeline creation
wizard** in Feature Previews and click **Add pipeline**. Select a
publication and confirm **Estimated costs** follows Data review.
2. Compare **All tables**, **No tables** and selected-table initial
sync. Confirm initial-sync totals follow the selection, while hourly
pipeline and streaming rates stay visible. With **No tables**, initial
sync should cost zero. Check desktop and phone widths.
3. Enter valid-format but incorrect destination credentials and click
**Start pipeline**. Confirm critical validation issues appear inline and
creation stops. Correct the credentials and retry. If warnings are
returned, confirm the review explains they may affect replication and
the existing **Start pipeline anyway** confirmation still applies.
Cancel before final creation.
4. Turn the preview off and reopen **Add pipeline**. Confirm there is no
inline cost review. With the preview on, edit an existing pipeline and
confirm its original layout remains.
This commit is contained in:
Danny White authored and GitHub committed 2026-10-15 11:31:13 +11:00
1 parent f20277377b
commit 4215b6ca32
6 files changed
+508 -4

No files matched your search

@@ -0,0 +1,94 @@
import { screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { PipelineCostEstimate } from './PipelineCostEstimate'
import type { ReplicationCostEstimateData } from '@/data/replication/cost-estimate-query'
import { customRender } from '@/tests/lib/custom-render'
const estimate = {
currency: 'usd',
pipeline: { hourly_cost: 0.05, monthly_cost: 36.5 },
streaming: { rate_per_gb: 3 },
table_copy: {
rate_per_gb: 0.6,
total_bytes: 10_000_000_000,
total_cost: 10,
tables: [
{
schema: 'public',
name: 'orders',
estimated_bytes: 600_000_000,
estimated_cost: 0.6,
is_row_filtered: false,
},
{
schema: 'billing',
name: 'invoices',
estimated_bytes: 9_400_000_000,
estimated_cost: 9.4,
is_row_filtered: false,
},
],
},
} satisfies ReplicationCostEstimateData
const publicationTables = [
{ id: 101, schema: 'public', name: 'orders' },
{ id: 202, schema: 'billing', name: 'invoices' },
]
describe('PipelineCostEstimate', () => {
test('shows the selected initial sync tables and ongoing rates inline', () => {
customRender(
<PipelineCostEstimate
estimate={estimate}
isLoading={false}
isError={false}
publicationTables={publicationTables}
tableSyncCopy={{ type: 'include_tables', table_ids: [101] }}
/>
)
expect(screen.getByRole('heading', { name: 'Estimated costs' })).toBeInTheDocument()
expect(screen.getByText('public.orders')).not.toHaveClass('font-mono')
expect(screen.queryByText('billing.invoices')).not.toBeInTheDocument()
expect(screen.getByText('$0.05/hour')).toBeInTheDocument()
expect(screen.getByText('$3.00/GB')).toBeInTheDocument()
})
test('keeps pipeline creation available when the estimate is unavailable', () => {
customRender(
<PipelineCostEstimate isLoading={false} isError publicationTables={publicationTables} />
)
expect(
screen.getByText('A cost estimate is unavailable. You can still start the pipeline.')
).toBeInTheDocument()
})
})
test('shows zero initial-sync cost when all existing rows are skipped', () => {
customRender(
<PipelineCostEstimate
estimate={estimate}
isLoading={false}
isError={false}
publicationTables={publicationTables}
tableSyncCopy={{ type: 'skip_all_tables' }}
/>
)
expect(screen.getByText('No tables will run an initial sync')).toBeInTheDocument()
expect(screen.getByText('$0.05/hour')).toBeInTheDocument()
})
test('does not show stale rates while an estimate is loading', () => {
customRender(
<PipelineCostEstimate
estimate={estimate}
isLoading
isError={false}
publicationTables={publicationTables}
/>
)
expect(screen.queryByText('$0.05/hour')).not.toBeInTheDocument()
})
@@ -0,0 +1,187 @@
import { useMemo } from 'react'
import {
CardContent,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
getTableCopyTargets,
summarizeTableCopyEstimate,
type ReplicationTableIdentity,
} from '../TableSyncCopy.utils'
import { InlineLink } from '@/components/ui/InlineLink'
import type { ReplicationCostEstimateData } from '@/data/replication/cost-estimate-query'
import type { TableSyncCopyConfig } from '@/data/replication/types'
import { DOCS_URL } from '@/lib/constants'
import { formatBytes, formatCurrency } from '@/lib/helpers'
const MAX_VISIBLE_TABLES = 10
export const PipelineCostEstimate = ({
estimate,
isLoading,
isError,
publicationTables,
tableSyncCopy,
}: {
estimate?: ReplicationCostEstimateData
isLoading: boolean
isError: boolean
publicationTables: ReplicationTableIdentity[]
tableSyncCopy?: TableSyncCopyConfig
}) => {
const copyTargets = useMemo(
() => getTableCopyTargets(publicationTables, tableSyncCopy),
[publicationTables, tableSyncCopy]
)
const copyEstimate = useMemo(
() =>
estimate === undefined
? undefined
: summarizeTableCopyEstimate(estimate.table_copy.tables, copyTargets),
[copyTargets, estimate]
)
const tables = copyEstimate?.tables ?? []
const visibleTables = tables.slice(0, MAX_VISIBLE_TABLES)
const hiddenTableCount = tables.length - visibleTables.length
const copyTableCount = copyTargets.length
return (
<CardContent className="space-y-6">
<header className="space-y-1">
<h3 className="text-sm text-foreground">Estimated costs</h3>
<p className="text-sm text-foreground-light">
Review one-time and ongoing Supabase charges. Destination-provider charges are separate.
</p>
</header>
{isLoading ? (
<GenericSkeletonLoader className="w-full" />
) : isError || estimate === undefined ? (
<p className="text-sm text-foreground-light" role="status">
A cost estimate is unavailable. You can still start the pipeline.
</p>
) : (
<div className="space-y-6">
<section className="space-y-2">
<h4 className="text-sm font-medium text-foreground mb-3">Initial sync</h4>
{copyTableCount === 0 ? (
<div className="overflow-hidden rounded-md border">
<Table>
<TableBody className="[&_td]:py-2">
<TableRow>
<TableCell className="text-sm text-foreground-light">
No tables will run an initial sync
</TableCell>
<TableCell className="text-right font-mono text-sm" translate="no">
{formatCurrency(0)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : copyEstimate?.isComplete ? (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader className="[&_th]:h-auto [&_th]:py-2 [&_th]:text-sm">
<TableRow>
<TableHead>Table</TableHead>
<TableHead className="text-right">Est. volume</TableHead>
<TableHead className="text-right" translate="no">
Est. cost ({formatCurrency(estimate.table_copy.rate_per_gb)}/GB)
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-2 [&_td]:text-sm">
{visibleTables.map((table) => (
<TableRow key={JSON.stringify([table.schema, table.name])}>
<TableCell translate="no">
{table.schema}.{table.name}
</TableCell>
<TableCell className="text-right">
{formatBytes(table.estimated_bytes)}
</TableCell>
<TableCell className="text-right font-mono" translate="no">
{formatCurrency(table.estimated_cost)}
</TableCell>
</TableRow>
))}
{hiddenTableCount > 0 && (
<TableRow>
<TableCell colSpan={3} className="text-foreground-lighter">
+{hiddenTableCount} more {hiddenTableCount === 1 ? 'table' : 'tables'}
</TableCell>
</TableRow>
)}
</TableBody>
<TableFooter className="[&_td]:text-sm">
<TableRow className="border-b-0">
<TableCell className="py-2">Initial sync total</TableCell>
<TableCell className="py-2 text-right">
{formatBytes(copyEstimate.estimatedBytes)}
</TableCell>
<TableCell className="py-2 text-right font-mono" translate="no">
{formatCurrency(copyEstimate.estimatedCost)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
) : (
<div className="overflow-hidden rounded-md border">
<p className="px-3 py-2 text-sm text-foreground-light">
Estimate unavailable for one or more selected tables
</p>
</div>
)}
<p className="text-sm text-foreground-lighter">
Final charges are based on data processed during initial sync.
{copyEstimate?.isComplete && copyEstimate.hasRowFilteredTables
? ' Row filters may reduce this amount.'
: ''}
</p>
</section>
<section className="space-y-2">
<h4 className="text-sm font-medium text-foreground mb-3">Ongoing</h4>
<div className="overflow-hidden rounded-md border">
<Table>
<TableBody className="[&_td]:py-2 [&_td]:text-sm">
<TableRow>
<TableCell className="text-foreground-light">Pipeline runtime</TableCell>
<TableCell className="text-right font-mono text-foreground" translate="no">
${estimate.pipeline.hourly_cost}/hour
</TableCell>
</TableRow>
<TableRow>
<TableCell className="text-foreground-light">
Replication data processed
</TableCell>
<TableCell className="text-right font-mono text-foreground" translate="no">
{formatCurrency(estimate.streaming.rate_per_gb)}/GB
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<p className="text-sm text-foreground-lighter">
Replication usage depends on how often published data changes.{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/manage-your-usage/pipelines`}>
Learn more
</InlineLink>
</p>
</section>
</div>
)}
</CardContent>
)
}
@@ -0,0 +1,54 @@
import { useParams } from 'common'
import { useWatch, type Control } from 'react-hook-form'
import type { DestinationPanelSchemaType } from '../DestinationPanel/DestinationForm/DestinationForm.schema'
import { buildTableSyncCopyConfig } from '../DestinationPanel/DestinationForm/DestinationForm.utils'
import { PipelineCostEstimate } from './PipelineCostEstimate'
import { useReplicationCostEstimateQuery } from '@/data/replication/cost-estimate-query'
import type { ReplicationPublicationData } from '@/data/replication/publication-query'
import { useReplicationSourceId } from '@/data/replication/sources-query'
export const PipelineReviewCosts = ({
control,
publication,
}: {
control: Control<DestinationPanelSchemaType>
publication?: ReplicationPublicationData
}) => {
const { ref: projectRef } = useParams()
const sourceId = useReplicationSourceId({ projectRef })
const [publicationName, mode, ids] = useWatch({
control,
name: ['publicationName', 'tableSyncCopyMode', 'tableSyncCopyTableIds'],
})
const hasPublication = !!publicationName && publication?.name === publicationName
const { data, isLoading, isError } = useReplicationCostEstimateQuery(
{ projectRef, sourceId, publicationName },
{ enabled: hasPublication }
)
if (!hasPublication)
return (
<div className="p-5 text-sm text-foreground-light">
Select an available publication to estimate costs.
</div>
)
let tableSyncCopy
try {
tableSyncCopy = buildTableSyncCopyConfig({ mode, selectedTableIds: ids })
} catch {
return (
<div className="p-5 text-sm text-foreground-light">
Complete the initial sync selection to estimate costs.
</div>
)
}
return (
<PipelineCostEstimate
estimate={data}
isLoading={isLoading}
isError={isError}
publicationTables={publication.tables}
tableSyncCopy={tableSyncCopy}
/>
)
}
@@ -0,0 +1,71 @@
import { screen } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { PipelineValidationAdmonition } from './PipelineValidationAdmonition'
import { customRender } from '@/tests/lib/custom-render'
describe('PipelineValidationAdmonition', () => {
test('replaces generic warning titles with clearer copy', () => {
customRender(
<PipelineValidationAdmonition
failures={[
{
name: 'Warning',
reason: 'Some tables use replica identity FULL.',
failure_type: 'warning',
},
]}
/>
)
expect(screen.getByText('Review before continuing')).toBeInTheDocument()
expect(
screen.getByText(
'Replication can start, but review these items before continuing. They may affect how changes are applied downstream.'
)
).toBeInTheDocument()
expect(screen.getByText('Some tables use replica identity FULL.')).toBeInTheDocument()
})
test('keeps specific warning titles from the API', () => {
customRender(
<PipelineValidationAdmonition
failures={[
{
name: 'Replica identity mismatch',
reason: 'Table `public.users` uses replica identity DEFAULT.',
failure_type: 'warning',
},
]}
/>
)
expect(screen.getByText('Replica identity mismatch')).toBeInTheDocument()
})
})
test('shows critical issues without suggesting replication can start', () => {
customRender(
<PipelineValidationAdmonition
failures={[
{ name: 'Invalid credentials', reason: 'Check your key.', failure_type: 'critical' },
]}
/>
)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
expect(screen.getByText('Check your key.')).toBeInTheDocument()
expect(screen.queryByText(/Replication can start/)).not.toBeInTheDocument()
})
test('distinguishes blockers and warnings in mixed results', () => {
customRender(
<PipelineValidationAdmonition
failures={[
{ name: 'Credentials', reason: 'Replace the key.', failure_type: 'critical' },
{ name: 'Replica identity', reason: 'Review table settings.', failure_type: 'warning' },
]}
/>
)
expect(screen.getByText('Required').closest('li')).toHaveTextContent('Replace the key.')
expect(screen.getByText('Warning').closest('li')).toHaveTextContent('Review table settings.')
})
@@ -0,0 +1,87 @@
import { forwardRef, type ComponentRef } from 'react'
import { Badge } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Markdown } from '@/components/interfaces/Markdown'
import type { ValidationFailure } from '@/data/replication/validate-destination-mutation'
export const SANDWICHED_ADMONITION_CLASS =
'mb-0 rounded-none border-x-0 border-t-0 border-b border-default'
export const CONNECTION_VALIDATION_HINT =
'Check destination credentials and connection settings, including Advanced settings under Destination connection.'
export const DATA_VALIDATION_HINT =
'Some issues may require changes to the project’s database settings.'
const GENERIC_WARNING_TITLES = new Set(['warning', 'warnings'])
const getValidationHeading = ({
failures,
hasCritical,
title,
}: {
failures: ValidationFailure[]
hasCritical: boolean
title?: string
}) => {
if (title) return title
if (hasCritical) {
return failures.length === 1 ? failures[0].name : 'Configuration issues'
}
if (failures.length === 1) {
const { name } = failures[0]
return GENERIC_WARNING_TITLES.has(name.trim().toLowerCase()) ? 'Review before continuing' : name
}
return `${failures.length} warnings to review`
}
export const PipelineValidationAdmonition = forwardRef<
ComponentRef<typeof Admonition>,
{
failures: ValidationFailure[]
title?: string
hint?: string
}
>(function PipelineValidationAdmonition({ failures, title, hint }, ref) {
if (failures.length === 0) return null
const hasCritical = failures.some((failure) => failure.failure_type === 'critical')
const heading = getValidationHeading({ failures, hasCritical, title })
return (
<Admonition
ref={ref}
type={hasCritical ? 'danger' : 'warning'}
title={heading}
layout="responsive"
className={SANDWICHED_ADMONITION_CLASS}
>
{!hasCritical ? (
<p className="mb-2! text-sm text-foreground-light">
Replication can start, but review these items before continuing. They may affect how
changes are applied downstream.
</p>
) : null}
<ul className="space-y-2">
{failures.map((failure, index) => (
<li key={`${failure.name}-${index}`}>
<Badge className="mb-1">
{failure.failure_type === 'critical' ? 'Required' : 'Warning'}
</Badge>
{failures.length > 1 ? (
<p className="mb-0.5! font-medium text-foreground">{failure.name}</p>
) : null}
<Markdown className="text-sm text-foreground-light [&>p]:mb-0!">
{failure.reason}
</Markdown>
</li>
))}
</ul>
{hint ? <p className="mt-2 mb-0! text-sm text-foreground-light">{hint}</p> : null}
</Admonition>
)
})
@@ -14,6 +14,8 @@ import { PipelineConnectionSection } from '../../CreatePipeline/PipelineConnecti
import { PipelineDataReview } from '../../CreatePipeline/PipelineDataReview'
import { PipelineDataSection } from '../../CreatePipeline/PipelineDataSection'
import { PipelineDestinationReview } from '../../CreatePipeline/PipelineDestinationReview'
import { PipelineReviewCosts } from '../../CreatePipeline/PipelineReviewCosts'
import { PipelineValidationAdmonition } from '../../CreatePipeline/PipelineValidationAdmonition'
import {
useIsETLBigQueryPrivateAlpha,
useIsETLClickHousePrivateAlpha,
@@ -549,6 +551,8 @@ export const DestinationForm = ({
control={form.control}
publication={selectedPublication}
/>
<DialogSectionSeparator />
<PipelineReviewCosts control={form.control} publication={selectedPublication} />
</>
)}
@@ -557,10 +561,17 @@ export const DestinationForm = ({
<DialogSectionSeparator />
<div ref={validationSectionRef}>
<ValidationFailuresSection
destinationFailures={destinationValidationFailures}
pipelineFailures={pipelineValidationFailures}
/>
{shouldShowConnectionSection ? (
<PipelineValidationAdmonition
failures={allValidationFailures}
hint="Check credentials and Advanced settings above. Some issues may require changes to the project’s database settings."
/>
) : (
<ValidationFailuresSection
destinationFailures={destinationValidationFailures}
pipelineFailures={pipelineValidationFailures}
/>
)}
</div>
</>
)}