From d1f0c7a6aa98e5e76b516997fdf493adef588981 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Mon, 1 Aug 2022 12:12:02 -0400 Subject: [PATCH] feat(ingestion) Add frontend connection test for Snowflake (#5520) --- .../ingest/IngestionResolverUtils.java | 13 ++ .../CreateTestConnectionRequestResolver.java | 1 - .../src/main/resources/ingestion.graphql | 23 +++ .../source/builder/DefineRecipeStep.tsx | 15 +- .../source/builder/RecipeForm/RecipeForm.tsx | 13 ++ .../ConnectionCapabilityView.tsx | 74 +++++++ .../TestConnection/TestConnectionButton.tsx | 128 ++++++++++++ .../TestConnection/TestConnectionModal.tsx | 191 ++++++++++++++++++ .../RecipeForm/TestConnection/types.ts | 32 +++ .../__tests__/DefineRecipeStep.test.tsx | 17 +- .../src/app/ingest/source/utils.ts | 29 +-- .../src/graphql/ingestion.graphql | 8 + 12 files changed, 514 insertions(+), 30 deletions(-) create mode 100644 datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/ConnectionCapabilityView.tsx create mode 100644 datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionButton.tsx create mode 100644 datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionModal.tsx create mode 100644 datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/types.ts diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/IngestionResolverUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/IngestionResolverUtils.java index 07b4b05f34e1e1..3701a5d2885129 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/IngestionResolverUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/IngestionResolverUtils.java @@ -5,6 +5,7 @@ import com.linkedin.datahub.graphql.generated.IngestionConfig; import com.linkedin.datahub.graphql.generated.IngestionSchedule; import com.linkedin.datahub.graphql.generated.IngestionSource; +import com.linkedin.datahub.graphql.generated.StructuredReport; import com.linkedin.datahub.graphql.types.common.mappers.StringMapMapper; import com.linkedin.entity.EntityResponse; import com.linkedin.entity.EnvelopedAspect; @@ -12,6 +13,7 @@ import com.linkedin.execution.ExecutionRequestInput; import com.linkedin.execution.ExecutionRequestResult; import com.linkedin.execution.ExecutionRequestSource; +import com.linkedin.execution.StructuredExecutionReport; import com.linkedin.ingestion.DataHubIngestionSourceConfig; import com.linkedin.ingestion.DataHubIngestionSourceInfo; import com.linkedin.ingestion.DataHubIngestionSourceSchedule; @@ -77,9 +79,20 @@ public static com.linkedin.datahub.graphql.generated.ExecutionRequestResult mapE result.setStartTimeMs(execRequestResult.getStartTimeMs()); result.setDurationMs(execRequestResult.getDurationMs()); result.setReport(execRequestResult.getReport()); + if (execRequestResult.hasStructuredReport()) { + result.setStructuredReport(mapStructuredReport(execRequestResult.getStructuredReport())); + } return result; } + public static StructuredReport mapStructuredReport(final StructuredExecutionReport structuredReport) { + StructuredReport structuredReportResult = new StructuredReport(); + structuredReportResult.setType(structuredReport.getType()); + structuredReportResult.setSerializedValue(structuredReport.getSerializedValue()); + structuredReportResult.setContentType(structuredReport.getContentType()); + return structuredReportResult; + } + public static List mapIngestionSources(final Collection entities) { final List results = new ArrayList<>(); for (EntityResponse response : entities) { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/CreateTestConnectionRequestResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/CreateTestConnectionRequestResolver.java index 8fe9acd2c36393..59e20fc49c0dfc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/CreateTestConnectionRequestResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/execution/CreateTestConnectionRequestResolver.java @@ -74,7 +74,6 @@ public CompletableFuture get(final DataFetchingEnvironment environment) Map arguments = new HashMap<>(); arguments.put(RECIPE_ARG_NAME, input.getRecipe()); - arguments.put(VERSION_ARG_NAME, _ingestionConfiguration.getDefaultCliVersion()); execInput.setArgs(new StringMap(arguments)); proposal.setEntityType(Constants.EXECUTION_REQUEST_ENTITY_NAME); diff --git a/datahub-graphql-core/src/main/resources/ingestion.graphql b/datahub-graphql-core/src/main/resources/ingestion.graphql index 60a20c4c2752c8..c279d497081de8 100644 --- a/datahub-graphql-core/src/main/resources/ingestion.graphql +++ b/datahub-graphql-core/src/main/resources/ingestion.graphql @@ -133,6 +133,29 @@ type ExecutionRequestResult { """ report: String + """ + A structured report for this Execution Request + """ + structuredReport: StructuredReport + +} + +""" +A flexible carrier for structured results of an execution request. +""" +type StructuredReport { + """ + The type of the structured report. (e.g. INGESTION_REPORT, TEST_CONNECTION_REPORT, etc.) + """ + type: String! + """ + The serialized value of the structured report + """ + serializedValue: String! + """ + The content-type of the serialized value (e.g. application/json, application/json;gzip etc.) + """ + contentType: String! } """ diff --git a/datahub-web-react/src/app/ingest/source/builder/DefineRecipeStep.tsx b/datahub-web-react/src/app/ingest/source/builder/DefineRecipeStep.tsx index 08ab29b3172d6b..4c75751f113b50 100644 --- a/datahub-web-react/src/app/ingest/source/builder/DefineRecipeStep.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/DefineRecipeStep.tsx @@ -1,13 +1,14 @@ -import { Alert, Button, message, Space, Typography } from 'antd'; +import { Alert, Button, Space, Typography } from 'antd'; import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import { StepProps } from './types'; -import { getSourceConfigs, jsonToYaml, yamlToJson } from '../utils'; +import { getSourceConfigs, jsonToYaml } from '../utils'; import { YamlEditor } from './YamlEditor'; import { ANTD_GRAY } from '../../../entity/shared/constants'; import { IngestionSourceBuilderStep } from './steps'; import RecipeBuilder from './RecipeBuilder'; import { CONNECTORS_WITH_FORM } from './RecipeForm/utils'; +import { getRecipeJson } from './RecipeForm/TestConnection/TestConnectionButton'; const LOOKML_DOC_LINK = 'https://datahubproject.io/docs/generated/ingestion/sources/looker#module-lookml'; @@ -68,14 +69,8 @@ export const DefineRecipeStep = ({ state, updateState, goTo, prev }: StepProps) }, [stagedRecipeYml, showLookerBanner]); const onClickNext = () => { - // Convert the recipe into it's json representation, and catch + report exceptions while we do it. - let recipeJson; - try { - recipeJson = yamlToJson(stagedRecipeYml); - } catch (e) { - message.warn('Found invalid YAML. Please check your recipe configuration.'); - return; - } + const recipeJson = getRecipeJson(stagedRecipeYml); + if (!recipeJson) return; const newState = { ...state, diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/RecipeForm.tsx b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/RecipeForm.tsx index 5d9d4bf014cb7c..93a6a98a0046fe 100644 --- a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/RecipeForm.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/RecipeForm.tsx @@ -7,6 +7,8 @@ import styled from 'styled-components/macro'; import { jsonToYaml } from '../../utils'; import { RecipeField, RECIPE_FIELDS, setFieldValueOnRecipe } from './utils'; import FormField from './FormField'; +import TestConnectionButton from './TestConnection/TestConnectionButton'; +import { SNOWFLAKE } from '../../conf/snowflake/snowflake'; export const ControlsContainer = styled.div` display: flex; @@ -32,6 +34,12 @@ const MarginWrapper = styled.div` margin-left: 20px; `; +const TestConnectionWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 16px; +`; + function getInitialValues(displayRecipe: string, allFields: any[]) { const initialValues = {}; let recipeObj; @@ -108,6 +116,11 @@ function RecipeForm(props: Props) { {fields.map((field, i) => ( ))} + {type === SNOWFLAKE && ( + + + + )} {filterFields.length > 0 && ( diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/ConnectionCapabilityView.tsx b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/ConnectionCapabilityView.tsx new file mode 100644 index 00000000000000..e2a9b0f146e405 --- /dev/null +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/ConnectionCapabilityView.tsx @@ -0,0 +1,74 @@ +import { CheckOutlined, CloseOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import React from 'react'; +import { green, red } from '@ant-design/colors'; +import styled from 'styled-components/macro'; +import { ANTD_GRAY } from '../../../../../entity/shared/constants'; + +const CapabilityWrapper = styled.div` + align-items: center; + display: flex; + margin: 10px 0; +`; + +const CapabilityName = styled.span` + color: ${ANTD_GRAY[8]}; + font-size: 18px; + margin-right: 12px; +`; + +const CapabilityMessage = styled.span<{ success: boolean }>` + color: ${(props) => (props.success ? `${green[6]}` : `${red[5]}`)}; + font-size: 12px; + flex: 1; + padding-left: 4px; +`; + +const StyledQuestion = styled(QuestionCircleOutlined)` + color: rgba(0, 0, 0, 0.45); + margin-left: 4px; +`; + +export const StyledCheck = styled(CheckOutlined)` + color: ${green[6]}; + margin-right: 15px; +`; + +export const StyledClose = styled(CloseOutlined)` + color: ${red[5]}; + margin-right: 15px; +`; + +const NumberWrapper = styled.span` + margin-right: 8px; +`; + +interface Props { + success: boolean; + capability: string; + displayMessage: string | null; + tooltipMessage: string | null; + number?: number; +} + +function ConnectionCapabilityView({ success, capability, displayMessage, tooltipMessage, number }: Props) { + return ( + + + {success ? : } + {number ? {number}. : ''} + {capability} + + + {displayMessage} + {tooltipMessage && ( + + + + )} + + + ); +} + +export default ConnectionCapabilityView; diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionButton.tsx b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionButton.tsx new file mode 100644 index 00000000000000..0d6902ee620d05 --- /dev/null +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionButton.tsx @@ -0,0 +1,128 @@ +import { CheckCircleOutlined } from '@ant-design/icons'; +import { Button, message } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { green } from '@ant-design/colors'; +import { + useCreateTestConnectionRequestMutation, + useGetIngestionExecutionRequestLazyQuery, +} from '../../../../../../graphql/ingestion.generated'; +import { FAILURE, getSourceConfigs, RUNNING, yamlToJson } from '../../../utils'; +import { TestConnectionResult } from './types'; +import TestConnectionModal from './TestConnectionModal'; + +export function getRecipeJson(recipeYaml: string) { + // Convert the recipe into it's json representation, and catch + report exceptions while we do it. + let recipeJson; + try { + recipeJson = yamlToJson(recipeYaml); + } catch (e) { + const messageText = (e as any).parsedLine + ? `Please fix line ${(e as any).parsedLine} in your recipe.` + : 'Please check your recipe configuration.'; + message.warn(`Found invalid YAML. ${messageText}`); + return null; + } + return recipeJson; +} + +interface Props { + type: string; + recipe: string; +} + +function TestConnectionButton(props: Props) { + const { type, recipe } = props; + const [isLoading, setIsLoading] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [pollingInterval, setPollingInterval] = useState(null); + const [testConnectionResult, setTestConnectionResult] = useState(null); + const [createTestConnectionRequest, { data: requestData }] = useCreateTestConnectionRequestMutation(); + const [getIngestionExecutionRequest, { data: resultData, loading }] = useGetIngestionExecutionRequestLazyQuery(); + + const sourceConfigs = getSourceConfigs(type); + + useEffect(() => { + if (requestData && requestData.createTestConnectionRequest) { + const interval = setInterval( + () => + getIngestionExecutionRequest({ + variables: { urn: requestData.createTestConnectionRequest as string }, + }), + 2000, + ); + setIsLoading(true); + setIsModalVisible(true); + setPollingInterval(interval); + } + }, [requestData, getIngestionExecutionRequest]); + + useEffect(() => { + if (!loading && resultData) { + const result = resultData.executionRequest?.result; + if (result && result.status !== RUNNING) { + if (result.status === FAILURE) { + message.error( + 'Something went wrong with your connection test. Please check your recipe and try again.', + ); + setIsModalVisible(false); + } + if (result.structuredReport) { + const testConnectionReport = JSON.parse(result.structuredReport.serializedValue); + setTestConnectionResult(testConnectionReport); + } + if (pollingInterval) clearInterval(pollingInterval); + setIsLoading(false); + } + } + }, [resultData, pollingInterval, loading]); + + useEffect(() => { + if (!isModalVisible && pollingInterval) { + clearInterval(pollingInterval); + } + }, [isModalVisible, pollingInterval]); + + function testConnection() { + const recipeJson = getRecipeJson(recipe); + if (recipeJson) { + createTestConnectionRequest({ variables: { input: { recipe: recipeJson } } }) + .then((res) => + getIngestionExecutionRequest({ + variables: { urn: res.data?.createTestConnectionRequest as string }, + }), + ) + .catch(() => { + message.error( + 'There was an unexpected error when trying to test your connection. Please try again.', + ); + }); + + setIsLoading(true); + setIsModalVisible(true); + } + } + + const internalFailure = !!testConnectionResult?.internal_failure; + const basicConnectivityFailure = testConnectionResult?.basic_connectivity?.capable === false; + const testConnectionFailed = internalFailure || basicConnectivityFailure; + + return ( + <> + + {isModalVisible && ( + setIsModalVisible(false)} + /> + )} + + ); +} + +export default TestConnectionButton; diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionModal.tsx b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionModal.tsx new file mode 100644 index 00000000000000..0b96c401857b6c --- /dev/null +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/TestConnectionModal.tsx @@ -0,0 +1,191 @@ +import { CheckOutlined, CloseOutlined } from '@ant-design/icons'; +import { Button, Divider, Modal, Typography } from 'antd'; +import React from 'react'; +import { green, red } from '@ant-design/colors'; +import styled from 'styled-components/macro'; +import { ReactComponent as LoadingSvg } from '../../../../../../images/datahub-logo-color-loading_pendulum.svg'; +import { ANTD_GRAY } from '../../../../../entity/shared/constants'; +import ConnectionCapabilityView from './ConnectionCapabilityView'; +import { SourceConfig } from '../../../conf/types'; +import { CapabilityReport, SourceCapability, TestConnectionResult } from './types'; + +const LoadingWrapper = styled.div` + display: flex; + justify-content: center; + margin: 50px 0 60px 0; +`; + +const LoadingSubheader = styled.div` + display: flex; + justify-content: center; + font-size: 12px; +`; + +const LoadingHeader = styled(Typography.Title)` + display: flex; + justify-content: center; +`; + +const ResultsHeader = styled.div<{ success: boolean }>` + align-items: center; + color: ${(props) => (props.success ? `${green[6]}` : `${red[5]}`)}; + display: flex; + margin-bottom: 5px; + font-size: 20px; + font-weight: 550; + + svg { + margin-right: 6px; + } +`; + +const ResultsSubHeader = styled.div` + color: ${ANTD_GRAY[7]}; +`; + +const ResultsWrapper = styled.div` + padding: 0 10px; +`; + +const ModalHeader = styled.div` + align-items: center; + display: flex; + padding: 10px 10px 0 10px; + padding: 5px; + font-size: 20px; +`; + +const SourceIcon = styled.img` + height: 22px; + width: 22px; + margin-right: 10px; +`; + +const CapabilitiesHeader = styled.div` + margin: -5px 0 20px 0; +`; + +const CapabilitiesTitle = styled.div` + font-size: 18px; + font-weight: bold; + margin-bottom: 5px; +`; + +const StyledCheck = styled(CheckOutlined)` + color: ${green[6]}; + margin-right: 5px; +`; + +const StyledClose = styled(CloseOutlined)` + color: ${red[5]}; + margin-right: 5px; +`; + +interface Props { + isLoading: boolean; + testConnectionFailed: boolean; + sourceConfig: SourceConfig; + testConnectionResult: TestConnectionResult | null; + hideModal: () => void; +} + +function TestConnectionModal({ + isLoading, + testConnectionFailed, + sourceConfig, + testConnectionResult, + hideModal, +}: Props) { + return ( + Done} + title={ + + + {sourceConfig.displayName} Connection Test + + } + width={750} + > + {isLoading && ( + + Testing your connection... + This could take a few minutes + + + + + )} + {!isLoading && ( + + + {testConnectionFailed ? ( + <> + Connection Failed + + ) : ( + <> + Connection Succeeded + + )} + + + {testConnectionFailed + ? `A connection was not able to be established with ${sourceConfig.displayName}.` + : `A connection was successfully established with ${sourceConfig.displayName}.`} + + + {testConnectionResult?.internal_failure ? ( + + ) : ( + + Capabilities + + The following connector capabilities are supported with your credentials + + + )} + {testConnectionResult?.basic_connectivity && ( + + )} + {testConnectionResult?.capability_report && + Object.keys(testConnectionResult.capability_report).map((capabilityKey, index) => { + return ( + + ); + })} + + )} + + ); +} + +export default TestConnectionModal; diff --git a/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/types.ts b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/types.ts new file mode 100644 index 00000000000000..3395f0c67d8c8a --- /dev/null +++ b/datahub-web-react/src/app/ingest/source/builder/RecipeForm/TestConnection/types.ts @@ -0,0 +1,32 @@ +export enum SourceCapability { + PLATFORM_INSTANCE = 'Platform Instance', + DOMAINS = 'Domains', + DATA_PROFILING = 'Data Profiling', + USAGE_STATS = 'Usage Stats', + PARTITION_SUPPORT = 'Partition Support', + DESCRIPTIONS = 'Descriptions', + LINEAGE_COARSE = 'Table-Level Lineage', + LINEAGE_FINE = 'Column-level Lineage', + OWNERSHIP = 'Extract Ownership', + DELETION_DETECTION = 'Detect Deleted Entities', + TAGS = 'Extract Tags', + SCHEMA_METADATA = 'Schema Metadata', + CONTAINERS = 'Asset Containers', +} + +export interface ConnectionCapability { + capable: boolean; + failure_reason: string | null; + mitigation_message: string | null; +} + +export interface CapabilityReport { + [key: string]: ConnectionCapability; +} + +export interface TestConnectionResult { + internal_failure?: boolean; + internal_failure_reason?: string; + basic_connectivity?: ConnectionCapability; + capability_report?: CapabilityReport; +} diff --git a/datahub-web-react/src/app/ingest/source/builder/__tests__/DefineRecipeStep.test.tsx b/datahub-web-react/src/app/ingest/source/builder/__tests__/DefineRecipeStep.test.tsx index ce1e9d6d7184dc..1cb38b6c87fbd8 100644 --- a/datahub-web-react/src/app/ingest/source/builder/__tests__/DefineRecipeStep.test.tsx +++ b/datahub-web-react/src/app/ingest/source/builder/__tests__/DefineRecipeStep.test.tsx @@ -1,3 +1,4 @@ +import { MockedProvider } from '@apollo/client/testing'; import { render } from '@testing-library/react'; import React from 'react'; import { DefineRecipeStep } from '../DefineRecipeStep'; @@ -5,13 +6,15 @@ import { DefineRecipeStep } from '../DefineRecipeStep'; describe('DefineRecipeStep', () => { it('should render the RecipeBuilder if the type is in CONNECTORS_WITH_FORM', () => { const { getByText, queryByText } = render( - {}} - goTo={() => {}} - submit={() => {}} - cancel={() => {}} - />, + + {}} + goTo={() => {}} + submit={() => {}} + cancel={() => {}} + /> + , ); expect(getByText('Connection')).toBeInTheDocument(); diff --git a/datahub-web-react/src/app/ingest/source/utils.ts b/datahub-web-react/src/app/ingest/source/utils.ts index 861b6d3e3d0387..ec6cff577180ef 100644 --- a/datahub-web-react/src/app/ingest/source/utils.ts +++ b/datahub-web-react/src/app/ingest/source/utils.ts @@ -27,32 +27,37 @@ export const jsonToYaml = (json: string): string => { return yamlStr; }; +export const RUNNING = 'RUNNING'; +export const SUCCESS = 'SUCCESS'; +export const FAILURE = 'FAILURE'; +export const CANCELLED = 'CANCELLED'; + export const getExecutionRequestStatusIcon = (status: string) => { return ( - (status === 'RUNNING' && LoadingOutlined) || - (status === 'SUCCESS' && CheckCircleOutlined) || - (status === 'FAILURE' && CloseCircleOutlined) || - (status === 'CANCELLED' && CloseCircleOutlined) || + (status === RUNNING && LoadingOutlined) || + (status === SUCCESS && CheckCircleOutlined) || + (status === FAILURE && CloseCircleOutlined) || + (status === CANCELLED && CloseCircleOutlined) || undefined ); }; export const getExecutionRequestStatusDisplayText = (status: string) => { return ( - (status === 'RUNNING' && 'Running') || - (status === 'SUCCESS' && 'Succeeded') || - (status === 'FAILURE' && 'Failed') || - (status === 'CANCELLED' && 'Cancelled') || + (status === RUNNING && 'Running') || + (status === SUCCESS && 'Succeeded') || + (status === FAILURE && 'Failed') || + (status === CANCELLED && 'Cancelled') || status ); }; export const getExecutionRequestStatusDisplayColor = (status: string) => { return ( - (status === 'RUNNING' && REDESIGN_COLORS.BLUE) || - (status === 'SUCCESS' && 'green') || - (status === 'FAILURE' && 'red') || - (status === 'CANCELLED' && ANTD_GRAY[9]) || + (status === RUNNING && REDESIGN_COLORS.BLUE) || + (status === SUCCESS && 'green') || + (status === FAILURE && 'red') || + (status === CANCELLED && ANTD_GRAY[9]) || ANTD_GRAY[7] ); }; diff --git a/datahub-web-react/src/graphql/ingestion.graphql b/datahub-web-react/src/graphql/ingestion.graphql index b4f503f0783edc..65449bcda84c92 100644 --- a/datahub-web-react/src/graphql/ingestion.graphql +++ b/datahub-web-react/src/graphql/ingestion.graphql @@ -85,6 +85,10 @@ query getIngestionExecutionRequest($urn: String!) { startTimeMs durationMs report + structuredReport { + type + serializedValue + } } } } @@ -129,3 +133,7 @@ mutation createIngestionExecutionRequest($input: CreateIngestionExecutionRequest mutation cancelIngestionExecutionRequest($input: CancelIngestionExecutionRequestInput!) { cancelIngestionExecutionRequest(input: $input) } + +mutation createTestConnectionRequest($input: CreateTestConnectionRequestInput!) { + createTestConnectionRequest(input: $input) +}