Merge remote-tracking branch 'downstream/release_3.3.0' into devel

# Conflicts:
#	awx/main/notifications/slack_backend.py
This commit is contained in:
Shane McDonald
2018-09-18 08:11:50 -04:00
246 changed files with 10831 additions and 6593 deletions

View File

@@ -382,6 +382,7 @@ test:
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
py.test -n auto $(TEST_DIRS) py.test -n auto $(TEST_DIRS)
awx-manage check_migrations --dry-run --check -n 'vNNN_missing_migration_file'
test_combined: test_ansible test test_combined: test_ansible test

View File

@@ -97,7 +97,7 @@ class DeprecatedCredentialField(serializers.IntegerField):
kwargs['allow_null'] = True kwargs['allow_null'] = True
kwargs['default'] = None kwargs['default'] = None
kwargs['min_value'] = 1 kwargs['min_value'] = 1
kwargs['help_text'] = 'This resource has been deprecated and will be removed in a future release' kwargs.setdefault('help_text', 'This resource has been deprecated and will be removed in a future release')
super(DeprecatedCredentialField, self).__init__(**kwargs) super(DeprecatedCredentialField, self).__init__(**kwargs)
def to_internal_value(self, pk): def to_internal_value(self, pk):

View File

@@ -390,7 +390,6 @@ class GenericAPIView(generics.GenericAPIView, APIView):
]: ]:
d[key] = self.metadata_class().get_serializer_info(serializer, method=method) d[key] = self.metadata_class().get_serializer_info(serializer, method=method)
d['settings'] = settings d['settings'] = settings
d['has_named_url'] = self.model in settings.NAMED_URL_GRAPH
return d return d

View File

@@ -1903,7 +1903,9 @@ class CustomInventoryScriptSerializer(BaseSerializer):
class InventorySourceOptionsSerializer(BaseSerializer): class InventorySourceOptionsSerializer(BaseSerializer):
credential = DeprecatedCredentialField() credential = DeprecatedCredentialField(
help_text=_('Cloud credential to use for inventory updates.')
)
class Meta: class Meta:
fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential', fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential',

View File

@@ -54,8 +54,6 @@ within all designated text fields of a model.
?search=findme ?search=findme
_Added in AWX 1.4_
(_Added in Ansible Tower 3.1.0_) Search across related fields: (_Added in Ansible Tower 3.1.0_) Search across related fields:
?related__search=findme ?related__search=findme
@@ -84,7 +82,7 @@ To exclude results matching certain criteria, prefix the field parameter with
?not__field=value ?not__field=value
(_Added in AWX 1.4_) By default, all query string filters are AND'ed together, so By default, all query string filters are AND'ed together, so
only the results matching *all* filters will be returned. To combine results only the results matching *all* filters will be returned. To combine results
matching *any* one of multiple criteria, prefix each query string parameter matching *any* one of multiple criteria, prefix each query string parameter
with `or__`: with `or__`:

View File

@@ -10,7 +10,7 @@ object containing groups, including the hosts, children and variables for each
group. The response data is equivalent to that returned by passing the group. The response data is equivalent to that returned by passing the
`--list` argument to an inventory script. `--list` argument to an inventory script.
_(Added in AWX 1.3)_ Specify a query string of `?hostvars=1` to retrieve the JSON Specify a query string of `?hostvars=1` to retrieve the JSON
object above including all host variables. The `['_meta']['hostvars']` object object above including all host variables. The `['_meta']['hostvars']` object
in the response contains an entry for each host with its variables. This in the response contains an entry for each host with its variables. This
response format can be used with Ansible 1.3 and later to avoid making a response format can be used with Ansible 1.3 and later to avoid making a
@@ -18,11 +18,16 @@ separate API request for each host. Refer to
[Tuning the External Inventory Script](http://docs.ansible.com/developing_inventory.html#tuning-the-external-inventory-script) [Tuning the External Inventory Script](http://docs.ansible.com/developing_inventory.html#tuning-the-external-inventory-script)
for more information on this feature. for more information on this feature.
_(Added in AWX 1.4)_ By default, the inventory script will only return hosts that By default, the inventory script will only return hosts that
are enabled in the inventory. This feature allows disabled hosts to be skipped are enabled in the inventory. This feature allows disabled hosts to be skipped
when running jobs without removing them from the inventory. Specify a query when running jobs without removing them from the inventory. Specify a query
string of `?all=1` to return all hosts, including disabled ones. string of `?all=1` to return all hosts, including disabled ones.
Specify a query string of `?towervars=1` to add variables
to the hostvars of each host that specifies its enabled state and database ID.
To apply multiple query strings, join them with the `&` character, like `?hostvars=1&all=1`.
## Host Response ## Host Response
Make a GET request to this resource with a query string similar to Make a GET request to this resource with a query string similar to

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title|anora }}: # Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }} Make GET request to this resource to retrieve a single {{ model_verbose_name }}

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %} {% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}: # Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %} {% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}: # Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %} {% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}: # Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -115,9 +115,10 @@ class ActivityStreamEnforcementMixin(object):
Mixin to check that license supports activity streams. Mixin to check that license supports activity streams.
''' '''
def check_permissions(self, request): def check_permissions(self, request):
ret = super(ActivityStreamEnforcementMixin, self).check_permissions(request)
if not feature_enabled('activity_streams'): if not feature_enabled('activity_streams'):
raise LicenseForbids(_('Your license does not allow use of the activity stream.')) raise LicenseForbids(_('Your license does not allow use of the activity stream.'))
return super(ActivityStreamEnforcementMixin, self).check_permissions(request) return ret
class SystemTrackingEnforcementMixin(object): class SystemTrackingEnforcementMixin(object):
@@ -125,9 +126,10 @@ class SystemTrackingEnforcementMixin(object):
Mixin to check that license supports system tracking. Mixin to check that license supports system tracking.
''' '''
def check_permissions(self, request): def check_permissions(self, request):
ret = super(SystemTrackingEnforcementMixin, self).check_permissions(request)
if not feature_enabled('system_tracking'): if not feature_enabled('system_tracking'):
raise LicenseForbids(_('Your license does not permit use of system tracking.')) raise LicenseForbids(_('Your license does not permit use of system tracking.'))
return super(SystemTrackingEnforcementMixin, self).check_permissions(request) return ret
class WorkflowsEnforcementMixin(object): class WorkflowsEnforcementMixin(object):
@@ -135,9 +137,10 @@ class WorkflowsEnforcementMixin(object):
Mixin to check that license supports workflows. Mixin to check that license supports workflows.
''' '''
def check_permissions(self, request): def check_permissions(self, request):
ret = super(WorkflowsEnforcementMixin, self).check_permissions(request)
if not feature_enabled('workflows') and request.method not in ('GET', 'OPTIONS', 'DELETE'): if not feature_enabled('workflows') and request.method not in ('GET', 'OPTIONS', 'DELETE'):
raise LicenseForbids(_('Your license does not allow use of workflows.')) raise LicenseForbids(_('Your license does not allow use of workflows.'))
return super(WorkflowsEnforcementMixin, self).check_permissions(request) return ret
class UnifiedJobDeletionMixin(object): class UnifiedJobDeletionMixin(object):
@@ -442,9 +445,9 @@ class ApiV1ConfigView(APIView):
data.update(dict( data.update(dict(
project_base_dir = settings.PROJECTS_ROOT, project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(), project_local_paths = Project.get_local_path_choices(),
custom_virtualenvs = get_custom_venv_choices()
)) ))
elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
if JobTemplate.accessible_objects(request.user, 'admin_role').exists():
data['custom_virtualenvs'] = get_custom_venv_choices() data['custom_virtualenvs'] = get_custom_venv_choices()
return Response(data) return Response(data)
@@ -2883,17 +2886,14 @@ class InventorySourceCredentialsList(SubListAttachDetachAPIView):
relationship = 'credentials' relationship = 'credentials'
def is_valid_relation(self, parent, sub, created=False): def is_valid_relation(self, parent, sub, created=False):
# Inventory source credentials are exclusive with all other credentials
# subject to change for https://github.com/ansible/awx/issues/277
# or https://github.com/ansible/awx/issues/223
if parent.credentials.exists():
return {'msg': _("Source already has credential assigned.")}
error = InventorySource.cloud_credential_validation(parent.source, sub) error = InventorySource.cloud_credential_validation(parent.source, sub)
if error: if error:
return {'msg': error} return {'msg': error}
if sub.credential_type == 'vault':
# TODO: support this
return {"msg": _("Vault credentials are not yet supported for inventory sources.")}
else:
# Cloud credentials are exclusive with all other cloud credentials
cloud_cred_qs = parent.credentials.exclude(credential_type__kind='vault')
if cloud_cred_qs.exists():
return {'msg': _("Source already has cloud credential assigned.")}
return None return None

View File

@@ -24,7 +24,12 @@ import os
import pwd import pwd
# PSUtil # PSUtil
import psutil try:
import psutil
except ImportError:
raise ImportError('psutil is missing; {}bin/pip install psutil'.format(
os.environ['VIRTUAL_ENV']
))
__all__ = [] __all__ = []

View File

@@ -27,7 +27,13 @@ import os
import stat import stat
import threading import threading
import uuid import uuid
import memcache
try:
import memcache
except ImportError:
raise ImportError('python-memcached is missing; {}bin/pip install python-memcached'.format(
os.environ['VIRTUAL_ENV']
))
from six.moves import xrange from six.moves import xrange

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-03 19:04+0000\n" "POT-Creation-Date: 2018-08-14 13:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -125,15 +125,15 @@ msgid ""
"our REST API, the Content-Type must be application/json" "our REST API, the Content-Type must be application/json"
msgstr "" msgstr ""
#: awx/api/generics.py:629 awx/api/generics.py:691 #: awx/api/generics.py:635 awx/api/generics.py:697
msgid "\"id\" field must be an integer." msgid "\"id\" field must be an integer."
msgstr "" msgstr ""
#: awx/api/generics.py:688 #: awx/api/generics.py:694
msgid "\"id\" is required to disassociate" msgid "\"id\" is required to disassociate"
msgstr "" msgstr ""
#: awx/api/generics.py:739 #: awx/api/generics.py:745
msgid "{} 'id' field is missing." msgid "{} 'id' field is missing."
msgstr "" msgstr ""
@@ -1642,86 +1642,86 @@ msgstr ""
msgid "Bad data found in related field %s." msgid "Bad data found in related field %s."
msgstr "" msgstr ""
#: awx/main/access.py:304 #: awx/main/access.py:302
msgid "License is missing." msgid "License is missing."
msgstr "" msgstr ""
#: awx/main/access.py:306 #: awx/main/access.py:304
msgid "License has expired." msgid "License has expired."
msgstr "" msgstr ""
#: awx/main/access.py:314 #: awx/main/access.py:312
#, python-format #, python-format
msgid "License count of %s instances has been reached." msgid "License count of %s instances has been reached."
msgstr "" msgstr ""
#: awx/main/access.py:316 #: awx/main/access.py:314
#, python-format #, python-format
msgid "License count of %s instances has been exceeded." msgid "License count of %s instances has been exceeded."
msgstr "" msgstr ""
#: awx/main/access.py:318 #: awx/main/access.py:316
msgid "Host count exceeds available instances." msgid "Host count exceeds available instances."
msgstr "" msgstr ""
#: awx/main/access.py:322 #: awx/main/access.py:320
#, python-format #, python-format
msgid "Feature %s is not enabled in the active license." msgid "Feature %s is not enabled in the active license."
msgstr "" msgstr ""
#: awx/main/access.py:324 #: awx/main/access.py:322
msgid "Features not found in active license." msgid "Features not found in active license."
msgstr "" msgstr ""
#: awx/main/access.py:837 #: awx/main/access.py:835
msgid "Unable to change inventory on a host." msgid "Unable to change inventory on a host."
msgstr "" msgstr ""
#: awx/main/access.py:854 awx/main/access.py:899 #: awx/main/access.py:852 awx/main/access.py:897
msgid "Cannot associate two items from different inventories." msgid "Cannot associate two items from different inventories."
msgstr "" msgstr ""
#: awx/main/access.py:887 #: awx/main/access.py:885
msgid "Unable to change inventory on a group." msgid "Unable to change inventory on a group."
msgstr "" msgstr ""
#: awx/main/access.py:1148 #: awx/main/access.py:1146
msgid "Unable to change organization on a team." msgid "Unable to change organization on a team."
msgstr "" msgstr ""
#: awx/main/access.py:1165 #: awx/main/access.py:1163
msgid "The {} role cannot be assigned to a team" msgid "The {} role cannot be assigned to a team"
msgstr "" msgstr ""
#: awx/main/access.py:1167 #: awx/main/access.py:1165
msgid "The admin_role for a User cannot be assigned to a team" msgid "The admin_role for a User cannot be assigned to a team"
msgstr "" msgstr ""
#: awx/main/access.py:1533 awx/main/access.py:1967 #: awx/main/access.py:1531 awx/main/access.py:1965
msgid "Job was launched with prompts provided by another user." msgid "Job was launched with prompts provided by another user."
msgstr "" msgstr ""
#: awx/main/access.py:1553 #: awx/main/access.py:1551
msgid "Job has been orphaned from its job template." msgid "Job has been orphaned from its job template."
msgstr "" msgstr ""
#: awx/main/access.py:1555 #: awx/main/access.py:1553
msgid "Job was launched with unknown prompted fields." msgid "Job was launched with unknown prompted fields."
msgstr "" msgstr ""
#: awx/main/access.py:1557 #: awx/main/access.py:1555
msgid "Job was launched with prompted fields." msgid "Job was launched with prompted fields."
msgstr "" msgstr ""
#: awx/main/access.py:1559 #: awx/main/access.py:1557
msgid " Organization level permissions required." msgid " Organization level permissions required."
msgstr "" msgstr ""
#: awx/main/access.py:1561 #: awx/main/access.py:1559
msgid " You do not have permission to related resources." msgid " You do not have permission to related resources."
msgstr "" msgstr ""
#: awx/main/access.py:1981 #: awx/main/access.py:1979
msgid "" msgid ""
"You do not have permission to the workflow job resources required for " "You do not have permission to the workflow job resources required for "
"relaunch." "relaunch."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -611,7 +611,8 @@ class OAuth2ApplicationAccess(BaseAccess):
select_related = ('user',) select_related = ('user',)
def filtered_queryset(self): def filtered_queryset(self):
return self.model.objects.filter(organization__in=self.user.organizations) org_access_qs = Organization.accessible_objects(self.user, 'member_role')
return self.model.objects.filter(organization__in=org_access_qs)
def can_change(self, obj, data): def can_change(self, obj, data):
return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj,

View File

@@ -117,10 +117,10 @@ class IsolatedManager(object):
@classmethod @classmethod
def awx_playbook_path(cls): def awx_playbook_path(cls):
return os.path.join( return os.path.abspath(os.path.join(
os.path.dirname(awx.__file__), os.path.dirname(awx.__file__),
'playbooks' 'playbooks'
) ))
def path_to(self, *args): def path_to(self, *args):
return os.path.join(self.private_data_dir, *args) return os.path.join(self.private_data_dir, *args)

View File

