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 ? ( + + ) : ( + + )}
)}