diff --git a/Makefile b/Makefile index 0d0217b339..daa9c35a71 100644 --- a/Makefile +++ b/Makefile @@ -361,7 +361,7 @@ check: flake8 pep8 # pyflakes pylint # Run all API unit tests. test: - py.test awx/main/tests awx/api/tests awx/fact/tests + py.test awx/main/tests awx/api/tests test_unit: py.test awx/main/tests/unit diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 038c607579..be34117c2a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1554,7 +1554,8 @@ class CredentialSerializer(BaseSerializer): class Meta: model = Credential fields = ('*', 'deprecated_user', 'deprecated_team', 'kind', 'cloud', 'host', 'username', - 'password', 'security_token', 'project', 'ssh_key_data', 'ssh_key_unlock', + 'password', 'security_token', 'project', 'domain', + 'ssh_key_data', 'ssh_key_unlock', 'become_method', 'become_username', 'become_password', 'vault_password') @@ -1665,16 +1666,15 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)), access_list = reverse('api:job_template_access_list', args=(obj.pk,)), + survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)) )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) - if obj.survey_enabled: - res['survey_spec'] = reverse('api:job_template_survey_spec', args=(obj.pk,)) return res def get_summary_fields(self, obj): d = super(JobTemplateSerializer, self).get_summary_fields(obj) - if obj.survey_enabled and ('name' in obj.survey_spec and 'description' in obj.survey_spec): + if obj.survey_spec is not None and ('name' in obj.survey_spec and 'description' in obj.survey_spec): d['survey'] = dict(title=obj.survey_spec['name'], description=obj.survey_spec['description']) request = self.context.get('request', None) if request is not None and request.user is not None and obj.inventory is not None and obj.project is not None: diff --git a/awx/main/constants.py b/awx/main/constants.py index 64f6265569..a6bdafdf5a 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -1,5 +1,5 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -CLOUD_PROVIDERS = ('azure', 'ec2', 'gce', 'rax', 'vmware', 'openstack') +CLOUD_PROVIDERS = ('azure', 'ec2', 'gce', 'rax', 'vmware', 'openstack', 'openstack_v3') SCHEDULEABLE_PROVIDERS = CLOUD_PROVIDERS + ('custom',) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index d06ed1edd8..01ebbafea6 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -137,7 +137,7 @@ class CallbackReceiver(object): 'playbook_on_import_for_host', 'playbook_on_not_import_for_host'): parent = job_parent_events.get('playbook_on_play_start', None) - elif message['event'].startswith('runner_on_'): + elif message['event'].startswith('runner_on_') or message['event'].startswith('runner_item_on_'): list_parents = [] list_parents.append(job_parent_events.get('playbook_on_setup', None)) list_parents.append(job_parent_events.get('playbook_on_task_start', None)) diff --git a/awx/main/migrations/0010_v300_credential_domain_field.py b/awx/main/migrations/0010_v300_credential_domain_field.py new file mode 100644 index 0000000000..fc77d9999e --- /dev/null +++ b/awx/main/migrations/0010_v300_credential_domain_field.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0009_v300_create_system_job_templates'), + ] + + operations = [ + migrations.AddField( + model_name='credential', + name='domain', + field=models.CharField(default=b'', help_text='The identifier for the domain.', max_length=100, verbose_name='Domain', blank=True), + ), + ] diff --git a/awx/main/models/base.py b/awx/main/models/base.py index a25dd1d154..e0f4b72c1a 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -56,7 +56,7 @@ PERMISSION_TYPE_CHOICES = [ (PERM_JOBTEMPLATE_CREATE, _('Create a Job Template')), ] -CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure', 'openstack', 'custom'] +CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure', 'openstack', 'openstack_v3', 'custom'] VERBOSITY_CHOICES = [ (0, '0 (Normal)'), diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 6d4325f1d9..298560e97e 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -40,6 +40,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): ('gce', _('Google Compute Engine')), ('azure', _('Microsoft Azure')), ('openstack', _('OpenStack')), + ('openstack_v3', _('OpenStack V3')), ] BECOME_METHOD_CHOICES = [ @@ -119,6 +120,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): verbose_name=_('Project'), help_text=_('The identifier for the project.'), ) + domain = models.CharField( + blank=True, + default='', + max_length=100, + verbose_name=_('Domain'), + help_text=_('The identifier for the domain.'), + ) ssh_key_data = models.TextField( blank=True, default='', @@ -229,10 +237,19 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): host = self.host or '' if not host and self.kind == 'vmware': raise ValidationError('Host required for VMware credential.') - if not host and self.kind == 'openstack': + if not host and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Host required for OpenStack credential.') return host + def clean_domain(self): + """For case of Keystone v3 identity service that requires a + `domain`, that a domain is provided. + """ + domain = self.domain or '' + if not domain and self.kind == 'openstack_v3': + raise ValidationError('Domain required for OpenStack with Keystone v3.') + return domain + def clean_username(self): username = self.username or '' if not username and self.kind == 'aws': @@ -242,7 +259,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): 'credential.') if not username and self.kind == 'vmware': raise ValidationError('Username required for VMware credential.') - if not username and self.kind == 'openstack': + if not username and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Username required for OpenStack credential.') return username @@ -254,13 +271,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): raise ValidationError('API key required for Rackspace credential.') if not password and self.kind == 'vmware': raise ValidationError('Password required for VMware credential.') - if not password and self.kind == 'openstack': + if not password and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Password or API key required for OpenStack credential.') return password def clean_project(self): project = self.project or '' - if self.kind == 'openstack' and not project: + if self.kind in ('openstack', 'openstack_v3') and not project: raise ValidationError('Project name required for OpenStack credential.') return project diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 0283a5c70c..1174c33fd6 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -733,6 +733,7 @@ class InventorySourceOptions(BaseModel): ('azure', _('Microsoft Azure')), ('vmware', _('VMware vCenter')), ('openstack', _('OpenStack')), + ('openstack_v3', _('OpenStack V3')), ('custom', _('Custom Script')), ] @@ -961,6 +962,11 @@ class InventorySourceOptions(BaseModel): """I don't think openstack has regions""" return [('all', 'All')] + @classmethod + def get_openstack_v3_region_choices(self): + """Defer to the behavior of openstack""" + return self.get_openstack_region_choices() + def clean_credential(self): if not self.source: return None diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 9a9d0a9e2d..0c5b6efa2d 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -20,9 +20,12 @@ class CustomEmailBackend(EmailBackend): sender_parameter = "sender" def format_body(self, body): - body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'], - body['id'], - body['status'], - body['url'])) - body_actual += pprint.pformat(body, indent=4) + if "body" in body: + body_actual = body['body'] + else: + body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'], + body['id'], + body['status'], + body['url'])) + body_actual += pprint.pformat(body, indent=4) return body_actual diff --git a/awx/main/tasks.py b/awx/main/tasks.py index f444e14c11..de912b0a05 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -695,12 +695,14 @@ class RunJob(BaseTask): if credential.ssh_key_data not in (None, ''): private_data[cred_name] = decrypt_field(credential, 'ssh_key_data') or '' - if job.cloud_credential and job.cloud_credential.kind == 'openstack': + if job.cloud_credential and job.cloud_credential.kind in ('openstack', 'openstack_v3'): credential = job.cloud_credential openstack_auth = dict(auth_url=credential.host, username=credential.username, password=decrypt_field(credential, "password"), project_name=credential.project) + if credential.domain not in (None, ''): + openstack_auth['domain_name'] = credential.domain openstack_data = { 'clouds': { 'devstack': { @@ -785,7 +787,7 @@ class RunJob(BaseTask): env['VMWARE_USER'] = cloud_cred.username env['VMWARE_PASSWORD'] = decrypt_field(cloud_cred, 'password') env['VMWARE_HOST'] = cloud_cred.host - elif cloud_cred and cloud_cred.kind == 'openstack': + elif cloud_cred and cloud_cred.kind in ('openstack', 'openstack_v3'): env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') # Set environment variables related to scan jobs @@ -1134,12 +1136,14 @@ class RunInventoryUpdate(BaseTask): credential = inventory_update.credential return dict(cloud_credential=decrypt_field(credential, 'ssh_key_data')) - if inventory_update.source == 'openstack': + if inventory_update.source in ('openstack', 'openstack_v3'): credential = inventory_update.credential openstack_auth = dict(auth_url=credential.host, username=credential.username, password=decrypt_field(credential, "password"), project_name=credential.project) + if credential.domain not in (None, ''): + openstack_auth['domain_name'] = credential.domain private_state = str(inventory_update.source_vars_dict.get('private', 'true')) # Retrieve cache path from inventory update vars if available, # otherwise create a temporary cache path only for this update. @@ -1287,7 +1291,7 @@ class RunInventoryUpdate(BaseTask): env['GCE_PROJECT'] = passwords.get('source_project', '') env['GCE_PEM_FILE_PATH'] = cloud_credential env['GCE_ZONE'] = inventory_update.source_regions - elif inventory_update.source == 'openstack': + elif inventory_update.source in ('openstack', 'openstack_v3'): env['OS_CLIENT_CONFIG_FILE'] = cloud_credential elif inventory_update.source == 'file': # FIXME: Parse source_env to dict, update env. @@ -1330,6 +1334,11 @@ class RunInventoryUpdate(BaseTask): # to a shorter variable. :) src = inventory_update.source + # OpenStack V3 has everything in common with OpenStack aside + # from one extra parameter, so share these resources between them. + if src == 'openstack_v3': + src = 'openstack' + # Get the path to the inventory plugin, and append it to our # arguments. plugin_path = self.get_path_to('..', 'plugins', 'inventory', diff --git a/awx/main/tests/old/inventory.py b/awx/main/tests/old/inventory.py index 5c4b22e7ab..81dbbb7423 100644 --- a/awx/main/tests/old/inventory.py +++ b/awx/main/tests/old/inventory.py @@ -1969,6 +1969,26 @@ class InventoryUpdatesTest(BaseTransactionTest): self.check_inventory_source(inventory_source) self.assertFalse(self.group.all_hosts.filter(instance_id='').exists()) + def test_update_from_openstack_v3(self): + # Check that update works with Keystone v3 identity service + api_url = getattr(settings, 'TEST_OPENSTACK_HOST_V3', '') + api_user = getattr(settings, 'TEST_OPENSTACK_USER', '') + api_password = getattr(settings, 'TEST_OPENSTACK_PASSWORD', '') + api_project = getattr(settings, 'TEST_OPENSTACK_PROJECT', '') + api_domain = getattr(settings, 'TEST_OPENSTACK_DOMAIN', '') + if not all([api_url, api_user, api_password, api_project, api_domain]): + self.skipTest("No test openstack v3 credentials defined") + self.create_test_license_file() + credential = Credential.objects.create(kind='openstack_v3', + host=api_url, + username=api_user, + password=api_password, + project=api_project, + domain=api_domain) + inventory_source = self.update_inventory_source(self.group, source='openstack_v3', credential=credential) + self.check_inventory_source(inventory_source) + self.assertFalse(self.group.all_hosts.filter(instance_id='').exists()) + def test_update_from_azure(self): source_username = getattr(settings, 'TEST_AZURE_USERNAME', '') source_key_data = getattr(settings, 'TEST_AZURE_KEY_DATA', '') @@ -2013,3 +2033,27 @@ class InventoryCredentialTest(BaseTest): self.assertIn('password', response) self.assertIn('host', response) self.assertIn('project', response) + + def test_openstack_v3_create_ok(self): + data = { + 'kind': 'openstack_v3', + 'name': 'Best credential ever', + 'username': 'some_user', + 'password': 'some_password', + 'project': 'some_project', + 'host': 'some_host', + 'domain': 'some_domain', + } + self.post(self.url, data=data, expect=201, auth=self.get_super_credentials()) + + def test_openstack_v3_create_fail_required_fields(self): + data = { + 'kind': 'openstack_v3', + 'name': 'Best credential ever', + } + response = self.post(self.url, data=data, expect=400, auth=self.get_super_credentials()) + self.assertIn('username', response) + self.assertIn('password', response) + self.assertIn('host', response) + self.assertIn('project', response) + self.assertIn('domain', response) diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py index ddffcaf974..3a70c03085 100644 --- a/awx/plugins/callback/job_event_callback.py +++ b/awx/plugins/callback/job_event_callback.py @@ -196,7 +196,7 @@ class BaseCallbackModule(object): self._init_connection() if self.context is None: self._start_connection() - if 'res' in event_data \ + if 'res' in event_data and hasattr(event_data['res'], 'get') \ and event_data['res'].get('_ansible_no_log', False): res = event_data['res'] if 'stdout' in res and res['stdout']: @@ -271,16 +271,19 @@ class BaseCallbackModule(object): ignore_errors=ignore_errors) def v2_runner_on_failed(self, result, ignore_errors=False): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_failed', host=result._host.name, res=result._result, task=result._task, - ignore_errors=ignore_errors) + ignore_errors=ignore_errors, event_loop=event_is_loop) def runner_on_ok(self, host, res): self._log_event('runner_on_ok', host=host, res=res) def v2_runner_on_ok(self, result): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_ok', host=result._host.name, - task=result._task, res=result._result) + task=result._task, res=result._result, + event_loop=event_is_loop) def runner_on_error(self, host, msg): self._log_event('runner_on_error', host=host, msg=msg) @@ -292,8 +295,9 @@ class BaseCallbackModule(object): self._log_event('runner_on_skipped', host=host, item=item) def v2_runner_on_skipped(self, result): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_skipped', host=result._host.name, - task=result._task) + task=result._task, event_loop=event_is_loop) def runner_on_unreachable(self, host, res): self._log_event('runner_on_unreachable', host=host, res=res) @@ -327,6 +331,18 @@ class BaseCallbackModule(object): self._log_event('runner_on_file_diff', host=result._host.name, task=result._task, diff=diff) + def v2_runner_item_on_ok(self, result): + self._log_event('runner_item_on_ok', res=result._result, host=result._host.name, + task=result._task) + + def v2_runner_item_on_failed(self, result): + self._log_event('runner_item_on_failed', res=result._result, host=result._host.name, + task=result._task) + + def v2_runner_item_on_skipped(self, result): + self._log_event('runner_item_on_skipped', res=result._result, host=result._host.name, + task=result._task) + @staticmethod @statsd.timer('terminate_ssh_control_masters') def terminate_ssh_control_masters(): diff --git a/awx/ui/client/src/about/about.route.js b/awx/ui/client/src/about/about.route.js index 5f8b5e9220..475cf1aea0 100644 --- a/awx/ui/client/src/about/about.route.js +++ b/awx/ui/client/src/about/about.route.js @@ -8,5 +8,10 @@ export default { ncyBreadcrumb: { label: "ABOUT" }, + onExit: function(){ + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + }, templateUrl: templateUrl('about/about') }; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 8e67143c5c..e5197bc390 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -183,7 +183,6 @@ var tower = angular.module('Tower', [ 'StandardOutHelper', 'LogViewerOptionsDefinition', 'EventViewerHelper', - 'HostEventsViewerHelper', 'JobDetailHelper', 'SocketIO', 'lrInfiniteScroll', diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index 221ab12b22..84aaf804e8 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -169,7 +169,7 @@ export default "host": { labelBind: 'hostLabel', type: 'text', - ngShow: "kind.value == 'vmware' || kind.value == 'openstack'", + ngShow: "kind.value == 'vmware' || kind.value == 'openstack' || kind.value === 'openstack_v3'", awPopOverWatch: "hostPopOver", awPopOver: "set in helpers/credentials", dataTitle: 'Host', @@ -243,7 +243,7 @@ export default "password": { labelBind: 'passwordLabel', type: 'sensitive', - ngShow: "kind.value == 'scm' || kind.value == 'vmware' || kind.value == 'openstack'", + ngShow: "kind.value == 'scm' || kind.value == 'vmware' || kind.value == 'openstack' || kind.value == 'openstack_v3'", addRequired: false, editRequired: false, ask: false, @@ -338,7 +338,7 @@ export default "project": { labelBind: 'projectLabel', type: 'text', - ngShow: "kind.value == 'gce' || kind.value == 'openstack'", + ngShow: "kind.value == 'gce' || kind.value == 'openstack' || kind.value == 'openstack_v3'", awPopOverWatch: "projectPopOver", awPopOver: "set in helpers/credentials", dataTitle: 'Project ID', @@ -352,6 +352,23 @@ export default }, subForm: 'credentialSubForm' }, + "domain": { + labelBind: 'domainLabel', + type: 'text', + ngShow: "kind.value == 'openstack_v3'", + awPopOverWatch: "domainPopOver", + awPopOver: "set in helpers/credentials", + dataTitle: 'Domain Name', + dataPlacement: 'right', + dataContainer: "body", + addRequired: false, + editRequired: false, + awRequiredWhen: { + variable: 'domain_required', + init: false + }, + subForm: 'credentialSubForm' + }, "vault_password": { label: "Vault Password", type: 'sensitive', diff --git a/awx/ui/client/src/forms/Source.js b/awx/ui/client/src/forms/Source.js index 1eee07b344..86e6db5477 100644 --- a/awx/ui/client/src/forms/Source.js +++ b/awx/ui/client/src/forms/Source.js @@ -169,7 +169,8 @@ export default label: 'Source Variables', //"{{vars_label}}" , ngShow: "source && (source.value == 'vmware' || " + - "source.value == 'openstack')", + "source.value == 'openstack' || " + + "source.value == 'openstack_v3')", type: 'textarea', addRequired: false, class: 'Form-textAreaLabel', diff --git a/awx/ui/client/src/helpers.js b/awx/ui/client/src/helpers.js index b298a635ef..e8190ea50e 100644 --- a/awx/ui/client/src/helpers.js +++ b/awx/ui/client/src/helpers.js @@ -12,7 +12,6 @@ import Credentials from "./helpers/Credentials"; import EventViewer from "./helpers/EventViewer"; import Events from "./helpers/Events"; import Groups from "./helpers/Groups"; -import HostEventsViewer from "./helpers/HostEventsViewer"; import Hosts from "./helpers/Hosts"; import JobDetail from "./helpers/JobDetail"; import JobSubmission from "./helpers/JobSubmission"; @@ -46,7 +45,6 @@ export EventViewer, Events, Groups, - HostEventsViewer, Hosts, JobDetail, JobSubmission, diff --git a/awx/ui/client/src/helpers/Credentials.js b/awx/ui/client/src/helpers/Credentials.js index f986f06e4e..f1af37a011 100644 --- a/awx/ui/client/src/helpers/Credentials.js +++ b/awx/ui/client/src/helpers/Credentials.js @@ -62,6 +62,7 @@ angular.module('CredentialsHelper', ['Utilities']) scope.username_required = false; // JT-- added username_required b/c mutliple 'kinds' need username to be required (GCE) scope.key_required = false; // JT -- doing the same for key and project scope.project_required = false; + scope.domain_required = false; scope.subscription_required = false; scope.key_description = "Paste the contents of the SSH private key file."; scope.key_hint= "drag and drop an SSH private key file on the field below"; @@ -69,9 +70,11 @@ angular.module('CredentialsHelper', ['Utilities']) scope.password_required = false; scope.hostLabel = ''; scope.projectLabel = ''; + scope.domainLabel = ''; scope.project_required = false; scope.passwordLabel = 'Password (API Key)'; scope.projectPopOver = "

