From 4215b6ca32a3b8fa95d433c84f00a391c39efbec Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:31:13 +1100 Subject: [PATCH] feat(studio): add cost estimates and validation feedback to pipeline review (#51641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../PipelineCostEstimate.test.tsx | 94 +++++++++ .../CreatePipeline/PipelineCostEstimate.tsx | 187 ++++++++++++++++++ .../CreatePipeline/PipelineReviewCosts.tsx | 54 +++++ .../PipelineValidationAdmonition.test.tsx | 71 +++++++ .../PipelineValidationAdmonition.tsx | 87 ++++++++ .../DestinationForm/index.tsx | 19 +- 6 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineReviewCosts.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.test.tsx b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.test.tsx new file mode 100644 index 00000000000..1c097a0cf24 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + 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( + + ) + expect(screen.queryByText('$0.05/hour')).not.toBeInTheDocument() +}) diff --git a/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.tsx b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.tsx new file mode 100644 index 00000000000..d77ea52701a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineCostEstimate.tsx @@ -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 ( + +
+

Estimated costs

+

+ Review one-time and ongoing Supabase charges. Destination-provider charges are separate. +

+
+ + {isLoading ? ( + + ) : isError || estimate === undefined ? ( +

+ A cost estimate is unavailable. You can still start the pipeline. +

+ ) : ( +
+
+

Initial sync

+ + {copyTableCount === 0 ? ( +
+ + + + + No tables will run an initial sync + + + {formatCurrency(0)} + + + +
+
+ ) : copyEstimate?.isComplete ? ( +
+ + + + Table + Est. volume + + Est. cost ({formatCurrency(estimate.table_copy.rate_per_gb)}/GB) + + + + + {visibleTables.map((table) => ( + + + {table.schema}.{table.name} + + + {formatBytes(table.estimated_bytes)} + + + {formatCurrency(table.estimated_cost)} + + + ))} + {hiddenTableCount > 0 && ( + + + +{hiddenTableCount} more {hiddenTableCount === 1 ? 'table' : 'tables'} + + + )} + + + + Initial sync total + + {formatBytes(copyEstimate.estimatedBytes)} + + + {formatCurrency(copyEstimate.estimatedCost)} + + + +
+
+ ) : ( +
+

+ Estimate unavailable for one or more selected tables +

+
+ )} + +

+ Final charges are based on data processed during initial sync. + {copyEstimate?.isComplete && copyEstimate.hasRowFilteredTables + ? ' Row filters may reduce this amount.' + : ''} +

+
+ +
+

Ongoing

+
+ + + + Pipeline runtime + + ${estimate.pipeline.hourly_cost}/hour + + + + + Replication data processed + + + {formatCurrency(estimate.streaming.rate_per_gb)}/GB + + + +
+
+

+ Replication usage depends on how often published data changes.{' '} + + Learn more + +

+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineReviewCosts.tsx b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineReviewCosts.tsx new file mode 100644 index 00000000000..efbaca43ec7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineReviewCosts.tsx @@ -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 + 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 ( +
+ Select an available publication to estimate costs. +
+ ) + let tableSyncCopy + try { + tableSyncCopy = buildTableSyncCopyConfig({ mode, selectedTableIds: ids }) + } catch { + return ( +
+ Complete the initial sync selection to estimate costs. +
+ ) + } + return ( + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.test.tsx b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.test.tsx new file mode 100644 index 00000000000..38e58ede62b --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.test.tsx @@ -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( + + ) + + 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( + + ) + + expect(screen.getByText('Replica identity mismatch')).toBeInTheDocument() + }) +}) + +test('shows critical issues without suggesting replication can start', () => { + customRender( + + ) + 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( + + ) + expect(screen.getByText('Required').closest('li')).toHaveTextContent('Replace the key.') + expect(screen.getByText('Warning').closest('li')).toHaveTextContent('Review table settings.') +}) diff --git a/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.tsx b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.tsx new file mode 100644 index 00000000000..5be0d8fb6a2 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/CreatePipeline/PipelineValidationAdmonition.tsx @@ -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, + { + 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 ( + + {!hasCritical ? ( +

+ Replication can start, but review these items before continuing. They may affect how + changes are applied downstream. +

+ ) : null} +
    + {failures.map((failure, index) => ( +
  • + + {failure.failure_type === 'critical' ? 'Required' : 'Warning'} + + {failures.length > 1 ? ( +

    {failure.name}

    + ) : null} + + {failure.reason} + +
  • + ))} +
+ {hint ?

{hint}

: null} +
+ ) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index 0ee4fe40469..f14a89b8a37 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -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} /> + + )} @@ -557,10 +561,17 @@ export const DestinationForm = ({
- + {shouldShowConnectionSection ? ( + + ) : ( + + )}
)}