1
0
mirror of https://github.com/ansible/awx.git synced 2024-11-01 08:21:15 +03:00

Merge branch 'merge-devel' into rbac

This commit is contained in:
Akita Noek 2016-03-24 15:23:23 -04:00
commit e323f5a48b
32 changed files with 585 additions and 382 deletions

View File

@ -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

View File

@ -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:

View File

@ -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',)

View File

@ -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))

View File

@ -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),
),
]

View File

@ -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)'),

View File

@ -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

View File

@ -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

View File

@ -20,6 +20,9 @@ class CustomEmailBackend(EmailBackend):
sender_parameter = "sender"
def format_body(self, body):
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'],

View File

@ -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',

View File

@ -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)

View File

@ -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():

View File

@ -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')
};

View File

@ -183,7 +183,6 @@ var tower = angular.module('Tower', [
'StandardOutHelper',
'LogViewerOptionsDefinition',
'EventViewerHelper',
'HostEventsViewerHelper',
'JobDetailHelper',
'SocketIO',
'lrInfiniteScroll',

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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 = "<p>The project value</p>";
scope.domainPopOver = "<p>The domain name</p>";
scope.hostPopOver = "<p>The host value</p>";
if (!Empty(scope.kind)) {
@ -133,6 +136,22 @@ angular.module('CredentialsHelper', ['Utilities'])
" as the username.</p>";
scope.hostPopOver = "<p>The host to authenticate with." +
"<br />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 = "<p>This is the tenant name " +
"or tenant id. This value is usually the same " +
" as the username.</p>";
scope.hostPopOver = "<p>The host to authenticate with." +
"<br />For example, https://openstack.business.com/v3</p>";
scope.domainPopOver = "<p>Domain used for Keystone v3 " +
"<br />identity service.</p>";
break;
}
}

View File

@ -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);
}

View File

@ -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 += "<tr>\n";
html += "<td class=\"col-md-3\"><a href=\"\" ng-click=\"showDetails(" + res.id + ")\" aw-tool-tip=\"Click to view details\" data-placement=\"top\"><i class=\"fa icon-job-" + res.status + "\"></i> " + res.status_text + "</a></td>\n";
html += "<td class=\"col-md=3\" ng-non-bindable>" + res.host_name + "</td>\n";
html += "<td class=\"col-md-3\" ng-non-bindable>" + res.play + "</td>\n";
html += "<td class=\"col-md-3\" ng-non-bindable>" + res.task + "</td>\n";
html += "</tr>";
return html;
};
buildTable = function(data) {
var html = "<table class=\"table\">\n";
html += "<tbody>\n";
data.results.forEach(function(result) {
var res = setStatus(result);
html += buildRow(res);
});
html += "</tbody>\n";
html += "</table>\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&not__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 });
});
};
}]);

View File

@ -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;
}

View File

@ -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();
}];

View File

@ -0,0 +1,64 @@
<div id="HostEvents" class="HostEvents modal fade" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-body">
<div class="HostEvents-header">
<span class="HostEvents-title">HOST EVENTS</span>
<!-- Close -->
<button ng-click="goBack()" type="button" class="close">
<i class="fa fa fa-times-circle"></i>
</button>
</div>
<div class="HostEvents-form--container">
<form ng-submit="search()" class="form-inline HostEvents-search--form">
<!-- Search -->
<div class="form-group" >
<div class="input-group">
<input type="text" ng-model="searchStr" class="form-control" placeholder="SEARCH">
<span ng-click="search()" type="submit" class="input-group-addon btn btn-default"><i class="fa fa-search"></i></span>
</div>
</div>
</form>
<select class="HostEvents-select">
<option ng-selected="filter == activeFilter" class="HostEvents-select--option" value="{{filter}}" ng-repeat="filter in filters">{{filter}}</option>
</select>
</div>
<!-- event results table -->
<div class="table-responsive">
<table class="table">
<!-- column labels -->
<th ng-hide="results.length == 0" class="HostEvents-table--header">STATUS</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">HOST</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">PLAY</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">TASK</th>
<!-- result rows -->
<tr class="HostEvents-table--row" ng-repeat="event in results track by $index" modal-paginate="event in results | page_size: page_size">
<td class=HostEvents-table--cell>
<!-- status circles -->
<a class="HostEvents-status">
<i class="fa fa-circle" ng-class="processStatus(event, $index)"></i>
</a>
{{event.status}}
</td>
<td class=HostEvents-table--cell>{{event.host_name}}</td>
<td class=HostEvents-table--cell>{{event.play}}</td>
<td class=HostEvents-table--cell>{{event.task}}</td>
</tr>
<tr ng-show="results.length == 0" class="HostEvents-table--row">
<td class=HostEvents-table--cell>
No results were found.
</td>
</tr>
</table>
</div>
</div>
<div class="modal-footer">
<!-- pagination -->
<!-- close -->
<button ng-click="goBack()" class="btn btn-default pull-right HostEvents-close">OK</button>
</div>
</div>
</div>
</div>

