Merge pull request #1304 from anoek/rbac

Next batch of minor fixes on RBAC
This commit is contained in:
Wayne Witzel III 2016-03-24 15:32:09 -04:00
commit ad9bfe9bda
38 changed files with 626 additions and 400 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

@ -3311,12 +3311,11 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
serializer_class = UserSerializer
parent_model = Role
relationship = 'members'
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
# XXX: Access control
role = Role.objects.get(pk=self.kwargs['pk'])
role = self.get_parent_object()
self.check_parent_access(role)
return role.members
def post(self, request, *args, **kwargs):

View File

@ -1332,7 +1332,11 @@ class TowerSettingsAccess(BaseAccess):
class RoleAccess(BaseAccess):
'''
TODO: XXX: Needs implemenation
- I can see roles when
- I am a super user
- I am a member of that role
- The role is a descdendent role of a role I am a member of
- The role is an implicit role of an object that I can see a role of.
'''
model = Role
@ -1340,11 +1344,26 @@ class RoleAccess(BaseAccess):
def get_queryset(self):
if self.user.is_superuser:
return self.model.objects.all()
return self.model.accessible_objects(self.user, {'read':True})
return Role.objects.none()
def can_change(self, obj, data):
return self.user.is_superuser
def can_read(self, obj):
if not obj:
return False
if self.user.is_superuser:
return True
if obj.object_id:
sister_roles = Role.objects.filter(
content_type = obj.content_type,
object_id = obj.object_id
)
else:
sister_roles = obj
return self.user.roles.filter(descendents__in=sister_roles).exists()
def can_add(self, obj, data):
# Unsupported for now
return False
@ -1367,6 +1386,9 @@ class RoleAccess(BaseAccess):
return False
register_access(User, UserAccess)
register_access(Organization, OrganizationAccess)
register_access(Inventory, InventoryAccess)

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

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

@ -10,7 +10,7 @@ from awx.main.models import Project
# Project listing and visibility tests
#
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_user_project_list(get, project_factory, admin, alice, bob):
'List of projects a user has access to, filtered by projects you can also see'
@ -41,7 +41,7 @@ def test_user_project_list(get, project_factory, admin, alice, bob):
assert get(reverse('api:user_projects_list', args=(admin.pk,)), alice).data['count'] == 2
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_team_project_list(get, project_factory, team_factory, admin, alice, bob):
'List of projects a team has access to, filtered by projects you can also see'
team1 = team_factory('team1')
@ -98,7 +98,7 @@ def test_team_project_list(get, project_factory, team_factory, admin, alice, bob
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_create_project(post, organization, org_admin, org_member, admin, rando):
test_list = [rando, org_member, org_admin, admin]
expected_status_codes = [403, 403, 201, 201]
@ -116,12 +116,12 @@ def test_create_project(post, organization, org_admin, org_member, admin, rando)
assert not Project.objects.filter(name='Project %d' % i, organization=organization).exists()
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_cant_create_project_without_org(post, organization, org_admin, org_member, admin, rando):
assert post(reverse('api:project_list'), { 'name': 'Project foo', }, admin).status_code == 400
assert post(reverse('api:project_list'), { 'name': 'Project foo', 'organization': None}, admin).status_code == 400
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_create_project_through_org_link(post, organization, org_admin, org_member, admin, rando):
test_list = [rando, org_member, org_admin, admin]
expected_status_codes = [403, 403, 201, 201]

View File

@ -265,7 +265,7 @@ def test_remove_user_to_role(post, admin, role):
post(url, {'disassociate': True, 'id': admin.id}, admin)
assert role.members.filter(id=admin.id).count() == 0
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_org_admin_add_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user with permissions to assign/revoke membership to a particular role can do so'
org_admin = user('org-admin')
@ -275,12 +275,13 @@ def test_org_admin_add_user_to_job_template(post, organization, check_jobtemplat
assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, org_admin)
res =post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, org_admin)
print(res.data)
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_org_admin_remove_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user with permissions to assign/revoke membership to a particular role can do so'
org_admin = user('org-admin')
@ -295,7 +296,7 @@ def test_org_admin_remove_user_to_job_template(post, organization, check_jobtemp
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_user_fail_to_add_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so'
rando = user('rando')
@ -305,12 +306,13 @@ def test_user_fail_to_add_user_to_job_template(post, organization, check_jobtemp
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
res = post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, rando)
print(res.data)
assert res.status_code == 403
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_user_fail_to_remove_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so'
rando = user('rando')

View File

@ -342,7 +342,7 @@ class BaseJobTestMixin(BaseTestMixin):
password='holly rocks',
created_by=self.user_sue,
)
self.cred_holly.usage_role.memebers.add(self.user_holly)
self.cred_holly.usage_role.members.add(self.user_holly)
self.cred_iris = Credential.objects.create(
username='iris',

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

@ -61,8 +61,8 @@ class ScheduleTest(BaseTest):
self.diff_org_user = self.make_user('fred')
self.organizations[1].member_role.members.add(self.diff_org_user)
self.cloud_source = Credential.objects.create(kind='awx', user=self.super_django_user,
username='Dummy', password='Dummy')
self.cloud_source = Credential.objects.create(kind='awx', username='Dummy', password='Dummy')
self.cloud_source.owner_role.members.add(self.super_django_user)
self.first_inventory = Inventory.objects.create(name='test_inventory', description='for org 0', organization=self.organizations[0])
self.first_inventory.hosts.create(name='host_1')

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

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