@@ -208,6 +208,12 @@ def run_isolated_job(private_data_dir, secrets, logfile=sys.stdout):
env['AWX_ISOLATED_DATA_DIR'] = private_data_dir env['AWX_ISOLATED_DATA_DIR'] = private_data_dir
env['PYTHONPATH'] = env.get('PYTHONPATH', '') + callback_dir + ':' env['PYTHONPATH'] = env.get('PYTHONPATH', '') + callback_dir + ':'
venv_path = env.get('VIRTUAL_ENV')
if venv_path and not os.path.exists(venv_path):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
return run_pexpect(args, cwd, env, logfile, return run_pexpect(args, cwd, env, logfile,
expect_passwords=expect_passwords, expect_passwords=expect_passwords,
idle_timeout=idle_timeout, idle_timeout=idle_timeout,

View File

@@ -0,0 +1,12 @@
from django.db import connections
from django.db.backends.sqlite3.base import DatabaseWrapper
from django.core.management.commands.makemigrations import Command as MakeMigrations
class Command(MakeMigrations):
def execute(self, *args, **options):
settings = connections['default'].settings_dict.copy()
settings['ENGINE'] = 'sqlite3'
connections['default'] = DatabaseWrapper(settings)
return MakeMigrations().execute(*args, **options)

View File

@@ -491,7 +491,7 @@ class Command(BaseCommand):
for host in hosts_qs.filter(pk__in=del_pks): for host in hosts_qs.filter(pk__in=del_pks):
host_name = host.name host_name = host.name
host.delete() host.delete()
logger.info('Deleted host "%s"', host_name) logger.debug('Deleted host "%s"', host_name)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning('host deletions took %d queries for %d hosts', logger.warning('host deletions took %d queries for %d hosts',
len(connection.queries) - queries_before, len(connection.queries) - queries_before,
@@ -528,7 +528,7 @@ class Command(BaseCommand):
group_name = group.name group_name = group.name
with ignore_inventory_computed_fields(): with ignore_inventory_computed_fields():
group.delete() group.delete()
logger.info('Group "%s" deleted', group_name) logger.debug('Group "%s" deleted', group_name)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning('group deletions took %d queries for %d groups', logger.warning('group deletions took %d queries for %d groups',
len(connection.queries) - queries_before, len(connection.queries) - queries_before,
@@ -549,7 +549,7 @@ class Command(BaseCommand):
db_groups = self.inventory_source.groups db_groups = self.inventory_source.groups
for db_group in db_groups.all(): for db_group in db_groups.all():
if self.inventory_source.deprecated_group_id == db_group.id: # TODO: remove in 3.3 if self.inventory_source.deprecated_group_id == db_group.id: # TODO: remove in 3.3
logger.info( logger.debug(
'Group "%s" from v1 API child group/host connections preserved', 'Group "%s" from v1 API child group/host connections preserved',
db_group.name db_group.name
) )
@@ -566,8 +566,8 @@ class Command(BaseCommand):
for db_child in db_children.filter(pk__in=child_group_pks): for db_child in db_children.filter(pk__in=child_group_pks):
group_group_count += 1 group_group_count += 1
db_group.children.remove(db_child) db_group.children.remove(db_child)
logger.info('Group "%s" removed from group "%s"', logger.debug('Group "%s" removed from group "%s"',
db_child.name, db_group.name) db_child.name, db_group.name)
# FIXME: Inventory source group relationships # FIXME: Inventory source group relationships
# Delete group/host relationships not present in imported data. # Delete group/host relationships not present in imported data.
db_hosts = db_group.hosts db_hosts = db_group.hosts
@@ -594,8 +594,8 @@ class Command(BaseCommand):
if db_host not in db_group.hosts.all(): if db_host not in db_group.hosts.all():
continue continue
db_group.hosts.remove(db_host) db_group.hosts.remove(db_host)
logger.info('Host "%s" removed from group "%s"', logger.debug('Host "%s" removed from group "%s"',
db_host.name, db_group.name) db_host.name, db_group.name)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning('group-group and group-host deletions took %d queries for %d relationships', logger.warning('group-group and group-host deletions took %d queries for %d relationships',
len(connection.queries) - queries_before, len(connection.queries) - queries_before,
@@ -614,9 +614,9 @@ class Command(BaseCommand):
if db_variables != all_obj.variables_dict: if db_variables != all_obj.variables_dict:
all_obj.variables = json.dumps(db_variables) all_obj.variables = json.dumps(db_variables)
all_obj.save(update_fields=['variables']) all_obj.save(update_fields=['variables'])
logger.info('Inventory variables updated from "all" group') logger.debug('Inventory variables updated from "all" group')
else: else:
logger.info('Inventory variables unmodified') logger.debug('Inventory variables unmodified')
def _create_update_groups(self): def _create_update_groups(self):
''' '''
@@ -648,11 +648,11 @@ class Command(BaseCommand):
group.variables = json.dumps(db_variables) group.variables = json.dumps(db_variables)
group.save(update_fields=['variables']) group.save(update_fields=['variables'])
if self.overwrite_vars: if self.overwrite_vars:
logger.info('Group "%s" variables replaced', group.name) logger.debug('Group "%s" variables replaced', group.name)
else: else:
logger.info('Group "%s" variables updated', group.name) logger.debug('Group "%s" variables updated', group.name)
else: else:
logger.info('Group "%s" variables unmodified', group.name) logger.debug('Group "%s" variables unmodified', group.name)
existing_group_names.add(group.name) existing_group_names.add(group.name)
self._batch_add_m2m(self.inventory_source.groups, group) self._batch_add_m2m(self.inventory_source.groups, group)
for group_name in all_group_names: for group_name in all_group_names:
@@ -666,7 +666,7 @@ class Command(BaseCommand):
'description':'imported' 'description':'imported'
} }
)[0] )[0]
logger.info('Group "%s" added', group.name) logger.debug('Group "%s" added', group.name)
self._batch_add_m2m(self.inventory_source.groups, group) self._batch_add_m2m(self.inventory_source.groups, group)
self._batch_add_m2m(self.inventory_source.groups, flush=True) self._batch_add_m2m(self.inventory_source.groups, flush=True)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
@@ -705,24 +705,24 @@ class Command(BaseCommand):
if update_fields: if update_fields:
db_host.save(update_fields=update_fields) db_host.save(update_fields=update_fields)
if 'name' in update_fields: if 'name' in update_fields:
logger.info('Host renamed from "%s" to "%s"', old_name, mem_host.name) logger.debug('Host renamed from "%s" to "%s"', old_name, mem_host.name)
if 'instance_id' in update_fields: if 'instance_id' in update_fields:
if old_instance_id: if old_instance_id:
logger.info('Host "%s" instance_id updated', mem_host.name) logger.debug('Host "%s" instance_id updated', mem_host.name)
else: else:
logger.info('Host "%s" instance_id added', mem_host.name) logger.debug('Host "%s" instance_id added', mem_host.name)
if 'variables' in update_fields: if 'variables' in update_fields:
if self.overwrite_vars: if self.overwrite_vars:
logger.info('Host "%s" variables replaced', mem_host.name) logger.debug('Host "%s" variables replaced', mem_host.name)
else: else:
logger.info('Host "%s" variables updated', mem_host.name) logger.debug('Host "%s" variables updated', mem_host.name)
else: else:
logger.info('Host "%s" variables unmodified', mem_host.name) logger.debug('Host "%s" variables unmodified', mem_host.name)
if 'enabled' in update_fields: if 'enabled' in update_fields:
if enabled: if enabled:
logger.info('Host "%s" is now enabled', mem_host.name) logger.debug('Host "%s" is now enabled', mem_host.name)
else: else:
logger.info('Host "%s" is now disabled', mem_host.name) logger.debug('Host "%s" is now disabled', mem_host.name)
self._batch_add_m2m(self.inventory_source.hosts, db_host) self._batch_add_m2m(self.inventory_source.hosts, db_host)
def _create_update_hosts(self): def _create_update_hosts(self):
@@ -796,9 +796,9 @@ class Command(BaseCommand):
host_attrs['instance_id'] = instance_id host_attrs['instance_id'] = instance_id
db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0] db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0]
if enabled is False: if enabled is False:
logger.info('Host "%s" added (disabled)', mem_host_name) logger.debug('Host "%s" added (disabled)', mem_host_name)
else: else:
logger.info('Host "%s" added', mem_host_name) logger.debug('Host "%s" added', mem_host_name)
self._batch_add_m2m(self.inventory_source.hosts, db_host) self._batch_add_m2m(self.inventory_source.hosts, db_host)
self._batch_add_m2m(self.inventory_source.hosts, flush=True) self._batch_add_m2m(self.inventory_source.hosts, flush=True)
@@ -827,10 +827,10 @@ class Command(BaseCommand):
child_names = all_child_names[offset2:(offset2 + self._batch_size)] child_names = all_child_names[offset2:(offset2 + self._batch_size)]
db_children_qs = self.inventory.groups.filter(name__in=child_names) db_children_qs = self.inventory.groups.filter(name__in=child_names)
for db_child in db_children_qs.filter(children__id=db_group.id): for db_child in db_children_qs.filter(children__id=db_group.id):
logger.info('Group "%s" already child of group "%s"', db_child.name, db_group.name) logger.debug('Group "%s" already child of group "%s"', db_child.name, db_group.name)
for db_child in db_children_qs.exclude(children__id=db_group.id): for db_child in db_children_qs.exclude(children__id=db_group.id):
self._batch_add_m2m(db_group.children, db_child) self._batch_add_m2m(db_group.children, db_child)
logger.info('Group "%s" added as child of "%s"', db_child.name, db_group.name) logger.debug('Group "%s" added as child of "%s"', db_child.name, db_group.name)
self._batch_add_m2m(db_group.children, flush=True) self._batch_add_m2m(db_group.children, flush=True)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning('Group-group updates took %d queries for %d group-group relationships', logger.warning('Group-group updates took %d queries for %d group-group relationships',
@@ -854,19 +854,19 @@ class Command(BaseCommand):
host_names = all_host_names[offset2:(offset2 + self._batch_size)] host_names = all_host_names[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(name__in=host_names) db_hosts_qs = self.inventory.hosts.filter(name__in=host_names)
for db_host in db_hosts_qs.filter(groups__id=db_group.id): for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name) logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id): for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host) self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name) logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
all_instance_ids = sorted([h.instance_id for h in mem_group.hosts if h.instance_id]) all_instance_ids = sorted([h.instance_id for h in mem_group.hosts if h.instance_id])
for offset2 in xrange(0, len(all_instance_ids), self._batch_size): for offset2 in xrange(0, len(all_instance_ids), self._batch_size):
instance_ids = all_instance_ids[offset2:(offset2 + self._batch_size)] instance_ids = all_instance_ids[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(instance_id__in=instance_ids) db_hosts_qs = self.inventory.hosts.filter(instance_id__in=instance_ids)
for db_host in db_hosts_qs.filter(groups__id=db_group.id): for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name) logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id): for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host) self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name) logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
self._batch_add_m2m(db_group.hosts, flush=True) self._batch_add_m2m(db_group.hosts, flush=True)
if settings.SQL_DEBUG: if settings.SQL_DEBUG:
logger.warning('Group-host updates took %d queries for %d group-host relationships', logger.warning('Group-host updates took %d queries for %d group-host relationships',

View File

@@ -6,6 +6,22 @@ from django.core.management.base import BaseCommand
import six import six
class Ungrouped(object):
name = 'ungrouped'
policy_instance_percentage = None
policy_instance_minimum = None
controller = None
@property
def instances(self):
return Instance.objects.filter(rampart_groups__isnull=True)
@property
def capacity(self):
return sum([x.capacity for x in self.instances])
class Command(BaseCommand): class Command(BaseCommand):
"""List instances from the Tower database """List instances from the Tower database
""" """
@@ -13,12 +29,28 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
super(Command, self).__init__() super(Command, self).__init__()
for instance in Instance.objects.all(): groups = list(InstanceGroup.objects.all())
print(six.text_type( ungrouped = Ungrouped()
"hostname: {0.hostname}; created: {0.created}; " if len(ungrouped.instances):
"heartbeat: {0.modified}; capacity: {0.capacity}").format(instance)) groups.append(ungrouped)
for instance_group in InstanceGroup.objects.all():
print(six.text_type( for instance_group in groups:
"Instance Group: {0.name}; created: {0.created}; " fmt = '[{0.name} capacity={0.capacity}'
"capacity: {0.capacity}; members: {1}").format(instance_group, if instance_group.policy_instance_percentage:
[x.hostname for x in instance_group.instances.all()])) fmt += ' policy={0.policy_instance_percentage}%'
if instance_group.policy_instance_minimum:
fmt += ' policy>={0.policy_instance_minimum}'
if instance_group.controller:
fmt += ' controller={0.controller.name}'
print(six.text_type(fmt + ']').format(instance_group))
for x in instance_group.instances.all():
color = '\033[92m'
if x.capacity == 0 or x.enabled is False:
color = '\033[91m'
fmt = '\t' + color + '{0.hostname} capacity={0.capacity} version={1}'
if x.last_isolated_check:
fmt += ' last_isolated_check="{0.last_isolated_check:%Y-%m-%d %H:%M:%S}"'
if x.capacity:
fmt += ' heartbeat="{0.modified:%Y-%m-%d %H:%M:%S}"'
print(six.text_type(fmt + '\033[0m').format(x, x.version or '?'))
print('')

View File

@@ -95,7 +95,7 @@ class ReplayJobEvents():
raise RuntimeError("Job is of type {} and replay is not yet supported.".format(type(job))) raise RuntimeError("Job is of type {} and replay is not yet supported.".format(type(job)))
sys.exit(1) sys.exit(1)
def run(self, job_id, speed=1.0, verbosity=0, skip=0): def run(self, job_id, speed=1.0, verbosity=0, skip_range=[]):
stats = { stats = {
'events_ontime': { 'events_ontime': {
'total': 0, 'total': 0,
@@ -127,7 +127,7 @@ class ReplayJobEvents():
je_previous = None je_previous = None
for n, je_current in enumerate(job_events): for n, je_current in enumerate(job_events):
if n < skip: if je_current.counter in skip_range:
continue continue
if not je_previous: if not je_previous:
@@ -193,19 +193,29 @@ class Command(BaseCommand):
help = 'Replay job events over websockets ordered by created on date.' help = 'Replay job events over websockets ordered by created on date.'
def _parse_slice_range(self, slice_arg):
slice_arg = tuple([int(n) for n in slice_arg.split(':')])
slice_obj = slice(*slice_arg)
start = slice_obj.start or 0
stop = slice_obj.stop or -1
step = slice_obj.step or 1
return range(start, stop, step)
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--job_id', dest='job_id', type=int, metavar='j', parser.add_argument('--job_id', dest='job_id', type=int, metavar='j',
help='Id of the job to replay (job or adhoc)') help='Id of the job to replay (job or adhoc)')
parser.add_argument('--speed', dest='speed', type=int, metavar='s', parser.add_argument('--speed', dest='speed', type=int, metavar='s',
help='Speedup factor.') help='Speedup factor.')
parser.add_argument('--skip', dest='skip', type=int, metavar='k', parser.add_argument('--skip-range', dest='skip_range', type=str, metavar='k',
help='Number of events to skip.') default='0:-1:1', help='Range of events to skip')
def handle(self, *args, **options): def handle(self, *args, **options):
job_id = options.get('job_id') job_id = options.get('job_id')
speed = options.get('speed') or 1 speed = options.get('speed') or 1
verbosity = options.get('verbosity') or 0 verbosity = options.get('verbosity') or 0
skip = options.get('skip') or 0 skip = self._parse_slice_range(options.get('skip_range'))
replayer = ReplayJobEvents() replayer = ReplayJobEvents()
replayer.run(job_id, speed, verbosity, skip) replayer.run(job_id, speed, verbosity, skip)

View File

@@ -64,15 +64,22 @@ class CallbackBrokerWorker(ConsumerMixin):
return _handler return _handler
if use_workers: if use_workers:
django_connection.close()
django_cache.close()
for idx in range(settings.JOB_EVENT_WORKERS): for idx in range(settings.JOB_EVENT_WORKERS):
queue_actual = MPQueue(settings.JOB_EVENT_MAX_QUEUE_SIZE) queue_actual = MPQueue(settings.JOB_EVENT_MAX_QUEUE_SIZE)
w = Process(target=self.callback_worker, args=(queue_actual, idx,)) w = Process(target=self.callback_worker, args=(queue_actual, idx,))
w.start()
if settings.DEBUG: if settings.DEBUG:
logger.info('Started worker %s' % str(idx)) logger.info('Starting worker %s' % str(idx))
self.worker_queues.append([0, queue_actual, w]) self.worker_queues.append([0, queue_actual, w])
# It's important to close these _right before_ we fork; we
# don't want the forked processes to inherit the open sockets
# for the DB and memcached connections (that way lies race
# conditions)
django_connection.close()
django_cache.close()
for _, _, w in self.worker_queues:
w.start()
elif settings.DEBUG: elif settings.DEBUG:
logger.warn('Started callback receiver (no workers)') logger.warn('Started callback receiver (no workers)')

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2015 Ansible, Inc. # Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import base64
import json
import logging import logging
import threading import threading
import uuid import uuid
@@ -9,12 +11,15 @@ import time
import cProfile import cProfile
import pstats import pstats
import os import os
import re
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor from django.db.migrations.executor import MigrationExecutor
from django.db import IntegrityError, connection from django.db import IntegrityError, connection
from django.http import HttpResponse
from django.utils.functional import curry from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.apps import apps from django.apps import apps
@@ -128,8 +133,9 @@ class SessionTimeoutMiddleware(object):
def process_response(self, request, response): def process_response(self, request, response):
req_session = getattr(request, 'session', None) req_session = getattr(request, 'session', None)
if req_session and not req_session.is_empty(): if req_session and not req_session.is_empty():
request.session.set_expiry(request.session.get_expiry_age()) expiry = int(settings.SESSION_COOKIE_AGE)
response['Session-Timeout'] = int(settings.SESSION_COOKIE_AGE) request.session.set_expiry(expiry)
response['Session-Timeout'] = expiry
return response return response
@@ -203,6 +209,56 @@ class URLModificationMiddleware(object):
request.path_info = new_path request.path_info = new_path
class DeprecatedAuthTokenMiddleware(object):
"""
Used to emulate support for the old Auth Token endpoint to ease the
transition to OAuth2.0. Specifically, this middleware:
1. Intercepts POST requests to `/api/v2/authtoken/` (which now no longer
_actually_ exists in our urls.py)
2. Rewrites `request.path` to `/api/v2/users/N/personal_tokens/`
3. Detects the username and password in the request body (either in JSON,
or form-encoded variables) and builds an appropriate HTTP_AUTHORIZATION
Basic header
"""
def process_request(self, request):
if re.match('^/api/v[12]/authtoken/?$', request.path):
if request.method != 'POST':
return HttpResponse('HTTP {} is not allowed.'.format(request.method), status=405)
try:
payload = json.loads(request.body)
except (ValueError, TypeError):
payload = request.POST
if 'username' not in payload or 'password' not in payload:
return HttpResponse('Unable to login with provided credentials.', status=401)
username = payload['username']
password = payload['password']
try:
pk = User.objects.get(username=username).pk
except ObjectDoesNotExist:
return HttpResponse('Unable to login with provided credentials.', status=401)
new_path = reverse('api:user_personal_token_list', kwargs={
'pk': pk,
'version': 'v2'
})
request._body = ''
request.META['CONTENT_TYPE'] = 'application/json'
request.path = request.path_info = new_path
auth = ' '.join([
'Basic',
base64.b64encode(
six.text_type('{}:{}').format(username, password)
)
])
request.environ['HTTP_AUTHORIZATION'] = auth
logger.warn(
'The Auth Token API (/api/v2/authtoken/) is deprecated and will '
'be replaced with OAuth2.0 in the next version of Ansible Tower '
'(see /api/o/ for more details).'
)
class MigrationRanCheckMiddleware(object): class MigrationRanCheckMiddleware(object):
def process_request(self, request): def process_request(self, request):

View File

@@ -157,7 +157,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])), ('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])),
('error', models.TextField(default=b'', editable=False, blank=True)), ('error', models.TextField(default=b'', editable=False, blank=True)),
('notifications_sent', models.IntegerField(default=0, editable=False)), ('notifications_sent', models.IntegerField(default=0, editable=False)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])), ('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('recipients', models.TextField(default=b'', editable=False, blank=True)), ('recipients', models.TextField(default=b'', editable=False, blank=True)),
('subject', models.TextField(default=b'', editable=False, blank=True)), ('subject', models.TextField(default=b'', editable=False, blank=True)),
('body', jsonfield.fields.JSONField(default=dict, blank=True)), ('body', jsonfield.fields.JSONField(default=dict, blank=True)),
@@ -174,7 +174,7 @@ class Migration(migrations.Migration):
('modified', models.DateTimeField(default=None, editable=False)), ('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(default=b'', blank=True)), ('description', models.TextField(default=b'', blank=True)),
('name', models.CharField(unique=True, max_length=512)), ('name', models.CharField(unique=True, max_length=512)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])), ('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('notification_configuration', jsonfield.fields.JSONField(default=dict)), ('notification_configuration', jsonfield.fields.JSONField(default=dict)),
('created_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), ('created_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
('modified_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)), ('modified_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),

View File

@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)), ('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)), ('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)), ('end_line', models.PositiveIntegerField(default=0, editable=False)),
('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.InventoryUpdate')), ('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_update_events', to='main.InventoryUpdate')),
], ],
options={ options={
'ordering': ('-pk',), 'ordering': ('-pk',),
@@ -53,7 +53,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)), ('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)), ('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)), ('end_line', models.PositiveIntegerField(default=0, editable=False)),
('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.ProjectUpdate')), ('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='project_update_events', to='main.ProjectUpdate')),
], ],
options={ options={
'ordering': ('pk',), 'ordering': ('pk',),
@@ -72,12 +72,24 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)), ('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)), ('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)), ('end_line', models.PositiveIntegerField(default=0, editable=False)),
('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.SystemJob')), ('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='system_job_events', to='main.SystemJob')),
], ],
options={ options={
'ordering': ('-pk',), 'ordering': ('-pk',),
}, },
), ),
migrations.AlterIndexTogether(
name='inventoryupdateevent',
index_together=set([('inventory_update', 'start_line'), ('inventory_update', 'uuid'), ('inventory_update', 'end_line')]),
),
migrations.AlterIndexTogether(
name='projectupdateevent',
index_together=set([('project_update', 'event'), ('project_update', 'end_line'), ('project_update', 'start_line'), ('project_update', 'uuid')]),
),
migrations.AlterIndexTogether(
name='systemjobevent',
index_together=set([('system_job', 'end_line'), ('system_job', 'uuid'), ('system_job', 'start_line')]),
),
migrations.RemoveField( migrations.RemoveField(
model_name='unifiedjob', model_name='unifiedjob',
name='result_stdout_file', name='result_stdout_file',

View File

@@ -64,12 +64,12 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='activitystream', model_name='activitystream',
name='o_auth2_access_token', name='o_auth2_access_token',
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True, related_name='main_o_auth2_accesstoken'), field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True),
), ),
migrations.AddField( migrations.AddField(
model_name='activitystream', model_name='activitystream',
name='o_auth2_application', name='o_auth2_application',
field=models.ManyToManyField(to='main.OAuth2Application', blank=True, related_name='main_o_auth2_application'), field=models.ManyToManyField(to='main.OAuth2Application', blank=True),
), ),
] ]

View File

@@ -16,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='oauth2accesstoken', model_name='oauth2accesstoken',
name='scope', name='scope',
field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."), field=models.TextField(blank=True, default=b'write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."),
), ),
] ]

View File