View File

@ -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();
}]
}
};

View File

@ -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)
}]);

View File

@ -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
) {
@ -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');
};

View File

@ -1,9 +1,8 @@
<div class="tab-pane" id="jobs-detail">
<div ng-cloak id="htmlTemplate" class="JobDetail">
<div ui-view></div>
<!--beginning of job-detail-container (left side) -->
<div id="job-detail-container" class="JobDetail-leftSide" ng-class="{'JobDetail-stdoutActionButton--active': stdoutFullScreen}">
<!--beginning of results-->
<div id="job-results-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
<div class="JobDetail-panelHeader">
@ -423,13 +422,13 @@
<tbody>
<tr class="List-tableRow" ng-repeat="host in summaryList = (hosts) track by $index" id="{{ host.id }}" ng-class-even="'List-tableRow--evenRow'" ng-class-odd="'List-tableRow--oddRow'">
<td class="List-tableCell name col-lg-6 col-md-6 col-sm-6 col-xs-6">
<a href="" ng-click="hostEventsViewer(host.id, host.name)" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a>
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id})" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a>
</td>
<td class="List-tableCell col-lg-6 col-md-5 col-sm-5 col-xs-5 badge-column">
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'ok')" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'changed')" aw-tool-tip="{{ host.changedTip }}" data-tip-watch="host.changedTip" data-placement="top" ng-hide="host.changed == 0"><span class="badge changed-hosts">{{ host.changed }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'unreachable')" aw-tool-tip="{{ host.unreachableTip }}" data-tip-watch="host.unreachableTip" data-placement="top" ng-hide="host.unreachable == 0"><span class="badge unreachable-hosts">{{ host.unreachable }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'failed')" aw-tool-tip="{{ host.failedTip }}" data-tip-watch="host.failedTip" data-placement="top" ng-hide="host.failed == 0"><span class="badge failed-hosts">{{ host.failed }}</span></a>
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'ok'})" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a>
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'changed'})" aw-tool-tip="{{ host.changedTip }}" data-tip-watch="host.changedTip" data-placement="top" ng-hide="host.changed == 0"><span class="badge changed-hosts">{{ host.changed }}</span></a>
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'unreachable'})" aw-tool-tip="{{ host.unreachableTip }}" data-tip-watch="host.unreachableTip" data-placement="top" ng-hide="host.unreachable == 0"><span class="badge unreachable-hosts">{{ host.unreachable }}</span></a>
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'failed'})" aw-tool-tip="{{ host.failedTip }}" data-tip-watch="host.failedTip" data-placement="top" ng-hide="host.failed == 0"><span class="badge failed-hosts">{{ host.failed }}</span></a>
</td>
</tr>
<tr ng-show="summaryList.length === 0 && waiting">
@ -483,40 +482,6 @@
<div ng-include="'/static/partials/eventviewer.html'"></div>
<div id="host-events-modal-dialog" style="display:none;">
<div id="search-form" class="form-inline">
<div class="form-group" style="position:relative;">
<label>Search</label>
<div class="search-name" style="display:inline-block; position:relative;">
<input type="text" class="form-control input-sm" id="host-events-search-name" ng-model="host_events_search_name" placeholder="Host name" ng-keypress="searchEventKeyPress($event)" >
<div id="search-all-input-icons">
<a class="search-icon" ng-show="!eventsSearchActive" ng-click="searchEvents()"><i class="fa fa-search"></i></a>
<a class="search-icon" ng-show="eventsSearchActive" ng-click="host_events_search_name=''; searchEvents()"><i class="fa fa-times"></i></a>
</div>
</div>
</div>
<div class="form-group" id="status-field">
<label>Status</label>
<select id="host-events-search-status" class="form-control input-sm" ng-model="host_events_search_status" name="host-events-search-name" ng-change="searchEvents()"
ng-options="opt.name for opt in host_events_status_options track by opt.value"></select>
</div>
<div class="form-group" id="search-indicator" ng-show="hostViewSearching"><i class="fa fa-lg fa-spin fa-cog"></i></div>
</div>
<!-- lr-infinite-scroll="hostEventsTable" scroll-threshold="10" time-threshold="500" -->
<div id="host-events-table">
<table id="fixed-table-header" class="table">
<thead>
<tr><th class="col-md-3">Status</th>
<th class="col-md-3">Host</th>
<th class="col-md-3">Play</th>
<th class="col-md-3">Task</th>
</tr>
</thead>
</table>
<div id="host-events" lr-infinite-scroll="hostEventsScrollDown" scroll-threshold="10" time-threshold="500"></div>
</div>
</div>
<div id="host-modal-dialog" style="display: none;" class="dialog-content"></div>
<div ng-include="'/static/partials/schedule_dialog.html'"></div>

View File

@ -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) {

View File

@ -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'

View File

@ -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'

View File

@ -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
});
}
};