diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index 2da1af9e62..e34359ee18 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -472,6 +472,7 @@ input[type='radio']:checked:before { transition: background-color 0.2s; padding-left:15px; padding-right: 15px; + margin-left: 20px; } .Form-cancelButton:hover{ @@ -496,6 +497,7 @@ input[type='radio']:checked:before { .Form-formGroup--singleColumn { width: 100% !important; padding-right: 0px; + max-width: 100% !important; } .Form-subCheckbox { diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index bd53e65ed4..1f60836f62 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -33,6 +33,7 @@ import organizations from './organizations/main'; import permissions from './permissions/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; +import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; import access from './access/main'; @@ -103,6 +104,7 @@ var tower = angular.module('Tower', [ activityStream.name, footer.name, jobDetail.name, + jobSubmission.name, notifications.name, standardOut.name, access.name, diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js index 9fb9839cfb..5052e073a4 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js @@ -1,9 +1,9 @@ /* jshint unused: vars */ export default - [ "PlaybookRun", + [ 'InitiatePlaybookRun', 'templateUrl', '$location', - function JobTemplatesList(PlaybookRun, templateUrl, $location) { + function JobTemplatesList(InitiatePlaybookRun, templateUrl, $location) { return { restrict: 'E', link: link, @@ -43,7 +43,7 @@ export default }; scope.launchJobTemplate = function(jobTemplateId){ - PlaybookRun({ scope: scope, id: jobTemplateId }); + InitiatePlaybookRun({ scope: scope, id: jobTemplateId }); }; scope.editJobTemplate = function (jobTemplateId) { diff --git a/awx/ui/client/src/helpers/Adhoc.js b/awx/ui/client/src/helpers/Adhoc.js index ef2baed981..6754a60611 100644 --- a/awx/ui/client/src/helpers/Adhoc.js +++ b/awx/ui/client/src/helpers/Adhoc.js @@ -55,12 +55,9 @@ export default // Submit request to run an adhoc comamand .factory('AdhocRun', ['$location','$stateParams', 'LaunchJob', 'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors', - 'Wait', 'Empty', 'PromptForCredential', 'PromptForVars', - 'PromptForSurvey' , 'CreateLaunchDialog', + 'Wait', 'Empty', 'CreateLaunchDialog', function ($location, $stateParams, LaunchJob, PromptForPasswords, - Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, - PromptForCredential, PromptForVars, PromptForSurvey, - CreateLaunchDialog) { + Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog) { return function (params) { var id = params.project_id, scope = params.scope.$new(), diff --git a/awx/ui/client/src/helpers/JobSubmission.js b/awx/ui/client/src/helpers/JobSubmission.js index f185231c40..5cbb426ade 100644 --- a/awx/ui/client/src/helpers/JobSubmission.js +++ b/awx/ui/client/src/helpers/JobSubmission.js @@ -1,306 +1,17 @@ /************************************************* - * Copyright (c) 2015 Ansible, Inc. + * Copyright (c) 2016 Ansible, Inc. * * All Rights Reserved *************************************************/ -/** -* @ngdoc function -* @name helpers.function:JobSubmission -* @description -* The JobSubmission.js file handles launching a job via a playbook run. There is a workflow that is involved in gathering all the -* variables needed to launch a job, including credentials, passwords, extra variables, and survey data. Depending on what information -* is needed to launch the job, a modal is built that prompts the user for any required information. This modal is built by creating -* an html string with all the fields necessary to launch the job. This html string then gets compiled and opened in a dialog modal. -* -* #Workflow when user hits launch button -* -* A 'get' call is made to the API's 'job_templates/:job_template_id/launch' endpoint for that job template. The response from the API will specify -* -*``` -* "credential_needed_to_start": true, -* "can_start_without_user_input": false, -* "ask_variables_on_launch": false, -* "passwords_needed_to_start": [], -* "variables_needed_to_start": [], -* "survey_enabled": false -*``` -* #Step 1a - Check if there is a credential included in the job template: PromptForCredential -* -* The first step is to check if a credential was specified in the job template, by looking at the value of `credential_needed_to_start` . -* If this boolean is true, then that means that the user did NOT specify a credential in the job template and we must prompt them to select a credential. -* This emits a call to `PromptForCredential` which will do a lookup on the credentials endpoint and show a modal window with the list -* of credentials for the user to choose from. -* -* #Step 1b - Check if the credential requires a password: CheckPasswords -* -* The second part of this process is to check if the credential the user picks requires a prompt for a password. A call is made (in the `CheckPasswords` factory) -* to the chosen credential -* and checks if ``password: ASK`` , ``become_password:ASK`` , or ``vault_password: ASK``. If any of these are ASK, then we begin building the html string for -* each required password (see step 2). If none of these require a password, then we contine on to prompting for vars (see step 3) -* -* #Step 2 - Build password html string: PromptForPasswords -* -* We may detect from the inital 'get' call that we may need to prompt the user for passwords. The ``passwords_needed_to_start`` array from the 'get' call -* will explictly tell us which passwords we must prompt for. Alternatively, we may have found that in steps 1a and 1b that -* we have must prompt for passwords. Either way, we arrive in `PromptForPasswords` factory which builds the html string depending on how the particular credential is setup. -* -* #Step 3 - extra vars text editor: PromptForVars -* -* We may arrive at step three if the credential selected does not require a password, or if the password html string is already done being built. -* if ``ask_variables_on_launch`` was true in the inital 'get' call, then we build the extra_vars text editor in the `PromptForVars` factory. -* This factory makes a REST call to the job template and finds if any 'extra_vars' were specified in the job template. It takes any specified -* extra vars and includes them in the extra_vars text editor that is built in the same factory. This code is added to the html string and passed along -* to the next step. -* -* #Step 4 - Survey Taker: PromptForSurvey -* -* The last step in building the job submission modal is building the survey taker. If ``survey_enabled`` is true from the initial 'get' call, -* we make a REST call to the survey endpoint for the specified job and gather the survey data. The `PromptForSurvey` factory takes the survey -* data and adds to the html string any various survey question. -* -* #Step 5 - build the modal: CreateLaunchDialog -* -* At this point, we need to compile our giant html string onto the modal and open the job submission modal. This happens in the `CreateLaunch` -* factory. In this factory the 'Launch' button for the job is tied to the validity of the form, which handles the validation of these fields. -* -* #Step 6 - Launch the job: LaunchJob -* -* This is maybe the most crucial step. We have setup everything we need in order to gather information from the user and now we want to be sure -* we handle it correctly. And there are many scenarios to take into account. The first scenario we check for is is ``survey_enabled=true`` and -* ``prompt_for_vars=false``, in which case we want to make sure to include the extra_vars from the job template in the data being -* sent to the API (it is important to note that anything specified in the extra vars on job submission will override vars specified in the job template. -* Likewise, any variables specified in the extra vars that are duplicated by the survey vars, will get overridden by the survey vars). -* If the previous scenario is NOT the case, then we continue to gather the modal's answers regularly: gather the passwords, then the extra_vars, then -* any survey results. Also note that we must gather any required survey answers, as well as any optional survey answers that happened to be provided -* by the user. We also include the credential that was chosen if the user was prompted to select a credential. -* At this point we have all the info we need and we are almost ready to perform a POST to the '/launch' endpoint. We must lastly check -* if the user was not prompted for anything and therefore we don't want to pass any extra_vars to the POST. Once this is done we -* make the REST POST call and provide all the data to hte API. The response from the API will be the job ID, which is used to redirect the user -* to the job detail page for that job run. -* -* @Usage -* This is usage information. -*/ - 'use strict'; export default angular.module('JobSubmissionHelper', [ 'RestServices', 'Utilities', 'CredentialFormDefinition', 'CredentialsListDefinition', 'LookUpHelper', 'JobSubmissionHelper', 'JobTemplateFormDefinition', 'ModalDialog', 'FormGenerator', 'JobVarsPromptFormDefinition']) -.factory('LaunchJob', ['Rest', 'Wait', 'ProcessErrors', 'ToJSON', 'Empty', 'GetBasePath', -function(Rest, Wait, ProcessErrors, ToJSON, Empty, GetBasePath) { - return function(params) { - var scope = params.scope, - callback = params.callback || 'JobLaunched', - job_launch_data = {}, - url = params.url, - vars_url = GetBasePath('job_templates')+scope.job_template_id + '/', - // fld, - extra_vars; - - //found it easier to assume that there will be extra vars, and then check for a blank object at the end - job_launch_data.extra_vars = {}; - - //gather the extra vars from the job template if survey is enabled and prompt for vars is false - if (scope.removeGetExtraVars) { - scope.removeGetExtraVars(); - } - scope.removeGetExtraVars = scope.$on('GetExtraVars', function() { - - Rest.setUrl(vars_url); - Rest.get() - .success(function (data) { - if(!Empty(data.extra_vars)){ - data.extra_vars = ToJSON('yaml', data.extra_vars, false); - $.each(data.extra_vars, function(key,value){ - job_launch_data.extra_vars[key] = value; - }); - } - scope.$emit('BuildData'); - }) - .error(function (data, status) { - ProcessErrors(scope, data, status, { hdr: 'Error!', - msg: 'Failed to retrieve job template extra variables.' }); - }); - }); - - //build the data object to be sent to the job launch endpoint. Any variables gathered from the survey and the extra variables text editor are inserted into the extra_vars dict of the job_launch_data - if (scope.removeBuildData) { - scope.removeBuildData(); - } - scope.removeBuildData = scope.$on('BuildData', function() { - if(!Empty(scope.passwords_needed_to_start) && scope.passwords_needed_to_start.length>0){ - scope.passwords.forEach(function(password) { - job_launch_data[password] = scope[password]; - scope.passwords_needed_to_start.push(password+'_confirm'); // i'm pushing these values into this array for use during the survey taker parsing - }); - } - if(scope.prompt_for_vars===true){ - extra_vars = ToJSON(scope.parseType, scope.extra_vars, false); - if(!Empty(extra_vars)){ - $.each(extra_vars, function(key,value){ - job_launch_data.extra_vars[key] = value; - }); - } - - } - - if(scope.survey_enabled===true){ - for (var i=0; i < scope.survey_questions.length; i++){ - var fld = scope.survey_questions[i].variable; - // grab all survey questions that have answers - if(scope.survey_questions[i].required || (scope.survey_questions[i].required === false && scope[fld].toString()!=="")) { - job_launch_data.extra_vars[fld] = scope[fld]; - } - - - if(scope.survey_questions[i].required === false && _.isEmpty(scope[fld])) { - switch (scope.survey_questions[i].type) { - // for optional text and text-areas, submit a blank string if min length is 0 - // -- this is confusing, for an explanation see: - // http://docs.ansible.com/ansible-tower/latest/html/userguide/job_templates.html#optional-survey-questions - // - case "text": - case "textarea": - if (scope.survey_questions[i].min === 0) { - job_launch_data.extra_vars[fld] = ""; - } - break; - - // for optional select lists, if they are left blank make sure we submit - // a value that the API will consider "empty" - // - case "multiplechoice": - job_launch_data.extra_vars[fld] = ""; - break; - case "multiselect": - job_launch_data.extra_vars[fld] = []; - break; - } - } - } - } - - // include the credential used if the user was prompted to choose a cred - if(!Empty(scope.credential)){ - job_launch_data.credential_id = scope.credential; - } - - // If the extra_vars dict is empty, we don't want to include it if we didn't prompt for anything. - if(jQuery.isEmptyObject(job_launch_data.extra_vars)===true && scope.prompt_for_vars===false){ - delete job_launch_data.extra_vars; - } - - Rest.setUrl(url); - Rest.post(job_launch_data) - .success(function(data) { - Wait('stop'); - if(!$('#password-modal').is(':hidden')){ - $('#password-modal').dialog('close'); - } - scope.$emit(callback, data); - }) - .error(function(data, status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed updating job ' + scope.job_template_id + ' with variables. POST returned: ' + status }); - }); - }); - - // if the user has a survey and does not have 'prompt for vars' selected, then we want to - // include the extra vars from the job template in the job launch. so first check for these conditions - // and then overlay any survey vars over those. - if(scope.prompt_for_vars===false && scope.survey_enabled===true){ - scope.$emit('GetExtraVars'); - } - else { - scope.$emit('BuildData'); - } - - - }; -}]) - -.factory('PromptForCredential', ['$location', 'Wait', 'GetBasePath', 'LookUpInit', 'JobTemplateForm', 'CredentialList', 'Rest', 'Prompt', 'ProcessErrors', - 'CheckPasswords', -function($location, Wait, GetBasePath, LookUpInit, JobTemplateForm, CredentialList, Rest, Prompt, ProcessErrors, CheckPasswords) { - return function(params) { - - var scope = params.scope, - selectionMade; - - Wait('stop'); - scope.credential = ''; - - if (scope.removeShowLookupDialog) { - scope.removeShowLookupDialog(); - } - scope.removeShowLookupDialog = scope.$on('ShowLookupDialog', function() { - selectionMade = function () { - // scope.$emit(callback, scope.credential); - CheckPasswords({ - scope: scope, - credential: scope.credential, - callback: 'ContinueCred' - }); - }; - - LookUpInit({ - url: GetBasePath('credentials') + '?kind=ssh', - scope: scope, - form: JobTemplateForm(), - current_item: null, - list: CredentialList, - field: 'credential', - hdr: 'Credential Required', - instructions: "Launching this job requires a machine credential. Please select your machine credential now or Cancel to quit.", - postAction: selectionMade, - input_type: 'radio' - }); - scope.lookUpCredential(); - }); - - if (scope.removeAlertNoCredentials) { - scope.removeAlertNoCredentials(); - } - scope.removeAlertNoCredentials = scope.$on('AlertNoCredentials', function() { - var action = function () { - $('#prompt-modal').modal('hide'); - $location.url('/credentials/add'); - }; - - Prompt({ - hdr: 'Machine Credential Required', - body: "
There are no machine credentials defined in Tower. Launching this job requires a machine credential. " + - "Create one now?", - action: action - }); - }); - - Rest.setUrl(GetBasePath('credentials') + '?kind=ssh'); - Rest.get() - .success(function(data) { - if (data.results.length > 0) { - scope.$emit('ShowLookupDialog'); - } - else { - scope.$emit('AlertNoCredentials'); - } - }) - .error(function(data,status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Checking for machine credentials failed. GET returned: ' + status }); - }); - }; -}]) - - - -.factory('CreateLaunchDialog', ['$compile', 'Rest', 'GetBasePath', 'TextareaResize', 'CreateDialog', 'GenerateForm', -'JobVarsPromptForm', 'Wait', 'ParseTypeChange', -function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, - JobVarsPromptForm, Wait, ParseTypeChange) { +.factory('CreateLaunchDialog', ['$compile', 'CreateDialog', 'Wait', 'ParseTypeChange', +function($compile, CreateDialog, Wait, ParseTypeChange) { return function(params) { var buttons, scope = params.scope, @@ -363,43 +74,25 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, $('#password-accept-button').attr('ng-disabled', 'job_launch_form.$invalid' ); e = angular.element(document.getElementById('password-accept-button')); $compile(e)(scope); - // if(scope.prompt_for_vars===true){ - // setTimeout(function() { - // TextareaResize({ - // scope: scope, - // textareaId: 'job_variables', - // modalId: 'password-modal', - // formId: 'job_launch_form', - // parse: true - // }); - // }, 300); - // } - }); }; }]) - - - - .factory('PromptForPasswords', ['$compile', 'Wait', 'Alert', 'CredentialForm', - function($compile, Wait, Alert, CredentialForm) { + .factory('PromptForPasswords', ['CredentialForm', + function(CredentialForm) { return function(params) { var scope = params.scope, callback = params.callback || 'PasswordsAccepted', url = params.url, form = CredentialForm, - // acceptedPasswords = {}, fld, field, html=params.html || ""; scope.passwords = params.passwords; - // Wait('stop'); - html += "
Launching this job requires the passwords listed below. Enter and confirm each password before continuing.
\n"; - // html += "
\n"; + scope.passwords.forEach(function(password) { // Prompt for password field = form.fields[password]; @@ -409,7 +102,6 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, html += "\n"; html += " " + field.label + "\n"; html += "", ""); - scope.helpContainer = "\n"; - - scope.helpText = "

After defining any extra variables, click Continue to start the job. Otherwise, click cancel to abort.

" + - "

Extra variables are passed as command line variables to the playbook run. It is equivalent to the -e or --extra-vars " + - "command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON.

" + - "JSON:
\n" + - "
{
 \"somevar\": \"somevalue\",
 \"password\": \"magic\"
}
\n" + - "YAML:
\n" + - "
---
somevar: somevalue
password: magic
\n"; - - scope.extra_vars = ParseVariableString(extra_vars); - scope.parseType = 'yaml'; - scope.$emit(callback, html, url); - } - - Rest.setUrl(vars_url); - Rest.get() - .success(function (data) { - buildHtml(data.extra_vars); - - }) - .error(function (data, status) { - ProcessErrors(scope, data, status, { hdr: 'Error!', - msg: 'Failed to retrieve organization: ' + $stateParams.id + '. GET status: ' + status }); - }); - - }; - }]) - - .factory('PromptForSurvey', ['$filter', '$compile', 'Wait', 'Alert', 'CredentialForm', 'CreateLaunchDialog', 'GetBasePath', 'Rest' , 'Empty', - 'GenerateForm', 'ProcessErrors', '$stateParams' , - function($filter, $compile, Wait, Alert, CredentialForm, CreateLaunchDialog, GetBasePath, Rest, Empty, - GenerateForm, ProcessErrors, $stateParams) { - return function(params) { - var html = params.html || "", - id= params.id, - url = params.url, - callback=params.callback, - scope = params.scope, - i, - requiredAsterisk, - requiredClasses, - defaultValue, - choices, - element, - minlength, maxlength, - min, max, - survey_url = GetBasePath('job_templates') + id + '/survey_spec/' ; - - //for toggling the input on password inputs - scope.toggleInput = function(id) { - var buttonId = id + "_show_input_button", - inputId = id, - buttonInnerHTML = $(buttonId).html(); - if (buttonInnerHTML.indexOf("Show") > -1) { - $(buttonId).html("Hide"); - $(inputId).attr("type", "text"); - } else { - $(buttonId).html("Show"); - $(inputId).attr("type", "password"); - } - }; - - function buildHtml(question, index){ - question.index = index; - question.question_name = $filter('sanitize')(question.question_name); - question.question_description = (question.question_description) ? $filter('sanitize')(question.question_description) : undefined; - - - requiredAsterisk = (question.required===true) ? "prepend-asterisk" : ""; - requiredClasses = (question.required===true) ? "ng-pristine ng-invalid-required ng-invalid" : ""; - - html+='
'; - html += ''; - - // html += '\n'; - - if(!Empty(question.question_description)){ - html += '
'+question.question_description+'
\n'; - } - - // if(question.default && question.default.indexOf('<') !== -1){ - // question.default = (question.default) ? question.default.replace(/') !== -1){ - // question.default = (question.default) ? question.default.replace(/>/g, ">") : undefined; - // } - scope[question.variable] = question.default; - - if(question.type === 'text' ){ - minlength = (!Empty(question.min)) ? Number(question.min) : ""; - maxlength =(!Empty(question.max)) ? Number(question.max) : "" ; - html+=''+ - '
Please enter an answer.
'+ - '
Please enter an answer between {{'+minlength+'}} to {{'+maxlength+'}} characters long.
'+ - '
'; - } - - if(question.type === "textarea"){ - scope[question.variable] = (question.default_textarea) ? question.default_textarea : (question.default) ? question.default : ""; - minlength = (!Empty(question.min)) ? Number(question.min) : ""; - maxlength =(!Empty(question.max)) ? Number(question.max) : "" ; - html+=''+ - '
Please enter an answer.
'+ - '
Please enter an answer between {{'+minlength+'}} to {{'+maxlength+'}} characters long.
'+ - '
'; - } - if(question.type === 'password' ){ - minlength = (!Empty(question.min)) ? Number(question.min) : ""; - maxlength =(!Empty(question.max)) ? Number(question.max) : "" ; - html+= '
'+ - ''+ - ''+ - ''+ - ''+ - '
'+ - '
Please enter an answer.
'+ - '
Please enter an answer between {{'+minlength+'}} to {{'+maxlength+'}} characters long.
'+ - '
'; - } - if(question.type === 'multiplechoice'){ - choices = question.choices.split(/\n/); - element = (question.type==="multiselect") ? "checkbox" : 'radio'; - - if (question.default) { - scope[question.variable] = question.default; - } else { - scope[question.variable] = ''; - } - - html+='
'; - html += ''; - // html+= '
Please select an answer.
'+ - // '
'; - html+= '
'; //end survey_taker_input - } - - if(question.type === "multiselect"){ - //seperate the choices out into an array - choices = question.choices.split(/\n/); - //ensure that the default answers are in an array - if (question.default) { - scope[question.variable] = question.default.split(/\n/); - } else { - scope[question.variable] = ''; - } - //create a new object to be used by the surveyCheckboxes directive - html += ''; - // html += ''+ - // '{{job_launch_form.'+question.variable+'_object.$error.checkbox}}'+ - // '
Please select at least one answer.
'; - } - - if(question.type === 'integer'){ - min = (!Empty(question.min)) ? Number(question.min) : ""; - max = (!Empty(question.max)) ? Number(question.max) : "" ; - html+=''+ - '
Please enter an answer.
'+ - '
Please enter an answer that is a valid integer.
'+ - '
Please enter an answer between {{'+min+'}} and {{'+max+'}}.
'; - - } - - if(question.type === "float"){ - min = (!Empty(question.min)) ? question.min : ""; - max = (!Empty(question.max)) ? question.max : "" ; - defaultValue = (!Empty(question.default)) ? question.default : (!Empty(question.default_float)) ? question.default_float : "" ; - html+=''+ - '
Please enter an answer.
'+ - '
Please enter an answer that is a decimal number.
'+ - '
Please enter a decimal number between {{'+min+'}} and {{'+max+'}}.
'; - } - html+='
'; - if(question.index === scope.survey_questions.length-1){ - scope.$emit(callback, html, url); - } - } - - Rest.setUrl(survey_url); - Rest.get() - .success(function (data) { - if(!Empty(data)){ - scope.survey_name = data.name; - scope.survey_description = data.description; - scope.survey_questions = data.spec; - - for(i=0; i0){ - scope.passwords_needed_to_start = passwords; - scope.$emit('PromptForPasswords', passwords, html, url); - } - else if (scope.ask_variables_on_launch){ - scope.$emit('PromptForVars', html, url); - } - else if (!Empty(scope.survey_enabled) && scope.survey_enabled===true) { - scope.$emit('PromptForSurvey', html, url); - } - else { - scope.$emit('StartPlaybookRun', url); - } - }); - - // Get the job or job_template record - Wait('start'); - Rest.setUrl(url); - Rest.get() - .success(function (data) { - new_job_id = data.id; - launch_url = url;//data.related.start; - scope.passwords_needed_to_start = data.passwords_needed_to_start; - scope.prompt_for_vars = data.ask_variables_on_launch; - scope.survey_enabled = data.survey_enabled; - scope.ask_variables_on_launch = data.ask_variables_on_launch; - scope.variables_needed_to_start = data.variables_needed_to_start; - html = ''; - - if(data.credential_needed_to_start === true){ - scope.$emit('PromptForCredential'); - } - else if (!Empty(data.passwords_needed_to_start) && data.passwords_needed_to_start.length > 0) { - scope.$emit('PromptForPasswords', data.passwords_needed_to_start, html, url); - } - else if (data.ask_variables_on_launch) { - scope.$emit('PromptForVars', html, url); - } - else if (!Empty(data.survey_enabled) && data.survey_enabled===true) { - scope.$emit('PromptForSurvey', html, url); - } - else { - scope.$emit('StartPlaybookRun', url); - } - - }) - .error(function (data, status) { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to get job template details. GET returned status: ' + status }); - }); - }; - } - ]) - // Submit SCM Update request - .factory('ProjectUpdate', ['PromptForPasswords', 'LaunchJob', 'Rest', '$location', 'GetBasePath', 'ProcessErrors', 'Alert', - 'ProjectsForm', 'Wait', - function (PromptForPasswords, LaunchJob, Rest, $location, GetBasePath, ProcessErrors, Alert, ProjectsForm, Wait) { + .factory('ProjectUpdate', ['PromptForPasswords', 'LaunchJob', 'Rest', '$location', 'GetBasePath', 'ProcessErrors', 'Alert', 'Wait', + function (PromptForPasswords, LaunchJob, Rest, $location, GetBasePath, ProcessErrors, Alert, Wait) { return function (params) { var scope = params.scope, project_id = params.project_id, @@ -1028,10 +266,9 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, } ]) - // Submit Inventory Update request - .factory('InventoryUpdate', ['PromptForPasswords', 'LaunchJob', 'Rest', '$location', 'GetBasePath', 'ProcessErrors', 'Alert', 'Wait', - function (PromptForPasswords, LaunchJob, Rest, $location, GetBasePath, ProcessErrors, Alert, Wait) { + .factory('InventoryUpdate', ['PromptForPasswords', 'LaunchJob', 'Rest', 'GetBasePath', 'ProcessErrors', 'Alert', 'Wait', + function (PromptForPasswords, LaunchJob, Rest, GetBasePath, ProcessErrors, Alert, Wait) { return function (params) { var scope = params.scope, diff --git a/awx/ui/client/src/helpers/Jobs.js b/awx/ui/client/src/helpers/Jobs.js index 065fe0eab7..7d8fee0d62 100644 --- a/awx/ui/client/src/helpers/Jobs.js +++ b/awx/ui/client/src/helpers/Jobs.js @@ -561,11 +561,11 @@ export default }; }]) - .factory('RelaunchPlaybook', ['PlaybookRun', function(PlaybookRun) { + .factory('RelaunchPlaybook', ['InitiatePlaybookRun', function(InitiatePlaybookRun) { return function(params) { var scope = params.scope, id = params.id; - PlaybookRun({ scope: scope, id: id }); + InitiatePlaybookRun({ scope: scope, id: id }); }; }]) diff --git a/awx/ui/client/src/inventories/edit/inventory-edit.controller.js b/awx/ui/client/src/inventories/edit/inventory-edit.controller.js index e956793cc0..1fb202b6db 100644 --- a/awx/ui/client/src/inventories/edit/inventory-edit.controller.js +++ b/awx/ui/client/src/inventories/edit/inventory-edit.controller.js @@ -15,7 +15,7 @@ function InventoriesEdit($scope, $rootScope, $compile, $location, ReturnToCaller, ClearScope, generateList, OrganizationList, SearchInit, PaginateInit, LookUpInit, GetBasePath, ParseTypeChange, Wait, ToJSON, ParseVariableString, RelatedSearchInit, RelatedPaginateInit, - Prompt, PlaybookRun, CreateDialog, deleteJobTemplate, $state) { + Prompt, InitiatePlaybookRun, CreateDialog, deleteJobTemplate, $state) { ClearScope(); @@ -153,7 +153,7 @@ function InventoriesEdit($scope, $rootScope, $compile, $location, }; $scope.launchScanJob = function(){ - PlaybookRun({ scope: $scope, id: this.scan_job_template.id }); + InitiatePlaybookRun({ scope: $scope, id: this.scan_job_template.id }); }; $scope.scheduleScanJob = function(){ @@ -327,6 +327,6 @@ export default ['$scope', '$rootScope', '$compile', '$location', 'OrganizationList', 'SearchInit', 'PaginateInit', 'LookUpInit', 'GetBasePath', 'ParseTypeChange', 'Wait', 'ToJSON', 'ParseVariableString', 'RelatedSearchInit', 'RelatedPaginateInit', 'Prompt', - 'PlaybookRun', 'CreateDialog', 'deleteJobTemplate', '$state', + 'InitiatePlaybookRun', 'CreateDialog', 'deleteJobTemplate', '$state', InventoriesEdit, ]; diff --git a/awx/ui/client/src/job-detail/job-detail.controller.js b/awx/ui/client/src/job-detail/job-detail.controller.js index a1a19594ad..6ee868a498 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -14,7 +14,7 @@ export default [ '$location', '$rootScope', '$filter', '$scope', '$compile', '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'GetElapsed', - 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', + 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'InitiatePlaybookRun', 'LoadPlays', 'LoadTasks', 'HostsEdit', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'EditSchedule', 'ParseTypeChange', 'JobDetailService', @@ -24,7 +24,7 @@ export default SelectPlay, SelectTask, GetElapsed, JobIsFinished, SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, - PlaybookRun, LoadPlays, LoadTasks, + InitiatePlaybookRun, LoadPlays, LoadTasks, HostsEdit, ParseVariableString, GetChoices, fieldChoices, fieldLabels, EditSchedule, ParseTypeChange, JobDetailService ) { @@ -879,7 +879,7 @@ export default }; scope.relaunchJob = function() { - PlaybookRun({ + InitiatePlaybookRun({ scope: scope, id: scope.job.id }); diff --git a/awx/ui/client/src/job-submission/job-submission-factories/getsurveyquestions.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/getsurveyquestions.factory.js new file mode 100644 index 0000000000..142c606a3e --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission-factories/getsurveyquestions.factory.js @@ -0,0 +1,77 @@ +export default + function GetSurveyQuestions($filter, GetBasePath, Rest, Empty, ProcessErrors, $stateParams) { + + // This factory goes out and gets a job template's survey questions and attaches + // them to scope so that they can be ng-repeated in the job submission template + + return function(params) { + var id= params.id, + scope = params.scope, + i, + survey_url = GetBasePath('job_templates') + id + '/survey_spec/'; + + Rest.setUrl(survey_url); + Rest.get() + .success(function (data) { + if(!Empty(data)){ + scope.survey_name = data.name; + scope.survey_description = data.description; + scope.survey_questions = data.spec; + + for(i=0; i" )( scope ); + $('#content-container').remove('submit-job').append( el ); + }; + } + +InitiatePlaybookRun.$inject = + [ '$location', + 'GetBasePath', + 'Empty', + '$compile' + ]; diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js new file mode 100644 index 0000000000..0da16a64ee --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -0,0 +1,164 @@ +export default + function LaunchJob(Rest, Wait, ProcessErrors, ToJSON, Empty, GetBasePath, $state, $location) { + + // This factory gathers up all the job launch data and POST's it. + + // TODO: outline how these things are gathered + + return function (params) { + var scope = params.scope, + job_launch_data = {}, + url = params.url, + vars_url = GetBasePath('job_templates')+scope.job_template_id + '/', + base = $location.path().replace(/^\//, '').split('/')[0], + extra_vars; + + //found it easier to assume that there will be extra vars, and then check for a blank object at the end + job_launch_data.extra_vars = {}; + + //build the data object to be sent to the job launch endpoint. Any variables gathered from the survey and the extra variables text editor are inserted into the extra_vars dict of the job_launch_data + var buildData = function() { + if(scope.ssh_password_required) { + job_launch_data.ssh_password = scope.passwords.ssh_password; + } + if(scope.ssh_key_unlock_required) { + job_launch_data.ssh_key_unlock = scope.passwords.ssh_key_unlock; + } + if(scope.become_password_required) { + job_launch_data.become_password = scope.passwords.become_password; + } + if(scope.vault_password_required) { + job_launch_data.vault_password = scope.passwords.vault_password; + } + + if(scope.ask_variables_on_launch){ + extra_vars = ToJSON(scope.parseType, scope.jobLaunchVariables, false); + if(!Empty(extra_vars)){ + $.each(extra_vars, function(key,value){ + job_launch_data.extra_vars[key] = value; + }); + } + + } + + if(scope.ask_tags_on_launch && scope.other_prompt_data && scope.other_prompt_data.job_tags){ + job_launch_data.job_tags = scope.other_prompt_data.job_tags; + } + + if(scope.ask_limit_on_launch && scope.other_prompt_data && scope.other_prompt_data.limit){ + job_launch_data.limit = scope.other_prompt_data.limit; + } + + if(scope.ask_job_type_on_launch && scope.other_prompt_data && scope.other_prompt_data.job_type) { + job_launch_data.job_type = scope.other_prompt_data.job_type; + } + + if(scope.survey_enabled===true){ + for (var i=0; i < scope.survey_questions.length; i++){ + var fld = scope.survey_questions[i].variable; + // grab all survey questions that have answers + if(scope.survey_questions[i].required || (scope.survey_questions[i].required === false && scope.survey_questions[i].model.toString()!=="")) { + job_launch_data.extra_vars[fld] = scope.survey_questions[i].model; + } + + if(scope.survey_questions[i].required === false && _.isEmpty(scope.survey_questions[i].model)) { + switch (scope.survey_questions[i].type) { + // for optional text and text-areas, submit a blank string if min length is 0 + // -- this is confusing, for an explanation see: + // http://docs.ansible.com/ansible-tower/latest/html/userguide/job_templates.html#optional-survey-questions + // + case "text": + case "textarea": + if (scope.survey_questions[i].min === 0) { + job_launch_data.extra_vars[fld] = ""; + } + break; + + // for optional select lists, if they are left blank make sure we submit + // a value that the API will consider "empty" + // + case "multiplechoice": + job_launch_data.extra_vars[fld] = ""; + break; + case "multiselect": + job_launch_data.extra_vars[fld] = []; + break; + } + } + } + } + + // include the inventory used if the user was prompted to choose a cred + if(scope.ask_inventory_on_launch && !Empty(scope.selected_inventory)){ + job_launch_data.inventory_id = scope.selected_inventory.id; + } + + // include the credential used if the user was prompted to choose a cred + if(scope.ask_credential_on_launch && !Empty(scope.selected_credential)){ + job_launch_data.credential_id = scope.selected_credential.id; + } + + // If the extra_vars dict is empty, we don't want to include it if we didn't prompt for anything. + if(jQuery.isEmptyObject(job_launch_data.extra_vars)===true && scope.prompt_for_vars===false){ + delete job_launch_data.extra_vars; + } + + Rest.setUrl(url); + Rest.post(job_launch_data) + .success(function(data) { + Wait('stop'); + var job = data.job || data.system_job; + if((scope.portalMode===false || scope.$parent.portalMode===false ) && Empty(data.system_job) || (base === 'home')){ + // use $state.go with reload: true option to re-instantiate sockets in + $state.go('jobDetail', {id: job}, {reload: true}); + } + scope.clearDialog(); + }) + .error(function(data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Failed updating job ' + scope.job_template_id + ' with variables. POST returned: ' + status }); + }); + }; + + //gather the extra vars from the job template if survey is enabled and prompt for vars is false + var getExtraVars = function() { + Rest.setUrl(vars_url); + Rest.get() + .success(function (data) { + if(!Empty(data.extra_vars)){ + data.extra_vars = ToJSON('yaml', data.extra_vars, false); + $.each(data.extra_vars, function(key,value){ + job_launch_data.extra_vars[key] = value; + }); + } + buildData(); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, { hdr: 'Error!', + msg: 'Failed to retrieve job template extra variables.' }); + }); + }; + + // if the user has a survey and does not have 'prompt for vars' selected, then we want to + // include the extra vars from the job template in the job launch. so first check for these conditions + // and then overlay any survey vars over those. + if(scope.prompt_for_vars===false && scope.survey_enabled===true){ + getExtraVars(); + } + else { + buildData(); + } + + }; + } + +LaunchJob.$inject = + [ 'Rest', + 'Wait', + 'ProcessErrors', + 'ToJSON', + 'Empty', + 'GetBasePath', + '$state', + '$location' + ]; diff --git a/awx/ui/client/src/job-submission/job-submission.block.less b/awx/ui/client/src/job-submission/job-submission.block.less new file mode 100644 index 0000000000..15d93c95cb --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission.block.less @@ -0,0 +1,220 @@ +@import '../shared/branding/colors.less'; +@import '../shared/branding/colors.default.less'; + +.JobSubmission { + padding: 20px!important; + display: none; + height: auto!important; + min-height: 400px!important; +} +.JobSubmission-container { + flex-direction: column; + display: flex; + height: auto; + min-height: 360px; +} +.JobSubmission-dialog { + padding: 0px; + margin-bottom: 20px; + .ui-dialog-buttonpane, .ui-dialog-titlebar { + display:none; + } +} +.JobSubmission-header { + display: flex; + flex: 0 0 auto; + align-items: center; +} +.JobSubmission-title { + align-items: center; + flex: 1 0 auto; + display: flex; +} +.JobSubmission-titleText { + color: @list-title-txt; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} +.JobSubmission-titleLockup { + margin-left: 4px; + margin-right: 6px; + display: inline-block; + margin-top: 0px; + padding-bottom: 2px; + vertical-align: bottom; +} +.JobSubmission-titleLockup:before { + content: "\007C"; + color: @default-icon-hov; + display: block; + font-size: 13px; +} +.JobSubmission-close { + justify-content: flex-end; + display: flex; +} +.JobSubmission-exit{ + cursor:pointer; + padding:0px; + border: none; + height:20px; + font-size: 20px; + background-color:@default-bg; + color:@d7grey; + transition: color 0.2s; + line-height:1; +} +.JobSubmission-exit:hover{ + color:@default-icon; +} +.JobSubmission-stepsContainer { + display: flex; + flex: 0 0 auto; + margin-top: 25px; +} +.JobSubmission-steps { + display: flex; + margin-bottom: 20px; + min-height: 30px; +} +.JobSubmission-step { + color: @default-interface-txt; + background-color: @default-bg; + font-size: 12px; + border: 1px solid @default-border; + height: 30px; + border-radius: 5px; + margin-right: 20px; + padding-left: 10px; + padding-right: 10px; + padding-bottom: 5px; + padding-top: 5px; + transition: background-color 0.2s; + text-transform: uppercase; + line-height: 20px; + white-space: nowrap; +} +.JobSubmission-step:hover { + color: @btn-txt; + background-color: @btn-bg-hov; + cursor: pointer; +} +.JobSubmission-step--active { + color: @default-bg!important; + background-color: @d7grey!important; + border-color: @d7grey!important; + cursor: default!important; +} +.JobSubmission-step--disabled { + opacity: 0.4; + cursor: not-allowed!important; +} +.JobSubmission-formContainer { + display: flex; + flex: 1 0 auto; +} +.JobSubmission-form { + display: flex; + flex: 1 0 auto; + max-width: 100%; + flex-direction: column; +} +.JobSubmission-footerContainer { + display: flex; + flex: 0 0 auto; + margin-top: 15px; +} +.JobSubmission-footerPreview { + display: flex; + flex: 1 0 auto; +} +.JobSubmission-footerButtons { + justify-content: flex-end; + display: flex; +} +.JobSubmission-previewItem { + min-width: 150px; + font-weight: normal; + font-size: small; +} +.JobSubmission-previewItemTitle { + color: @default-interface-txt; +} +.JobSubmission-previewItemNone { + color: @default-icon; +} +.JobSubmission-actionButton { + background-color: @submit-button-bg; + color: @submit-button-text; + height: 30px; + padding-left:15px; + padding-right: 15px; + width: 85px; +} +.JobSubmission-actionButton:hover, +.JobSubmission-actionButton:focus { + color: @submit-button-text; + background-color: @submit-button-bg-hov; +} +.JobSubmission-defaultButton{ + background-color: @default-bg; + color: @btn-txt; + text-transform: uppercase; + border-radius: 5px; + border: 1px solid @btn-bord; + padding-left:15px; + padding-right: 15px; + height: 30px; + min-width: 85px; + margin-right: 20px; +} +.JobSubmission-defaultButton:hover{ + background-color: @btn-bg-hov; + color: @btn-txt; +} +.JobSubmission-revertButton { + background-color: @default-link; + color: @default-bg; + text-transform: uppercase; + padding-left:15px; + padding-right: 15px; + font-size: 9px; +} +.JobSubmission-revertButton:hover{ + background-color: @default-link-hov; + color: @default-bg; +} +.JobSubmission-selectedItem { + display: flex; + flex: 1 0 auto; + margin-bottom: 15px; +} +.JobSubmission-selectedItemInfo { + display: flex; + flex: 1 0 auto; +} +.JobSubmission-selectedItemRevert { + display: flex; + flex: 0 0 auto; +} +.JobSubmission-selectedItemLabel { + color: @default-interface-txt; + margin-right: 10px; +} +.JobSubmission-selectedItemNone { + color: @default-icon; +} +.JobSubmission-selectedItemContainer { + display: block; + width: 100%; +} +.JobSubmission-instructions { + color: @default-interface-txt; + margin-top: 25px; + margin-bottom: 15px; +} +.JobSubmission-passwordButton { + padding: 5px 13px!important; +} diff --git a/awx/ui/client/src/job-submission/job-submission.controller.js b/awx/ui/client/src/job-submission/job-submission.controller.js new file mode 100644 index 0000000000..761e797bd9 --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission.controller.js @@ -0,0 +1,530 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +/** + * @ngdoc function + * @name controllers.function:JobSubmission + * @description This controller's for the Job Submission Modal +* The job-submission directive is intended to handle job launch/relaunch from a playbook. There are 4 potential steps involved in launching a job: +* +* Select an Inventory +* Select a Credential +* Extra prompts (extra vars, limit, job type, job tags) +* Fill in survey +* +* #Workflow when user hits launch button +* +* A 'get' call is made to the API's 'job_templates/:job_template_id/launch' endpoint for that job template. The response from the API will specify +* +*``` +* "credential_needed_to_start": true, +* "can_start_without_user_input": false, +* "ask_variables_on_launch": false, +* "passwords_needed_to_start": [], +* "variables_needed_to_start": [], +* "survey_enabled": false +*``` +* #Step 1 - Hit the launch/relaunch endpoint +* +* The launch/relaunch endpoint(s) let us know what the default values are for a particular job template. It also lets us know what the creator of +* the job template selected as "promptable" fields. +* +* #Step 2 - Gather inv/credential lists and job template survey questions +* +* If the job template allows for inventory or credential prompting then we need to go out and get the available inventories and credentials for the +* launching user. We also go out and get the survey from its endpoint if a survey has been created and is enabled for this job template (getsurveyquestions.factory). +* +* #Step 3 - Fill out the job launch form +* +* No server calls needed as a user fills out the form. Note that if no user input is required (no prompts, no passwords) then we skip ahead to the next +* step. +* +* #Step 4 - Launch the job: LaunchJob +* +* This is maybe the most crucial step. We have setup everything we need in order to gather information from the user and now we want to be sure +* we handle it correctly. And there are many scenarios to take into account. The first scenario we check for is is ``survey_enabled=true`` and +* ``prompt_for_vars=false``, in which case we want to make sure to include the extra_vars from the job template in the data being +* sent to the API (it is important to note that anything specified in the extra vars on job submission will override vars specified in the job template. +* Likewise, any variables specified in the extra vars that are duplicated by the survey vars, will get overridden by the survey vars). +* If the previous scenario is NOT the case, then we continue to gather the modal's answers regularly: gather the passwords, then the extra_vars, then +* any survey results. Also note that we must gather any required survey answers, as well as any optional survey answers that happened to be provided +* by the user. We also include the credential that was chosen if the user was prompted to select a credential. +* At this point we have all the info we need and we are almost ready to perform a POST to the '/launch' endpoint. We must lastly check +* if the user was not prompted for anything and therefore we don't want to pass any extra_vars to the POST. Once this is done we +* make the REST POST call and provide all the data to hte API. The response from the API will be the job ID, which is used to redirect the user +* to the job detail page for that job run. +* +* @Usage +* This is usage information. +*/ + +export default + [ '$scope', '$location', 'GetBasePath', 'Empty', 'Wait', 'Rest', 'ProcessErrors', + 'LaunchJob', '$state', 'generateList', 'InventoryList', 'SearchInit', 'PaginateInit', 'CredentialList', 'ParseTypeChange', 'GetSurveyQuestions', + function($scope, $location, GetBasePath, Empty, Wait, Rest, ProcessErrors, + LaunchJob, $state, GenerateList, InventoryList, SearchInit, PaginateInit, CredentialList, ParseTypeChange, GetSurveyQuestions) { + + var launch_url; + + var clearRequiredPasswords = function() { + $scope.ssh_password_required = false; + $scope.ssh_key_unlock_required = false; + $scope.become_password_required = false; + $scope.vault_password_required = false; + + $scope.ssh_password = ""; + $scope.ssh_key_unlock = ""; + $scope.become_password = ""; + $scope.vault_password = ""; + }; + + var updateRequiredPasswords = function() { + if($scope.selected_credential) { + if($scope.selected_credential.id === $scope.defaults.credential.id) { + clearRequiredPasswords(); + for(var i=0; i<$scope.passwords_needed_to_start.length; i++) { + var password = $scope.passwords_needed_to_start[i]; + switch(password) { + case "ssh_password": + $scope.ssh_password_required = true; + break; + case "ssh_key_unlock": + $scope.ssh_key_unlock_required = true; + break; + case "become_password": + $scope.become_password_required = true; + break; + case "vault_password": + $scope.vault_password_required = true; + break; + } + } + } + else { + if($scope.selected_credential.kind === "ssh"){ + $scope.ssh_password_required = ($scope.selected_credential.password === "ASK") ? true : false; + $scope.ssh_key_unlock_required = ($scope.selected_credential.ssh_key_unlock === "ASK") ? true : false; + $scope.become_password_required = ($scope.selected_credential.become_password === "ASK") ? true : false; + $scope.vault_password_required = ($scope.selected_credential.vault_password === "ASK") ? true : false; + } + else { + clearRequiredPasswords(); + } + } + } + + }; + + var launchJob = function() { + LaunchJob({ + scope: $scope, + url: launch_url + }); + }; + + // This gets things started - goes out and hits the launch endpoint (based on launch/relaunch) and + // prepares the form fields, defauts, etc. + $scope.init = function() { + + $scope.forms = {}; + $scope.passwords = {}; + + var base = $location.path().replace(/^\//, '').split('/')[0], + isRelaunch = !(base === 'job_templates' || base === 'portal' || base === 'inventories' || base === 'home'); + + if (!isRelaunch) { + launch_url = GetBasePath('job_templates') + $scope.submitJobId + '/launch/'; + } + else { + launch_url = GetBasePath('jobs') + $scope.submitJobId + '/relaunch/'; + } + + // Get the job or job_template record + Wait('start'); + Rest.setUrl(launch_url); + Rest.get() + .success(function (data) { + + // Put all the data that we get back about the launch onto scope + angular.extend($scope, data); + + // General catch-all for "other prompts" - used in this link function and to hide the Other Prompts tab when + // it should be hidden + $scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_variables_on_launch) ? true : false; + $scope.password_needed = data.passwords_needed_to_start && data.passwords_needed_to_start.length > 0; + $scope.has_default_inventory = data.defaults && data.defaults.inventory && data.defaults.inventory.id; + $scope.has_default_credential = data.defaults && data.defaults.credential && data.defaults.credential.id; + + $scope.other_prompt_data = {}; + + if($scope.ask_job_type_on_launch) { + $scope.other_prompt_data.job_type = (data.defaults && data.defaults.job_type) ? data.defaults.job_type : ""; + } + + if($scope.ask_limit_on_launch) { + $scope.other_prompt_data.limit = (data.defaults && data.defaults.limit) ? data.defaults.limit : ""; + } + + if($scope.ask_tags_on_launch) { + $scope.other_prompt_data.job_tags = (data.defaults && data.defaults.job_tags) ? data.defaults.job_tags : ""; + } + + if($scope.ask_variables_on_launch) { + $scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---"; + $scope.other_prompt_data.parseType = 'yaml'; + $scope.parseType = 'yaml'; + } + + if($scope.has_default_inventory) { + $scope.selected_inventory = angular.copy($scope.defaults.inventory); + } + + if($scope.has_default_credential) { + $scope.selected_credential = angular.copy($scope.defaults.credential); + updateRequiredPasswords(); + } + + if($scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts) { + // The job can be launched without any user input + launchJob(); + Wait('stop'); + } + else { + + var initiateModal = function() { + // Figure out which step the user needs to start on + if($scope.ask_inventory_on_launch) { + $scope.setStep("inventory", true); + } + else if($scope.ask_credential_on_launch || $scope.password_needed) { + $scope.setStep("credential", true); + } + else if($scope.has_other_prompts) { + $scope.setStep("otherprompts", true); + } + else if($scope.survey_enabled) { + $scope.setStep("survey", true); + } + + $scope.openLaunchModal(); + }; + + if(isRelaunch) { + // Go out and get some of the job details like inv, cred, name + Rest.setUrl(GetBasePath('jobs') + $scope.submitJobId); + Rest.get() + .success(function (jobDetailData) { + $scope.job_template_data = { + name: jobDetailData.name + }; + $scope.defaults = {}; + if(jobDetailData.summary_fields.inventory) { + $scope.defaults.inventory = angular.copy(jobDetailData.summary_fields.inventory); + $scope.selected_inventory = angular.copy(jobDetailData.summary_fields.inventory); + } + if(jobDetailData.summary_fields.credential) { + $scope.defaults.credential = angular.copy(jobDetailData.summary_fields.credential); + $scope.selected_credential = angular.copy(jobDetailData.summary_fields.credential); + updateRequiredPasswords(); + } + initiateModal(); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Failed to get job details. GET returned status: ' + status }); + }); + } + else { + // Move forward with the modal + initiateModal(); + } + + } + + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Failed to get job template details. GET returned status: ' + status }); + }); + }; + + $scope.setStep = function(step, initialStep) { + $scope.step = step; + + if(step === "credential") { + $scope.credentialTabEnabled = true; + } + else if(step === "otherprompts") { + $scope.otherPromptsTabEnabled = true; + + if(!initialStep && $scope.step === 'otherprompts' && $scope.ask_variables_on_launch && !$scope.extra_vars_code_mirror_loaded) { + ParseTypeChange({ + scope: $scope, + variable: 'jobLaunchVariables', + field_id: 'job_launch_variables' + }); + + $scope.extra_vars_code_mirror_loaded = true; + } + } + else if(step === "survey") { + $scope.surveyTabEnabled = true; + } + + }; + + $scope.getListsAndSurvey = function() { + if($scope.ask_inventory_on_launch) { + var inventory_url = GetBasePath('inventory'); + + GenerateList.inject(InventoryList, { + mode: 'lookup', + id: 'job-submission-inventory-lookup', + scope: $scope, + input_type: 'radio' + }); + + SearchInit({ + scope: $scope, + set: InventoryList.name, + list: InventoryList, + url: inventory_url + }); + + PaginateInit({ + scope: $scope, + list: InventoryList, + url: inventory_url, + mode: 'lookup' + }); + + $scope.search(InventoryList.iterator); + + $scope.$watchCollection('inventories', function () { + if($scope.selected_inventory) { + // Loop across the inventories and see if one of them should be "checked" + $scope.inventories.forEach(function(row, i) { + if (row.id === $scope.selected_inventory.id) { + $scope.inventories[i].checked = 1; + } + else { + $scope.inventories[i].checked = 0; + } + }); + } + }); + } + if($scope.ask_credential_on_launch) { + var credential_url = GetBasePath('credentials') + '?kind=ssh'; + + GenerateList.inject(CredentialList, { + mode: 'lookup', + id: 'job-submission-credential-lookup', + scope: $scope, + input_type: 'radio' + }); + + SearchInit({ + scope: $scope, + set: CredentialList.name, + list: CredentialList, + url: credential_url + }); + + PaginateInit({ + scope: $scope, + list: CredentialList, + url: credential_url, + mode: 'lookup' + }); + + $scope.search(CredentialList.iterator); + + $scope.$watchCollection('credentials', function () { + if($scope.selected_credential) { + // Loop across the inventories and see if one of them should be "checked" + $scope.credentials.forEach(function(row, i) { + if (row.id === $scope.selected_credential.id) { + $scope.credentials[i].checked = 1; + } + else { + $scope.credentials[i].checked = 0; + } + }); + } + }); + } + if($scope.survey_enabled) { + GetSurveyQuestions({ + scope: $scope, + id: $scope.submitJobId + }); + + } + }; + + $scope.revertToDefaultInventory = function() { + if($scope.has_default_inventory) { + $scope.selected_inventory = angular.copy($scope.defaults.inventory); + + // Loop across inventories and set update the "checked" attribute for each row + $scope.inventories.forEach(function(row, i) { + if (row.id === $scope.selected_inventory.id) { + $scope.inventories[i].checked = 1; + } else { + $scope.inventories[i].checked = 0; + } + }); + } + }; + + $scope.revertToDefaultCredential = function() { + if($scope.has_default_credential) { + $scope.selected_credential = angular.copy($scope.defaults.credential); + updateRequiredPasswords(); + + // Loop across credentials and set update the "checked" attribute for each row + $scope.credentials.forEach(function(row, i) { + if (row.id === $scope.selected_credential.id) { + $scope.credentials[i].checked = 1; + } else { + $scope.credentials[i].checked = 0; + } + }); + } + }; + + $scope.toggle_inventory = function(id) { + $scope.inventories.forEach(function(row, i) { + if (row.id === id) { + $scope.selected_inventory = angular.copy(row); + $scope.inventories[i].checked = 1; + } else { + $scope.inventories[i].checked = 0; + } + }); + }; + + $scope.toggle_credential = function(id) { + $scope.credentials.forEach(function(row, i) { + if (row.id === id) { + $scope.selected_credential = angular.copy(row); + updateRequiredPasswords(); + $scope.credentials[i].checked = 1; + } else { + $scope.credentials[i].checked = 0; + } + }); + }; + + $scope.getActionButtonText = function() { + if($scope.step === "inventory") { + return ($scope.ask_credential_on_launch || $scope.password_needed || $scope.has_other_prompts || $scope.survey_enabled) ? "NEXT" : "LAUNCH"; + } + else if($scope.step === "credential") { + return ($scope.has_other_prompts || $scope.survey_enabled) ? "NEXT" : "LAUNCH"; + } + else if($scope.step === "otherprompts") { + return ($scope.survey_enabled) ? "NEXT" : "LAUNCH"; + } + else if($scope.step === "survey") { + return "LAUNCH"; + } + }; + + $scope.actionButtonDisabled = function() { + if($scope.step === "inventory") { + if($scope.selected_inventory) { + return false; + } + else { + $scope.credentialTabEnabled = false; + $scope.otherPromptsTabEnabled = false; + $scope.surveyTabEnabled = false; + return true; + } + } + else if($scope.step === "credential") { + if($scope.selected_credential && $scope.forms.credentialpasswords.$valid) { + return false; + } + else { + $scope.otherPromptsTabEnabled = false; + $scope.surveyTabEnabled = false; + return true; + } + } + else if($scope.step === "otherprompts") { + if($scope.forms.otherprompts.$valid) { + return false; + } + else { + $scope.surveyTabEnabled = false; + return true; + } + } + else if($scope.step === "survey") { + if($scope.forms.survey.$valid) { + return false; + } + else { + return true; + } + } + + }; + + $scope.takeAction = function() { + if($scope.step === "inventory") { + // Check to see if there's another step after this one + if($scope.ask_credential_on_launch || $scope.password_needed) { + $scope.setStep("credential"); + } + else if($scope.has_other_prompts) { + $scope.setStep("otherprompts"); + } + else if($scope.survey_enabled) { + $scope.setStep("survey"); + } + else { + launchJob(); + } + } + else if($scope.step === "credential") { + if($scope.has_other_prompts) { + $scope.setStep("otherprompts"); + } + else if($scope.survey_enabled) { + $scope.setStep("survey"); + } + else { + launchJob(); + } + } + else if($scope.step === "otherprompts") { + if($scope.survey_enabled) { + $scope.setStep("survey"); + } + else { + launchJob(); + } + } + else { + launchJob(); + } + }; + + $scope.updateParseType = function() { + // This is what the ParseTypeChange factory is expecting + // It shares the same scope with this directive and will + // pull the new value of parseType out to determine which + // direction to convert the extra vars + $scope.parseType = $scope.other_prompt_data.parseType; + $scope.parseTypeChange(); + }; + + } + ]; diff --git a/awx/ui/client/src/job-submission/job-submission.directive.js b/awx/ui/client/src/job-submission/job-submission.directive.js new file mode 100644 index 0000000000..d31279f00d --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission.directive.js @@ -0,0 +1,95 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import jobSubmissionController from './job-submission.controller'; + +export default [ 'templateUrl', 'CreateDialog', 'Wait', 'CreateSelect2', 'ParseTypeChange', + function(templateUrl, CreateDialog, Wait, CreateSelect2, ParseTypeChange) { + return { + scope: { + submitJobId: '=', + submitJobSystem: '=' + }, + templateUrl: templateUrl('job-submission/job-submission'), + controller: jobSubmissionController, + restrict: 'E', + link: function(scope) { + + scope.openLaunchModal = function() { + if (scope.removeLaunchJobModalReady) { + scope.removeLaunchJobModalReady(); + } + scope.removeLaunchJobModalReady = scope.$on('LaunchJobModalReady', function() { + // Go get the list/survey data that we need from the server + scope.getListsAndSurvey(); + + $('#job-launch-modal').dialog('open'); + + // select2-ify the job type dropdown + CreateSelect2({ + element: '#job_launch_job_type', + multiple: false + }); + + if(scope.step === 'otherprompts' && scope.ask_variables_on_launch) { + ParseTypeChange({ + scope: scope, + variable: 'jobLaunchVariables', + field_id: 'job_launch_variables' + }); + + scope.extra_vars_code_mirror_loaded = true; + } + + }); + + CreateDialog({ + id: 'job-launch-modal', + scope: scope, + width: 800, + minWidth: 400, + draggable: false, + dialogClass: 'JobSubmission-dialog', + onOpen: function() { + Wait('stop'); + }, + callback: 'LaunchJobModalReady' + }); + }; + + scope.clearDialog = function() { + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page + $('#content-container').find('submit-job').remove(); + + // Clear out the scope (we'll create a new scope the next time + // job launch is called) + scope.$destroy(); + }; + + // This function is used to hide/show the contents of a password + // within a form + scope.togglePassword = function(id) { + var buttonId = id + "_show_input_button", + inputId = id, + buttonInnerHTML = $(buttonId).html(); + if (buttonInnerHTML.indexOf("Show") > -1) { + $(buttonId).html("Hide"); + $(inputId).attr("type", "text"); + } else { + $(buttonId).html("Show"); + $(inputId).attr("type", "password"); + } + }; + + scope.init(); + + } + }; +}]; diff --git a/awx/ui/client/src/job-submission/job-submission.partial.html b/awx/ui/client/src/job-submission/job-submission.partial.html new file mode 100644 index 0000000000..d1f39af9a9 --- /dev/null +++ b/awx/ui/client/src/job-submission/job-submission.partial.html @@ -0,0 +1,241 @@ +
+
+
+
+
LAUNCH JOB
{{job_template_data.name}}
+
+
+ +
+
+
+
+
Inventory
+
Credential
+
Other Prompts
+
Survey
+
+
+
+
+
+
+
+ SELECTED INVENTORY: + + None selected +
+
+ +
+
+
+
+
+
+
+
+
+ SELECTED CREDENTIAL: + + None selected +
+
+ +
+
+
+
+
Launching this job requires the passwords listed below. Enter and confirm each password before continuing.
+ +
+ +
+ + + + +
+
Please enter a password.
+
+
+
+ +
+ + + + +
+
Please enter a password.
+
+
+
+ +
+ + + + +
+
Please enter a password.
+
+
+
+ +
+ + + + +
+
Please enter a password.
+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+ + +
+ +
+
+ +
Please enter an answer.
+
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
+
+
+ +
Please enter an answer.
+
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
+
+
+
+ + + + +
+
Please enter an answer.
+
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
+
+
+ +
Please enter an answer.
+
Please enter an answer that is a valid integer.
+
Please enter an answer between {{question.minValue}} and {{question.maxValue}}.
+
+
+ +
Please enter an answer.
+
Please enter an answer that is a decimal number.
+
Please enter an answer between {{question.minValue}} and {{question.maxValue}}.
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
INVENTORY
+
+
None selected
+
+
+
CREDENTIAL
+
+
None selected
+
+
+
+ + +
+
+
+
diff --git a/awx/ui/client/src/job-submission/main.js b/awx/ui/client/src/job-submission/main.js new file mode 100644 index 0000000000..711bf00a2f --- /dev/null +++ b/awx/ui/client/src/job-submission/main.js @@ -0,0 +1,17 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import InitiatePlaybookRun from './job-submission-factories/initiateplaybookrun.factory'; +import LaunchJob from './job-submission-factories/launchjob.factory'; +import GetSurveyQuestions from './job-submission-factories/getsurveyquestions.factory'; +import submitJob from './job-submission.directive'; + +export default + angular.module('jobSubmission', []) + .factory('InitiatePlaybookRun', InitiatePlaybookRun) + .factory('LaunchJob', LaunchJob) + .factory('GetSurveyQuestions', GetSurveyQuestions) + .directive('submitJob', submitJob); diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js index 2b1412f113..b31f565b2b 100644 --- a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js @@ -20,7 +20,7 @@ export default 'Empty', 'Prompt', 'ParseVariableString', 'ToJSON', 'SchedulesControllerInit', 'JobsControllerInit', 'JobsListUpdate', 'GetChoices', 'SchedulesListInit', 'SchedulesList', 'CallbackHelpInit', - 'PlaybookRun' , 'initSurvey', '$state', 'CreateSelect2', + 'InitiatePlaybookRun' , 'initSurvey', '$state', 'CreateSelect2', 'ToggleNotification', 'NotificationsListInit', '$q', function( $filter, $scope, $rootScope, $compile, @@ -30,7 +30,7 @@ export default GetBasePath, md5Setup, ParseTypeChange, JobStatusToolTip, FormatDate, Wait, Empty, Prompt, ParseVariableString, ToJSON, SchedulesControllerInit, JobsControllerInit, JobsListUpdate, GetChoices, SchedulesListInit, - SchedulesList, CallbackHelpInit, PlaybookRun, SurveyControllerInit, $state, + SchedulesList, CallbackHelpInit, InitiatePlaybookRun, SurveyControllerInit, $state, CreateSelect2, ToggleNotification, NotificationsListInit, $q ) { @@ -653,7 +653,7 @@ export default } else { - PlaybookRun({ + InitiatePlaybookRun({ scope: $scope, id: id }); diff --git a/awx/ui/client/src/job-templates/list/job-templates-list.controller.js b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js index 3fe1fe3337..366f89ddf3 100644 --- a/awx/ui/client/src/job-templates/list/job-templates-list.controller.js +++ b/awx/ui/client/src/job-templates/list/job-templates-list.controller.js @@ -9,14 +9,14 @@ export default '$stateParams', 'Rest', 'Alert', 'JobTemplateList', 'generateList', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', 'GetBasePath', 'JobTemplateForm', 'CredentialList', - 'LookUpInit', 'PlaybookRun', 'Wait', '$compile', + 'LookUpInit', 'InitiatePlaybookRun', 'Wait', '$compile', '$state', function( $scope, $rootScope, $location, $log, $stateParams, Rest, Alert, JobTemplateList, GenerateList, Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, - GetBasePath, JobTemplateForm, CredentialList, LookUpInit, PlaybookRun, + GetBasePath, JobTemplateForm, CredentialList, LookUpInit, InitiatePlaybookRun, Wait, $compile, $state ) { @@ -95,7 +95,7 @@ export default }; $scope.submitJob = function (id) { - PlaybookRun({ scope: $scope, id: id }); + InitiatePlaybookRun({ scope: $scope, id: id }); }; $scope.scheduleJob = function (id) { diff --git a/awx/ui/client/src/job-templates/survey-maker/survey-maker.block.less b/awx/ui/client/src/job-templates/survey-maker/survey-maker.block.less index 02fe6252c6..c96e5fad54 100644 --- a/awx/ui/client/src/job-templates/survey-maker/survey-maker.block.less +++ b/awx/ui/client/src/job-templates/survey-maker/survey-maker.block.less @@ -102,7 +102,6 @@ text-transform: uppercase; padding-left:15px; padding-right: 15px; - margin-left: 20px; } .SurveyMaker-deleteButton:hover { background-color: @default-err-hov; diff --git a/awx/ui/client/src/lists/Credentials.js b/awx/ui/client/src/lists/Credentials.js index 3b780e7e11..1dd42d6244 100644 --- a/awx/ui/client/src/lists/Credentials.js +++ b/awx/ui/client/src/lists/Credentials.js @@ -26,7 +26,7 @@ export default key: true, label: 'Name', columnClass: 'col-md-3 col-sm-9 col-xs-9', - modalColumnClass: 'col-md-8' + modalColumnClass: 'col-md-11' }, description: { label: 'Description', diff --git a/awx/ui/client/src/lists/Inventories.js b/awx/ui/client/src/lists/Inventories.js index 75c02ad7c3..a40850649f 100644 --- a/awx/ui/client/src/lists/Inventories.js +++ b/awx/ui/client/src/lists/Inventories.js @@ -46,7 +46,7 @@ export default key: true, label: 'Name', columnClass: 'col-md-5 col-sm-5 col-xs-8 List-staticColumnAdjacent', - modalColumnClass: 'col-md-8', + modalColumnClass: 'col-md-11', linkTo: '/#/inventories/{{inventory.id}}/manage' }, organization: { diff --git a/awx/ui/client/src/partials/survey-maker-modal.html b/awx/ui/client/src/partials/survey-maker-modal.html index ebbfe20c79..b1728690b6 100644 --- a/awx/ui/client/src/partials/survey-maker-modal.html +++ b/awx/ui/client/src/partials/survey-maker-modal.html @@ -14,8 +14,8 @@
{{survey_questions[questionToBeDeleted].question_name}}
@@ -81,9 +81,9 @@
- - + +
diff --git a/awx/ui/client/src/portal-mode/portal-mode-job-templates.controller.js b/awx/ui/client/src/portal-mode/portal-mode-job-templates.controller.js index 86e2f51644..379b144454 100644 --- a/awx/ui/client/src/portal-mode/portal-mode-job-templates.controller.js +++ b/awx/ui/client/src/portal-mode/portal-mode-job-templates.controller.js @@ -4,7 +4,7 @@ * All Rights Reserved *************************************************/ -export function PortalModeJobTemplatesController($scope, $rootScope, GetBasePath, GenerateList, PortalJobTemplateList, SearchInit, PaginateInit, PlaybookRun){ +export function PortalModeJobTemplatesController($scope, $rootScope, GetBasePath, GenerateList, PortalJobTemplateList, SearchInit, PaginateInit, InitiatePlaybookRun){ var list = PortalJobTemplateList, @@ -13,7 +13,7 @@ export function PortalModeJobTemplatesController($scope, $rootScope, GetBasePat pageSize = 12; $scope.submitJob = function (id) { - PlaybookRun({ scope: $scope, id: id }); + InitiatePlaybookRun({ scope: $scope, id: id }); }; var init = function(){ @@ -42,5 +42,5 @@ export function PortalModeJobTemplatesController($scope, $rootScope, GetBasePat init(); } -PortalModeJobTemplatesController.$inject = ['$scope', '$rootScope', 'GetBasePath', 'generateList', 'PortalJobTemplateList', 'SearchInit', 'PaginateInit', 'PlaybookRun' +PortalModeJobTemplatesController.$inject = ['$scope', '$rootScope', 'GetBasePath', 'generateList', 'PortalJobTemplateList', 'SearchInit', 'PaginateInit', 'InitiatePlaybookRun' ]; diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 8b85204057..9e2da09fc4 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -454,14 +454,14 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate // Change layout if a lookup list, place radio buttons before labels if (options.mode === 'lookup') { if(options.input_type==="radio"){ //added by JT so that lookup forms can be either radio inputs or check box inputs - innerTable += ""; + "ng-false-value=\"0\" id=\"check_" + list.iterator + "_{{" + list.iterator + ".id}}\" />"; } else { // its assumed that options.input_type = checkbox - innerTable += ""; + "ng-false-value=\"0\" id=\"check_" + list.iterator + "_{{" + list.iterator + ".id}}\" />"; } } @@ -619,7 +619,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate html += buildSelectAll().prop('outerHTML'); } else if (options.mode === 'lookup') { - html += ""; + html += ""; } for (fld in list.fields) { if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index 3d1c7ba0d6..c105235a44 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -218,8 +218,6 @@ - -

working...