@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='oauth2accesstoken', model_name='oauth2accesstoken',
name='modified', name='modified',
field=models.DateTimeField(editable=False), field=models.DateTimeField(editable=False, auto_now=True),
), ),
] ]

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-16 16:46
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0047_v330_activitystream_instance'),
]
operations = [
migrations.AlterField(
model_name='credential',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credential',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-17 16:13
from __future__ import unicode_literals
from decimal import Decimal
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0048_v330_django_created_modified_by_model_name'),
]
operations = [
migrations.AlterField(
model_name='instance',
name='capacity_adjustment',
field=models.DecimalField(decimal_places=2, default=Decimal('1'), max_digits=3, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -35,9 +35,9 @@ def sanitize_event_keys(kwargs, valid_keys):
for key in [ for key in [
'play', 'role', 'task', 'playbook' 'play', 'role', 'task', 'playbook'
]: ]:
if isinstance(kwargs.get(key), six.string_types): if isinstance(kwargs.get('event_data', {}).get(key), six.string_types):
if len(kwargs[key]) > 1024: if len(kwargs['event_data'][key]) > 1024:
kwargs[key] = Truncator(kwargs[key]).chars(1024) kwargs['event_data'][key] = Truncator(kwargs['event_data'][key]).chars(1024)
def create_host_status_counts(event_data): def create_host_status_counts(event_data):

View File

@@ -6,6 +6,7 @@ import random
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, connection from django.db import models, connection
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -81,6 +82,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
default=Decimal(1.0), default=Decimal(1.0),
max_digits=3, max_digits=3,
decimal_places=2, decimal_places=2,
validators=[MinValueValidator(0)]
) )
enabled = models.BooleanField( enabled = models.BooleanField(
default=True default=True

View File

@@ -1262,6 +1262,11 @@ class InventorySourceOptions(BaseModel):
'Credentials of type machine, source control, insights and vault are ' 'Credentials of type machine, source control, insights and vault are '
'disallowed for custom inventory sources.' 'disallowed for custom inventory sources.'
) )
elif source == 'scm' and cred and cred.credential_type.kind in ('insights', 'vault'):
return _(
'Credentials of type insights and vault are '
'disallowed for scm inventory sources.'
)
return None return None
def get_inventory_plugin_name(self): def get_inventory_plugin_name(self):

View File

@@ -238,11 +238,11 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
app_label = 'main' app_label = 'main'
ordering = ('name',) ordering = ('name',)
host_config_key = models.CharField( host_config_key = prevent_search(models.CharField(
max_length=1024, max_length=1024,
blank=True, blank=True,
default='', default='',
) ))
ask_diff_mode_on_launch = AskForField( ask_diff_mode_on_launch = AskForField(
blank=True, blank=True,
default=False, default=False,

View File

@@ -37,6 +37,7 @@ class SlackBackend(AWXBaseEmailBackend):
if self.color: if self.color:
ret = connection.api_call("chat.postMessage", ret = connection.api_call("chat.postMessage",
channel=r, channel=r,
as_user=True,
attachments=[{ attachments=[{
"color": self.color, "color": self.color,
"text": m.subject "text": m.subject

View File

@@ -76,7 +76,8 @@ class TaskManager():
inventory_updates_qs = InventoryUpdate.objects.filter( inventory_updates_qs = InventoryUpdate.objects.filter(
status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group') status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group')
inventory_updates = [i for i in inventory_updates_qs] inventory_updates = [i for i in inventory_updates_qs]
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list).prefetch_related('instance_group')] # Notice the job_type='check': we want to prevent implicit project updates from blocking our jobs.
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list, job_type='check').prefetch_related('instance_group')]
system_jobs = [s for s in SystemJob.objects.filter(status__in=status_list).prefetch_related('instance_group')] system_jobs = [s for s in SystemJob.objects.filter(status__in=status_list).prefetch_related('instance_group')]
ad_hoc_commands = [a for a in AdHocCommand.objects.filter(status__in=status_list).prefetch_related('instance_group')] ad_hoc_commands = [a for a in AdHocCommand.objects.filter(status__in=status_list).prefetch_related('instance_group')]
workflow_jobs = [w for w in WorkflowJob.objects.filter(status__in=status_list)] workflow_jobs = [w for w in WorkflowJob.objects.filter(status__in=status_list)]
@@ -678,9 +679,9 @@ class TaskManager():
return finished_wfjs return finished_wfjs
def schedule(self): def schedule(self):
with transaction.atomic(): # Lock
# Lock with advisory_lock('task_manager_lock', wait=False) as acquired:
with advisory_lock('task_manager_lock', wait=False) as acquired: with transaction.atomic():
if acquired is False: if acquired is False:
logger.debug("Not running scheduler, another task holds lock") logger.debug("Not running scheduler, another task holds lock")
return return

View File

@@ -32,7 +32,7 @@ except Exception:
from kombu import Queue, Exchange from kombu import Queue, Exchange
from kombu.common import Broadcast from kombu.common import Broadcast
from celery import Task, shared_task from celery import Task, shared_task
from celery.signals import celeryd_init, worker_shutdown, celeryd_after_setup from celery.signals import celeryd_init, worker_shutdown
# Django # Django
from django.conf import settings from django.conf import settings
@@ -108,6 +108,31 @@ def log_celery_failure(self, exc, task_id, args, kwargs, einfo):
@celeryd_init.connect @celeryd_init.connect
def celery_startup(conf=None, **kwargs): def celery_startup(conf=None, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if kwargs['instance'].hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
startup_logger = logging.getLogger('awx.main.tasks') startup_logger = logging.getLogger('awx.main.tasks')
startup_logger.info("Syncing Schedules") startup_logger.info("Syncing Schedules")
for sch in Schedule.objects.all(): for sch in Schedule.objects.all():
@@ -147,9 +172,17 @@ def inform_cluster_of_shutdown(*args, **kwargs):
@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) @shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE)
def apply_cluster_membership_policies(self): def apply_cluster_membership_policies(self):
started_waiting = time.time()
with advisory_lock('cluster_policy_lock', wait=True): with advisory_lock('cluster_policy_lock', wait=True):
lock_time = time.time() - started_waiting
if lock_time > 1.0:
to_log = logger.info
else:
to_log = logger.debug
to_log('Waited {} seconds to obtain lock name: cluster_policy_lock'.format(lock_time))
started_compute = time.time()
all_instances = list(Instance.objects.order_by('id')) all_instances = list(Instance.objects.order_by('id'))
all_groups = list(InstanceGroup.objects.all()) all_groups = list(InstanceGroup.objects.prefetch_related('instances'))
iso_hostnames = set([]) iso_hostnames = set([])
for ig in all_groups: for ig in all_groups:
if ig.controller_id is not None: if ig.controller_id is not None:
@@ -159,28 +192,32 @@ def apply_cluster_membership_policies(self):
total_instances = len(considered_instances) total_instances = len(considered_instances)
actual_groups = [] actual_groups = []
actual_instances = [] actual_instances = []
Group = namedtuple('Group', ['obj', 'instances']) Group = namedtuple('Group', ['obj', 'instances', 'prior_instances'])
Node = namedtuple('Instance', ['obj', 'groups']) Node = namedtuple('Instance', ['obj', 'groups'])
# Process policy instance list first, these will represent manually managed memberships # Process policy instance list first, these will represent manually managed memberships
instance_hostnames_map = {inst.hostname: inst for inst in all_instances} instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
for ig in all_groups: for ig in all_groups:
group_actual = Group(obj=ig, instances=[]) group_actual = Group(obj=ig, instances=[], prior_instances=[
instance.pk for instance in ig.instances.all() # obtained in prefetch
])
for hostname in ig.policy_instance_list: for hostname in ig.policy_instance_list:
if hostname not in instance_hostnames_map: if hostname not in instance_hostnames_map:
logger.info(six.text_type("Unknown instance {} in {} policy list").format(hostname, ig.name))
continue continue
inst = instance_hostnames_map[hostname] inst = instance_hostnames_map[hostname]
logger.info(six.text_type("Policy List, adding Instance {} to Group {}").format(inst.hostname, ig.name))
group_actual.instances.append(inst.id) group_actual.instances.append(inst.id)
# NOTE: arguable behavior: policy-list-group is not added to # NOTE: arguable behavior: policy-list-group is not added to
# instance's group count for consideration in minimum-policy rules # instance's group count for consideration in minimum-policy rules
if group_actual.instances:
logger.info(six.text_type("Policy List, adding Instances {} to Group {}").format(group_actual.instances, ig.name))
if ig.controller_id is None: if ig.controller_id is None:
actual_groups.append(group_actual) actual_groups.append(group_actual)
else: else:
# For isolated groups, _only_ apply the policy_instance_list # For isolated groups, _only_ apply the policy_instance_list
# do not add to in-memory list, so minimum rules not applied # do not add to in-memory list, so minimum rules not applied
logger.info('Committing instances {} to isolated group {}'.format(group_actual.instances, ig.name)) logger.info('Committing instances to isolated group {}'.format(ig.name))
ig.instances.set(group_actual.instances) ig.instances.set(group_actual.instances)
# Process Instance minimum policies next, since it represents a concrete lower bound to the # Process Instance minimum policies next, since it represents a concrete lower bound to the
@@ -189,6 +226,7 @@ def apply_cluster_membership_policies(self):
logger.info("Total non-isolated instances:{} available for policy: {}".format( logger.info("Total non-isolated instances:{} available for policy: {}".format(
total_instances, len(actual_instances))) total_instances, len(actual_instances)))
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_min_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if len(g.instances) >= g.obj.policy_instance_minimum: if len(g.instances) >= g.obj.policy_instance_minimum:
break break
@@ -196,12 +234,15 @@ def apply_cluster_membership_policies(self):
# If the instance is already _in_ the group, it was # If the instance is already _in_ the group, it was
# applied earlier via the policy list # applied earlier via the policy list
continue continue
logger.info(six.text_type("Policy minimum, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id) g.instances.append(i.obj.id)
i.groups.append(g.obj.id) i.groups.append(g.obj.id)
policy_min_added.append(i.obj.id)
if policy_min_added:
logger.info(six.text_type("Policy minimum, adding Instances {} to Group {}").format(policy_min_added, g.obj.name))
# Finally, process instance policy percentages # Finally, process instance policy percentages
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_per_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if i.obj.id in g.instances: if i.obj.id in g.instances:
# If the instance is already _in_ the group, it was # If the instance is already _in_ the group, it was
@@ -209,15 +250,34 @@ def apply_cluster_membership_policies(self):
continue continue
if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage: if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage:
break break
logger.info(six.text_type("Policy percentage, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id) g.instances.append(i.obj.id)
i.groups.append(g.obj.id) i.groups.append(g.obj.id)
policy_per_added.append(i.obj.id)
if policy_per_added:
logger.info(six.text_type("Policy percentage, adding Instances {} to Group {}").format(policy_per_added, g.obj.name))
# Determine if any changes need to be made
needs_change = False
for g in actual_groups:
if set(g.instances) != set(g.prior_instances):
needs_change = True
break
if not needs_change:
logger.info('Cluster policy no-op finished in {} seconds'.format(time.time() - started_compute))
return
# On a differential basis, apply instances to non-isolated groups # On a differential basis, apply instances to non-isolated groups
with transaction.atomic(): with transaction.atomic():
for g in actual_groups: for g in actual_groups:
logger.info('Committing instances {} to group {}'.format(g.instances, g.obj.name)) instances_to_add = set(g.instances) - set(g.prior_instances)
g.obj.instances.set(g.instances) instances_to_remove = set(g.prior_instances) - set(g.instances)
if instances_to_add:
logger.info('Adding instances {} to group {}'.format(list(instances_to_add), g.obj.name))
g.obj.instances.add(*instances_to_add)
if instances_to_remove:
logger.info('Removing instances {} from group {}'.format(list(instances_to_remove), g.obj.name))
g.obj.instances.remove(*instances_to_remove)
logger.info('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
@shared_task(exchange='tower_broadcast_all', bind=True) @shared_task(exchange='tower_broadcast_all', bind=True)
@@ -233,34 +293,6 @@ def handle_setting_changes(self, setting_keys):
cache.delete_many(cache_keys) cache.delete_many(cache_keys)
@celeryd_after_setup.connect
def auto_register_ha_instance(sender, instance, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if instance.hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
@shared_task(queue=settings.CELERY_DEFAULT_QUEUE) @shared_task(queue=settings.CELERY_DEFAULT_QUEUE)
def send_notifications(notification_list, job_id=None): def send_notifications(notification_list, job_id=None):
if not isinstance(notification_list, list): if not isinstance(notification_list, list):
@@ -761,12 +793,12 @@ class BaseTask(Task):
os.chmod(path, stat.S_IRUSR) os.chmod(path, stat.S_IRUSR)
return path return path
def add_ansible_venv(self, venv_path, env, add_awx_lib=True): def add_ansible_venv(self, venv_path, env, add_awx_lib=True, **kwargs):
env['VIRTUAL_ENV'] = venv_path env['VIRTUAL_ENV'] = venv_path
env['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH'] env['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH']
venv_libdir = os.path.join(venv_path, "lib") venv_libdir = os.path.join(venv_path, "lib")
if not os.path.exists(venv_libdir): if not kwargs.get('isolated', False) and not os.path.exists(venv_libdir):
raise RuntimeError( raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path) 'a valid Python virtualenv does not exist at {}'.format(venv_path)
) )
@@ -1179,7 +1211,7 @@ class RunJob(BaseTask):
plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS) plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
plugin_path = ':'.join(plugin_dirs) plugin_path = ':'.join(plugin_dirs)
env = super(RunJob, self).build_env(job, **kwargs) env = super(RunJob, self).build_env(job, **kwargs)
env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False)) env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False), **kwargs)
# Set environment variables needed for inventory and job event # Set environment variables needed for inventory and job event
# callbacks to work. # callbacks to work.
env['JOB_ID'] = str(job.pk) env['JOB_ID'] = str(job.pk)
@@ -2129,8 +2161,7 @@ class RunInventoryUpdate(BaseTask):
elif src == 'scm': elif src == 'scm':
args.append(inventory_update.get_actual_source_path()) args.append(inventory_update.get_actual_source_path())
elif src == 'custom': elif src == 'custom':
runpath = tempfile.mkdtemp(prefix='awx_inventory_', dir=settings.AWX_PROOT_BASE_PATH) handle, path = tempfile.mkstemp(dir=kwargs['private_data_dir'])
handle, path = tempfile.mkstemp(dir=runpath)
f = os.fdopen(handle, 'w') f = os.fdopen(handle, 'w')
if inventory_update.source_script is None: if inventory_update.source_script is None:
raise RuntimeError('Inventory Script does not exist') raise RuntimeError('Inventory Script does not exist')
@@ -2139,7 +2170,6 @@ class RunInventoryUpdate(BaseTask):
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
args.append(path) args.append(path)
args.append("--custom") args.append("--custom")
self.cleanup_paths.append(runpath)
args.append('-v%d' % inventory_update.verbosity) args.append('-v%d' % inventory_update.verbosity)
if settings.DEBUG: if settings.DEBUG:
args.append('--traceback') args.append('--traceback')

View File

@@ -365,6 +365,116 @@ def test_inventory_source_vars_prohibition(post, inventory, admin_user):
assert 'FOOBAR' in r.data['source_vars'][0] assert 'FOOBAR' in r.data['source_vars'][0]
@pytest.mark.django_db
class TestInventorySourceCredential:
def test_need_cloud_credential(self, inventory, admin_user, post):
"""Test that a cloud-based source requires credential"""
r = post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'foo', 'source': 'openstack'},
expect=400,
user=admin_user
)
assert 'Credential is required for a cloud source' in r.data['credential'][0]
def test_ec2_no_credential(self, inventory, admin_user, post):
"""Test that an ec2 inventory source can be added with no credential"""
post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2'},
expect=201,
user=admin_user
)
def test_validating_credential_type(self, organization, inventory, admin_user, post):
"""Test that cloud sources must use their respective credential type"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2',
'credential': os_cred.pk
},
expect=400,
user=admin_user
)
assert 'Cloud-based inventory sources (such as ec2)' in r.data['credential'][0]
assert 'require credentials for the matching cloud service' in r.data['credential'][0]
def test_vault_credential_not_allowed(self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via the deprecated field"""
# TODO: when feature is added, add tests to use the related credentials
# endpoint for multi-vault attachment
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['credential'][0]
assert 'disallowed for scm inventory sources' in r.data['credential'][0]
def test_vault_credential_not_allowed_via_related(
self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via related endpoint"""
inv_src = InventorySource.objects.create(
inventory=inventory, name='foobar', source='scm',
source_project=project, source_path=''
)
r = post(
url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}),
data={
'id': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['msg']
assert 'disallowed for scm inventory sources' in r.data['msg']
def test_credentials_relationship_mapping(self, project, inventory, organization, admin_user, post, patch):
"""The credentials relationship is used to manage the cloud credential
this test checks that replacement works"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': os_cred.pk
},
expect=201,
user=admin_user
)
aws = CredentialType.defaults['aws']()
aws.save()
aws_cred = Credential.objects.create(
credential_type=aws, name='bar2', organization=organization)
inv_src = InventorySource.objects.get(pk=r.data['id'])
assert list(inv_src.credentials.values_list('id', flat=True)) == [os_cred.pk]
patch(
url=inv_src.get_absolute_url(),
data={
'credential': aws_cred.pk
},
expect=200,
user=admin_user
)
assert list(inv_src.credentials.values_list('id', flat=True)) == [aws_cred.pk]
@pytest.mark.django_db @pytest.mark.django_db
class TestControlledBySCM: class TestControlledBySCM:
''' '''

View File

@@ -5,7 +5,10 @@ import json
from django.db import connection from django.db import connection
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test import Client from django.test import Client
from django.core.urlresolvers import resolve
from rest_framework.test import APIRequestFactory
from awx.main.middleware import DeprecatedAuthTokenMiddleware
from awx.main.utils.encryption import decrypt_value, get_encryption_key from awx.main.utils.encryption import decrypt_value, get_encryption_key
from awx.api.versioning import reverse, drf_reverse from awx.api.versioning import reverse, drf_reverse
from awx.main.models.oauth import (OAuth2Application as Application, from awx.main.models.oauth import (OAuth2Application as Application,
@@ -262,36 +265,6 @@ def test_oauth_list_user_tokens(oauth_application, post, get, admin, alice):
assert response.data['count'] == 1 assert response.data['count'] == 1
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert token not in AccessToken.objects.all()
assert AccessToken.objects.get(token=new_token) != 0
assert RefreshToken.objects.get(token=new_refresh_token) != 0
refresh_token = RefreshToken.objects.get(token=refresh_token)
assert refresh_token.revoked
@pytest.mark.django_db @pytest.mark.django_db
def test_implicit_authorization(oauth_application, admin): def test_implicit_authorization(oauth_application, admin):
oauth_application.client_type = 'confidential' oauth_application.client_type = 'confidential'
@@ -314,3 +287,117 @@ def test_implicit_authorization(oauth_application, admin):
assert 'http://test.com' in response.url and 'access_token' in response.url assert 'http://test.com' in response.url and 'access_token' in response.url
# Make sure no refresh token is created for app with implicit grant type. # Make sure no refresh token is created for app with implicit grant type.
assert refresh_token_count == RefreshToken.objects.count() assert refresh_token_count == RefreshToken.objects.count()
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
assert RefreshToken.objects.filter(token=refresh_token).exists()
original_refresh_token = RefreshToken.objects.get(token=refresh_token)
assert token not in AccessToken.objects.all()
assert AccessToken.objects.count() == 1
# the same RefreshToken remains but is marked revoked
assert RefreshToken.objects.count() == 2
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert AccessToken.objects.filter(token=new_token).count() == 1
# checks that RefreshTokens are rotated (new RefreshToken issued)
assert RefreshToken.objects.filter(token=new_refresh_token).count() == 1
assert original_refresh_token.revoked # is not None
@pytest.mark.django_db
def test_revoke_access_then_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
assert not refresh_token.revoked
refresh_token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
@pytest.mark.django_db
def test_revoke_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_token.revoke()
assert AccessToken.objects.count() == 0
# the same RefreshToken is recycled
new_refresh_token = RefreshToken.objects.all().first()
assert refresh_token == new_refresh_token
assert new_refresh_token.revoked
@pytest.mark.django_db
@pytest.mark.parametrize('fmt', ['json', 'multipart'])
def test_deprecated_authtoken_support(alice, fmt):
kwargs = {
'data': {'username': 'alice', 'password': 'alice'},
'format': fmt
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
DeprecatedAuthTokenMiddleware().process_request(request)
assert request.path == request.path_info == '/api/v2/users/{}/personal_tokens/'.format(alice.pk)
view, view_args, view_kwargs = resolve(request.path)
resp = view(request, *view_args, **view_kwargs)
assert resp.status_code == 201
assert 'token' in resp.data
assert resp.data['refresh_token'] is None
assert resp.data['scope'] == 'write'
@pytest.mark.django_db
def test_deprecated_authtoken_invalid_username(alice):
kwargs = {
'data': {'username': 'nobody', 'password': 'nobody'},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401
@pytest.mark.django_db
def test_deprecated_authtoken_missing_credentials(alice):
kwargs = {
'data': {},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401

View File

@@ -35,6 +35,15 @@ class TestOAuth2Application:
) )
assert access.can_read(app) is can_access assert access.can_read(app) is can_access
def test_admin_only_can_read(self, user, organization):
user = user('org-admin', False)
organization.admin_role.members.add(user)
access = OAuth2ApplicationAccess(user)
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password', organization=organization
)
assert access.can_read(app) is True
def test_app_activity_stream(self, org_admin, alice, organization): def test_app_activity_stream(self, org_admin, alice, organization):
app = Application.objects.create( app = Application.objects.create(

View File

@@ -53,9 +53,9 @@ def test_really_long_event_fields(field):
with mock.patch.object(JobEvent, 'objects') as manager: with mock.patch.object(JobEvent, 'objects') as manager:
JobEvent.create_from_data(**{ JobEvent.create_from_data(**{
'job_id': 123, 'job_id': 123,
field: 'X' * 4096 'event_data': {field: 'X' * 4096}
}) })
manager.create.assert_called_with(**{ manager.create.assert_called_with(**{
'job_id': 123, 'job_id': 123,
field: 'X' * 1021 + '...' 'event_data': {field: 'X' * 1021 + '...'}
}) })

View File

@@ -2,7 +2,6 @@
# Python # Python
import pytest import pytest
import mock import mock
from collections import namedtuple
# AWX # AWX
from awx.main.utils.filters import SmartFilter, ExternalLoggerEnabled from awx.main.utils.filters import SmartFilter, ExternalLoggerEnabled
@@ -44,8 +43,26 @@ def test_log_configurable_severity(level, expect, dummy_log_record):
assert filter.filter(dummy_log_record) is expect assert filter.filter(dummy_log_record) is expect
Field = namedtuple('Field', 'name') class Field(object):
Meta = namedtuple('Meta', 'fields')
def __init__(self, name, related_model=None, __prevent_search__=None):
self.name = name
self.related_model = related_model
self.__prevent_search__ = __prevent_search__
class Meta(object):
def __init__(self, fields):
self._fields = {
f.name: f for f in fields
}
self.object_name = 'Host'
self.fields_map = {}
self.fields = self._fields.values()
def get_field(self, f):
return self._fields.get(f)
class mockObjects: class mockObjects:
@@ -53,15 +70,32 @@ class mockObjects:
return Q(*args, **kwargs) return Q(*args, **kwargs)
class mockUser:
def __init__(self):
print("Host user created")
self._meta = Meta(fields=[
Field(name='password', __prevent_search__=True)
])
class mockHost: class mockHost:
def __init__(self): def __init__(self):
print("Host mock created") print("Host mock created")
self.objects = mockObjects() self.objects = mockObjects()
self._meta = Meta(fields=(Field(name='name'), Field(name='description'))) fields = [
Field(name='name'),
Field(name='description'),
Field(name='created_by', related_model=mockUser())
]
self._meta = Meta(fields=fields)
@mock.patch('awx.main.utils.filters.get_model', return_value=mockHost()) @mock.patch('awx.main.utils.filters.get_model', return_value=mockHost())
class TestSmartFilterQueryFromString(): class TestSmartFilterQueryFromString():
@mock.patch(
'awx.api.filters.get_field_from_path',
lambda model, path: (model, path) # disable field filtering, because a__b isn't a real Host field
)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})), ('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})),
('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})), ('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})),
@@ -88,6 +122,16 @@ class TestSmartFilterQueryFromString():
SmartFilter.query_from_string(filter_string) SmartFilter.query_from_string(filter_string)
assert e.value.message == u"Invalid query " + filter_string assert e.value.message == u"Invalid query " + filter_string
@pytest.mark.parametrize("filter_string", [
'created_by__password__icontains=pbkdf2'
'search=foo or created_by__password__icontains=pbkdf2',
'created_by__password__icontains=pbkdf2 or search=foo',
])
def test_forbidden_filter_string(self, mock_get_host_model, filter_string):
with pytest.raises(Exception) as e:
SmartFilter.query_from_string(filter_string)
"Filtering on password is not allowed." in str(e)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
(u'(a=abc\u1F5E3def)', Q(**{u"a": u"abc\u1F5E3def"})), (u'(a=abc\u1F5E3def)', Q(**{u"a": u"abc\u1F5E3def"})),
(u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})), (u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})),

View File

@@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved.
# python
import pytest
import mock
# AWX
from awx.main.utils.ha import (
AWXCeleryRouter,
)
class TestAddRemoveCeleryWorkerQueues():
@pytest.fixture
def instance_generator(self, mocker):
def fn(hostname='east-1'):
groups=['east', 'west', 'north', 'south']
instance = mocker.MagicMock()
instance.hostname = hostname
instance.rampart_groups = mocker.MagicMock()
instance.rampart_groups.values_list = mocker.MagicMock(return_value=groups)
return instance
return fn
@pytest.fixture
def worker_queues_generator(self, mocker):
def fn(queues=['east', 'west']):
return [dict(name=n, alias='') for n in queues]
return fn
@pytest.fixture
def mock_app(self, mocker):
app = mocker.MagicMock()
app.control = mocker.MagicMock()
app.control.cancel_consumer = mocker.MagicMock()
return app
class TestUpdateCeleryWorkerRouter():
@pytest.mark.parametrize("is_controller,expected_routes", [
(False, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'}
}),
(True, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.awx_isolated_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
}),
])
def test_update_celery_worker_routes(self, mocker, is_controller, expected_routes):
def get_or_register():
instance = mock.MagicMock()
instance.hostname = 'east-1'
instance.is_controller = mock.MagicMock(return_value=is_controller)
return (False, instance)
with mock.patch('awx.main.models.Instance.objects.get_or_register', get_or_register):
router = AWXCeleryRouter()
for k,v in expected_routes.iteritems():
assert router.route_for_task(k) == v

View File

@@ -147,6 +147,10 @@ class SmartFilter(object):
q = reduce(lambda x, y: x | y, [models.Q(**{u'%s__icontains' % _k:_v}) for _k, _v in kwargs.items()]) q = reduce(lambda x, y: x | y, [models.Q(**{u'%s__icontains' % _k:_v}) for _k, _v in kwargs.items()])
self.result = Host.objects.filter(q) self.result = Host.objects.filter(q)
else: else:
# detect loops and restrict access to sensitive fields
# this import is intentional here to avoid a circular import
from awx.api.filters import FieldLookupBackend
FieldLookupBackend().get_field_from_lookup(Host, k)
kwargs[k] = v kwargs[k] = v
self.result = Host.objects.filter(**kwargs) self.result = Host.objects.filter(**kwargs)

View File

@@ -3,21 +3,15 @@
# Copyright (c) 2017 Ansible Tower by Red Hat # Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved. # All Rights Reserved.
from awx.main.models import Instance from django.conf import settings
class AWXCeleryRouter(object): class AWXCeleryRouter(object):
def route_for_task(self, task, args=None, kwargs=None): def route_for_task(self, task, args=None, kwargs=None):
(changed, instance) = Instance.objects.get_or_register()
tasks = [ tasks = [
'awx.main.tasks.cluster_node_heartbeat', 'awx.main.tasks.cluster_node_heartbeat',
'awx.main.tasks.purge_old_stdout_files', 'awx.main.tasks.purge_old_stdout_files',
]
isolated_tasks = [
'awx.main.tasks.awx_isolated_heartbeat', 'awx.main.tasks.awx_isolated_heartbeat',
] ]
if task in tasks: if task in tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")} return {'queue': settings.CLUSTER_HOST_ID, 'routing_key': settings.CLUSTER_HOST_ID}
if instance.is_controller() and task in isolated_tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")}

View File

