mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
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:
1 parent
f20277377b
commit
4215b6ca32
6 files changed
+508
-4
No files matched your search
+94
@@ -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()
|
||||
})
|
||||
+187
@@ -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>
|
||||
)
|
||||
}
|
||||
+54
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+71
@@ -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.')
|
||||
})
|
||||
+87
@@ -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>
|
||||
)
|
||||
})
|
||||
+15
-4
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user