The project value

"; + scope.domainPopOver = "

The domain name

"; scope.hostPopOver = "

The host value

"; if (!Empty(scope.kind)) { @@ -133,6 +136,22 @@ angular.module('CredentialsHelper', ['Utilities']) " as the username.

"; scope.hostPopOver = "

The host to authenticate with." + "
For example, https://openstack.business.com/v2.0/"; + case 'openstack_v3': + scope.hostLabel = "Host (Authentication URL)"; + scope.projectLabel = "Project (Tenet Name/ID)"; + scope.domainLabel = "Domain Name"; + scope.password_required = true; + scope.project_required = true; + scope.domain_required = true; + scope.host_required = true; + scope.username_required = true; + scope.projectPopOver = "

This is the tenant name " + + "or tenant id. This value is usually the same " + + " as the username.

"; + scope.hostPopOver = "

The host to authenticate with." + + "
For example, https://openstack.business.com/v3

"; + scope.domainPopOver = "

Domain used for Keystone v3 " + + "
identity service.

"; break; } } diff --git a/awx/ui/client/src/helpers/Groups.js b/awx/ui/client/src/helpers/Groups.js index eeebb9d8bf..4e95d96857 100644 --- a/awx/ui/client/src/helpers/Groups.js +++ b/awx/ui/client/src/helpers/Groups.js @@ -305,7 +305,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name field_id: 'source_extra_vars', onReady: callback }); } if(scope.source.value==="vmware" || - scope.source.value==="openstack"){ + scope.source.value==="openstack" || + scope.source.value==="openstack_v3"){ scope.inventory_variables = (Empty(scope.source_vars)) ? "---" : scope.source_vars; ParseTypeChange({ scope: scope, variable: 'inventory_variables', parse_variable: form.fields.inventory_variables.parseTypeName, field_id: 'source_inventory_variables', onReady: callback }); @@ -315,7 +316,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name scope.source.value==='gce' || scope.source.value === 'azure' || scope.source.value === 'vmware' || - scope.source.value === 'openstack') { + scope.source.value === 'openstack' || + scope.source.value === 'openstack_v3') { if (scope.source.value === 'ec2') { kind = 'aws'; } else { @@ -924,7 +926,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name ParseTypeChange({ scope: sources_scope, variable: 'source_vars', parse_variable: SourceForm.fields.source_vars.parseTypeName, field_id: 'source_source_vars', onReady: waitStop }); } else if (sources_scope.source && (sources_scope.source.value === 'vmware' || - sources_scope.source.value === 'openstack')) { + sources_scope.source.value === 'openstack' || + sources_scope.source.value === 'openstack_v3')) { Wait('start'); ParseTypeChange({ scope: sources_scope, variable: 'inventory_variables', parse_variable: SourceForm.fields.inventory_variables.parseTypeName, field_id: 'source_inventory_variables', onReady: waitStop }); @@ -1303,7 +1306,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name } if (sources_scope.source && (sources_scope.source.value === 'vmware' || - sources_scope.source.value === 'openstack')) { + sources_scope.source.value === 'openstack' || + sources_scope.source.value === 'openstack_v3')) { data.source_vars = ToJSON(sources_scope.envParseType, sources_scope.inventory_variables, true); } diff --git a/awx/ui/client/src/helpers/HostEventsViewer.js b/awx/ui/client/src/helpers/HostEventsViewer.js deleted file mode 100644 index e8fc5a940a..0000000000 --- a/awx/ui/client/src/helpers/HostEventsViewer.js +++ /dev/null @@ -1,287 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - - /** - * @ngdoc function - * @name helpers.function:HostEventsViewer - * @description view a list of events for a given job and host -*/ - -export default - angular.module('HostEventsViewerHelper', ['ModalDialog', 'Utilities', 'EventViewerHelper']) - - .factory('HostEventsViewer', ['$log', '$compile', 'CreateDialog', 'Wait', 'GetBasePath', 'Empty', 'GetEvents', 'EventViewer', - function($log, $compile, CreateDialog, Wait, GetBasePath, Empty, GetEvents, EventViewer) { - return function(params) { - var parent_scope = params.scope, - scope = parent_scope.$new(true), - job_id = params.job_id, - url = params.url, - title = params.title, //optional - fixHeight, buildTable, - lastID, setStatus, buildRow, status; - - // initialize the status dropdown - scope.host_events_status_options = [ - { value: "all", name: "All" }, - { value: "changed", name: "Changed" }, - { value: "failed", name: "Failed" }, - { value: "ok", name: "OK" }, - { value: "unreachable", name: "Unreachable" } - ]; - scope.host_events_search_name = params.name; - status = (params.status) ? params.status : 'all'; - scope.host_events_status_options.every(function(opt, idx) { - if (opt.value === status) { - scope.host_events_search_status = scope.host_events_status_options[idx]; - return false; - } - return true; - }); - if (!scope.host_events_search_status) { - scope.host_events_search_status = scope.host_events_status_options[0]; - } - - $log.debug('job_id: ' + job_id + ' url: ' + url + ' title: ' + title + ' name: ' + name + ' status: ' + status); - - scope.eventsSearchActive = (scope.host_events_search_name) ? true : false; - - if (scope.removeModalReady) { - scope.removeModalReady(); - } - scope.removeModalReady = scope.$on('ModalReady', function() { - scope.hostViewSearching = false; - $('#host-events-modal-dialog').dialog('open'); - }); - - if (scope.removeJobReady) { - scope.removeJobReady(); - } - scope.removeEventReady = scope.$on('EventsReady', function(e, data, maxID) { - var elem, html; - - lastID = maxID; - html = buildTable(data); - $('#host-events').html(html); - elem = angular.element(document.getElementById('host-events-modal-dialog')); - $compile(elem)(scope); - - CreateDialog({ - scope: scope, - width: 675, - height: 600, - minWidth: 450, - callback: 'ModalReady', - id: 'host-events-modal-dialog', - onResizeStop: fixHeight, - title: ( (title) ? title : 'Host Events' ), - onClose: function() { - try { - scope.$destroy(); - } - catch(e) { - //ignore - } - }, - onOpen: function() { - fixHeight(); - } - }); - }); - - if (scope.removeRefreshHTML) { - scope.removeRefreshHTML(); - } - scope.removeRefreshHTML = scope.$on('RefreshHTML', function(e, data) { - var elem, html = buildTable(data); - $('#host-events').html(html); - scope.hostViewSearching = false; - elem = angular.element(document.getElementById('host-events')); - $compile(elem)(scope); - }); - - setStatus = function(result) { - var msg = '', status = 'ok', status_text = 'OK'; - if (!result.task && result.event_data && result.event_data.res && result.event_data.res.ansible_facts) { - result.task = "Gathering Facts"; - } - if (result.event === "runner_on_no_hosts") { - msg = "No hosts remaining"; - } - if (result.event === 'runner_on_unreachable') { - status = 'unreachable'; - status_text = 'Unreachable'; - } - else if (result.failed) { - status = 'failed'; - status_text = 'Failed'; - } - else if (result.changed) { - status = 'changed'; - status_text = 'Changed'; - } - if (result.event_data.res && result.event_data.res.msg) { - msg = result.event_data.res.msg; - } - result.msg = msg; - result.status = status; - result.status_text = status_text; - return result; - }; - - buildRow = function(res) { - var html = ''; - html += "\n"; - html += " " + res.status_text + "\n"; - html += "" + res.host_name + "\n"; - html += "" + res.play + "\n"; - html += "" + res.task + "\n"; - html += ""; - return html; - }; - - buildTable = function(data) { - var html = "\n"; - html += "\n"; - data.results.forEach(function(result) { - var res = setStatus(result); - html += buildRow(res); - }); - html += "\n"; - html += "
\n"; - return html; - }; - - fixHeight = function() { - var available_height = $('#host-events-modal-dialog').height() - $('#host-events-modal-dialog #search-form').height() - $('#host-events-modal-dialog #fixed-table-header').height(); - $('#host-events').height(available_height); - $log.debug('set height to: ' + available_height); - // Check width and reset search fields - if ($('#host-events-modal-dialog').width() <= 450) { - $('#host-events-modal-dialog #status-field').css({'margin-left': '7px'}); - } - else { - $('#host-events-modal-dialog #status-field').css({'margin-left': '15px'}); - } - }; - - GetEvents({ - url: url, - scope: scope, - callback: 'EventsReady' - }); - - scope.modalOK = function() { - $('#host-events-modal-dialog').dialog('close'); - scope.$destroy(); - }; - - scope.searchEvents = function() { - scope.eventsSearchActive = (scope.host_events_search_name) ? true : false; - GetEvents({ - scope: scope, - url: url, - callback: 'RefreshHTML' - }); - }; - - scope.searchEventKeyPress = function(e) { - if (e.keyCode === 13) { - scope.searchEvents(); - } - }; - - scope.showDetails = function(id) { - EventViewer({ - scope: parent_scope, - url: GetBasePath('jobs') + job_id + '/job_events/?id=' + id, - }); - }; - - if (scope.removeEventsScrollDownBuild) { - scope.removeEventsScrollDownBuild(); - } - scope.removeEventsScrollDownBuild = scope.$on('EventScrollDownBuild', function(e, data, maxID) { - var elem, html = ''; - lastID = maxID; - data.results.forEach(function(result) { - var res = setStatus(result); - html += buildRow(res); - }); - if (html) { - $('#host-events table tbody').append(html); - elem = angular.element(document.getElementById('host-events')); - $compile(elem)(scope); - } - }); - - scope.hostEventsScrollDown = function() { - GetEvents({ - scope: scope, - url: url, - gt: lastID, - callback: 'EventScrollDownBuild' - }); - }; - - }; - }]) - - .factory('GetEvents', ['Rest', 'ProcessErrors', function(Rest, ProcessErrors) { - return function(params) { - var url = params.url, - scope = params.scope, - gt = params.gt, - callback = params.callback; - - if (scope.host_events_search_name) { - url += '?host_name=' + scope.host_events_search_name; - } - else { - url += '?host_name__isnull=false'; - } - - if (scope.host_events_search_status.value === 'changed') { - url += '&event__icontains=runner&changed=true'; - } - else if (scope.host_events_search_status.value === 'failed') { - url += '&event__icontains=runner&failed=true'; - } - else if (scope.host_events_search_status.value === 'ok') { - url += '&event=runner_on_ok&changed=false'; - } - else if (scope.host_events_search_status.value === 'unreachable') { - url += '&event=runner_on_unreachable'; - } - else if (scope.host_events_search_status.value === 'all') { - url += '&event__icontains=runner¬__event=runner_on_skipped'; - } - - if (gt) { - // used for endless scroll - url += '&id__gt=' + gt; - } - - url += '&page_size=50&order=id'; - - scope.hostViewSearching = true; - Rest.setUrl(url); - Rest.get() - .success(function(data) { - var lastID; - scope.hostViewSearching = false; - if (data.results.length > 0) { - lastID = data.results[data.results.length - 1].id; - } - scope.$emit(callback, data, lastID); - }) - .error(function(data, status) { - scope.hostViewSearching = false; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to get events ' + url + '. GET returned: ' + status }); - }); - }; - }]); diff --git a/awx/ui/client/src/job-detail/host-event/host-event.route.js b/awx/ui/client/src/job-detail/host-event/host-event.route.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/awx/ui/client/src/job-detail/host-events/host-events.block.less b/awx/ui/client/src/job-detail/host-events/host-events.block.less new file mode 100644 index 0000000000..17d318dc89 --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.block.less @@ -0,0 +1,82 @@ +@import "awx/ui/client/src/shared/branding/colors.less"; +@import "awx/ui/client/src/shared/branding/colors.default.less"; + +.HostEvents .modal-footer{ + border: 0; + margin-top: 0px; + padding-top: 5px; +} +.HostEvents-status--ok{ + color: @green; +} +.HostEvents-status--unreachable{ + color: @unreachable; +} +.HostEvents-status--changed{ + color: @changed; +} +.HostEvents-status--failed{ + color: @warning; +} +.HostEvents-status--skipped{ + color: @skipped; +} +.HostEvents-search--form{ + max-width: 420px; + display: inline-block; +} +.HostEvents-close{ + width: 70px; +} +.HostEvents-filter--form{ + padding-top: 15px; + padding-bottom: 15px; + float: right; + display: inline-block; +} +.HostEvents .modal-body{ + padding: 20px; +} +.HostEvents .select2-container{ + text-transform: capitalize; + max-width: 220px; + float: right; +} +.HostEvents-form--container{ + padding-top: 15px; + padding-bottom: 15px; +} +.HostEvents-title{ + color: @default-interface-txt; + font-weight: 600; +} +.HostEvents-status i { + padding-right: 10px; +} +.HostEvents-table--header { + height: 30px; + font-size: 14px; + font-weight: normal; + text-transform: uppercase; + color: @default-interface-txt; + background-color: @default-list-header-bg; + padding-left: 15px; + padding-right: 15px; + border-bottom-width: 0px; +} +.HostEvents-table--header:first-of-type{ + border-top-left-radius: 5px; +} +.HostEvents-table--header:last-of-type{ + border-top-right-radius: 5px; +} +.HostEvents-table--row{ + color: @default-data-txt; + border: 0 !important; +} +.HostEvents-table--row:nth-child(odd){ + background: @default-tertiary-bg; +} +.HostEvents-table--cell{ + border: 0 !important; +} diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js new file mode 100644 index 0000000000..a3a1c8faaf --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -0,0 +1,177 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + export default + ['$stateParams', '$scope', '$rootScope', '$state', 'Wait', + 'JobDetailService', 'CreateSelect2', + function($stateParams, $scope, $rootScope, $state, Wait, + JobDetailService, CreateSelect2){ + + // pagination not implemented yet, but it'll depend on this + $scope.page_size = $stateParams.page_size; + + $scope.activeFilter = $stateParams.filter || null; + + $scope.search = function(){ + Wait('start'); + if ($scope.searchStr == undefined){ + return + } + //http://docs.ansible.com/ansible-tower/latest/html/towerapi/intro.html#filtering + // SELECT WHERE host_name LIKE str OR WHERE play LIKE str OR WHERE task LIKE str AND host_name NOT "" + // selecting non-empty host_name fields prevents us from displaying non-runner events, like playbook_on_task_start + JobDetailService.getRelatedJobEvents($stateParams.id, { + or__host_name__icontains: $scope.searchStr, + or__play__icontains: $scope.searchStr, + or__task__icontains: $scope.searchStr, + not__host_name: "" , + page_size: $scope.pageSize}) + .success(function(res){ + $scope.results = res.results; + Wait('stop') + }); + }; + + $scope.filters = ['all', 'changed', 'failed', 'ok', 'unreachable', 'skipped']; + + var filter = function(filter){ + Wait('start'); + if (filter == 'all'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + page_size: $scope.pageSize}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + // handle runner cases + if (filter == 'skipped'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_skipped'}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'unreachable'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_unreachable'}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'ok'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_ok', + changed: false + }) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + // handle convience properties .changed .failed + if (filter == 'changed'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + changed: true}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'failed'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + failed: true}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + }; + + // watch select2 for changes + $('.HostEvents-select').on("select2:select", function (e) { + filter($('.HostEvents-select').val()); + }); + + $scope.processStatus = function(event, $index){ + // the stack for which status to display is + // unreachable > failed > changed > ok + // uses the API's runner events and convenience properties .failed .changed to determine status. + // see: job_event_callback.py + if (event.event == 'runner_on_unreachable'){ + $scope.results[$index].status = 'Unreachable'; + return 'HostEvents-status--unreachable' + } + // equiv to 'runner_on_error' && 'runner on failed' + if (event.failed){ + $scope.results[$index].status = 'Failed'; + return 'HostEvents-status--failed' + } + // catch the changed case before ok, because both can be true + if (event.changed){ + $scope.results[$index].status = 'Changed'; + return 'HostEvents-status--changed' + } + if (event.event == 'runner_on_ok'){ + $scope.results[$index].status = 'OK'; + return 'HostEvents-status--ok' + } + if (event.event == 'runner_on_skipped'){ + $scope.results[$index].status = 'Skipped'; + return 'HostEvents-status--skipped' + } + else{ + // study a case where none of these apply + } + }; + + + var init = function(){ + // create filter dropdown + CreateSelect2({ + element: '.HostEvents-select', + multiple: false + }); + // process the filter if one was passed + if ($stateParams.filter){ + filter($stateParams.filter).success(function(res){ + $scope.results = res.results; + Wait('stop'); + $('#HostEvents').modal('show'); + });; + } + else{ + Wait('start'); + JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + page_size: $stateParams.page_size}) + .success(function(res){ + $scope.pagination = res; + $scope.results = res.results; + Wait('stop'); + $('#HostEvents').modal('show'); + + }); + } + }; + + $scope.goBack = function(){ + // go back to the job details state + // we're leaning on $stateProvider's onExit to close the modal + $state.go('jobDetail'); + }; + + init(); + + }]; \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/host-events/host-events.partial.html b/awx/ui/client/src/job-detail/host-events/host-events.partial.html new file mode 100644 index 0000000000..ff2d21714a --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.partial.html @@ -0,0 +1,64 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/host-events/host-events.route.js b/awx/ui/client/src/job-detail/host-events/host-events.route.js new file mode 100644 index 0000000000..ebb2bb7bdd --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.route.js @@ -0,0 +1,30 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'jobDetail.host-events', + url: '/host-events/:hostName?:filter', + controller: 'HostEventsController', + params: { + page_size: 10 + }, + templateUrl: templateUrl('job-detail/host-events/host-events'), + onExit: function(){ + // close the modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + $('#HostEvents').modal('hide'); + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-detail/host-events/main.js b/awx/ui/client/src/job-detail/host-events/main.js new file mode 100644 index 0000000000..8a9487aec4 --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/main.js @@ -0,0 +1,15 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import route from './host-events.route'; +import controller from './host-events.controller'; + +export default + angular.module('jobDetail.hostEvents', []) + .controller('HostEventsController', controller) + .run(['$stateExtender', function($stateExtender){ + $stateExtender.addState(route) + }]); \ No newline at end of file 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 e36dbb13de..1383f04c35 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -15,7 +15,7 @@ export default '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed', 'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList', - 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer', + 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'EditSchedule', 'ParseTypeChange', 'JobDetailService', 'EventViewer', @@ -25,7 +25,7 @@ export default SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph, LoadHostSummary, ReloadHostSummaryList, JobIsFinished, SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, - PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, + PlaybookRun, LoadPlays, LoadTasks, LoadHosts, HostsEdit, ParseVariableString, GetChoices, fieldChoices, fieldLabels, EditSchedule, ParseTypeChange, JobDetailService, EventViewer ) { @@ -43,7 +43,7 @@ export default scope.parseType = 'yaml'; scope.previousTaskFailed = false; $scope.stdoutFullScreen = false; - + scope.$watch('job_status', function(job_status) { if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") { scope.previousTaskFailed = true; @@ -1400,17 +1400,6 @@ export default } }; - scope.hostEventsViewer = function(id, name, status) { - HostEventsViewer({ - scope: scope, - id: id, - name: name, - url: scope.job.related.job_events, - job_id: scope.job.id, - status: status - }); - }; - scope.refresh = function(){ $scope.$emit('LoadJob'); }; diff --git a/awx/ui/client/src/job-detail/job-detail.partial.html b/awx/ui/client/src/job-detail/job-detail.partial.html index 3ff7262d1c..8daba354b5 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -1,9 +1,8 @@
- +
-
@@ -423,13 +422,13 @@ - {{ host.name }} + {{ host.name }} - {{ host.ok }} - {{ host.changed }} - {{ host.unreachable }} - {{ host.failed }} + {{ host.ok }} + {{ host.changed }} + {{ host.unreachable }} + {{ host.failed }} @@ -483,40 +482,6 @@
- -
diff --git a/awx/ui/client/src/job-detail/main.js b/awx/ui/client/src/job-detail/main.js index d985a310e6..8a9fc30aff 100644 --- a/awx/ui/client/src/job-detail/main.js +++ b/awx/ui/client/src/job-detail/main.js @@ -7,9 +7,12 @@ import route from './job-detail.route'; import controller from './job-detail.controller'; import service from './job-detail.service'; +import hostEvents from './host-events/main'; export default - angular.module('jobDetail', []) + angular.module('jobDetail', [ + hostEvents.name + ]) .controller('JobDetailController', controller) .service('JobDetailService', service) .run(['$stateExtender', function($stateExtender) { diff --git a/awx/ui/client/src/lists/HomeGroups.js b/awx/ui/client/src/lists/HomeGroups.js index ad7180dff0..1c21fb6268 100644 --- a/awx/ui/client/src/lists/HomeGroups.js +++ b/awx/ui/client/src/lists/HomeGroups.js @@ -76,6 +76,9 @@ export default },{ name: "OpenStack", value: "openstack" + },{ + name: "OpenStack V3", + value: "openstack_v3" }], sourceModel: 'inventory_source', sourceField: 'source', @@ -84,7 +87,7 @@ export default has_external_source: { label: 'Has external source?', searchType: 'in', - searchValue: 'ec2,rax,vmware,azure,gce,openstack', + searchValue: 'ec2,rax,vmware,azure,gce,openstack,openstack_v3', searchOnly: true, sourceModel: 'inventory_source', sourceField: 'source' diff --git a/awx/ui/client/src/lists/InventoryGroups.js b/awx/ui/client/src/lists/InventoryGroups.js index 53881f3d7c..3b221e54e0 100644 --- a/awx/ui/client/src/lists/InventoryGroups.js +++ b/awx/ui/client/src/lists/InventoryGroups.js @@ -51,6 +51,9 @@ export default },{ name: "OpenStack", value: "openstack" + },{ + name: "OpenStack V3", + value: "openstack_v3" }], sourceModel: 'inventory_source', sourceField: 'source', @@ -59,7 +62,7 @@ export default has_external_source: { label: 'Has external source?', searchType: 'in', - searchValue: 'ec2,rax,vmware,azure,gce,openstack', + searchValue: 'ec2,rax,vmware,azure,gce,openstack,openstack_v3', searchOnly: true, sourceModel: 'inventory_source', sourceField: 'source' diff --git a/awx/ui/client/src/shared/stateExtender.provider.js b/awx/ui/client/src/shared/stateExtender.provider.js index f899f00c32..07b3c2051e 100644 --- a/awx/ui/client/src/shared/stateExtender.provider.js +++ b/awx/ui/client/src/shared/stateExtender.provider.js @@ -11,7 +11,9 @@ export default function($stateProvider){ resolve: state.resolve, params: state.params, data: state.data, - ncyBreadcrumb: state.ncyBreadcrumb + ncyBreadcrumb: state.ncyBreadcrumb, + onEnter: state.onEnter, + onExit: state.onExit }); } };