@@ -261,6 +261,7 @@ MIDDLEWARE_CLASSES = ( # NOQA
'awx.sso.middleware.SocialAuthMiddleware', 'awx.sso.middleware.SocialAuthMiddleware',
'crum.CurrentRequestUserMiddleware', 'crum.CurrentRequestUserMiddleware',
'awx.main.middleware.URLModificationMiddleware', 'awx.main.middleware.URLModificationMiddleware',
'awx.main.middleware.DeprecatedAuthTokenMiddleware',
'awx.main.middleware.SessionTimeoutMiddleware', 'awx.main.middleware.SessionTimeoutMiddleware',
) )
@@ -1177,16 +1178,13 @@ LOGGING = {
'propagate': False 'propagate': False
}, },
'awx.main.access': { 'awx.main.access': {
'handlers': ['null'], 'level': 'INFO', # very verbose debug-level logs
'propagate': False,
}, },
'awx.main.signals': { 'awx.main.signals': {
'handlers': ['null'], 'level': 'INFO', # very verbose debug-level logs
'propagate': False,
}, },
'awx.api.permissions': { 'awx.api.permissions': {
'handlers': ['null'], 'level': 'INFO', # very verbose debug-level logs
'propagate': False,
}, },
'awx.analytics': { 'awx.analytics': {
'handlers': ['external_logger'], 'handlers': ['external_logger'],

View File

@@ -1,3 +1,16 @@
@import 'portalMode/_index'; @import 'portalMode/_index';
@import 'output/_index'; @import 'output/_index';
@import 'users/tokens/_index';
/** @define Popup Modal after create new token and applicaiton and save form */
.PopupModal {
display: flex;
}
.PopupModal-label {
font-weight: bold;
width: 130px;
}
.PopupModal-value {
width: 70%;
}

View File

@@ -1,4 +1,4 @@
function AddApplicationsController (models, $state, strings, $scope) { function AddApplicationsController (models, $state, strings, $scope, Alert, $filter) {
const vm = this || {}; const vm = this || {};
const { application, me, organization } = models; const { application, me, organization } = models;
@@ -60,6 +60,41 @@ function AddApplicationsController (models, $state, strings, $scope) {
}; };
vm.form.onSaveSuccess = res => { vm.form.onSaveSuccess = res => {
if (res.data && res.data.client_id) {
const name = res.data.name ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.NAME_LABEL')}
</div>
<div class="PopupModal-value">
${$filter('sanitize')(res.data.name)}
</div>
</div>` : '';
const clientId = res.data.client_id ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.CLIENT_ID_LABEL')}
</div>
<div class="PopupModal-value">
${res.data.client_id}
</div>
</div>` : '';
const clientSecret = res.data.client_secret ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.CLIENT_SECRECT_LABEL')}
</div>
<div class="PopupModal-value">
${res.data.client_secret}
</div>
</div>` : '';
Alert(strings.get('add.MODAL_HEADER'), `
${name}
${clientId}
${clientSecret}
`, null, null, null, null, null, true);
}
$state.go('applications.edit', { application_id: res.data.id }, { reload: true }); $state.go('applications.edit', { application_id: res.data.id }, { reload: true });
}; };
@@ -74,7 +109,9 @@ AddApplicationsController.$inject = [
'resolvedModels', 'resolvedModels',
'$state', '$state',
'ApplicationsStrings', 'ApplicationsStrings',
'$scope' '$scope',
'Alert',
'$filter',
]; ];
export default AddApplicationsController; export default AddApplicationsController;

View File

@@ -21,7 +21,11 @@ function ApplicationsStrings (BaseString) {
}; };
ns.add = { ns.add = {
PANEL_TITLE: t.s('NEW APPLICATION') PANEL_TITLE: t.s('NEW APPLICATION'),
CLIENT_ID_LABEL: t.s('CLIENT ID'),
CLIENT_SECRECT_LABEL: t.s('CLIENT SECRET'),
MODAL_HEADER: t.s('APPLICATION INFORMATION'),
NAME_LABEL: t.s('NAME'),
}; };
ns.list = { ns.list = {

View File

@@ -38,6 +38,16 @@
} }
} }
&-menuIcon--md {
font-size: 14px;
padding: 10px;
cursor: pointer;
&:hover {
color: @at-blue;
}
}
&-menuIcon--lg { &-menuIcon--lg {
font-size: 22px; font-size: 22px;
line-height: 12px; line-height: 12px;
@@ -94,6 +104,10 @@
user-select: none; user-select: none;
} }
&-line--clickable {
cursor: pointer;
}
&-event { &-event {
.at-mixin-event(); .at-mixin-event();
} }
@@ -138,6 +152,10 @@
margin: 0; margin: 0;
overflow-y: scroll; overflow-y: scroll;
padding: 0; padding: 0;
@media screen and (max-width: @breakpoint-md) {
max-height: calc(100vh - 30px);
}
} }
&-borderHeader { &-borderHeader {

View File

@@ -13,9 +13,30 @@ export const JOB_STATUS_INCOMPLETE = ['canceled', 'error'];
export const JOB_STATUS_UNSUCCESSFUL = ['failed'].concat(JOB_STATUS_INCOMPLETE); export const JOB_STATUS_UNSUCCESSFUL = ['failed'].concat(JOB_STATUS_INCOMPLETE);
export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPLETE); export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPLETE);
export const OUTPUT_ANSI_COLORMAP = {
0: '#000',
1: '#A00',
2: '#0A0',
3: '#F0AD4E',
4: '#00A',
5: '#A0A',
6: '#0AA',
7: '#AAA',
8: '#555',
9: '#F55',
10: '#5F5',
11: '#FF5',
12: '#55F',
13: '#F5F',
14: '#5FF',
15: '#FFF'
};
export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container'; export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container';
export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable'; export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable';
export const OUTPUT_ELEMENT_LAST = '#atStdoutMenuLast';
export const OUTPUT_MAX_BUFFER_LENGTH = 1000;
export const OUTPUT_MAX_LAG = 120; export const OUTPUT_MAX_LAG = 120;
export const OUTPUT_NO_COUNT_JOB_TYPES = ['ad_hoc_command', 'system_job', 'inventory_update'];
export const OUTPUT_ORDER_BY = 'counter'; export const OUTPUT_ORDER_BY = 'counter';
export const OUTPUT_PAGE_CACHE = true; export const OUTPUT_PAGE_CACHE = true;
export const OUTPUT_PAGE_LIMIT = 5; export const OUTPUT_PAGE_LIMIT = 5;
@@ -23,8 +44,8 @@ export const OUTPUT_PAGE_SIZE = 50;
export const OUTPUT_SCROLL_DELAY = 100; export const OUTPUT_SCROLL_DELAY = 100;
export const OUTPUT_SCROLL_THRESHOLD = 0.1; export const OUTPUT_SCROLL_THRESHOLD = 0.1;
export const OUTPUT_SEARCH_DOCLINK = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html'; export const OUTPUT_SEARCH_DOCLINK = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html';
export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play', 'start_line', 'end_line'];
export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01']; export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01', 'start_line:>=9000'];
export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE; export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE;
export const WS_PREFIX = 'ws'; export const WS_PREFIX = 'ws';

View File

@@ -2,6 +2,7 @@
import { import {
EVENT_START_PLAY, EVENT_START_PLAY,
EVENT_START_TASK, EVENT_START_TASK,
OUTPUT_ELEMENT_LAST,
OUTPUT_PAGE_SIZE, OUTPUT_PAGE_SIZE,
} from './constants'; } from './constants';
@@ -16,61 +17,21 @@ let scroll;
let status; let status;
let slide; let slide;
let stream; let stream;
let page;
let vm; let vm;
const bufferState = [0, 0]; // [length, count]
const listeners = []; const listeners = [];
const rx = []; let lockFrames = false;
function bufferInit () {
rx.length = 0;
bufferState[0] = 0;
bufferState[1] = 0;
}
function bufferAdd (event) {
rx.push(event);
bufferState[0] += 1;
bufferState[1] += 1;
return bufferState[1];
}
function bufferEmpty (min, max) {
let count = 0;
let removed = [];
for (let i = bufferState[0] - 1; i >= 0; i--) {
if (rx[i].counter <= max) {
removed = removed.concat(rx.splice(i, 1));
count++;
}
}
bufferState[0] -= count;
return removed;
}
let lockFrames;
function onFrames (events) { function onFrames (events) {
if (lockFrames) {
events.forEach(bufferAdd);
return $q.resolve();
}
events = slide.pushFrames(events); events = slide.pushFrames(events);
const popCount = events.length - slide.getCapacity();
const isAttached = events.length > 0;
if (!isAttached) { if (lockFrames) {
stopFollowing();
return $q.resolve(); return $q.resolve();
} }
const popCount = events.length - render.getCapacity();
if (!vm.isFollowing && canStartFollowing()) { if (!vm.isFollowing && canStartFollowing()) {
startFollowing(); startFollowing();
} }
@@ -85,13 +46,13 @@ function onFrames (events) {
scroll.scrollToBottom(); scroll.scrollToBottom();
} }
return slide.popBack(popCount) return render.popBack(popCount)
.then(() => { .then(() => {
if (vm.isFollowing) { if (vm.isFollowing) {
scroll.scrollToBottom(); scroll.scrollToBottom();
} }
return slide.pushFront(events); return render.pushFront(events);
}) })
.then(() => { .then(() => {
if (vm.isFollowing) { if (vm.isFollowing) {
@@ -104,27 +65,44 @@ function onFrames (events) {
}); });
} }
function first () { //
// Menu Controls (Running)
//
function firstRange () {
if (scroll.isPaused()) { if (scroll.isPaused()) {
return $q.resolve(); return $q.resolve();
} }
stopFollowing();
lockFollow = true;
if (slide.isOnFirstPage()) {
scroll.resetScrollPosition();
return $q.resolve();
}
scroll.pause(); scroll.pause();
lockFrames = true; lockFrames = true;
stopFollowing(); return render.clear()
.then(() => slide.getFirst())
.then(results => render.pushFront(results))
.then(() => slide.getNext())
.then(results => {
const popCount = results.length - render.getCapacity();
return slide.getFirst() return render.popBack(popCount)
.then(() => { .then(() => render.pushFront(results));
scroll.resetScrollPosition();
}) })
.finally(() => { .finally(() => {
scroll.resume(); scroll.resume();
lockFrames = false; lockFollow = false;
}); });
} }
function next () { function nextRange () {
if (vm.isFollowing) { if (vm.isFollowing) {
scroll.scrollToBottom(); scroll.scrollToBottom();
@@ -135,34 +113,49 @@ function next () {
return $q.resolve(); return $q.resolve();
} }
if (slide.getTailCounter() >= slide.getMaxCounter()) {
return $q.resolve();
}
scroll.pause(); scroll.pause();
lockFrames = true; lockFrames = true;
return slide.getNext() return slide.getNext()
.then(results => {
const popCount = results.length - render.getCapacity();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => { .finally(() => {
scroll.resume(); scroll.resume();
lockFrames = false; lockFrames = false;
return $q.resolve();
}); });
} }
function previous () { function previousRange () {
if (scroll.isPaused()) { if (scroll.isPaused()) {
return $q.resolve(); return $q.resolve();
} }
scroll.pause(); scroll.pause();
stopFollowing();
lockFrames = true; lockFrames = true;
stopFollowing(); let initialPosition;
let popHeight;
const initialPosition = scroll.getScrollPosition();
return slide.getPrevious() return slide.getPrevious()
.then(popHeight => { .then(results => {
const popCount = results.length - render.getCapacity();
initialPosition = scroll.getScrollPosition();
return render.popFront(popCount)
.then(() => {
popHeight = scroll.getScrollHeight();
return render.pushBack(results);
});
})
.then(() => {
const currentHeight = scroll.getScrollHeight(); const currentHeight = scroll.getScrollHeight();
scroll.setScrollPosition(currentHeight - popHeight + initialPosition); scroll.setScrollPosition(currentHeight - popHeight + initialPosition);
@@ -171,10 +164,12 @@ function previous () {
.finally(() => { .finally(() => {
scroll.resume(); scroll.resume();
lockFrames = false; lockFrames = false;
return $q.resolve();
}); });
} }
function last () { function lastRange () {
if (scroll.isPaused()) { if (scroll.isPaused()) {
return $q.resolve(); return $q.resolve();
} }
@@ -182,16 +177,39 @@ function last () {
scroll.pause(); scroll.pause();
lockFrames = true; lockFrames = true;
return slide.getLast() return render.clear()
.then(() => slide.getLast())
.then(results => render.pushFront(results))
.then(() => { .then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1); stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
scroll.scrollToBottom(); scroll.scrollToBottom();
lockFrames = false;
return $q.resolve(); return $q.resolve();
}) })
.finally(() => { .finally(() => {
scroll.resume(); scroll.resume();
lockFrames = false;
return $q.resolve();
});
}
function menuLastRange () {
if (vm.isFollowing) {
lockFollow = true;
stopFollowing();
return $q.resolve();
}
lockFollow = false;
return lastRange()
.then(() => {
startFollowing();
return $q.resolve();
}); });
} }
@@ -210,8 +228,7 @@ function canStartFollowing () {
if (followOnce && // one-time activation from top of first page if (followOnce && // one-time activation from top of first page
scroll.isBeyondUpperThreshold() && scroll.isBeyondUpperThreshold() &&
slide.getHeadCounter() === 1 && slide.getTailCounter() - slide.getHeadCounter() >= OUTPUT_PAGE_SIZE) {
slide.getTailCounter() >= OUTPUT_PAGE_SIZE) {
followOnce = false; followOnce = false;
return true; return true;
@@ -234,27 +251,166 @@ function stopFollowing () {
return; return;
} }
scroll.unlock();
scroll.unhide();
vm.isFollowing = false; vm.isFollowing = false;
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
} }
//
// Menu Controls (Page Mode)
//
function firstPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return render.clear()
.then(() => page.getFirst())
.then(results => render.pushFront(results))
.then(() => page.getNext())
.then(results => {
const popCount = page.trimHead();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
function lastPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return render.clear()
.then(() => page.getLast())
.then(results => render.pushBack(results))
.then(() => page.getPrevious())
.then(results => {
const popCount = page.trimTail();
return render.popFront(popCount)
.then(() => render.pushBack(results));
})
.then(() => {
scroll.scrollToBottom();
return $q.resolve();
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
function nextPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return page.getNext()
.then(results => {
const popCount = page.trimHead();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
});
}
function previousPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
let initialPosition;
let popHeight;
return page.getPrevious()
.then(results => {
const popCount = page.trimTail();
initialPosition = scroll.getScrollPosition();
return render.popFront(popCount)
.then(() => {
popHeight = scroll.getScrollHeight();
return render.pushBack(results);
});
})
.then(() => {
const currentHeight = scroll.getScrollHeight();
scroll.setScrollPosition(currentHeight - popHeight + initialPosition);
return $q.resolve();
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
//
// Menu Controls
//
function first () {
if (vm.isProcessingFinished) {
return firstPage();
}
return firstRange();
}
function last () {
if (vm.isProcessingFinished) {
return lastPage();
}
return lastRange();
}
function next () {
if (vm.isProcessingFinished) {
return nextPage();
}
return nextRange();
}
function previous () {
if (vm.isProcessingFinished) {
return previousPage();
}
return previousRange();
}
function menuLast () { function menuLast () {
if (vm.isFollowing) { if (vm.isProcessingFinished) {
lockFollow = true; return lastPage();
stopFollowing();
return $q.resolve();
} }
lockFollow = false; return menuLastRange();
if (slide.isOnLastPage()) {
scroll.scrollToBottom();
return $q.resolve();
}
return last();
} }
function down () { function down () {
@@ -269,64 +425,125 @@ function togglePanelExpand () {
vm.isPanelExpanded = !vm.isPanelExpanded; vm.isPanelExpanded = !vm.isPanelExpanded;
} }
function toggleMenuExpand () { //
// Line Interaction
//
const iconCollapsed = 'fa-angle-right';
const iconExpanded = 'fa-angle-down';
const iconSelector = '.at-Stdout-toggle > i';
const lineCollapsed = 'hidden';
function toggleCollapseAll () {
if (scroll.isPaused()) return; if (scroll.isPaused()) return;
const recordList = Object.keys(render.record).map(key => render.record[key]); const records = Object.keys(render.records).map(key => render.records[key]);
const playRecords = recordList.filter(({ name }) => name === EVENT_START_PLAY); const plays = records.filter(({ name }) => name === EVENT_START_PLAY);
const playIds = playRecords.map(({ uuid }) => uuid); const tasks = records.filter(({ name }) => name === EVENT_START_TASK);
// get any task record that does not have a parent play record const orphanLines = records
const orphanTaskRecords = recordList .filter(({ level }) => level === 3)
.filter(({ name }) => name === EVENT_START_TASK) .filter(({ parents }) => !records[parents[0]]);
.filter(({ parents }) => !parents.some(uuid => playIds.indexOf(uuid) >= 0));
const toggled = playRecords.concat(orphanTaskRecords) const orphanLineParents = orphanLines
.map(({ uuid }) => getToggleElements(uuid)) .map(({ parents }) => ({ uuid: parents[0] }));
.filter(({ icon }) => icon.length > 0)
.map(({ icon, lines }) => setExpanded(icon, lines, !vm.isMenuExpanded));
if (toggled.length > 0) { plays.concat(tasks).forEach(({ uuid }) => {
vm.isMenuExpanded = !vm.isMenuExpanded; const icon = $(`#${uuid} ${iconSelector}`);
if (vm.isMenuCollapsed) {
icon.removeClass(iconCollapsed);
icon.addClass(iconExpanded);
} else {
icon.removeClass(iconExpanded);
icon.addClass(iconCollapsed);
}
});
tasks.concat(orphanLineParents).forEach(({ uuid }) => {
const lines = $(`.child-of-${uuid}`);
if (vm.isMenuCollapsed) {
lines.removeClass(lineCollapsed);
} else {
lines.addClass(lineCollapsed);
}
});
vm.isMenuCollapsed = !vm.isMenuCollapsed;
render.setCollapseAll(vm.isMenuCollapsed);
}
function toggleCollapse (uuid) {
if (scroll.isPaused()) return;
const record = render.records[uuid];
if (record.name === EVENT_START_PLAY) {
togglePlayCollapse(uuid);
}
if (record.name === EVENT_START_TASK) {
toggleTaskCollapse(uuid);
} }
} }
function toggleLineExpand (uuid) { function togglePlayCollapse (uuid) {
if (scroll.isPaused()) return; const record = render.records[uuid];
const descendants = record.children || [];
const { icon, lines } = getToggleElements(uuid); const icon = $(`#${uuid} ${iconSelector}`);
const isExpanded = icon.hasClass('fa-angle-down'); const lines = $(`.child-of-${uuid}`);
const taskIcons = $(`#${descendants.join(', #')}`).find(iconSelector);
setExpanded(icon, lines, !isExpanded); const isCollapsed = icon.hasClass(iconCollapsed);
vm.isMenuExpanded = !isExpanded; if (isCollapsed) {
icon.removeClass(iconCollapsed);
icon.addClass(iconExpanded);
taskIcons.removeClass(iconExpanded);
taskIcons.addClass(iconCollapsed);
lines.removeClass(lineCollapsed);
descendants
.map(item => $(`.child-of-${item}`))
.forEach(line => line.addClass(lineCollapsed));
} else {
icon.removeClass(iconExpanded);
icon.addClass(iconCollapsed);
taskIcons.removeClass(iconExpanded);
taskIcons.addClass(iconCollapsed);
lines.addClass(lineCollapsed);
}
descendants
.map(item => render.records[item])
.filter(({ name }) => name === EVENT_START_TASK)
.forEach(rec => { render.records[rec.uuid].isCollapsed = true; });
render.records[uuid].isCollapsed = !isCollapsed;
} }
function getToggleElements (uuid) { function toggleTaskCollapse (uuid) {
const record = render.record[uuid]; const icon = $(`#${uuid} ${iconSelector}`);
const lines = $(`.child-of-${uuid}`); const lines = $(`.child-of-${uuid}`);
const iconSelector = '.at-Stdout-toggle > i'; const isCollapsed = icon.hasClass(iconCollapsed);
const additionalSelector = `#${(record.children || []).join(', #')}`;
let icon = $(`#${uuid} ${iconSelector}`); if (isCollapsed) {
if (additionalSelector) { icon.removeClass(iconCollapsed);
icon = icon.add($(additionalSelector).find(iconSelector)); icon.addClass(iconExpanded);
} lines.removeClass(lineCollapsed);
return { icon, lines };
}
function setExpanded (icon, lines, expanded) {
if (expanded) {
icon.removeClass('fa-angle-right');
icon.addClass('fa-angle-down');
lines.removeClass('hidden');
} else { } else {
icon.removeClass('fa-angle-down'); icon.removeClass(iconExpanded);
icon.addClass('fa-angle-right'); icon.addClass(iconCollapsed);
lines.addClass('hidden'); lines.addClass(lineCollapsed);
} }
render.records[uuid].isCollapsed = !isCollapsed;
} }
function compile (html) { function compile (html) {
@@ -337,6 +554,60 @@ function showHostDetails (id, uuid) {
$state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); $state.go('output.host-event.json', { eventId: id, taskUuid: uuid });
} }
function showMissingEvents (uuid) {
const record = render.records[uuid];
const min = Math.min(...record.counters);
const max = Math.min(Math.max(...record.counters), min + OUTPUT_PAGE_SIZE);
const selector = `#${uuid}`;
const clicked = $(selector);
return resource.events.getRange([min, max])
.then(results => {
const counters = results.map(({ counter }) => counter);
for (let i = min; i <= max; i++) {
if (counters.indexOf(i) < 0) {
results = results.filter(({ counter }) => counter < i);
break;
}
}
let lines = 0;
let untrusted = '';
for (let i = 0; i <= results.length - 1; i++) {
const { html, count } = render.transformEvent(results[i]);
lines += count;
untrusted += html;
const shifted = render.records[uuid].counters.shift();
delete render.uuids[shifted];
}
const trusted = render.trustHtml(untrusted);
const elements = angular.element(trusted);
return render
.requestAnimationFrame(() => {
elements.insertBefore(clicked);
if (render.records[uuid].counters.length === 0) {
clicked.remove();
delete render.records[uuid];
}
})
.then(() => render.compile(elements))
.then(() => lines);
});
}
//
// Event Handling
//
let streaming; let streaming;
function stopListening () { function stopListening () {
streaming = null; streaming = null;
@@ -361,7 +632,7 @@ function handleJobEvent (data) {
streaming = streaming || resource.events streaming = streaming || resource.events
.getRange([Math.max(1, data.counter - 50), data.counter + 50]) .getRange([Math.max(1, data.counter - 50), data.counter + 50])
.then(results => { .then(results => {
results = results.concat(data); results.push(data);
const counters = results.map(({ counter }) => counter); const counters = results.map(({ counter }) => counter);
const min = Math.min(...counters); const min = Math.min(...counters);
@@ -379,12 +650,13 @@ function handleJobEvent (data) {
results = results.filter(({ counter }) => counter > maxMissing); results = results.filter(({ counter }) => counter > maxMissing);
} }
stream.setMissingCounterThreshold(max + 1);
results.forEach(item => { results.forEach(item => {
stream.pushJobEvent(item); stream.pushJobEvent(item);
status.pushJobEvent(item); status.pushJobEvent(item);
}); });
stream.setMissingCounterThreshold(min);
return $q.resolve(); return $q.resolve();
}); });
@@ -406,12 +678,36 @@ function handleSummaryEvent (data) {
stream.setFinalCounter(data.final_counter); stream.setFinalCounter(data.final_counter);
} }
//
// Search
//
function reloadState (params) { function reloadState (params) {
params.isPanelExpanded = vm.isPanelExpanded; params.isPanelExpanded = vm.isPanelExpanded;
return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' }); return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' });
} }
//
// Debug Mode
//
function clear () {
stopListening();
render.clear();
followOnce = true;
lockFollow = false;
lockFrames = false;
stream.bufferInit();
status.init(resource);
slide.init(resource.events, render);
status.subscribe(data => { vm.status = data.status; });
startListening();
}
function OutputIndexController ( function OutputIndexController (
_$compile_, _$compile_,
_$q_, _$q_,
@@ -428,7 +724,8 @@ function OutputIndexController (
strings, strings,
$stateParams, $stateParams,
) { ) {
const { isPanelExpanded } = $stateParams; const { isPanelExpanded, _debug } = $stateParams;
const isProcessingFinished = !_debug && _resource_.model.get('event_processing_finished');
$compile = _$compile_; $compile = _$compile_;
$q = _$q_; $q = _$q_;
@@ -440,7 +737,8 @@ function OutputIndexController (
render = _render_; render = _render_;
status = _status_; status = _status_;
stream = _stream_; stream = _stream_;
slide = resource.model.get('event_processing_finished') ? _page_ : _slide_; slide = _slide_;
page = _page_;
vm = this || {}; vm = this || {};
@@ -451,24 +749,27 @@ function OutputIndexController (
vm.resource = resource; vm.resource = resource;
vm.reloadState = reloadState; vm.reloadState = reloadState;
vm.isPanelExpanded = isPanelExpanded; vm.isPanelExpanded = isPanelExpanded;
vm.isProcessingFinished = isProcessingFinished;
vm.togglePanelExpand = togglePanelExpand; vm.togglePanelExpand = togglePanelExpand;
// Stdout Navigation // Stdout Navigation
vm.menu = { last: menuLast, first, down, up }; vm.menu = { last: menuLast, first, down, up, clear };
vm.isMenuExpanded = true; vm.isMenuCollapsed = false;
vm.isFollowing = false; vm.isFollowing = false;
vm.toggleMenuExpand = toggleMenuExpand; vm.toggleCollapseAll = toggleCollapseAll;
vm.toggleLineExpand = toggleLineExpand; vm.toggleCollapse = toggleCollapse;
vm.showHostDetails = showHostDetails; vm.showHostDetails = showHostDetails;
vm.showMissingEvents = showMissingEvents;
vm.toggleLineEnabled = resource.model.get('type') === 'job'; vm.toggleLineEnabled = resource.model.get('type') === 'job';
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
vm.debug = _debug;
render.requestAnimationFrame(() => { render.requestAnimationFrame(() => {
bufferInit(); render.init({ compile, toggles: vm.toggleLineEnabled });
status.init(resource); status.init(resource);
slide.init(render, resource.events, scroll); page.init(resource.events);
render.init({ compile, toggles: vm.toggleLineEnabled }); slide.init(resource.events, render);
scroll.init({ scroll.init({
next, next,
@@ -482,10 +783,29 @@ function OutputIndexController (
}, },
}); });
let showFollowTip = true;
const rates = [];
stream.init({ stream.init({
bufferAdd,
bufferEmpty,
onFrames, onFrames,
onFrameRate (rate) {
rates.push(rate);
rates.splice(0, rates.length - 5);
if (rates.every(value => value === 1)) {
scroll.unlock();
scroll.unhide();
}
if (rate > 1 && vm.isFollowing) {
scroll.lock();
scroll.hide();
if (showFollowTip) {
showFollowTip = false;
$(OUTPUT_ELEMENT_LAST).trigger('mouseenter');
}
}
},
onStop () { onStop () {
lockFollow = true; lockFollow = true;
stopFollowing(); stopFollowing();
@@ -493,11 +813,12 @@ function OutputIndexController (
status.updateStats(); status.updateStats();
status.dispatch(); status.dispatch();
status.sync(); status.sync();
scroll.stop(); scroll.unlock();
scroll.unhide();
} }
}); });
if (resource.model.get('event_processing_finished')) { if (isProcessingFinished) {
followOnce = false; followOnce = false;
lockFollow = true; lockFollow = true;
lockFrames = true; lockFrames = true;
@@ -511,8 +832,21 @@ function OutputIndexController (
startListening(); startListening();
} }
if (_debug) {
return render.clear();
}
return last(); return last();
}); });
$scope.$on('$destroy', () => {
stopListening();
render.clear();
render.el.remove();
slide.clear();
stream.bufferInit();
});
} }
OutputIndexController.$inject = [ OutputIndexController.$inject = [

View File

@@ -82,7 +82,7 @@ function resolveResource (
order_by: OUTPUT_ORDER_BY, order_by: OUTPUT_ORDER_BY,
}; };
if (job_event_search) { // eslint-disable-line camelcase if (job_event_search) {
const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search));
Object.assign(params, query); Object.assign(params, query);
} }
@@ -173,7 +173,7 @@ function JobsRun ($stateRegistry, $filter, strings) {
const sanitize = $filter('sanitize'); const sanitize = $filter('sanitize');
const state = { const state = {
url: '/:type/:id?job_event_search', url: '/:type/:id?job_event_search?_debug',
name: 'output', name: 'output',
parent, parent,
ncyBreadcrumb, ncyBreadcrumb,

View File

@@ -21,13 +21,14 @@
reload="vm.reloadState"> reload="vm.reloadState">
</at-job-search> </at-job-search>
<div class="at-Stdout-menuTop"> <div class="at-Stdout-menuTop">
<div class="pull-left" ng-click="vm.toggleMenuExpand()"> <div class="pull-left" ng-click="vm.toggleCollapseAll()">
<i class="at-Stdout-menuIcon fa" ng-if="vm.toggleLineEnabled" <i class="at-Stdout-menuIcon fa" ng-if="vm.toggleLineEnabled"
ng-class="{ 'fa-minus': vm.isMenuExpanded, 'fa-plus': !vm.isMenuExpanded }"></i> ng-class="{ 'fa-minus': !vm.isMenuCollapsed, 'fa-plus': vm.isMenuCollapsed }"></i>
</div> </div>
<div class="pull-right" ng-click="vm.menu.last()"> <div class="pull-right" ng-click="vm.menu.last()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-down" <i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
ng-class="{ 'at-Stdout-menuIcon--active': vm.isFollowing }" ng-class="{ 'at-Stdout-menuIcon--active': vm.isFollowing }"
id="atStdoutMenuLast"
data-placement="top" data-placement="top"
data-trigger="hover" data-trigger="hover"
data-tip-watch="vm.followTooltip" data-tip-watch="vm.followTooltip"
@@ -46,6 +47,9 @@
<i class="at-Stdout-menuIcon--lg fa fa-angle-up" <i class="at-Stdout-menuIcon--lg fa fa-angle-up"
data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_UP') }}"></i> data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_UP') }}"></i>
</div> </div>
<div class="pull-right" ng-if="vm.debug" ng-click="vm.menu.clear()">
<i class="at-Stdout-menuIcon--md fa fa-undo"></i>
</div>
<div class="at-u-clear"></div> <div class="at-u-clear"></div>
</div> </div>
<div class="at-Stdout-container"> <div class="at-Stdout-container">

View File

@@ -2,244 +2,153 @@
import { OUTPUT_PAGE_LIMIT } from './constants'; import { OUTPUT_PAGE_LIMIT } from './constants';
function PageService ($q) { function PageService ($q) {
this.init = (storage, api, { getScrollHeight }) => { this.init = ({ getPage, getFirst, getLast, getLastPageNumber }) => {
const { prepend, append, shift, pop, deleteRecord } = storage;
const { getPage, getFirst, getLast, getLastPageNumber, getMaxCounter } = api;
this.api = { this.api = {
getPage, getPage,
getFirst, getFirst,
getLast, getLast,
getLastPageNumber, getLastPageNumber,
getMaxCounter,
}; };
this.pages = {};
this.storage = { this.state = { head: 0, tail: 0 };
prepend,
append,
shift,
pop,
deleteRecord,
};
this.hooks = {
getScrollHeight,
};
this.records = {};
this.uuids = {};
this.state = {
head: 0,
tail: 0,
};
this.chain = $q.resolve();
};
this.pushFront = (results, key) => {
if (!results) {
return $q.resolve();
}
return this.storage.append(results)
.then(() => {
const tail = key || ++this.state.tail;
this.records[tail] = {};
results.forEach(({ counter, start_line, end_line, uuid }) => {
this.records[tail][counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve();
});
};
this.pushBack = (results, key) => {
if (!results) {
return $q.resolve();
}
return this.storage.prepend(results)
.then(() => {
const head = key || --this.state.head;
this.records[head] = {};
results.forEach(({ counter, start_line, end_line, uuid }) => {
this.records[head][counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve();
});
};
this.popBack = () => {
if (this.getRecordCount() === 0) {
return $q.resolve();
}
const pageRecord = this.records[this.state.head] || {};
let lines = 0;
const counters = [];
Object.keys(pageRecord)
.forEach(counter => {
lines += pageRecord[counter].end_line - pageRecord[counter].start_line;
counters.push(counter);
});
return this.storage.shift(lines)
.then(() => {
counters.forEach(counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
});
delete this.records[this.state.head++];
return $q.resolve();
});
};
this.popFront = () => {
if (this.getRecordCount() === 0) {
return $q.resolve();
}
const pageRecord = this.records[this.state.tail] || {};
let lines = 0;
const counters = [];
Object.keys(pageRecord)
.forEach(counter => {
lines += pageRecord[counter].end_line - pageRecord[counter].start_line;
counters.push(counter);
});
return this.storage.pop(lines)
.then(() => {
counters.forEach(counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
});
delete this.records[this.state.tail--];
return $q.resolve();
});
}; };
this.getNext = () => { this.getNext = () => {
const lastPageNumber = this.api.getLastPageNumber(); const lastPageNumber = this.api.getLastPageNumber();
const number = Math.min(this.state.tail + 1, lastPageNumber); const number = Math.min(this.state.tail + 1, lastPageNumber);
const isLoaded = (number >= this.state.head && number <= this.state.tail); if (number < 1) {
const isValid = (number >= 1 && number <= lastPageNumber); return $q.resolve([]);
let popHeight = this.hooks.getScrollHeight();
if (!isValid || isLoaded) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
} }
const pageCount = this.state.head - this.state.tail; if (number > lastPageNumber) {
return $q.resolve([]);
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popBack())
.then(() => {
popHeight = this.hooks.getScrollHeight();
return $q.resolve();
});
} }
this.chain = this.chain let promise;
.then(() => this.api.getPage(number))
.then(events => this.pushFront(events))
.then(() => $q.resolve(popHeight));
return this.chain; if (this.pages[number]) {
promise = $q.resolve(this.pages[number]);
} else {
promise = this.api.getPage(number);
}
return promise
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
this.state.tail = number;
this.pages[number] = results;
return $q.resolve(results);
});
}; };
this.getPrevious = () => { this.getPrevious = () => {
const number = Math.max(this.state.head - 1, 1); const number = Math.max(this.state.head - 1, 1);
const isLoaded = (number >= this.state.head && number <= this.state.tail); if (number < 1) {
const isValid = (number >= 1 && number <= this.api.getLastPageNumber()); return $q.resolve([]);
let popHeight = this.hooks.getScrollHeight();
if (!isValid || isLoaded) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
} }
const pageCount = this.state.head - this.state.tail; if (number > this.api.getLastPageNumber()) {
return $q.resolve([]);
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popFront())
.then(() => {
popHeight = this.hooks.getScrollHeight();
return $q.resolve();
});
} }
this.chain = this.chain let promise;
.then(() => this.api.getPage(number))
.then(events => this.pushBack(events))
.then(() => $q.resolve(popHeight));
return this.chain; if (this.pages[number]) {
promise = $q.resolve(this.pages[number]);
} else {
promise = this.api.getPage(number);
}
return promise
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
this.state.head = number;
this.pages[number] = results;
return $q.resolve(results);
});
}; };
this.clear = () => { this.getLast = () => this.api.getLast()
const count = this.getRecordCount(); .then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
for (let i = 0; i <= count; ++i) { const number = this.api.getLastPageNumber();
this.chain = this.chain.then(() => this.popBack());
}
return this.chain; this.state.head = number;
}; this.state.tail = number;
this.pages[number] = results;
this.getLast = () => this.clear() return $q.resolve(results);
.then(() => this.api.getLast()) });
.then(events => {
const lastPage = this.api.getLastPageNumber();
this.state.head = lastPage; this.getFirst = () => this.api.getFirst()
this.state.tail = lastPage; .then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
return this.pushBack(events, lastPage);
})
.then(() => this.getPrevious());
this.getFirst = () => this.clear()
.then(() => this.api.getFirst())
.then(events => {
this.state.head = 1; this.state.head = 1;
this.state.tail = 1; this.state.tail = 1;
this.pages[1] = results;
return this.pushBack(events, 1); return $q.resolve(results);
}) });
.then(() => this.getNext());
this.isOnLastPage = () => this.api.getLastPageNumber() === this.state.tail; this.trimTail = () => {
this.getRecordCount = () => Object.keys(this.records).length; const { tail, head } = this.state;
this.getTailCounter = () => this.state.tail; let popCount = 0;
this.getMaxCounter = () => this.api.getMaxCounter();
for (let i = tail; i > head; i--) {
if (!this.isOverCapacity()) {
break;
}
if (this.pages[i]) {
popCount += this.pages[i].length;
}
delete this.pages[i];
this.state.tail--;
}
return popCount;
};
this.trimHead = () => {
const { head, tail } = this.state;
let popCount = 0;
for (let i = head; i < tail; i++) {
if (!this.isOverCapacity()) {
break;
}
if (this.pages[i]) {
popCount += this.pages[i].length;
}
delete this.pages[i];
this.state.head++;
}
return popCount;
};
this.isOverCapacity = () => this.state.tail - this.state.head > OUTPUT_PAGE_LIMIT;
} }
PageService.$inject = ['$q']; PageService.$inject = ['$q'];

View File

@@ -3,9 +3,12 @@ import Entities from 'html-entities';
import { import {
EVENT_START_PLAY, EVENT_START_PLAY,
EVENT_START_PLAYBOOK,
EVENT_STATS_PLAY, EVENT_STATS_PLAY,
EVENT_START_TASK, EVENT_START_TASK,
OUTPUT_ANSI_COLORMAP,
OUTPUT_ELEMENT_TBODY, OUTPUT_ELEMENT_TBODY,
OUTPUT_EVENT_LIMIT,
} from './constants'; } from './constants';
const EVENT_GROUPS = [ const EVENT_GROUPS = [
@@ -19,7 +22,7 @@ const TIME_EVENTS = [
EVENT_STATS_PLAY, EVENT_STATS_PLAY,
]; ];
const ansi = new Ansi(); const ansi = new Ansi({ stream: true, colors: OUTPUT_ANSI_COLORMAP });
const entities = new Entities.AllHtmlEntities(); const entities = new Entities.AllHtmlEntities();
// https://github.com/chalk/ansi-regex // https://github.com/chalk/ansi-regex
@@ -33,98 +36,243 @@ const hasAnsi = input => re.test(input);
function JobRenderService ($q, $sce, $window) { function JobRenderService ($q, $sce, $window) {
this.init = ({ compile, toggles }) => { this.init = ({ compile, toggles }) => {
this.parent = null;
this.record = {};
this.el = $(OUTPUT_ELEMENT_TBODY);
this.hooks = { compile }; this.hooks = { compile };
this.el = $(OUTPUT_ELEMENT_TBODY);
this.parent = null;
this.createToggles = toggles; this.state = {
head: 0,
tail: 0,
collapseAll: false,
toggleMode: toggles,
};
this.records = {};
this.uuids = {};
}; };
this.sortByLineNumber = (a, b) => { this.setCollapseAll = value => {
if (a.start_line > b.start_line) { this.state.collapseAll = value;
Object.keys(this.records).forEach(key => {
this.records[key].isCollapsed = value;
});
};
this.sortByCounter = (a, b) => {
if (a.counter > b.counter) {
return 1; return 1;
} }
if (a.start_line < b.start_line) { if (a.counter < b.counter) {
return -1; return -1;
} }
return 0; return 0;
}; };
this.transformEventGroup = events => { //
// Event Data Transformation / HTML Building
//
this.appendEventGroup = events => {
let lines = 0; let lines = 0;
let html = ''; let html = '';
events.sort(this.sortByLineNumber); events.sort(this.sortByCounter);
for (let i = 0; i < events.length; ++i) { for (let i = 0; i <= events.length - 1; i++) {
const line = this.transformEvent(events[i]); const current = events[i];
html += line.html;
lines += line.count; if (this.state.tail && current.counter !== this.state.tail + 1) {
const missing = this.appendMissingEventGroup(current);
html += missing.html;
lines += missing.count;
}
const eventLines = this.transformEvent(current);
html += eventLines.html;
lines += eventLines.count;
} }
return { html, lines }; return { html, lines };
}; };
this.transformEvent = event => { this.appendMissingEventGroup = event => {
if (this.record[event.uuid]) { const tailUUID = this.uuids[this.state.tail];
const tailRecord = this.records[tailUUID];
if (!tailRecord) {
return { html: '', count: 0 }; return { html: '', count: 0 };
} }
if (!event || !event.stdout) { let uuid;
if (tailRecord.isMissing) {
uuid = tailUUID;
} else {
uuid = `${event.counter}-${tailUUID}`;
this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true };
}
for (let i = this.state.tail + 1; i < event.counter; i++) {
this.records[uuid].counters.push(i);
this.uuids[i] = uuid;
}
if (tailRecord.isMissing) {
return { html: '', count: 0 };
}
if (tailRecord.end === event.start_line) {
return { html: '', count: 0 };
}
const html = this.buildRowHTML(this.records[uuid]);
const count = 1;
return { html, count };
};
this.prependEventGroup = events => {
let lines = 0;
let html = '';
events.sort(this.sortByCounter);
for (let i = events.length - 1; i >= 0; i--) {
const current = events[i];
if (this.state.head && current.counter !== this.state.head - 1) {
const missing = this.prependMissingEventGroup(current);
html = missing.html + html;
lines += missing.count;
}
const eventLines = this.transformEvent(current);
html = eventLines.html + html;
lines += eventLines.count;
}
return { html, lines };
};
this.prependMissingEventGroup = event => {
const headUUID = this.uuids[this.state.head];
const headRecord = this.records[headUUID];
if (!headRecord) {
return { html: '', count: 0 };
}
let uuid;
if (headRecord.isMissing) {
uuid = headUUID;
} else {
uuid = `${headUUID}-${event.counter}`;
this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true };
}
for (let i = this.state.head - 1; i > event.counter; i--) {
this.records[uuid].counters.unshift(i);
this.uuids[i] = uuid;
}
if (headRecord.isMissing) {
return { html: '', count: 0 };
}
if (event.end_line === headRecord.start) {
return { html: '', count: 0 };
}
const html = this.buildRowHTML(this.records[uuid]);
const count = 1;
return { html, count };
};
this.transformEvent = event => {
if (!event || event.stdout === null || event.stdout === undefined) {
return { html: '', count: 0 };
}
if (event.uuid && this.records[event.uuid]) {
return { html: '', count: 0 }; return { html: '', count: 0 };
} }
const stdout = this.sanitize(event.stdout); const stdout = this.sanitize(event.stdout);
const lines = stdout.split('\r\n'); const lines = stdout.split('\r\n');
const record = this.createRecord(event, lines);
if (event.event === EVENT_START_PLAYBOOK) {
return { html: '', count: 0 };
}
let html = '';
let count = lines.length; let count = lines.length;
let ln = event.start_line; let ln = event.start_line;
const current = this.createRecord(ln, lines, event); for (let i = 0; i <= lines.length - 1; i++) {
const html = lines.reduce((concat, line, i) => {
ln++; ln++;
const line = lines[i];
const isLastLine = i === lines.length - 1; const isLastLine = i === lines.length - 1;
let row = this.createRow(current, ln, line); let row = this.buildRowHTML(record, ln, line);
if (current && current.isTruncated && isLastLine) { if (record && record.isTruncated && isLastLine) {
row += this.createRow(current); row += this.buildRowHTML(record);
count++; count++;
} }
return `${concat}${row}`; html += row;
}, ''); }
if (this.records[event.uuid]) {
this.records[event.uuid].lineCount = count;
}
return { html, count }; return { html, count };
}; };
this.isHostEvent = (event) => { this.createRecord = (event, lines) => {
if (typeof event.host === 'number') { if (!event.counter) {
return true;
}
if (event.type === 'project_update_event' &&
event.event !== 'runner_on_skipped' &&
event.event_data.host) {
return true;
}
return false;
};
this.createRecord = (ln, lines, event) => {
if (!event.uuid) {
return null; return null;
} }
const info = { if (!this.state.head || event.counter < this.state.head) {
this.state.head = event.counter;
}
if (!this.state.tail || event.counter > this.state.tail) {
this.state.tail = event.counter;
}
if (!event.uuid) {
this.uuids[event.counter] = event.counter;
this.records[event.counter] = { counters: [event.counter], lineCount: lines.length };
return this.records[event.counter];
}
let isHost = false;
if (typeof event.host === 'number') {
isHost = true;
} else if (event.type === 'project_update_event' &&
event.event !== 'runner_on_skipped' &&
event.event_data.host) {
isHost = true;
}
const record = {
isHost,
id: event.id, id: event.id,
line: ln + 1, line: event.start_line + 1,
name: event.event, name: event.event,
uuid: event.uuid, uuid: event.uuid,
level: event.event_level, level: event.event_level,
@@ -132,50 +280,49 @@ function JobRenderService ($q, $sce, $window) {
end: event.end_line, end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length, isTruncated: (event.end_line - event.start_line) > lines.length,
lineCount: lines.length, lineCount: lines.length,
isHost: this.isHostEvent(event), isCollapsed: this.state.collapseAll,
counters: [event.counter],
}; };
if (event.parent_uuid) { if (event.parent_uuid) {
info.parents = this.getParentEvents(event.parent_uuid); record.parents = this.getParentEvents(event.parent_uuid);
if (this.records[event.parent_uuid]) {
record.isCollapsed = this.records[event.parent_uuid].isCollapsed;
}
} }
if (info.isTruncated) { if (record.isTruncated) {
info.truncatedAt = event.start_line + lines.length; record.truncatedAt = event.start_line + lines.length;
} }
if (EVENT_GROUPS.includes(event.event)) { if (EVENT_GROUPS.includes(event.event)) {
info.isParent = true; record.isParent = true;
if (event.event_level === 1) { if (event.event_level === 1) {
this.parent = event.uuid; this.parent = event.uuid;
} }
if (event.parent_uuid) { if (event.parent_uuid) {
if (this.record[event.parent_uuid]) { if (this.records[event.parent_uuid]) {
if (this.record[event.parent_uuid].children && if (this.records[event.parent_uuid].children &&
!this.record[event.parent_uuid].children.includes(event.uuid)) { !this.records[event.parent_uuid].children.includes(event.uuid)) {
this.record[event.parent_uuid].children.push(event.uuid); this.records[event.parent_uuid].children.push(event.uuid);
} else { } else {
this.record[event.parent_uuid].children = [event.uuid]; this.records[event.parent_uuid].children = [event.uuid];
} }
} }
} }
} }
if (TIME_EVENTS.includes(event.event)) { if (TIME_EVENTS.includes(event.event)) {
info.time = this.getTimestamp(event.created); record.time = this.getTimestamp(event.created);
info.line++; record.line++;
} }
this.record[event.uuid] = info; this.records[event.uuid] = record;
this.uuids[event.counter] = event.uuid;
return info; return record;
};
this.getRecord = uuid => this.record[uuid];
this.deleteRecord = uuid => {
delete this.record[uuid];
}; };
this.getParentEvents = (uuid, list) => { this.getParentEvents = (uuid, list) => {
@@ -183,42 +330,56 @@ function JobRenderService ($q, $sce, $window) {
// always push its parent if exists // always push its parent if exists
list.push(uuid); list.push(uuid);
// if we can get grandparent in current visible lines, we also push it // if we can get grandparent in current visible lines, we also push it
if (this.record[uuid] && this.record[uuid].parents) { if (this.records[uuid] && this.records[uuid].parents) {
list = list.concat(this.record[uuid].parents); list = list.concat(this.records[uuid].parents);
} }
return list; return list;
}; };
this.createRow = (current, ln, content) => { this.buildRowHTML = (record, ln, content) => {
let id = ''; let id = '';
let icon = '';
let timestamp = ''; let timestamp = '';
let tdToggle = ''; let tdToggle = '';
let tdEvent = ''; let tdEvent = '';
let classList = ''; let classList = '';
if (record.isMissing) {
return `<div id="${record.uuid}" class="at-Stdout-row">
<div class="at-Stdout-toggle"></div>
<div class="at-Stdout-line at-Stdout-line--clickable" ng-click="vm.showMissingEvents('${record.uuid}')">...</div></div>`;
}
content = content || ''; content = content || '';
if (hasAnsi(content)) { if (hasAnsi(content)) {
content = ansi.toHtml(content); content = ansi.toHtml(content);
} }
if (current) { if (record) {
if (this.createToggles && current.isParent && current.line === ln) { if (this.state.toggleMode && record.isParent && record.line === ln) {
id = current.uuid; id = record.uuid;
tdToggle = `<div class="at-Stdout-toggle" ng-click="vm.toggleLineExpand('${id}')"><i class="fa fa-angle-down can-toggle"></i></div>`;
if (record.isCollapsed) {
icon = 'fa-angle-right';
} else {
icon = 'fa-angle-down';
}
tdToggle = `<div class="at-Stdout-toggle" ng-click="vm.toggleCollapse('${id}')"><i class="fa ${icon} can-toggle"></i></div>`;
} }
if (current.isHost) { if (record.isHost) {
tdEvent = `<div class="at-Stdout-event--host" ng-click="vm.showHostDetails('${current.id}', '${current.uuid}')"><span ng-non-bindable>${content}</span></div>`; tdEvent = `<div class="at-Stdout-event--host" ng-click="vm.showHostDetails('${record.id}', '${record.uuid}')"><span ng-non-bindable>${content}</span></div>`;
} }
if (current.time && current.line === ln) { if (record.time && record.line === ln) {
timestamp = `<span>${current.time}</span>`; timestamp = `<span>${record.time}</span>`;
} }
if (current.parents) { if (record.parents) {
classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, ''); classList = record.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
} }
} }
@@ -234,6 +395,12 @@ function JobRenderService ($q, $sce, $window) {
ln = '...'; ln = '...';
} }
if (record && record.isCollapsed) {
if (record.level === 3 || record.level === 0) {
classList += ' hidden';
}
}
return ` return `
<div id="${id}" class="at-Stdout-row ${classList}"> <div id="${id}" class="at-Stdout-row ${classList}">
${tdToggle} ${tdToggle}
@@ -252,6 +419,10 @@ function JobRenderService ($q, $sce, $window) {
return `${hour}:${minute}:${second}`; return `${hour}:${minute}:${second}`;
}; };
//
// Element Operations
//
this.remove = elements => this.requestAnimationFrame(() => elements.remove()); this.remove = elements => this.requestAnimationFrame(() => elements.remove());
this.requestAnimationFrame = fn => $q(resolve => { this.requestAnimationFrame = fn => $q(resolve => {
@@ -270,19 +441,25 @@ function JobRenderService ($q, $sce, $window) {
return this.requestAnimationFrame(); return this.requestAnimationFrame();
}; };
this.clear = () => { this.removeAll = () => {
const elements = this.el.children(); const elements = this.el.contents();
return this.remove(elements); return this.remove(elements);
}; };
this.shift = lines => { this.shift = lines => {
const elements = this.el.children().slice(0, lines); // We multiply by two here under the assumption that one element and one text node
// is generated for each line of output.
const count = 2 * lines;
const elements = this.el.contents().slice(0, count);
return this.remove(elements); return this.remove(elements);
}; };
this.pop = lines => { this.pop = lines => {
const elements = this.el.children().slice(-lines); // We multiply by two here under the assumption that one element and one text node
// is generated for each line of output.
const count = 2 * lines;
const elements = this.el.contents().slice(-count);
return this.remove(elements); return this.remove(elements);
}; };
@@ -292,7 +469,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve(); return $q.resolve();
} }
const result = this.transformEventGroup(events); const result = this.prependEventGroup(events);
const html = this.trustHtml(result.html); const html = this.trustHtml(result.html);
const newElements = angular.element(html); const newElements = angular.element(html);
@@ -307,7 +484,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve(); return $q.resolve();
} }
const result = this.transformEventGroup(events); const result = this.appendEventGroup(events);
const html = this.trustHtml(result.html); const html = this.trustHtml(result.html);
const newElements = angular.element(html); const newElements = angular.element(html);
@@ -318,8 +495,110 @@ function JobRenderService ($q, $sce, $window) {
}; };
this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html)); this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html));
this.sanitize = html => entities.encode(html); this.sanitize = html => entities.encode(html);
//
// Event Counter Methods - External code should prefer these.
//
this.clear = () => this.removeAll()
.then(() => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
for (let i = head; i <= tail; ++i) {
const uuid = this.uuids[i];
if (uuid) {
delete this.records[uuid];
delete this.uuids[i];
}
}
this.state.head = 0;
this.state.tail = 0;
return $q.resolve();
});
this.pushFront = events => {
const tail = this.getTailCounter();
return this.append(events.filter(({ counter }) => counter > tail));
};
this.pushBack = events => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
return this.prepend(events.filter(({ counter }) => counter < head || counter > tail));
};
this.popFront = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const max = this.state.tail;
const min = max - count;
let lines = 0;
for (let i = max; i >= min; --i) {
const uuid = this.uuids[i];
if (!uuid) {
continue;
}
this.records[uuid].counters.pop();
delete this.uuids[i];
if (this.records[uuid].counters.length === 0) {
lines += this.records[uuid].lineCount;
delete this.records[uuid];
this.state.tail--;
}
}
return this.pop(lines);
};
this.popBack = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const min = this.state.head;
const max = min + count;
let lines = 0;
for (let i = min; i <= max; ++i) {
const uuid = this.uuids[i];
if (!uuid) {
continue;
}
this.records[uuid].counters.shift();
delete this.uuids[i];
if (this.records[uuid].counters.length === 0) {
lines += this.records[uuid].lineCount;
delete this.records[uuid];
this.state.head++;
}
}
return this.shift(lines);
};
this.getHeadCounter = () => this.state.head;
this.getTailCounter = () => this.state.tail;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - (this.getTailCounter() - this.getHeadCounter());
} }
JobRenderService.$inject = ['$q', '$sce', '$window']; JobRenderService.$inject = ['$q', '$sce', '$window'];

View File

@@ -5,8 +5,6 @@ import {
OUTPUT_SCROLL_THRESHOLD, OUTPUT_SCROLL_THRESHOLD,
} from './constants'; } from './constants';
const MAX_THRASH = 20;
function JobScrollService ($q, $timeout) { function JobScrollService ($q, $timeout) {
this.init = ({ next, previous, onThresholdLeave }) => { this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(OUTPUT_ELEMENT_CONTAINER); this.el = $(OUTPUT_ELEMENT_CONTAINER);
@@ -33,7 +31,6 @@ function JobScrollService ($q, $timeout) {
paused: false, paused: false,
locked: false, locked: false,
hover: false, hover: false,
running: true,
thrash: 0, thrash: 0,
}; };
@@ -44,13 +41,6 @@ function JobScrollService ($q, $timeout) {
this.onMouseEnter = () => { this.onMouseEnter = () => {
this.state.hover = true; this.state.hover = true;
if (this.state.thrash >= MAX_THRASH) {
this.state.thrash = MAX_THRASH - 1;
}
this.unlock();
this.unhide();
}; };
this.onMouseLeave = () => { this.onMouseLeave = () => {
@@ -62,23 +52,6 @@ function JobScrollService ($q, $timeout) {
return; return;
} }
if (this.state.thrash > 0) {
if (this.isLocked() || this.state.hover) {
this.state.thrash--;
}
}
if (!this.state.hover) {
this.state.thrash++;
}
if (this.state.thrash >= MAX_THRASH) {
if (this.isRunning()) {
this.lock();
this.hide();
}
}
if (this.isLocked()) { if (this.isLocked()) {
return; return;
} }
@@ -195,16 +168,6 @@ function JobScrollService ($q, $timeout) {
this.setScrollPosition(this.getScrollHeight()); this.setScrollPosition(this.getScrollHeight());
}; };
this.start = () => {
this.state.running = true;
};
this.stop = () => {
this.unlock();
this.unhide();
this.state.running = false;
};
this.lock = () => { this.lock = () => {
this.state.locked = true; this.state.locked = true;
}; };
@@ -256,7 +219,6 @@ function JobScrollService ($q, $timeout) {
}; };
this.isPaused = () => this.state.paused; this.isPaused = () => this.state.paused;
this.isRunning = () => this.state.running;
this.isLocked = () => this.state.locked; this.isLocked = () => this.state.locked;
this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
} }

View File

@@ -1,42 +1,12 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import { import {
API_MAX_PAGE_SIZE, OUTPUT_MAX_BUFFER_LENGTH,
OUTPUT_EVENT_LIMIT,
OUTPUT_PAGE_SIZE, OUTPUT_PAGE_SIZE,
} from './constants'; } from './constants';
function getContinuous (events, reverse = false) {
const counters = events.map(({ counter }) => counter);
const min = Math.min(...counters);
const max = Math.max(...counters);
const missing = [];
for (let i = min; i <= max; i++) {
if (counters.indexOf(i) < 0) {
missing.push(i);
}
}
if (missing.length === 0) {
return events;
}
if (reverse) {
const threshold = Math.max(...missing);
return events.filter(({ counter }) => counter > threshold);
}
const threshold = Math.min(...missing);
return events.filter(({ counter }) => counter < threshold);
}
function SlidingWindowService ($q) { function SlidingWindowService ($q) {
this.init = (storage, api, { getScrollHeight }) => { this.init = ({ getRange, getFirst, getLast, getMaxCounter }, storage) => {
const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage; const { getHeadCounter, getTailCounter } = storage;
const { getRange, getFirst, getLast, getMaxCounter } = api;
this.api = { this.api = {
getRange, getRange,
@@ -46,32 +16,20 @@ function SlidingWindowService ($q) {
}; };
this.storage = { this.storage = {
clear, getHeadCounter,
prepend, getTailCounter,
append,
shift,
pop,
getRecord,
deleteRecord,
}; };
this.hooks = {
getScrollHeight,
};
this.lines = {};
this.uuids = {};
this.chain = $q.resolve();
this.state = { head: null, tail: null };
this.cache = { first: null };
this.buffer = { this.buffer = {
events: [], events: [],
min: 0, min: 0,
max: 0, max: 0,
count: 0, count: 0,
}; };
this.cache = {
first: null
};
}; };
this.getBoundedRange = range => { this.getBoundedRange = range => {
@@ -92,273 +50,46 @@ function SlidingWindowService ($q) {
return this.getBoundedRange([head - 1 - displacement, head - 1]); return this.getBoundedRange([head - 1 - displacement, head - 1]);
}; };
this.createRecord = ({ counter, uuid, start_line, end_line }) => {
this.lines[counter] = end_line - start_line;
this.uuids[counter] = uuid;
if (this.state.tail === null) {
this.state.tail = counter;
}
if (counter > this.state.tail) {
this.state.tail = counter;
}
if (this.state.head === null) {
this.state.head = counter;
}
if (counter < this.state.head) {
this.state.head = counter;
}
};
this.deleteRecord = counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
delete this.lines[counter];
};
this.getLineCount = counter => {
const record = this.storage.getRecord(counter);
if (record && record.lineCount) {
return record.lineCount;
}
if (this.lines[counter]) {
return this.lines[counter];
}
return 0;
};
this.pushFront = events => {
const tail = this.getTailCounter();
const newEvents = events.filter(({ counter }) => counter > tail);
return this.storage.append(newEvents)
.then(() => {
newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
};
this.pushBack = events => {
const [head, tail] = this.getRange();
const newEvents = events
.filter(({ counter }) => counter < head || counter > tail);
return this.storage.prepend(newEvents)
.then(() => {
newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
};
this.popFront = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const max = this.getTailCounter();
const min = max - count;
let lines = 0;
for (let i = max; i >= min; --i) {
lines += this.getLineCount(i);
}
return this.storage.pop(lines)
.then(() => {
for (let i = max; i >= min; --i) {
this.deleteRecord(i);
this.state.tail--;
}
return $q.resolve();
});
};
this.popBack = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const min = this.getHeadCounter();
const max = min + count;
let lines = 0;
for (let i = min; i <= max; ++i) {
lines += this.getLineCount(i);
}
return this.storage.shift(lines)
.then(() => {
for (let i = min; i <= max; ++i) {
this.deleteRecord(i);
this.state.head++;
}
return $q.resolve();
});
};
this.clear = () => this.storage.clear()
.then(() => {
const [head, tail] = this.getRange();
for (let i = head; i <= tail; ++i) {
this.deleteRecord(i);
}
this.state.head = null;
this.state.tail = null;
return $q.resolve();
});
this.getNext = (displacement = OUTPUT_PAGE_SIZE) => { this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
const next = this.getNextRange(displacement); const next = this.getNextRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain return this.api.getRange(next);
.then(() => this.api.getRange(next))
.then(events => {
const results = getContinuous(events);
const min = Math.min(...results.map(({ counter }) => counter));
if (min > tail + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popBack(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushFront(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
}; };
this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => { this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
const previous = this.getPreviousRange(displacement); const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain return this.api.getRange(previous);
.then(() => this.api.getRange(previous))
.then(events => {
const results = getContinuous(events, true);
const max = Math.max(...results.map(({ counter }) => counter));
if (head > max + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popFront(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushBack(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
}; };
this.getFirst = () => { this.getFirst = () => {
this.chain = this.chain if (this.cache.first) {
.then(() => this.clear()) return $q.resolve(this.cache.first);
.then(() => { }
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
return this.api.getFirst(); return this.api.getFirst()
})
.then(events => { .then(events => {
if (events.length === OUTPUT_PAGE_SIZE) { if (events.length === OUTPUT_PAGE_SIZE) {
this.cache.first = events; this.cache.first = events;
} }
return this.pushFront(events); return $q.resolve(events);
}); });
return this.chain
.then(() => this.getNext());
}; };
this.getLast = () => { this.getLast = () => this.getFrames()
this.chain = this.chain .then(frames => {
.then(() => this.getFrames()) if (frames.length > 0) {
.then(frames => { return $q.resolve(frames);
if (frames.length > 0) { }
return $q.resolve(frames);
}
return this.api.getLast(); return this.api.getLast();
}) });
.then(events => {
const min = Math.min(...events.map(({ counter }) => counter));
if (min <= this.getTailCounter() + 1) {
return this.pushFront(events);
}
return this.clear()
.then(() => this.pushBack(events));
});
return this.chain
.then(() => this.getPrevious());
};
this.getTailCounter = () => {
if (this.state.tail === null) {
return 0;
}
if (this.state.tail < 0) {
return 0;
}
return this.state.tail;
};
this.getHeadCounter = () => {
if (this.state.head === null) {
return 0;
}
if (this.state.head < 0) {
return 0;
}
return this.state.head;
};
this.pushFrames = events => { this.pushFrames = events => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
const frames = this.buffer.events.concat(events); const frames = this.buffer.events.concat(events);
const [head, tail] = this.getRange();
let min; let min;
let max; let max;
@@ -367,7 +98,7 @@ function SlidingWindowService ($q) {
for (let i = frames.length - 1; i >= 0; i--) { for (let i = frames.length - 1; i >= 0; i--) {
count++; count++;
if (count > API_MAX_PAGE_SIZE) { if (count > OUTPUT_MAX_BUFFER_LENGTH) {
frames.splice(i, 1); frames.splice(i, 1);
count--; count--;
@@ -388,27 +119,41 @@ function SlidingWindowService ($q) {
this.buffer.max = max; this.buffer.max = max;
this.buffer.count = count; this.buffer.count = count;
if (min >= head && min <= tail + 1) { if (tail - head === 0) {
return frames.filter(({ counter }) => counter > tail); return frames;
} }
return []; return frames.filter(({ counter }) => counter > tail);
};
this.clear = () => {
this.buffer.events.length = 0;
this.buffer.min = 0;
this.buffer.max = 0;
this.buffer.count = 0;
}; };
this.getFrames = () => $q.resolve(this.buffer.events); this.getFrames = () => $q.resolve(this.buffer.events);
this.getMaxCounter = () => { this.getMaxCounter = () => {
if (this.buffer.min) { if (this.buffer.max && this.buffer.max > 1) {
return this.buffer.min; return this.buffer.max;
} }
return this.api.getMaxCounter(); return this.api.getMaxCounter();
}; };
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE); this.isOnLastPage = () => {
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; if (this.buffer.min) {
this.getRecordCount = () => Object.keys(this.lines).length; return this.getTailCounter() >= this.buffer.min - 1;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount(); }
return this.getTailCounter() >= this.getMaxCounter() - OUTPUT_PAGE_SIZE;
};
this.isOnFirstPage = () => this.getHeadCounter() === 1;
this.getTailCounter = () => this.storage.getTailCounter();
this.getHeadCounter = () => this.storage.getHeadCounter();
} }
SlidingWindowService.$inject = ['$q']; SlidingWindowService.$inject = ['$q'];

View File

@@ -1,3 +1,5 @@
import { OUTPUT_NO_COUNT_JOB_TYPES } from './constants';
const templateUrl = require('~features/output/stats.partial.html'); const templateUrl = require('~features/output/stats.partial.html');
let vm; let vm;
@@ -21,6 +23,7 @@ function JobStatsController (strings, { subscribe }) {
}; };
vm.$onInit = () => { vm.$onInit = () => {
vm.hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(vm.resource.model.get('type'));
vm.download = vm.resource.model.get('related.stdout'); vm.download = vm.resource.model.get('related.stdout');
vm.tooltips.toggleExpand = vm.expanded ? vm.tooltips.toggleExpand = vm.expanded ?
strings.get('tooltips.COLLAPSE_OUTPUT') : strings.get('tooltips.COLLAPSE_OUTPUT') :

View File

@@ -1,20 +1,20 @@
<!-- todo: styling, markup, css etc. - disposition according to project lib conventions --> <!-- todo: styling, markup, css etc. - disposition according to project lib conventions -->
<div class="at-u-floatRight"> <div class="at-u-floatRight">
<span class="at-Panel-label">plays</span> <span ng-show="!vm.hideCounts" class="at-Panel-label">plays</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span> <span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.plays || 0 }}</span> <span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.plays || 0 }}</span>
<span class="at-Panel-label">tasks</span> <span ng-show="!vm.hideCounts" class="at-Panel-label">tasks</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span> <span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.tasks || 0 }}</span> <span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.tasks || 0 }}</span>
<span class="at-Panel-label">{{:: vm.strings.get('stats.HOSTS')}}</span> <span ng-show="!vm.hideCounts" class="at-Panel-label">{{:: vm.strings.get('stats.HOSTS')}}</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span> <span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.hosts || 1 }}</span> <span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.hosts || 1 }}</span>
<span class="at-Panel-label">{{:: vm.strings.get('stats.ELAPSED') }}</span> <span class="at-Panel-label">{{:: vm.strings.get('stats.ELAPSED') }}</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span> <span ng-show="vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge"> <span ng-show="!vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">
{{ (vm.elapsed * 1000 || 0) | duration: "hh:mm:ss"}} {{ (vm.elapsed * 1000 || 0) | duration: "hh:mm:ss"}}
</span> </span>

View File

@@ -1,36 +1,49 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import { import {
EVENT_STATS_PLAY, EVENT_STATS_PLAY,
OUTPUT_MAX_BUFFER_LENGTH,
OUTPUT_MAX_LAG, OUTPUT_MAX_LAG,
OUTPUT_PAGE_SIZE, OUTPUT_PAGE_SIZE,
OUTPUT_EVENT_LIMIT,
} from './constants'; } from './constants';
const rx = [];
function OutputStream ($q) { function OutputStream ($q) {
this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => { this.init = ({ onFrames, onFrameRate, onStop }) => {
this.hooks = { this.hooks = {
bufferAdd,
bufferEmpty,
onFrames, onFrames,
onFrameRate,
onStop, onStop,
}; };
this.bufferInit();
};
this.bufferInit = () => {
rx.length = 0;
this.counters = { this.counters = {
used: [],
ready: [],
min: 1, min: 1,
max: 0, max: -1,
ready: -1,
final: null, final: null,
used: [],
missing: [],
total: 0,
length: 0,
}; };
this.state = { this.state = {
ending: false, ending: false,
ended: false, ended: false,
overflow: false,
}; };
this.lag = 0; this.lag = 0;
this.chain = $q.resolve(); this.chain = $q.resolve();
this.factors = this.calcFactors(OUTPUT_PAGE_SIZE); this.factors = this.calcFactors(OUTPUT_EVENT_LIMIT);
this.setFramesPerRender(); this.setFramesPerRender();
}; };
@@ -53,6 +66,7 @@ function OutputStream ($q) {
const boundedIndex = Math.min(this.factors.length - 1, index); const boundedIndex = Math.min(this.factors.length - 1, index);
this.framesPerRender = this.factors[boundedIndex]; this.framesPerRender = this.factors[boundedIndex];
this.hooks.onFrameRate(this.framesPerRender);
}; };
this.setMissingCounterThreshold = counter => { this.setMissingCounterThreshold = counter => {
@@ -61,36 +75,87 @@ function OutputStream ($q) {
} }
}; };
this.updateCounterState = ({ counter }) => { this.bufferAdd = event => {
this.counters.used.push(counter); const { counter } = event;
if (counter > this.counters.max) { if (counter > this.counters.max) {
this.counters.max = counter; this.counters.max = counter;
} }
let ready;
const used = [];
const missing = []; const missing = [];
let minReady;
let maxReady;
for (let i = this.counters.min; i <= this.counters.max; i++) { for (let i = this.counters.min; i <= this.counters.max; i++) {
if (this.counters.used.indexOf(i) === -1) { if (this.counters.used.indexOf(i) === -1) {
missing.push(i); if (i === counter) {
} else if (missing.length === 0) { rx.push(event);
maxReady = i; used.push(i);
this.counters.length += 1;
} else {
missing.push(i);
}
} else {
used.push(i);
} }
} }
if (maxReady) { const excess = this.counters.length - OUTPUT_MAX_BUFFER_LENGTH;
minReady = this.counters.min; this.state.overflow = (excess > 0);
this.counters.min = maxReady + 1; if (missing.length === 0) {
this.counters.used = this.counters.used.filter(c => c > maxReady); ready = this.counters.max;
} else if (this.state.overflow) {
ready = this.counters.min + this.framesPerRender;
} else {
ready = missing[0] - 1;
} }
this.counters.total += 1;
this.counters.ready = ready;
this.counters.used = used;
this.counters.missing = missing; this.counters.missing = missing;
this.counters.ready = [minReady, maxReady]; };
return this.counters.ready; this.bufferEmpty = threshold => {
let removed = [];
for (let i = rx.length - 1; i >= 0; i--) {
if (rx[i].counter <= threshold) {
removed = removed.concat(rx.splice(i, 1));
}
}
this.counters.min = threshold + 1;
this.counters.used = this.counters.used.filter(c => c > threshold);
this.counters.length = rx.length;
return removed;
};
this.isReadyToRender = () => {
const { total } = this.counters;
const readyCount = this.counters.ready - this.counters.min;
if (readyCount <= 0) {
return false;
}
if (this.state.ending) {
return true;
}
if (total % this.framesPerRender === 0) {
return true;
}
if (total < OUTPUT_PAGE_SIZE) {
if (readyCount % this.framesPerRender === 0) {
return true;
}
}
return false;
}; };
this.pushJobEvent = data => { this.pushJobEvent = data => {
@@ -103,24 +168,24 @@ function OutputStream ($q) {
this.counters.final = data.counter; this.counters.final = data.counter;
} }
const [minReady, maxReady] = this.updateCounterState(data); this.bufferAdd(data);
const count = this.hooks.bufferAdd(data);
if (count % OUTPUT_PAGE_SIZE === 0) { if (this.counters.total % OUTPUT_PAGE_SIZE === 0) {
this.setFramesPerRender(); this.setFramesPerRender();
} }
const isReady = maxReady && (this.state.ending || if (!this.isReadyToRender()) {
(maxReady - minReady) % this.framesPerRender === 0);
if (!isReady) {
return $q.resolve(); return $q.resolve();
} }
const isLastFrame = this.state.ending && (maxReady >= this.counters.final); const isLast = this.state.ending && (this.counters.ready >= this.counters.final);
const events = this.hooks.bufferEmpty(minReady, maxReady); const events = this.bufferEmpty(this.counters.ready);
return this.emitFrames(events, isLastFrame); if (events.length > 0) {
return this.emitFrames(events, isLast);
}
return $q.resolve();
}) })
.then(() => --this.lag); .then(() => --this.lag);
@@ -133,16 +198,20 @@ function OutputStream ($q) {
this.state.ending = true; this.state.ending = true;
this.counters.final = counter; this.counters.final = counter;
if (counter >= this.counters.min) { if (counter > this.counters.ready) {
return $q.resolve(); return $q.resolve();
} }
const readyCount = this.counters.ready - this.counters.min;
let events = []; let events = [];
if (this.counters.ready.length > 0) { if (readyCount > 0) {
events = this.hooks.bufferEmpty(...this.counters.ready); events = this.bufferEmpty(this.counters.ready);
return this.emitFrames(events, true);
} }
return this.emitFrames(events, true); return $q.resolve();
}); });
return this.chain; return this.chain;
@@ -157,7 +226,6 @@ function OutputStream ($q) {
this.hooks.onStop(); this.hooks.onStop();
} }
this.counters.ready.length = 0;
return $q.resolve(); return $q.resolve();
}); });

View File

@@ -1,9 +0,0 @@
/** @define TokenModal */
.TokenModal {
display: flex;
}
.TokenModal-label {
font-weight: bold;
width: 130px;
}

View File

@@ -58,30 +58,30 @@ function AddTokensController (
return postToken return postToken
.then(({ data }) => { .then(({ data }) => {
const refreshHTML = data.refresh_token ? const refreshHTML = data.refresh_token ?
`<div class="TokenModal"> `<div class="PopupModal">
<div class="TokenModal-label"> <div class="PopupModal-label">
${strings.get('add.REFRESH_TOKEN_LABEL')} ${strings.get('add.REFRESH_TOKEN_LABEL')}
</div> </div>
<div class="TokenModal-value"> <div class="PopupModal-value">
${data.refresh_token} ${data.refresh_token}
</div> </div>
</div>` : ''; </div>` : '';
Alert(strings.get('add.TOKEN_MODAL_HEADER'), ` Alert(strings.get('add.TOKEN_MODAL_HEADER'), `
<div class="TokenModal"> <div class="PopupModal">
<div class="TokenModal-label"> <div class="PopupModal-label">
${strings.get('add.TOKEN_LABEL')} ${strings.get('add.TOKEN_LABEL')}
</div> </div>
<div class="TokenModal-value"> <div class="PopupModal-value">
${data.token} ${data.token}
</div> </div>
</div> </div>
${refreshHTML} ${refreshHTML}
<div class="TokenModal"> <div class="PopupModal">
<div class="TokenModal-label"> <div class="PopupModal-label">
${strings.get('add.TOKEN_EXPIRES_LABEL')} ${strings.get('add.TOKEN_EXPIRES_LABEL')}
</div> </div>
<div class="TokenModal-value"> <div class="PopupModal-value">
${$filter('longDate')(data.expires)} ${$filter('longDate')(data.expires)}
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions
if (!vm.isSuperUser) { if (!vm.isSuperUser) {
checkOrgAdmin(); checkOrgAdmin();
checkNotificationAdmin();
} }
} }
}); });
@@ -54,6 +55,24 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions
}); });
}); });
} }
function checkNotificationAdmin () {
const usersPath = `/api/v2/users/${vm.currentUserId}/roles/?role_field=notification_admin_role`;
$http.get(usersPath)
.then(({ data }) => {
if (data.count > 0) {
vm.isNotificationAdmin = true;
} else {
vm.isNotificationAdmin = false;
}
})
.catch(({ data, status }) => {
ProcessErrors(null, data, status, null, {
hdr: strings.get('error.HEADER'),
msg: strings.get('error.CALL', { path: usersPath, action: 'GET', status })
});
});
}
} }
AtLayoutController.$inject = ['$scope', '$http', 'ComponentsStrings', 'ProcessErrors', '$transitions']; AtLayoutController.$inject = ['$scope', '$http', 'ComponentsStrings', 'ProcessErrors', '$transitions'];

View File

@@ -81,10 +81,10 @@
<span> <span>
</div> </div>
<at-side-nav-item icon-class="fa-list-alt" route="credentialTypes" name="CREDENTIAL_TYPES" <at-side-nav-item icon-class="fa-list-alt" route="credentialTypes" name="CREDENTIAL_TYPES"
system-admin-only="true"> system-admin-only="true">
</at-side-nav-item> </at-side-nav-item>
<at-side-nav-item icon-class="fa-bell" route="notifications" name="NOTIFICATIONS" <at-side-nav-item icon-class="fa-bell" route="notifications" name="NOTIFICATIONS"
system-admin-only="true"> ng-show="$parent.layoutVm.isSuperUser || $parent.layoutVm.isOrgAdmin || $parent.layoutVm.isNotificationAdmin">
</at-side-nav-item> </at-side-nav-item>
<at-side-nav-item icon-class="fa-briefcase" route="managementJobsList" name="MANAGEMENT_JOBS" <at-side-nav-item icon-class="fa-briefcase" route="managementJobsList" name="MANAGEMENT_JOBS"
system-admin-only="true"> system-admin-only="true">

View File

@@ -46,6 +46,11 @@
text-align: center; text-align: center;
margin-left: 10px; margin-left: 10px;
margin-right: auto; margin-right: auto;
&--inline {
margin-right: @at-space-2x;
margin-left: 0;
}
} }
.at-Panel-headingCustomContent { .at-Panel-headingCustomContent {
@@ -59,6 +64,7 @@
font-size: 12px; font-size: 12px;
font-weight: normal!important; font-weight: normal!important;
width: 30%; width: 30%;
margin: @at-space-2x;
@media screen and (max-width: @breakpoint-md) { @media screen and (max-width: @breakpoint-md) {
flex: 2.5 0 auto; flex: 2.5 0 auto;

View File

@@ -88,7 +88,7 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr
}; };
scope.hasSelectedRows = function(){ scope.hasSelectedRows = function(){
return _.any(scope.allSelected, (type) => Object.keys(type).length > 0); return _.some(scope.allSelected, (type) => Object.keys(type).length > 0);
}; };
scope.selectTab = function(selected){ scope.selectTab = function(selected){

View File

@@ -94,7 +94,7 @@ function(scope, $state, i18n, CreateSelect2, Rest, $q, Wait, ProcessErrors) {
}; };
scope.showSection2Container = function(){ scope.showSection2Container = function(){
return _.any(scope.allSelected, (type) => Object.keys(type).length > 0); return _.some(scope.allSelected, (type) => Object.keys(type).length > 0);
}; };
scope.showSection2Tab = function(tab){ scope.showSection2Tab = function(tab){

View File

@@ -85,6 +85,9 @@ export default function BuildAnchor($log, $filter) {
const inventoryId = _.get(obj, 'inventory', '').split('-').reverse()[0]; const inventoryId = _.get(obj, 'inventory', '').split('-').reverse()[0];
url += `inventories/inventory/${inventoryId}/inventory_sources/edit/${obj.id}`; url += `inventories/inventory/${inventoryId}/inventory_sources/edit/${obj.id}`;
break; break;
case 'o_auth2_application':
url += `applications/${obj.id}`;
break;
default: default:
url += resource + 's/' + obj.id + '/'; url += resource + 's/' + obj.id + '/';
} }

View File

@@ -349,7 +349,7 @@ angular
$rootScope.$broadcast("RemoveIndicator"); $rootScope.$broadcast("RemoveIndicator");
} }
if(_.contains(trans.from().name, 'output') && trans.to().name === 'jobs'){ if(_.includes(trans.from().name, 'output') && trans.to().name === 'jobs'){
$state.reload(); $state.reload();
} }
}); });
@@ -375,7 +375,7 @@ angular
$rootScope.user_is_system_auditor = Authorization.getUserInfo('is_system_auditor'); $rootScope.user_is_system_auditor = Authorization.getUserInfo('is_system_auditor');
// state the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket) // state the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket)
if (!_.contains($location.$$url, '/login')) { if (!_.includes($location.$$url, '/login')) {
ConfigService.getConfig().then(function() { ConfigService.getConfig().then(function() {
Timer.init().then(function(timer) { Timer.init().then(function(timer) {
$rootScope.sessionTimer = timer; $rootScope.sessionTimer = timer;

View File

@@ -90,7 +90,7 @@ export default
if(streamConfig && streamConfig.activityStream) { if(streamConfig && streamConfig.activityStream) {
if(streamConfig.activityStreamTarget) { if(streamConfig.activityStreamTarget) {
stateGoParams.target = streamConfig.activityStreamTarget; stateGoParams.target = streamConfig.activityStreamTarget;
let isTemplateTarget = _.contains(['template', 'job_template', 'workflow_job_template'], streamConfig.activityStreamTarget); let isTemplateTarget = _.includes(['template', 'job_template', 'workflow_job_template'], streamConfig.activityStreamTarget);
stateGoParams.activity_search = { stateGoParams.activity_search = {
or__object1__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget, or__object1__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget,
or__object2__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget, or__object2__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget,

View File

@@ -25,7 +25,7 @@ export default
if(expandedBreadcrumbWidth > availableWidth) { if(expandedBreadcrumbWidth > availableWidth) {
let widthToTrim = expandedBreadcrumbWidth - availableWidth; let widthToTrim = expandedBreadcrumbWidth - availableWidth;
// Sort the crumbs from biggest to smallest // Sort the crumbs from biggest to smallest
let sortedCrumbs = _.sortByOrder(crumbs, ["origWidth"], ["desc"]); let sortedCrumbs = _.orderBy(crumbs, ["origWidth"], ["desc"]);
let maxWidth; let maxWidth;
for(let i=0; i<sortedCrumbs.length; i++) { for(let i=0; i<sortedCrumbs.length; i++) {
if(sortedCrumbs[i+1]) { if(sortedCrumbs[i+1]) {

View File

@@ -252,7 +252,7 @@ export default [
}, },
]; ];
var forms = _.pluck(authForms, 'formDef'); var forms = _.map(authForms, 'formDef');
_.each(forms, function(form) { _.each(forms, function(form) {
var keys = _.keys(form.fields); var keys = _.keys(form.fields);
_.each(keys, function(key) { _.each(keys, function(key) {

View File

@@ -119,7 +119,7 @@ export default [
})); }));
$('.select2-selection__choice').each(function(i, element){ $('.select2-selection__choice').each(function(i, element){
if(!_.contains($scope.$parent.configDataResolve.AD_HOC_COMMANDS.default, element.title)){ if(!_.includes($scope.$parent.configDataResolve.AD_HOC_COMMANDS.default, element.title)){
$(`#configuration_jobs_template_AD_HOC_COMMANDS option[value='${element.title}']`).remove(); $(`#configuration_jobs_template_AD_HOC_COMMANDS option[value='${element.title}']`).remove();
element.remove(); element.remove();
} }

View File

@@ -106,7 +106,7 @@ export default [
id: 'system-misc-form' id: 'system-misc-form'
}]; }];
var forms = _.pluck(systemForms, 'formDef'); var forms = _.map(systemForms, 'formDef');
_.each(forms, function(form) { _.each(forms, function(form) {
var keys = _.keys(form.fields); var keys = _.keys(form.fields);
_.each(keys, function(key) { _.each(keys, function(key) {

View File

@@ -27,7 +27,7 @@ export default ['i18n', function(i18n) {
SESSION_COOKIE_AGE: { SESSION_COOKIE_AGE: {
type: 'number', type: 'number',
integer: true, integer: true,
min: 60, min: 61,
reset: 'SESSION_COOKIE_AGE', reset: 'SESSION_COOKIE_AGE',
}, },
SESSIONS_PER_USER: { SESSIONS_PER_USER: {

View File

@@ -17,8 +17,8 @@ function CapacityAdjuster (templateUrl, ProcessErrors, Wait, strings) {
value: scope.state.mem_capacity value: scope.state.mem_capacity
}]; }];
scope.min_capacity = _.min(adjustment_values, 'value'); scope.min_capacity = _.minBy(adjustment_values, 'value');
scope.max_capacity = _.max(adjustment_values, 'value'); scope.max_capacity = _.maxBy(adjustment_values, 'value');
capacityAdjusterController.init(); capacityAdjusterController.init();
}, },

View File

@@ -22,7 +22,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
init(); init();
function init() { function init() {
Rest.setUrl(GetBasePath('projects')); Rest.setUrl(GetBasePath('notification_templates'));
Rest.options() Rest.options()
.then(({data}) => { .then(({data}) => {
if (!data.actions.POST) { if (!data.actions.POST) {
@@ -205,7 +205,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
return $scope[i]; return $scope[i];
} }
params.notification_configuration = _.object(Object.keys(form.fields) params.notification_configuration = _.fromPairs(Object.keys(form.fields)
.filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1)) .filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1))
.map(i => [i, processValue($scope[i], i, form.fields[i])])); .map(i => [i, processValue($scope[i], i, form.fields[i])]));

View File

@@ -275,7 +275,7 @@ export default ['Rest', 'Wait',
return $scope[i]; return $scope[i];
} }
params.notification_configuration = _.object(Object.keys(form.fields) params.notification_configuration = _.fromPairs(Object.keys(form.fields)
.filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1)) .filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1))
.map(i => [i, processValue($scope[i], i, form.fields[i])])); .map(i => [i, processValue($scope[i], i, form.fields[i])]));

View File

@@ -20,7 +20,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
hover: false, hover: false,
emptyListText: i18n.sprintf(i18n._("This list is populated by notification templates added from the %sNotifications%s section"), "&nbsp;<a ui-sref='notifications.add'>", "</a>&nbsp;"), emptyListText: i18n.sprintf(i18n._("This list is populated by notification templates added from the %sNotifications%s section"), "&nbsp;<a ui-sref='notifications.add'>", "</a>&nbsp;"),
basePath: 'notification_templates', basePath: 'notification_templates',
ngIf: 'current_user.is_superuser || isOrgAdmin', ngIf: 'current_user.is_superuser || isOrgAdmin || isNotificationAdmin',
fields: { fields: {
name: { name: {
key: true, key: true,
@@ -40,6 +40,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_success', flag: 'notification_templates_success',
type: "toggle", type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_success\")", ngClick: "toggleNotification($event, notification.id, \"notification_templates_success\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}", awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip", dataTipWatch: "schedule.play_tip",
dataPlacement: "right", dataPlacement: "right",
@@ -51,6 +52,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_error', flag: 'notification_templates_error',
type: "toggle", type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_error\")", ngClick: "toggleNotification($event, notification.id, \"notification_templates_error\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}", awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip", dataTipWatch: "schedule.play_tip",
dataPlacement: "right", dataPlacement: "right",

View File

@@ -5,10 +5,10 @@
*************************************************/ *************************************************/
export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup', export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt', 'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt', '$rootScope',
'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', 'InstanceGroupsData', 'ConfigData', 'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', 'InstanceGroupsData', 'ConfigData',
function($scope, $location, $stateParams, OrgAdminLookup, function($scope, $location, $stateParams, OrgAdminLookup,
OrganizationForm, Rest, ProcessErrors, Prompt, OrganizationForm, Rest, ProcessErrors, Prompt, $rootScope,
GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, InstanceGroupsData, ConfigData) { GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, InstanceGroupsData, ConfigData) {
let form = OrganizationForm(), let form = OrganizationForm(),
@@ -26,6 +26,12 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
$scope.isOrgAdmin = isOrgAdmin; $scope.isOrgAdmin = isOrgAdmin;
}); });
Rest.setUrl(GetBasePath('users') + $rootScope.current_user.id + '/roles/?role_field=notification_admin_role');
Rest.get()
.then(({data}) => {
$scope.isNotificationAdmin = (data.count && data.count > 0);
});
$scope.$watch('organization_obj.summary_fields.user_capabilities.edit', function(val) { $scope.$watch('organization_obj.summary_fields.user_capabilities.edit', function(val) {
if (val === false) { if (val === false) {
$scope.canAdd = false; $scope.canAdd = false;

View File

@@ -133,7 +133,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
} }
switch ($scope.scm_type.value) { switch ($scope.scm_type.value) {
case 'git': case 'git':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + $scope.urlPopover = '<p>' +
i18n._('Example URLs for GIT SCM include:') + i18n._('Example URLs for GIT SCM include:') +
'</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' + '</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
@@ -146,7 +146,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit'); $scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break; break;
case 'svn': case 'svn':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' + $scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' + '<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>'; '<li>svn+ssh://servername.example.com/path</li></ul>';
@@ -155,7 +155,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('Revision #'); $scope.scmBranchLabel = i18n._('Revision #');
break; break;
case 'hg': case 'hg':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' + $scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' + '<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' + '<li>ssh://server.example.com/path</li></ul>' +
@@ -174,7 +174,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.lookupType = 'insights_credential'; $scope.lookupType = 'insights_credential';
break; break;
default: default:
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text') + '</p>'; $scope.urlPopover = '<p> ' + i18n._('URL popover text') + '</p>';
$scope.credRequired = false; $scope.credRequired = false;
$scope.lookupType = 'scm_credential'; $scope.lookupType = 'scm_credential';

View File

@@ -270,7 +270,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
} }
switch ($scope.scm_type.value) { switch ($scope.scm_type.value) {
case 'git': case 'git':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for GIT SCM include:') + '</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' + $scope.urlPopover = '<p>' + i18n._('Example URLs for GIT SCM include:') + '</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
'<li>git@github.com:ansible/ansible.git</li><li>git://servername.example.com/ansible.git</li></ul>' + '<li>git@github.com:ansible/ansible.git</li><li>git://servername.example.com/ansible.git</li></ul>' +
'<p>' + i18n.sprintf(i18n._('%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' + '<p>' + i18n.sprintf(i18n._('%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' +
@@ -281,7 +281,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit'); $scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break; break;
case 'svn': case 'svn':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' + $scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' + '<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>'; '<li>svn+ssh://servername.example.com/path</li></ul>';
@@ -290,7 +290,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('Revision #'); $scope.scmBranchLabel = i18n._('Revision #');
break; break;
case 'hg': case 'hg':
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' + $scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' + '<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' + '<li>ssh://server.example.com/path</li></ul>' +
@@ -309,7 +309,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.lookupType = 'insights_credential'; $scope.lookupType = 'insights_credential';
break; break;
default: default:
$scope.credentialLabel = "SCM Credential"; $scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text'); $scope.urlPopover = '<p> ' + i18n._('URL popover text');
$scope.credRequired = false; $scope.credRequired = false;
$scope.lookupType = 'scm_credential'; $scope.lookupType = 'scm_credential';

View File

@@ -670,6 +670,8 @@ function(ConfigurationUtils, i18n, $rootScope) {
query += '&role_level=workflow_admin_role'; query += '&role_level=workflow_admin_role';
} else if ($state.current.name.includes('projects')) { } else if ($state.current.name.includes('projects')) {
query += '&role_level=project_admin_role'; query += '&role_level=project_admin_role';
} else if ($state.current.name.includes('notifications')) {
query += '&role_level=notification_admin_role';
} else { } else {
query += '&role_level=admin_role'; query += '&role_level=admin_role';
} }

View File

@@ -36,10 +36,10 @@ export default ['$scope',
$scope.selection.selectedItems = $scope.selection.selectedItems =
_items.filter(function(item) { _items.filter(function(item) {
return item.isSelected; return item.isSelected;
}).pluck('value').value(); }).map('value').value();
$scope.selection.deselectedItems = $scope.selection.deselectedItems =
_items.pluck('value').difference($scope.selection.selectedItems) _items.map('value').difference($scope.selection.selectedItems)
.value(); .value();
/** /**

View File

@@ -310,12 +310,12 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
return []; return [];
} }
if(defaultParams) { if(defaultParams) {
let stripped =_.pick(params, (value, key) => { let stripped =_.pickBy(params, (value, key) => {
// setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value // setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value
return defaultParams[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaultParams[key] !== null; return defaultParams[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaultParams[key] !== null;
}); });
let strippedCopy = _.cloneDeep(stripped); let strippedCopy = _.cloneDeep(stripped);
if(_.keys(_.pick(defaultParams, _.keys(strippedCopy))).length > 0){ if(_.keys(_.pickBy(defaultParams, _.keys(strippedCopy))).length > 0){
for (var key in strippedCopy) { for (var key in strippedCopy) {
if (strippedCopy.hasOwnProperty(key)) { if (strippedCopy.hasOwnProperty(key)) {
let value = strippedCopy[key]; let value = strippedCopy[key];
@@ -336,7 +336,7 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
mergeQueryset (queryset, additional, singleSearchParam) { mergeQueryset (queryset, additional, singleSearchParam) {
const space = '%20and%20'; const space = '%20and%20';
const merged = _.merge({}, queryset, additional, (objectValue, sourceValue, key, object) => { const merged = _.mergeWith({}, queryset, additional, (objectValue, sourceValue, key, object) => {
if (!(object[key] && object[key] !== sourceValue)) { if (!(object[key] && object[key] !== sourceValue)) {
// // https://lodash.com/docs/3.10.1#each // // https://lodash.com/docs/3.10.1#each
// If this returns undefined merging is handled by default _.merge algorithm // If this returns undefined merging is handled by default _.merge algorithm
@@ -418,7 +418,7 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
termParams = searchWithoutKey(term, singleSearchParam); termParams = searchWithoutKey(term, singleSearchParam);
} }
params = _.merge(params, termParams, combineSameSearches); params = _.mergeWith(params, termParams, combineSameSearches);
}); });
return params; return params;

View File

@@ -102,7 +102,7 @@ function SmartSearchController (
const listName = $scope.list.name; const listName = $scope.list.name;
const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`; const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`;
const isRelatedSearchTermField = (_.contains($scope.models[listName].related, rootField)); const isRelatedSearchTermField = (_.includes($scope.models[listName].related, rootField));
const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field'); const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field');
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField); return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);
@@ -254,7 +254,7 @@ function SmartSearchController (
defaults[key] = queryset[key]; defaults[key] = queryset[key];
} }
}); });
const cleared = _(defaults).omit(_.isNull).value(); const cleared = _(defaults).omitBy(_.isNull).value();
delete cleared.page; delete cleared.page;
queryset = cleared; queryset = cleared;

View File

@@ -744,10 +744,20 @@ function($injector, $stateExtender, $log, i18n) {
// search will think they need to be set as search tags. // search will think they need to be set as search tags.
var params; var params;
if(field.sourceModel === "organization"){ if(field.sourceModel === "organization"){
params = { if (form.name === "notification_template") {
page_size: '5', // Users with admin_role role level should also have
role_level: 'admin_role' // notification_admin_role so this should handle regular admin
}; // users as well as notification admin users
params = {
page_size: '5',
role_level: 'notification_admin_role'
};
} else {
params = {
page_size: '5',
role_level: 'admin_role'
};
}
} }
else if(field.sourceModel === "inventory_script"){ else if(field.sourceModel === "inventory_script"){
params = { params = {

View File

@@ -21,7 +21,9 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
$scope.canEdit = me.get('summary_fields.user_capabilities.edit'); $scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0; $scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$scope.team_id = id; $scope.team_id = id;
setScopeFields(data); _.forEach(form.fields, (value, key) => {
$scope[key] = data[key];
});
$scope.organization_name = data.summary_fields.organization.name; $scope.organization_name = data.summary_fields.organization.name;
OrgAdminLookup.checkForAdminAccess({organization: data.organization}) OrgAdminLookup.checkForAdminAccess({organization: data.organization})
@@ -36,19 +38,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
}); });
} }
// @issue I think all this really want to do is _.forEach(form.fields, (field) =>{ $scope[field] = data[field]})
function setScopeFields(data) {
_(data)
.pick(function(value, key) {
return form.fields.hasOwnProperty(key) === true;
})
.forEach(function(value, key) {
$scope[key] = value;
})
.value();
return;
}
// prepares a data payload for a PUT request to the API // prepares a data payload for a PUT request to the API
function processNewData(fields) { function processNewData(fields) {
var data = {}; var data = {};

View File

@@ -6,8 +6,8 @@
</div> </div>
<div class="Prompt-previewTags--outer"> <div class="Prompt-previewTags--outer">
<div ng-show="promptData.launchConf.defaults.inventory.id && !promptData.prompts.inventory.value.id" class="Prompt-noSelectedItem">{{:: vm.strings.get('prompt.NO_INVENTORY_SELECTED') }}</div> <div ng-show="promptData.launchConf.defaults.inventory.id && !promptData.prompts.inventory.value.id" class="Prompt-noSelectedItem">{{:: vm.strings.get('prompt.NO_INVENTORY_SELECTED') }}</div>
<at-tag tag="promptData.prompts.inventory.value.name" remove-tag="vm.deleteSelectedInventory()" ng-show="!readOnlyPrompts"></at-tag> <at-tag tag="promptData.prompts.inventory.value.name" remove-tag="vm.deleteSelectedInventory()" ng-show="!readOnlyPrompts && promptData.prompts.inventory.value.id"></at-tag>
<at-tag tag="promptData.prompts.inventory.value.name" ng-show="readOnlyPrompts"></at-tag> <at-tag tag="promptData.prompts.inventory.value.name" ng-show="readOnlyPrompts && promptData.prompts.inventory.value.id"></at-tag>
</div> </div>
<div class="Prompt-previewTagRevert"> <div class="Prompt-previewTagRevert">
<a class="Prompt-revertLink" href="" ng-hide="readOnlyPrompts || promptData.prompts.inventory.value.id === promptData.launchConf.defaults.inventory.id" ng-click="vm.revert()">{{:: vm.strings.get('prompt.REVERT') }}</a> <a class="Prompt-revertLink" href="" ng-hide="readOnlyPrompts || promptData.prompts.inventory.value.id === promptData.launchConf.defaults.inventory.id" ng-click="vm.revert()">{{:: vm.strings.get('prompt.REVERT') }}</a>

View File

@@ -261,7 +261,7 @@ export default
scope.maxTextError = false; scope.maxTextError = false;
if(scope.type.type==="text"){ if(scope.type.type==="text"){
if(scope.default.trim() !== ""){ if(scope.default && scope.default.trim() !== ""){
if(scope.default.trim().length < scope.text_min && scope.text_min !== "" ){ if(scope.default.trim().length < scope.text_min && scope.text_min !== "" ){
scope.minTextError = true; scope.minTextError = true;
} }
@@ -272,7 +272,7 @@ export default
} }
if(scope.type.type==="textarea"){ if(scope.type.type==="textarea"){
if(scope.default_textarea.trim() !== ""){ if(scope.default_textarea && scope.default_textarea.trim() !== ""){
if(scope.default_textarea.trim().length < scope.textarea_min && scope.textarea_min !== "" ){ if(scope.default_textarea.trim().length < scope.textarea_min && scope.textarea_min !== "" ){
scope.minTextError = true; scope.minTextError = true;
} }
@@ -283,7 +283,7 @@ export default
} }
if(scope.type.type==="password"){ if(scope.type.type==="password"){
if(scope.default_password.trim() !== ""){ if(scope.default_password && scope.default_password.trim() !== ""){
if(scope.default_password.trim().length < scope.password_min && scope.password_min !== "" ){ if(scope.default_password.trim().length < scope.password_min && scope.password_min !== "" ){
scope.minTextError = true; scope.minTextError = true;
} }
@@ -293,7 +293,7 @@ export default
} }
} }
if(scope.type.type==="multiselect" && scope.default_multiselect.trim() !== ""){ if(scope.type.type==="multiselect" && scope.default_multiselect && scope.default_multiselect.trim() !== ""){
choiceArray = scope.choices.split(/\n/); choiceArray = scope.choices.split(/\n/);
answerArray = scope.default_multiselect.split(/\n/); answerArray = scope.default_multiselect.split(/\n/);
@@ -306,7 +306,7 @@ export default
} }
} }
if(scope.type.type==="multiplechoice" && scope.default.trim() !== ""){ if(scope.type.type==="multiplechoice" && scope.default && scope.default.trim() !== ""){
choiceArray = scope.choices.split(/\n/); choiceArray = scope.choices.split(/\n/);
if($.inArray(scope.default, choiceArray)===-1){ if($.inArray(scope.default, choiceArray)===-1){
scope.invalidChoice = true; scope.invalidChoice = true;

View File

@@ -7,9 +7,10 @@
export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
'$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', '$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel',
'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout',
'i18n',
function($scope, WorkflowService, GetBasePath, TemplatesService, function($scope, WorkflowService, GetBasePath, TemplatesService,
$state, ProcessErrors, CreateSelect2, $q, JobTemplate, $state, ProcessErrors, CreateSelect2, $q, JobTemplate,
Empty, PromptService, Rest, TemplatesStrings, $timeout) { Empty, PromptService, Rest, TemplatesStrings, $timeout, i18n) {
let promptWatcher, surveyQuestionWatcher, credentialsWatcher; let promptWatcher, surveyQuestionWatcher, credentialsWatcher;
@@ -301,15 +302,15 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!optionsToInclude) { if (!optionsToInclude) {
$scope.edgeTypeOptions = [ $scope.edgeTypeOptions = [
{ {
label: 'Always', label: i18n._('Always'),
value: 'always' value: 'always'
}, },
{ {
label: 'On Success', label: i18n._('On Success'),
value: 'success' value: 'success'
}, },
{ {
label: 'On Failure', label: i18n._('On Failure'),
value: 'failure' value: 'failure'
} }
]; ];
@@ -641,6 +642,31 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!_.isEmpty($scope.nodeBeingEdited.promptData)) { if (!_.isEmpty($scope.nodeBeingEdited.promptData)) {
$scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData); $scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData);
const launchConf = $scope.promptData.launchConf;
if (!launchConf.survey_enabled &&
!launchConf.ask_inventory_on_launch &&
!launchConf.ask_credential_on_launch &&
!launchConf.ask_verbosity_on_launch &&
!launchConf.ask_job_type_on_launch &&
!launchConf.ask_limit_on_launch &&
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
} else {
$scope.showPromptButton = true;
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) {
$scope.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
}
}
} else if ( } else if (
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' || _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template'
@@ -727,8 +753,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_tags_on_launch && !launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch && !launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch && !launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start && !launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) { launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false; $scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false; $scope.promptModalMissingReqFields = false;
@@ -839,19 +865,19 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) { switch($scope.nodeBeingEdited.edgeType) {
case "always": case "always":
$scope.edgeType = {label: "Always", value: "always"}; $scope.edgeType = {label: i18n._("Always"), value: "always"};
if (siblingConnectionTypes.length === 1 && _.includes(siblingConnectionTypes, "always") || $scope.nodeBeingEdited.isRoot) { if (siblingConnectionTypes.length === 1 && _.includes(siblingConnectionTypes, "always") || $scope.nodeBeingEdited.isRoot) {
edgeDropdownOptions = ["always"]; edgeDropdownOptions = ["always"];
} }
break; break;
case "success": case "success":
$scope.edgeType = {label: "On Success", value: "success"}; $scope.edgeType = {label: i18n._("On Success"), value: "success"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) { if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"]; edgeDropdownOptions = ["success", "failure"];
} }
break; break;
case "failure": case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"}; $scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) { if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"]; edgeDropdownOptions = ["success", "failure"];
} }
@@ -978,7 +1004,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) { switch($scope.nodeBeingEdited.edgeType) {
case "always": case "always":
$scope.edgeType = {label: "Always", value: "always"}; $scope.edgeType = {label: i18n._("Always"), value: "always"};
if ( if (
_.includes(siblingConnectionTypes, "always") && _.includes(siblingConnectionTypes, "always") &&
!_.includes(siblingConnectionTypes, "success") && !_.includes(siblingConnectionTypes, "success") &&
@@ -990,7 +1016,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
} }
break; break;
case "success": case "success":
$scope.edgeType = {label: "On Success", value: "success"}; $scope.edgeType = {label: i18n._("On Success"), value: "success"};
if ( if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) && (_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always") !_.includes(siblingConnectionTypes, "always")
@@ -1001,7 +1027,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
} }
break; break;
case "failure": case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"}; $scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if ( if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) && (_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always") !_.includes(siblingConnectionTypes, "always")
@@ -1071,8 +1097,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_tags_on_launch && !launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch && !launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch && !launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start && !launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) { launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false; $scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false; $scope.promptModalMissingReqFields = false;

View File

@@ -34,6 +34,10 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
init(); init();
function init() { function init() {
_.forEach(form.fields, (value, key) => {
$scope[key] = user_obj[key];
});
$scope.canEdit = me.get('summary_fields.user_capabilities.edit'); $scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0; $scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$scope.isCurrentlyLoggedInUser = (parseInt(id) === $rootScope.current_user.id); $scope.isCurrentlyLoggedInUser = (parseInt(id) === $rootScope.current_user.id);
@@ -73,9 +77,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
$scope.$watch('user_obj.summary_fields.user_capabilities.edit', function(val) { $scope.$watch('user_obj.summary_fields.user_capabilities.edit', function(val) {
$scope.canAdd = (val === false) ? false : true; $scope.canAdd = (val === false) ? false : true;
}); });
setScopeFields(user_obj);
} }
function user_type_sync($scope) { function user_type_sync($scope) {
@@ -107,19 +108,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
}; };
} }
function setScopeFields(data) {
_(data)
.pick(function(value, key) {
return form.fields.hasOwnProperty(key) === true;
})
.forEach(function(value, key) {
$scope[key] = value;
})
.value();
return;
}
$scope.redirectToResource = function(resource) { $scope.redirectToResource = function(resource) {
let type = resource.summary_fields.resource_type.replace(/ /g , "_"); let type = resource.summary_fields.resource_type.replace(/ /g , "_");
var id = resource.related[type].split("/")[4]; var id = resource.related[type].split("/")[4];
@@ -152,7 +140,11 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
var processNewData = function(fields) { var processNewData = function(fields) {
var data = {}; var data = {};
_.forEach(fields, function(value, key) { _.forEach(fields, function(value, key) {
if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) { if (value.type === 'sensitive') {
if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) {
data[key] = $scope[key];
}
} else {
data[key] = $scope[key]; data[key] = $scope[key];
} }
}); });

View File

@@ -223,6 +223,11 @@
} }
} }
}, },
"lodash": {
"version": "3.8.0",
"from": "lodash@>=3.8.0 <3.9.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz"
},
"rrule": { "rrule": {
"version": "2.2.0-dev", "version": "2.2.0-dev",
"from": "jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c", "from": "jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
@@ -233,7 +238,7 @@
"angular-tz-extensions": { "angular-tz-extensions": {
"version": "0.5.2", "version": "0.5.2",
"from": "git+https://git@github.com/ansible/angular-tz-extensions.git#v0.5.2", "from": "git+https://git@github.com/ansible/angular-tz-extensions.git#v0.5.2",
"resolved": "git+https://git@github.com/ansible/angular-tz-extensions.git#9cabb05d58079092bfb29ccae721b35b46f28af6", "resolved": "git://github.com/ansible/angular-tz-extensions.git#9cabb05d58079092bfb29ccae721b35b46f28af6",
"dependencies": { "dependencies": {
"jquery": { "jquery": {
"version": "3.3.1", "version": "3.3.1",
@@ -1496,7 +1501,15 @@
"version": "0.19.0", "version": "0.19.0",
"from": "cheerio@>=0.19.0 <0.20.0", "from": "cheerio@>=0.19.0 <0.20.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.19.0.tgz", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.19.0.tgz",
"dev": true "dev": true,
"dependencies": {
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.2.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
}
}
}, },
"chokidar": { "chokidar": {
"version": "1.7.0", "version": "1.7.0",
@@ -5420,6 +5433,12 @@
"resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz", "resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.8.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
},
"source-map": { "source-map": {
"version": "0.5.7", "version": "0.5.7",
"from": "source-map@>=0.5.3 <0.6.0", "from": "source-map@>=0.5.3 <0.6.0",
@@ -5723,6 +5742,12 @@
"from": "inquirer@>=0.8.2 <0.9.0", "from": "inquirer@>=0.8.2 <0.9.0",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.8.5.tgz", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.8.5.tgz",
"dev": true "dev": true
},
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.6.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
} }
} }
}, },
@@ -5796,9 +5821,9 @@
"dev": true "dev": true
}, },
"lodash": { "lodash": {
"version": "3.8.0", "version": "4.17.10",
"from": "lodash@>=3.8.0 <3.9.0", "from": "lodash@>=4.17.10 <4.18.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz" "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz"
}, },
"lodash._arraycopy": { "lodash._arraycopy": {
"version": "3.0.0", "version": "3.0.0",
@@ -6317,6 +6342,12 @@
"from": "glob@>=5.0.0 <6.0.0", "from": "glob@>=5.0.0 <6.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz",
"dev": true "dev": true
},
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.0.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
} }
} }
}, },

View File

@@ -29,7 +29,8 @@
"lint": "eslint .", "lint": "eslint .",
"dev": "webpack --config build/webpack.development.js --progress", "dev": "webpack --config build/webpack.development.js --progress",
"watch": "webpack-dev-server --config build/webpack.watch.js --progress --https", "watch": "webpack-dev-server --config build/webpack.watch.js --progress --https",
"production": "webpack --config build/webpack.production.js" "production": "webpack --config build/webpack.production.js",
"grab-licenses": "./utils/get_licenses.js"
}, },
"devDependencies": { "devDependencies": {
"angular-mocks": "~1.6.6", "angular-mocks": "~1.6.6",
@@ -120,7 +121,7 @@
"jquery-ui": "^1.12.1", "jquery-ui": "^1.12.1",
"js-yaml": "^3.2.7", "js-yaml": "^3.2.7",
"legacy-loader": "0.0.2", "legacy-loader": "0.0.2",
"lodash": "~3.8.0", "lodash": "~4.17.10",
"lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll", "lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll",
"moment": "^2.19.4", "moment": "^2.19.4",
"ng-toast": "git+https://git@github.com/ansible/ngToast#v2.1.1", "ng-toast": "git+https://git@github.com/ansible/ngToast#v2.1.1",

View File

@@ -236,8 +236,8 @@ msgstr ""
msgid "Add Project" msgid "Add Project"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1718 #: client/src/shared/form-generator.js:1731
#: client/src/templates/job_templates/job-template.form.js:464 #: client/src/templates/job_templates/job-template.form.js:468
#: client/src/templates/workflows.form.js:205 #: client/src/templates/workflows.form.js:205
msgid "Add Survey" msgid "Add Survey"
msgstr "" msgstr ""
@@ -277,12 +277,12 @@ msgstr ""
#: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:115 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:115
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:117 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:117
#: client/src/projects/projects.form.js:255 #: client/src/projects/projects.form.js:255
#: client/src/templates/job_templates/job-template.form.js:407 #: client/src/templates/job_templates/job-template.form.js:411
#: client/src/templates/workflows.form.js:148 #: client/src/templates/workflows.form.js:148
msgid "Add a permission" msgid "Add a permission"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1453 #: client/src/shared/form-generator.js:1466
msgid "Admin" msgid "Admin"
msgstr "" msgstr ""
@@ -315,8 +315,8 @@ msgstr ""
msgid "All Jobs" msgid "All Jobs"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:286 #: client/src/templates/job_templates/job-template.form.js:290
#: client/src/templates/job_templates/job-template.form.js:293 #: client/src/templates/job_templates/job-template.form.js:297
msgid "Allow Provisioning Callbacks" msgid "Allow Provisioning Callbacks"
msgstr "" msgstr ""
@@ -337,8 +337,8 @@ msgstr ""
#: client/src/organizations/organizations.form.js:52 #: client/src/organizations/organizations.form.js:52
#: client/src/projects/projects.form.js:207 #: client/src/projects/projects.form.js:207
#: client/src/projects/projects.form.js:212 #: client/src/projects/projects.form.js:212
#: client/src/templates/job_templates/job-template.form.js:235 #: client/src/templates/job_templates/job-template.form.js:239
#: client/src/templates/job_templates/job-template.form.js:241 #: client/src/templates/job_templates/job-template.form.js:245
msgid "Ansible Environment" msgid "Ansible Environment"
msgstr "" msgstr ""
@@ -434,7 +434,7 @@ msgstr ""
msgid "Associate this host with a new group" msgid "Associate this host with a new group"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1455 #: client/src/shared/form-generator.js:1468
msgid "Auditor" msgid "Auditor"
msgstr "" msgstr ""
@@ -703,7 +703,7 @@ msgstr ""
#: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:188 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:188
#: client/src/configuration/configuration.controller.js:617 #: client/src/configuration/configuration.controller.js:617
#: client/src/scheduler/scheduler.strings.js:56 #: client/src/scheduler/scheduler.strings.js:56
#: client/src/shared/form-generator.js:1706 #: client/src/shared/form-generator.js:1719
#: client/src/shared/lookup/lookup-modal.partial.html:19 #: client/src/shared/lookup/lookup-modal.partial.html:19
#: client/src/workflow-results/workflow-results.controller.js:38 #: client/src/workflow-results/workflow-results.controller.js:38
msgid "Cancel" msgid "Cancel"
@@ -759,7 +759,7 @@ msgstr ""
msgid "Check" msgid "Check"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1078 #: client/src/shared/form-generator.js:1087
msgid "Choose a %s" msgid "Choose a %s"
msgstr "" msgstr ""
@@ -844,7 +844,7 @@ msgid "Client Secret"
msgstr "" msgstr ""
#: client/src/scheduler/scheduler.strings.js:55 #: client/src/scheduler/scheduler.strings.js:55
#: client/src/shared/form-generator.js:1710 #: client/src/shared/form-generator.js:1723
msgid "Close" msgid "Close"
msgstr "" msgstr ""
@@ -870,7 +870,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/related/hosts/related-host.form.js:128 #: client/src/inventories-hosts/inventories/related/hosts/related-host.form.js:128
#: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:155 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:155
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:172 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:172
#: client/src/templates/job_templates/job-template.form.js:439 #: client/src/templates/job_templates/job-template.form.js:443
#: client/src/templates/workflows.form.js:180 #: client/src/templates/workflows.form.js:180
msgid "Completed Jobs" msgid "Completed Jobs"
msgstr "" msgstr ""
@@ -1338,7 +1338,7 @@ msgstr ""
#: client/features/output/output.strings.js:34 #: client/features/output/output.strings.js:34
#: client/features/users/tokens/tokens.strings.js:14 #: client/features/users/tokens/tokens.strings.js:14
#: client/src/license/license.partial.html:5 #: client/src/license/license.partial.html:5
#: client/src/shared/form-generator.js:1488 #: client/src/shared/form-generator.js:1501
msgid "Details" msgid "Details"
msgstr "" msgstr ""
@@ -1514,8 +1514,8 @@ msgstr ""
msgid "Edit Question" msgid "Edit Question"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1722 #: client/src/shared/form-generator.js:1735
#: client/src/templates/job_templates/job-template.form.js:471 #: client/src/templates/job_templates/job-template.form.js:475
#: client/src/templates/workflows.form.js:212 #: client/src/templates/workflows.form.js:212
msgid "Edit Survey" msgid "Edit Survey"
msgstr "" msgstr ""
@@ -1608,8 +1608,8 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:299 #: client/src/templates/job_templates/job-template.form.js:303
#: client/src/templates/job_templates/job-template.form.js:304 #: client/src/templates/job_templates/job-template.form.js:308
#: client/src/templates/workflows.form.js:100 #: client/src/templates/workflows.form.js:100
#: client/src/templates/workflows.form.js:105 #: client/src/templates/workflows.form.js:105
msgid "Enable Concurrent Jobs" msgid "Enable Concurrent Jobs"
@@ -1620,8 +1620,8 @@ msgid "Enable External Logging"
msgstr "" msgstr ""
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:124 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:124
#: client/src/templates/job_templates/job-template.form.js:275 #: client/src/templates/job_templates/job-template.form.js:279
#: client/src/templates/job_templates/job-template.form.js:280 #: client/src/templates/job_templates/job-template.form.js:284
msgid "Enable Privilege Escalation" msgid "Enable Privilege Escalation"
msgstr "" msgstr ""
@@ -1629,7 +1629,7 @@ msgstr ""
msgid "Enable survey" msgid "Enable survey"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:290 #: client/src/templates/job_templates/job-template.form.js:294
msgid "Enables creation of a provisioning callback URL. Using the URL a host can contact {{BRAND_NAME}} and request a configuration update using this job template." msgid "Enables creation of a provisioning callback URL. Using the URL a host can contact {{BRAND_NAME}} and request a configuration update using this job template."
msgstr "" msgstr ""
@@ -1815,8 +1815,8 @@ msgstr ""
#: client/src/job-submission/job-submission.partial.html:165 #: client/src/job-submission/job-submission.partial.html:165
#: client/src/partials/logviewer.html:8 #: client/src/partials/logviewer.html:8
#: client/src/scheduler/scheduler.strings.js:53 #: client/src/scheduler/scheduler.strings.js:53
#: client/src/templates/job_templates/job-template.form.js:353 #: client/src/templates/job_templates/job-template.form.js:357
#: client/src/templates/job_templates/job-template.form.js:360 #: client/src/templates/job_templates/job-template.form.js:364
#: client/src/templates/workflows.form.js:83 #: client/src/templates/workflows.form.js:83
#: client/src/templates/workflows.form.js:90 #: client/src/templates/workflows.form.js:90
#: client/src/workflow-results/workflow-results.controller.js:122 #: client/src/workflow-results/workflow-results.controller.js:122
@@ -2091,8 +2091,8 @@ msgstr ""
msgid "Host (Authentication URL)" msgid "Host (Authentication URL)"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:335 #: client/src/templates/job_templates/job-template.form.js:339
#: client/src/templates/job_templates/job-template.form.js:344 #: client/src/templates/job_templates/job-template.form.js:348
msgid "Host Config Key" msgid "Host Config Key"
msgstr "" msgstr ""
@@ -2269,7 +2269,7 @@ msgstr ""
msgid "If checked, any hosts and groups that were previously present on the external source but are now removed will be removed from the Tower inventory. Hosts and groups that were not managed by the inventory source will be promoted to the next manually created group or if there is no manually created group to promote them into, they will be left in the \"all\" default group for the inventory." msgid "If checked, any hosts and groups that were previously present on the external source but are now removed will be removed from the Tower inventory. Hosts and groups that were not managed by the inventory source will be promoted to the next manually created group or if there is no manually created group to promote them into, they will be left in the \"all\" default group for the inventory."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:278 #: client/src/templates/job_templates/job-template.form.js:282
msgid "If enabled, run this playbook as an administrator." msgid "If enabled, run this playbook as an administrator."
msgstr "" msgstr ""
@@ -2277,11 +2277,11 @@ msgstr ""
msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019;s --diff mode." msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019;s --diff mode."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:263 #: client/src/templates/job_templates/job-template.form.js:267
msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019s --diff mode." msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019s --diff mode."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:302 #: client/src/templates/job_templates/job-template.form.js:306
msgid "If enabled, simultaneous runs of this job template will be allowed." msgid "If enabled, simultaneous runs of this job template will be allowed."
msgstr "" msgstr ""
@@ -2289,7 +2289,7 @@ msgstr ""
msgid "If enabled, simultaneous runs of this workflow job template will be allowed." msgid "If enabled, simultaneous runs of this workflow job template will be allowed."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:313 #: client/src/templates/job_templates/job-template.form.js:317
msgid "If enabled, use cached facts if available and store discovered facts in the cache." msgid "If enabled, use cached facts if available and store discovered facts in the cache."
msgstr "" msgstr ""
@@ -2360,8 +2360,8 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:64 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:64
#: client/src/organizations/organizations.form.js:38 #: client/src/organizations/organizations.form.js:38
#: client/src/organizations/organizations.form.js:41 #: client/src/organizations/organizations.form.js:41
#: client/src/templates/job_templates/job-template.form.js:248 #: client/src/templates/job_templates/job-template.form.js:252
#: client/src/templates/job_templates/job-template.form.js:251 #: client/src/templates/job_templates/job-template.form.js:255
msgid "Instance Groups" msgid "Instance Groups"
msgstr "" msgstr ""
@@ -2679,7 +2679,7 @@ msgstr ""
msgid "Last Updated" msgid "Last Updated"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1714 #: client/src/shared/form-generator.js:1727
msgid "Launch" msgid "Launch"
msgstr "" msgstr ""
@@ -2773,7 +2773,7 @@ msgstr ""
msgid "Live events: error connecting to the server." msgid "Live events: error connecting to the server."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1992 #: client/src/shared/form-generator.js:2005
msgid "Loading..." msgid "Loading..."
msgstr "" msgstr ""
@@ -2843,6 +2843,10 @@ msgstr ""
msgid "Manual projects do not require an SCM update" msgid "Manual projects do not require an SCM update"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:234
msgid "Max 512 characters per label."
msgstr ""
#: client/src/login/loginModal/loginModal.partial.html:28 #: client/src/login/loginModal/loginModal.partial.html:28
msgid "Maximum per-user sessions reached. Please sign in." msgid "Maximum per-user sessions reached. Please sign in."
msgstr "" msgstr ""
@@ -3153,7 +3157,7 @@ msgid "No recent notifications."
msgstr "" msgstr ""
#: client/src/inventories-hosts/hosts/hosts.partial.html:36 #: client/src/inventories-hosts/hosts/hosts.partial.html:36
#: client/src/shared/form-generator.js:1886 #: client/src/shared/form-generator.js:1899
#: client/src/shared/list-generator/list-generator.factory.js:240 #: client/src/shared/list-generator/list-generator.factory.js:240
msgid "No records matched your search." msgid "No records matched your search."
msgstr "" msgstr ""
@@ -3300,7 +3304,7 @@ msgstr ""
#: client/src/notifications/notificationTemplates.form.js:453 #: client/src/notifications/notificationTemplates.form.js:453
#: client/src/partials/logviewer.html:7 #: client/src/partials/logviewer.html:7
#: client/src/templates/job_templates/job-template.form.js:271 #: client/src/templates/job_templates/job-template.form.js:275
#: client/src/templates/workflows.form.js:96 #: client/src/templates/workflows.form.js:96
msgid "Options" msgid "Options"
msgstr "" msgstr ""
@@ -3414,7 +3418,7 @@ msgid "PLEASE ADD A SURVEY PROMPT."
msgstr "" msgstr ""
#: client/src/organizations/list/organizations-list.partial.html:37 #: client/src/organizations/list/organizations-list.partial.html:37
#: client/src/shared/form-generator.js:1892 #: client/src/shared/form-generator.js:1905
#: client/src/shared/list-generator/list-generator.factory.js:248 #: client/src/shared/list-generator/list-generator.factory.js:248
msgid "PLEASE ADD ITEMS TO THIS LIST" msgid "PLEASE ADD ITEMS TO THIS LIST"
msgstr "" msgstr ""
@@ -3448,7 +3452,7 @@ msgstr ""
msgid "Pagerduty subdomain" msgid "Pagerduty subdomain"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:359 #: client/src/templates/job_templates/job-template.form.js:363
msgid "Pass extra command line variables to the playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentation for example syntax." msgid "Pass extra command line variables to the playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentation for example syntax."
msgstr "" msgstr ""
@@ -3531,7 +3535,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:106 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:106
#: client/src/projects/projects.form.js:247 #: client/src/projects/projects.form.js:247
#: client/src/teams/teams.form.js:120 #: client/src/teams/teams.form.js:120
#: client/src/templates/job_templates/job-template.form.js:398 #: client/src/templates/job_templates/job-template.form.js:402
#: client/src/templates/workflows.form.js:139 #: client/src/templates/workflows.form.js:139
#: client/src/users/users.form.js:189 #: client/src/users/users.form.js:189
msgid "Permissions" msgid "Permissions"
@@ -3542,7 +3546,7 @@ msgid "Personal Access Token"
msgstr "" msgstr ""
#: client/features/output/output.strings.js:63 #: client/features/output/output.strings.js:63
#: client/src/shared/form-generator.js:1076 #: client/src/shared/form-generator.js:1085
#: client/src/templates/job_templates/job-template.form.js:107 #: client/src/templates/job_templates/job-template.form.js:107
#: client/src/templates/job_templates/job-template.form.js:115 #: client/src/templates/job_templates/job-template.form.js:115
msgid "Playbook" msgid "Playbook"
@@ -3598,15 +3602,15 @@ msgstr ""
msgid "Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character." msgid "Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1165 #: client/src/shared/form-generator.js:1178
msgid "Please enter a number greater than %d and less than %d." msgid "Please enter a number greater than %d and less than %d."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1167 #: client/src/shared/form-generator.js:1180
msgid "Please enter a number greater than %d." msgid "Please enter a number greater than %d."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1159 #: client/src/shared/form-generator.js:1172
msgid "Please enter a number." msgid "Please enter a number."
msgstr "" msgstr ""
@@ -3708,7 +3712,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:102 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:102
#: client/src/projects/projects.form.js:239 #: client/src/projects/projects.form.js:239
#: client/src/teams/teams.form.js:116 #: client/src/teams/teams.form.js:116
#: client/src/templates/job_templates/job-template.form.js:391 #: client/src/templates/job_templates/job-template.form.js:395
#: client/src/templates/workflows.form.js:132 #: client/src/templates/workflows.form.js:132
msgid "Please save before assigning permissions." msgid "Please save before assigning permissions."
msgstr "" msgstr ""
@@ -3772,11 +3776,11 @@ msgstr ""
msgid "Please select Users from the list below." msgid "Please select Users from the list below."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1200 #: client/src/shared/form-generator.js:1213
msgid "Please select a number between" msgid "Please select a number between"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1196 #: client/src/shared/form-generator.js:1209
msgid "Please select a number." msgid "Please select a number."
msgstr "" msgstr ""
@@ -3784,10 +3788,10 @@ msgstr ""
msgid "Please select a value" msgid "Please select a value"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1088 #: client/src/shared/form-generator.js:1097
#: client/src/shared/form-generator.js:1156 #: client/src/shared/form-generator.js:1169
#: client/src/shared/form-generator.js:1277 #: client/src/shared/form-generator.js:1290
#: client/src/shared/form-generator.js:1385 #: client/src/shared/form-generator.js:1398
msgid "Please select a value." msgid "Please select a value."
msgstr "" msgstr ""
@@ -3799,7 +3803,7 @@ msgstr ""
msgid "Please select an organization before editing the host filter." msgid "Please select an organization before editing the host filter."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1193 #: client/src/shared/form-generator.js:1206
msgid "Please select at least one value." msgid "Please select at least one value."
msgstr "" msgstr ""
@@ -3942,8 +3946,8 @@ msgstr ""
#: client/src/templates/job_templates/job-template.form.js:185 #: client/src/templates/job_templates/job-template.form.js:185
#: client/src/templates/job_templates/job-template.form.js:202 #: client/src/templates/job_templates/job-template.form.js:202
#: client/src/templates/job_templates/job-template.form.js:219 #: client/src/templates/job_templates/job-template.form.js:219
#: client/src/templates/job_templates/job-template.form.js:266 #: client/src/templates/job_templates/job-template.form.js:270
#: client/src/templates/job_templates/job-template.form.js:366 #: client/src/templates/job_templates/job-template.form.js:370
#: client/src/templates/job_templates/job-template.form.js:60 #: client/src/templates/job_templates/job-template.form.js:60
#: client/src/templates/job_templates/job-template.form.js:86 #: client/src/templates/job_templates/job-template.form.js:86
msgid "Prompt on launch" msgid "Prompt on launch"
@@ -3982,8 +3986,8 @@ msgstr ""
msgid "Provide the named URL encoded name or id of the remote Tower inventory to be imported." msgid "Provide the named URL encoded name or id of the remote Tower inventory to be imported."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:322 #: client/src/templates/job_templates/job-template.form.js:326
#: client/src/templates/job_templates/job-template.form.js:330 #: client/src/templates/job_templates/job-template.form.js:334
msgid "Provisioning Callback URL" msgid "Provisioning Callback URL"
msgstr "" msgstr ""
@@ -4492,7 +4496,7 @@ msgstr ""
#: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:193 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:193
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:158 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:158
#: client/src/scheduler/scheduler.strings.js:57 #: client/src/scheduler/scheduler.strings.js:57
#: client/src/shared/form-generator.js:1698 #: client/src/shared/form-generator.js:1711
msgid "Save" msgid "Save"
msgstr "" msgstr ""
@@ -4543,7 +4547,7 @@ msgstr ""
#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35 #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35
#: client/src/inventories-hosts/inventories/related/sources/sources.form.js:440 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:440
#: client/src/projects/projects.form.js:290 #: client/src/projects/projects.form.js:290
#: client/src/templates/job_templates/job-template.form.js:444 #: client/src/templates/job_templates/job-template.form.js:448
#: client/src/templates/workflows.form.js:185 #: client/src/templates/workflows.form.js:185
msgid "Schedules" msgid "Schedules"
msgstr "" msgstr ""
@@ -4566,7 +4570,7 @@ msgstr ""
msgid "Security Token Service (STS) is a web service that enables you to request temporary, limited-privilege credentials for AWS Identity and Access Management (IAM) users." msgid "Security Token Service (STS) is a web service that enables you to request temporary, limited-privilege credentials for AWS Identity and Access Management (IAM) users."
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1702 #: client/src/shared/form-generator.js:1715
#: client/src/shared/lookup/lookup-modal.directive.js:59 #: client/src/shared/lookup/lookup-modal.directive.js:59
#: client/src/shared/lookup/lookup-modal.partial.html:20 #: client/src/shared/lookup/lookup-modal.partial.html:20
msgid "Select" msgid "Select"
@@ -4634,7 +4638,7 @@ msgstr ""
msgid "Select the Instance Groups for this Inventory to run on. Refer to the Ansible Tower documentation for more detail." msgid "Select the Instance Groups for this Inventory to run on. Refer to the Ansible Tower documentation for more detail."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:250 #: client/src/templates/job_templates/job-template.form.js:254
msgid "Select the Instance Groups for this Job Template to run on." msgid "Select the Instance Groups for this Job Template to run on."
msgstr "" msgstr ""
@@ -4642,7 +4646,7 @@ msgstr ""
msgid "Select the Instance Groups for this Organization to run on." msgid "Select the Instance Groups for this Organization to run on."
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:240 #: client/src/templates/job_templates/job-template.form.js:244
msgid "Select the custom Python virtual environment for this job template to run on." msgid "Select the custom Python virtual environment for this job template to run on."
msgstr "" msgstr ""
@@ -4709,8 +4713,8 @@ msgstr ""
#: client/features/templates/templates.strings.js:46 #: client/features/templates/templates.strings.js:46
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:115 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:115
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:118 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:118
#: client/src/templates/job_templates/job-template.form.js:257 #: client/src/templates/job_templates/job-template.form.js:261
#: client/src/templates/job_templates/job-template.form.js:260 #: client/src/templates/job_templates/job-template.form.js:264
msgid "Show Changes" msgid "Show Changes"
msgstr "" msgstr ""
@@ -4757,7 +4761,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/inventory.list.js:86 #: client/src/inventories-hosts/inventories/inventory.list.js:86
#: client/src/inventories-hosts/inventories/list/inventory-list.controller.js:76 #: client/src/inventories-hosts/inventories/list/inventory-list.controller.js:76
#: client/src/organizations/linkout/controllers/organizations-inventories.controller.js:70 #: client/src/organizations/linkout/controllers/organizations-inventories.controller.js:70
#: client/src/shared/form-generator.js:1463 #: client/src/shared/form-generator.js:1476
msgid "Smart Inventory" msgid "Smart Inventory"
msgstr "" msgstr ""
@@ -5111,8 +5115,8 @@ msgstr ""
msgid "Textarea" msgid "Textarea"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1393 #: client/src/shared/form-generator.js:1406
#: client/src/shared/form-generator.js:1399 #: client/src/shared/form-generator.js:1412
msgid "That value was not found. Please enter or select a valid value." msgid "That value was not found. Please enter or select a valid value."
msgstr "" msgstr ""
@@ -5564,12 +5568,12 @@ msgstr ""
#: client/src/organizations/organizations.form.js:48 #: client/src/organizations/organizations.form.js:48
#: client/src/projects/projects.form.js:209 #: client/src/projects/projects.form.js:209
#: client/src/templates/job_templates/job-template.form.js:237 #: client/src/templates/job_templates/job-template.form.js:241
msgid "Use Default Environment" msgid "Use Default Environment"
msgstr "" msgstr ""
#: client/src/templates/job_templates/job-template.form.js:310 #: client/src/templates/job_templates/job-template.form.js:314
#: client/src/templates/job_templates/job-template.form.js:315 #: client/src/templates/job_templates/job-template.form.js:319
msgid "Use Fact Cache" msgid "Use Fact Cache"
msgstr "" msgstr ""
@@ -5764,8 +5768,8 @@ msgstr ""
msgid "View Project checkout results" msgid "View Project checkout results"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1726 #: client/src/shared/form-generator.js:1739
#: client/src/templates/job_templates/job-template.form.js:455 #: client/src/templates/job_templates/job-template.form.js:459
#: client/src/templates/workflows.form.js:196 #: client/src/templates/workflows.form.js:196
msgid "View Survey" msgid "View Survey"
msgstr "" msgstr ""
@@ -5946,7 +5950,7 @@ msgstr ""
msgid "Workflow Templates" msgid "Workflow Templates"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1730 #: client/src/shared/form-generator.js:1743
#: client/src/templates/workflows.form.js:222 #: client/src/templates/workflows.form.js:222
msgid "Workflow Visualizer" msgid "Workflow Visualizer"
msgstr "" msgstr ""
@@ -6032,7 +6036,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/related/groups/list/groups-list.partial.html:24 #: client/src/inventories-hosts/inventories/related/groups/list/groups-list.partial.html:24
#: client/src/job-submission/job-submission.partial.html:317 #: client/src/job-submission/job-submission.partial.html:317
#: client/src/shared/form-generator.js:1200 #: client/src/shared/form-generator.js:1213
#: client/src/templates/prompt/steps/survey/prompt-survey.partial.html:42 #: client/src/templates/prompt/steps/survey/prompt-survey.partial.html:42
msgid "and" msgid "and"
msgstr "" msgstr ""
@@ -6144,7 +6148,7 @@ msgstr ""
msgid "organization" msgid "organization"
msgstr "" msgstr ""
#: client/src/shared/form-generator.js:1076 #: client/src/shared/form-generator.js:1085
msgid "playbook" msgid "playbook"
msgstr "" msgstr ""

Some files were not shown because too many files have changed in this diff Show More