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
commit 580004b395
246 changed files with 10831 additions and 6593 deletions

View File

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

View File

@ -97,7 +97,7 @@ class DeprecatedCredentialField(serializers.IntegerField):
kwargs['allow_null'] = True
kwargs['default'] = None
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)
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['settings'] = settings
d['has_named_url'] = self.model in settings.NAMED_URL_GRAPH
return d

View File

@ -1903,7 +1903,9 @@ class CustomInventoryScriptSerializer(BaseSerializer):
class InventorySourceOptionsSerializer(BaseSerializer):
credential = DeprecatedCredentialField()
credential = DeprecatedCredentialField(
help_text=_('Cloud credential to use for inventory updates.')
)
class Meta:
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
_Added in AWX 1.4_
(_Added in Ansible Tower 3.1.0_) Search across related fields:
?related__search=findme
@ -84,7 +82,7 @@ To exclude results matching certain criteria, prefix the field parameter with
?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
matching *any* one of multiple criteria, prefix each query string parameter
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
`--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
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
@ -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)
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
when running jobs without removing them from the inventory. Specify a query
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
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 }}:
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 %}
# 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 %}
# 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 %}
# Retrieve {{ model_verbose_name|title|anora }}:

View File

@ -115,9 +115,10 @@ class ActivityStreamEnforcementMixin(object):
Mixin to check that license supports activity streams.
'''
def check_permissions(self, request):
ret = super(ActivityStreamEnforcementMixin, self).check_permissions(request)
if not feature_enabled('activity_streams'):
raise LicenseForbids(_('Your license does not allow use of the activity stream.'))
return super(ActivityStreamEnforcementMixin, self).check_permissions(request)
return ret
class SystemTrackingEnforcementMixin(object):
@ -125,9 +126,10 @@ class SystemTrackingEnforcementMixin(object):
Mixin to check that license supports system tracking.
'''
def check_permissions(self, request):
ret = super(SystemTrackingEnforcementMixin, self).check_permissions(request)
if not feature_enabled('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):
@ -135,9 +137,10 @@ class WorkflowsEnforcementMixin(object):
Mixin to check that license supports workflows.
'''
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'):
raise LicenseForbids(_('Your license does not allow use of workflows.'))
return super(WorkflowsEnforcementMixin, self).check_permissions(request)
return ret
class UnifiedJobDeletionMixin(object):
@ -442,9 +445,9 @@ class ApiV1ConfigView(APIView):
data.update(dict(
project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(),
custom_virtualenvs = get_custom_venv_choices()
))
if JobTemplate.accessible_objects(request.user, 'admin_role').exists():
elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
data['custom_virtualenvs'] = get_custom_venv_choices()
return Response(data)
@ -2883,17 +2886,14 @@ class InventorySourceCredentialsList(SubListAttachDetachAPIView):
relationship = 'credentials'
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)
if 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

View File

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

View File

@ -27,7 +27,13 @@ import os
import stat
import threading
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

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -125,15 +125,15 @@ msgid ""
"our REST API, the Content-Type must be application/json"
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."
msgstr ""
#: awx/api/generics.py:688
#: awx/api/generics.py:694
msgid "\"id\" is required to disassociate"
msgstr ""
#: awx/api/generics.py:739
#: awx/api/generics.py:745
msgid "{} 'id' field is missing."
msgstr ""
@ -1642,86 +1642,86 @@ msgstr ""
msgid "Bad data found in related field %s."
msgstr ""
#: awx/main/access.py:304
#: awx/main/access.py:302
msgid "License is missing."
msgstr ""
#: awx/main/access.py:306
#: awx/main/access.py:304
msgid "License has expired."
msgstr ""
#: awx/main/access.py:314
#: awx/main/access.py:312
#, python-format
msgid "License count of %s instances has been reached."
msgstr ""
#: awx/main/access.py:316
#: awx/main/access.py:314
#, python-format
msgid "License count of %s instances has been exceeded."
msgstr ""
#: awx/main/access.py:318
#: awx/main/access.py:316
msgid "Host count exceeds available instances."
msgstr ""
#: awx/main/access.py:322
#: awx/main/access.py:320
#, python-format
msgid "Feature %s is not enabled in the active license."
msgstr ""
#: awx/main/access.py:324
#: awx/main/access.py:322
msgid "Features not found in active license."
msgstr ""
#: awx/main/access.py:837
#: awx/main/access.py:835
msgid "Unable to change inventory on a host."
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."
msgstr ""
#: awx/main/access.py:887
#: awx/main/access.py:885
msgid "Unable to change inventory on a group."
msgstr ""
#: awx/main/access.py:1148
#: awx/main/access.py:1146
msgid "Unable to change organization on a team."
msgstr ""
#: awx/main/access.py:1165
#: awx/main/access.py:1163
msgid "The {} role cannot be assigned to a team"
msgstr ""
#: awx/main/access.py:1167
#: awx/main/access.py:1165
msgid "The admin_role for a User cannot be assigned to a team"
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."
msgstr ""
#: awx/main/access.py:1553
#: awx/main/access.py:1551
msgid "Job has been orphaned from its job template."
msgstr ""
#: awx/main/access.py:1555
#: awx/main/access.py:1553
msgid "Job was launched with unknown prompted fields."
msgstr ""
#: awx/main/access.py:1557
#: awx/main/access.py:1555
msgid "Job was launched with prompted fields."
msgstr ""
#: awx/main/access.py:1559
#: awx/main/access.py:1557
msgid " Organization level permissions required."
msgstr ""
#: awx/main/access.py:1561
#: awx/main/access.py:1559
msgid " You do not have permission to related resources."
msgstr ""
#: awx/main/access.py:1981
#: awx/main/access.py:1979
msgid ""
"You do not have permission to the workflow job resources required for "
"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',)
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):
return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj,

View File

@ -117,10 +117,10 @@ class IsolatedManager(object):
@classmethod
def awx_playbook_path(cls):
return os.path.join(
return os.path.abspath(os.path.join(
os.path.dirname(awx.__file__),
'playbooks'
)
))
def path_to(self, *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['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,
expect_passwords=expect_passwords,
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):
host_name = host.name
host.delete()
logger.info('Deleted host "%s"', host_name)
logger.debug('Deleted host "%s"', host_name)
if settings.SQL_DEBUG:
logger.warning('host deletions took %d queries for %d hosts',
len(connection.queries) - queries_before,
@ -528,7 +528,7 @@ class Command(BaseCommand):
group_name = group.name
with ignore_inventory_computed_fields():
group.delete()
logger.info('Group "%s" deleted', group_name)
logger.debug('Group "%s" deleted', group_name)
if settings.SQL_DEBUG:
logger.warning('group deletions took %d queries for %d groups',
len(connection.queries) - queries_before,
@ -549,7 +549,7 @@ class Command(BaseCommand):
db_groups = self.inventory_source.groups
for db_group in db_groups.all():
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',
db_group.name
)
@ -566,8 +566,8 @@ class Command(BaseCommand):
for db_child in db_children.filter(pk__in=child_group_pks):
group_group_count += 1
db_group.children.remove(db_child)
logger.info('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
logger.debug('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
# FIXME: Inventory source group relationships
# Delete group/host relationships not present in imported data.
db_hosts = db_group.hosts
@ -594,8 +594,8 @@ class Command(BaseCommand):
if db_host not in db_group.hosts.all():
continue
db_group.hosts.remove(db_host)
logger.info('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
logger.debug('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
if settings.SQL_DEBUG:
logger.warning('group-group and group-host deletions took %d queries for %d relationships',
len(connection.queries) - queries_before,
@ -614,9 +614,9 @@ class Command(BaseCommand):
if db_variables != all_obj.variables_dict:
all_obj.variables = json.dumps(db_variables)
all_obj.save(update_fields=['variables'])
logger.info('Inventory variables updated from "all" group')
logger.debug('Inventory variables updated from "all" group')
else:
logger.info('Inventory variables unmodified')
logger.debug('Inventory variables unmodified')
def _create_update_groups(self):
'''
@ -648,11 +648,11 @@ class Command(BaseCommand):
group.variables = json.dumps(db_variables)
group.save(update_fields=['variables'])
if self.overwrite_vars:
logger.info('Group "%s" variables replaced', group.name)
logger.debug('Group "%s" variables replaced', group.name)
else:
logger.info('Group "%s" variables updated', group.name)
logger.debug('Group "%s" variables updated', group.name)
else:
logger.info('Group "%s" variables unmodified', group.name)
logger.debug('Group "%s" variables unmodified', group.name)
existing_group_names.add(group.name)
self._batch_add_m2m(self.inventory_source.groups, group)
for group_name in all_group_names:
@ -666,7 +666,7 @@ class Command(BaseCommand):
'description':'imported'
}
)[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, flush=True)
if settings.SQL_DEBUG:
@ -705,24 +705,24 @@ class Command(BaseCommand):
if update_fields:
db_host.save(update_fields=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 old_instance_id:
logger.info('Host "%s" instance_id updated', mem_host.name)
logger.debug('Host "%s" instance_id updated', mem_host.name)
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 self.overwrite_vars:
logger.info('Host "%s" variables replaced', mem_host.name)
logger.debug('Host "%s" variables replaced', mem_host.name)
else:
logger.info('Host "%s" variables updated', mem_host.name)
logger.debug('Host "%s" variables updated', mem_host.name)
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:
logger.info('Host "%s" is now enabled', mem_host.name)
logger.debug('Host "%s" is now enabled', mem_host.name)
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)
def _create_update_hosts(self):
@ -796,9 +796,9 @@ class Command(BaseCommand):
host_attrs['instance_id'] = instance_id
db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0]
if enabled is False:
logger.info('Host "%s" added (disabled)', mem_host_name)
logger.debug('Host "%s" added (disabled)', mem_host_name)
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, flush=True)
@ -827,10 +827,10 @@ class Command(BaseCommand):
child_names = all_child_names[offset2:(offset2 + self._batch_size)]
db_children_qs = self.inventory.groups.filter(name__in=child_names)
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):
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)
if settings.SQL_DEBUG:
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)]
db_hosts_qs = self.inventory.hosts.filter(name__in=host_names)
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):
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])
for offset2 in xrange(0, len(all_instance_ids), 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)
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):
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)
if settings.SQL_DEBUG:
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
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):
"""List instances from the Tower database
"""
@ -13,12 +29,28 @@ class Command(BaseCommand):
def handle(self, *args, **options):
super(Command, self).__init__()
for instance in Instance.objects.all():
print(six.text_type(
"hostname: {0.hostname}; created: {0.created}; "
"heartbeat: {0.modified}; capacity: {0.capacity}").format(instance))
for instance_group in InstanceGroup.objects.all():
print(six.text_type(
"Instance Group: {0.name}; created: {0.created}; "
"capacity: {0.capacity}; members: {1}").format(instance_group,
[x.hostname for x in instance_group.instances.all()]))
groups = list(InstanceGroup.objects.all())
ungrouped = Ungrouped()
if len(ungrouped.instances):
groups.append(ungrouped)
for instance_group in groups:
fmt = '[{0.name} capacity={0.capacity}'
if instance_group.policy_instance_percentage:
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)))
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 = {
'events_ontime': {
'total': 0,
@ -127,7 +127,7 @@ class ReplayJobEvents():
je_previous = None
for n, je_current in enumerate(job_events):
if n < skip:
if je_current.counter in skip_range:
continue
if not je_previous:
@ -193,19 +193,29 @@ class Command(BaseCommand):
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):
parser.add_argument('--job_id', dest='job_id', type=int, metavar='j',
help='Id of the job to replay (job or adhoc)')
parser.add_argument('--speed', dest='speed', type=int, metavar='s',
help='Speedup factor.')
parser.add_argument('--skip', dest='skip', type=int, metavar='k',
help='Number of events to skip.')
parser.add_argument('--skip-range', dest='skip_range', type=str, metavar='k',
default='0:-1:1', help='Range of events to skip')
def handle(self, *args, **options):
job_id = options.get('job_id')
speed = options.get('speed') or 1
verbosity = options.get('verbosity') or 0
skip = options.get('skip') or 0
skip = self._parse_slice_range(options.get('skip_range'))
replayer = ReplayJobEvents()
replayer.run(job_id, speed, verbosity, skip)

View File

@ -64,15 +64,22 @@ class CallbackBrokerWorker(ConsumerMixin):
return _handler
if use_workers:
django_connection.close()
django_cache.close()
for idx in range(settings.JOB_EVENT_WORKERS):
queue_actual = MPQueue(settings.JOB_EVENT_MAX_QUEUE_SIZE)
w = Process(target=self.callback_worker, args=(queue_actual, idx,))
w.start()
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])
# 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:
logger.warn('Started callback receiver (no workers)')

View File

@ -1,6 +1,8 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import base64
import json
import logging
import threading
import uuid
@ -9,12 +11,15 @@ import time
import cProfile
import pstats
import os
import re
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor
from django.db import IntegrityError, connection
from django.http import HttpResponse
from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect
from django.apps import apps
@ -128,8 +133,9 @@ class SessionTimeoutMiddleware(object):
def process_response(self, request, response):
req_session = getattr(request, 'session', None)
if req_session and not req_session.is_empty():
request.session.set_expiry(request.session.get_expiry_age())
response['Session-Timeout'] = int(settings.SESSION_COOKIE_AGE)
expiry = int(settings.SESSION_COOKIE_AGE)
request.session.set_expiry(expiry)
response['Session-Timeout'] = expiry
return response
@ -203,6 +209,56 @@ class URLModificationMiddleware(object):
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):
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')])),
('error', models.TextField(default=b'', editable=False, blank=True)),
('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)),
('subject', models.TextField(default=b'', editable=False, 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)),
('description', models.TextField(default=b'', blank=True)),
('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)),
('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)),

View File

@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_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={
'ordering': ('-pk',),
@ -53,7 +53,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_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={
'ordering': ('pk',),
@ -72,12 +72,24 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_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={
'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(
model_name='unifiedjob',
name='result_stdout_file',

View File

@ -64,12 +64,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='activitystream',
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(
model_name='activitystream',
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(
model_name='oauth2accesstoken',
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(
model_name='oauth2accesstoken',
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 [
'play', 'role', 'task', 'playbook'
]:
if isinstance(kwargs.get(key), six.string_types):
if len(kwargs[key]) > 1024:
kwargs[key] = Truncator(kwargs[key]).chars(1024)
if isinstance(kwargs.get('event_data', {}).get(key), six.string_types):
if len(kwargs['event_data'][key]) > 1024:
kwargs['event_data'][key] = Truncator(kwargs['event_data'][key]).chars(1024)
def create_host_status_counts(event_data):

View File

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

View File

@ -1262,6 +1262,11 @@ class InventorySourceOptions(BaseModel):
'Credentials of type machine, source control, insights and vault are '
'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
def get_inventory_plugin_name(self):

View File

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

View File

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

View File

@ -76,7 +76,8 @@ class TaskManager():
inventory_updates_qs = InventoryUpdate.objects.filter(
status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group')
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')]
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)]
@ -678,9 +679,9 @@ class TaskManager():
return finished_wfjs
def schedule(self):
with transaction.atomic():
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
with transaction.atomic():
if acquired is False:
logger.debug("Not running scheduler, another task holds lock")
return

View File

@ -32,7 +32,7 @@ except Exception:
from kombu import Queue, Exchange
from kombu.common import Broadcast
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
from django.conf import settings
@ -108,6 +108,31 @@ def log_celery_failure(self, exc, task_id, args, kwargs, einfo):
@celeryd_init.connect
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.info("Syncing Schedules")
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)
def apply_cluster_membership_policies(self):
started_waiting = time.time()
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_groups = list(InstanceGroup.objects.all())
all_groups = list(InstanceGroup.objects.prefetch_related('instances'))
iso_hostnames = set([])
for ig in all_groups:
if ig.controller_id is not None:
@ -159,28 +192,32 @@ def apply_cluster_membership_policies(self):
total_instances = len(considered_instances)
actual_groups = []
actual_instances = []
Group = namedtuple('Group', ['obj', 'instances'])
Group = namedtuple('Group', ['obj', 'instances', 'prior_instances'])
Node = namedtuple('Instance', ['obj', 'groups'])
# Process policy instance list first, these will represent manually managed memberships
instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
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:
if hostname not in instance_hostnames_map:
logger.info(six.text_type("Unknown instance {} in {} policy list").format(hostname, ig.name))
continue
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)
# NOTE: arguable behavior: policy-list-group is not added to
# 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:
actual_groups.append(group_actual)
else:
# For isolated groups, _only_ apply the policy_instance_list
# 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)
# 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(
total_instances, len(actual_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)):
if len(g.instances) >= g.obj.policy_instance_minimum:
break
@ -196,12 +234,15 @@ def apply_cluster_membership_policies(self):
# If the instance is already _in_ the group, it was
# applied earlier via the policy list
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)
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
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)):
if i.obj.id in g.instances:
# If the instance is already _in_ the group, it was
@ -209,15 +250,34 @@ def apply_cluster_membership_policies(self):
continue
if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage:
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)
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
with transaction.atomic():
for g in actual_groups:
logger.info('Committing instances {} to group {}'.format(g.instances, g.obj.name))
g.obj.instances.set(g.instances)
instances_to_add = set(g.instances) - set(g.prior_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)
@ -233,34 +293,6 @@ def handle_setting_changes(self, setting_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)
def send_notifications(notification_list, job_id=None):
if not isinstance(notification_list, list):
@ -761,12 +793,12 @@ class BaseTask(Task):
os.chmod(path, stat.S_IRUSR)
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['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH']
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(
'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_path = ':'.join(plugin_dirs)
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
# callbacks to work.
env['JOB_ID'] = str(job.pk)
@ -2129,8 +2161,7 @@ class RunInventoryUpdate(BaseTask):
elif src == 'scm':
args.append(inventory_update.get_actual_source_path())
elif src == 'custom':
runpath = tempfile.mkdtemp(prefix='awx_inventory_', dir=settings.AWX_PROOT_BASE_PATH)
handle, path = tempfile.mkstemp(dir=runpath)
handle, path = tempfile.mkstemp(dir=kwargs['private_data_dir'])
f = os.fdopen(handle, 'w')
if inventory_update.source_script is None:
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)
args.append(path)
args.append("--custom")
self.cleanup_paths.append(runpath)
args.append('-v%d' % inventory_update.verbosity)
if settings.DEBUG:
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]
@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
class TestControlledBySCM:
'''

View File

@ -5,7 +5,10 @@ import json
from django.db import connection
from django.test.utils import override_settings
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.api.versioning import reverse, drf_reverse
from awx.main.models.oauth import (OAuth2Application as Application,
@ -260,36 +263,6 @@ def test_oauth_list_user_tokens(oauth_application, post, get, admin, alice):
post(url, {'scope': 'read'}, user, expect=201)
response = get(url, admin, expect=200)
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
@ -314,3 +287,117 @@ def test_implicit_authorization(oauth_application, admin):
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.
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

@ -34,8 +34,17 @@ class TestOAuth2Application:
client_type='confidential', authorization_grant_type='password', organization=organization
)
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):
app = Application.objects.create(
name='test app for {}'.format(org_admin.username), user=org_admin,

View File

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

View File

@ -2,7 +2,6 @@
# Python
import pytest
import mock
from collections import namedtuple
# AWX
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
Field = namedtuple('Field', 'name')
Meta = namedtuple('Meta', 'fields')
class Field(object):
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:
@ -53,15 +70,32 @@ class mockObjects:
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:
def __init__(self):
print("Host mock created")
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())
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", [
('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})),
('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})),
@ -88,6 +122,16 @@ class TestSmartFilterQueryFromString():
SmartFilter.query_from_string(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", [
(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"}})),

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()])
self.result = Host.objects.filter(q)
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
self.result = Host.objects.filter(**kwargs)

View File

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

View File

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

View File

@ -1,3 +1,16 @@
@import 'portalMode/_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 { application, me, organization } = models;
@ -60,6 +60,41 @@ function AddApplicationsController (models, $state, strings, $scope) {
};
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 });
};
@ -74,7 +109,9 @@ AddApplicationsController.$inject = [
'resolvedModels',
'$state',
'ApplicationsStrings',
'$scope'
'$scope',
'Alert',
'$filter',
];
export default AddApplicationsController;

View File

@ -21,7 +21,11 @@ function ApplicationsStrings (BaseString) {
};
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 = {

View File

@ -38,6 +38,16 @@
}
}
&-menuIcon--md {
font-size: 14px;
padding: 10px;
cursor: pointer;
&:hover {
color: @at-blue;
}
}
&-menuIcon--lg {
font-size: 22px;
line-height: 12px;
@ -94,6 +104,10 @@
user-select: none;
}
&-line--clickable {
cursor: pointer;
}
&-event {
.at-mixin-event();
}
@ -138,6 +152,10 @@
margin: 0;
overflow-y: scroll;
padding: 0;
@media screen and (max-width: @breakpoint-md) {
max-height: calc(100vh - 30px);
}
}
&-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_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_TBODY = '#atStdoutResultTable';
export const OUTPUT_ELEMENT_LAST = '#atStdoutMenuLast';
export const OUTPUT_MAX_BUFFER_LENGTH = 1000;
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_PAGE_CACHE = true;
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_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_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play'];
export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01'];
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', 'start_line:>=9000'];
export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE;
export const WS_PREFIX = 'ws';

View File

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

View File

@ -82,7 +82,7 @@ function resolveResource (
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));
Object.assign(params, query);
}
@ -173,7 +173,7 @@ function JobsRun ($stateRegistry, $filter, strings) {
const sanitize = $filter('sanitize');
const state = {
url: '/:type/:id?job_event_search',
url: '/:type/:id?job_event_search?_debug',
name: 'output',
parent,
ncyBreadcrumb,

View File

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

View File

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

View File

@ -3,9 +3,12 @@ import Entities from 'html-entities';
import {
EVENT_START_PLAY,
EVENT_START_PLAYBOOK,
EVENT_STATS_PLAY,
EVENT_START_TASK,
OUTPUT_ANSI_COLORMAP,
OUTPUT_ELEMENT_TBODY,
OUTPUT_EVENT_LIMIT,
} from './constants';
const EVENT_GROUPS = [
@ -19,7 +22,7 @@ const TIME_EVENTS = [
EVENT_STATS_PLAY,
];
const ansi = new Ansi();
const ansi = new Ansi({ stream: true, colors: OUTPUT_ANSI_COLORMAP });
const entities = new Entities.AllHtmlEntities();
// https://github.com/chalk/ansi-regex
@ -33,98 +36,243 @@ const hasAnsi = input => re.test(input);
function JobRenderService ($q, $sce, $window) {
this.init = ({ compile, toggles }) => {
this.parent = null;
this.record = {};
this.el = $(OUTPUT_ELEMENT_TBODY);
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) => {
if (a.start_line > b.start_line) {
this.setCollapseAll = value => {
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;
}
if (a.start_line < b.start_line) {
if (a.counter < b.counter) {
return -1;
}
return 0;
};
this.transformEventGroup = events => {
//
// Event Data Transformation / HTML Building
//
this.appendEventGroup = events => {
let lines = 0;
let html = '';
events.sort(this.sortByLineNumber);
events.sort(this.sortByCounter);
for (let i = 0; i < events.length; ++i) {
const line = this.transformEvent(events[i]);
html += line.html;
lines += line.count;
for (let i = 0; i <= events.length - 1; i++) {
const current = events[i];
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 };
};
this.transformEvent = event => {
if (this.record[event.uuid]) {
this.appendMissingEventGroup = event => {
const tailUUID = this.uuids[this.state.tail];
const tailRecord = this.records[tailUUID];
if (!tailRecord) {
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 };
}
const stdout = this.sanitize(event.stdout);
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 ln = event.start_line;
const current = this.createRecord(ln, lines, event);
const html = lines.reduce((concat, line, i) => {
for (let i = 0; i <= lines.length - 1; i++) {
ln++;
const line = lines[i];
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) {
row += this.createRow(current);
if (record && record.isTruncated && isLastLine) {
row += this.buildRowHTML(record);
count++;
}
return `${concat}${row}`;
}, '');
html += row;
}
if (this.records[event.uuid]) {
this.records[event.uuid].lineCount = count;
}
return { html, count };
};
this.isHostEvent = (event) => {
if (typeof event.host === 'number') {
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) {
this.createRecord = (event, lines) => {
if (!event.counter) {
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,
line: ln + 1,
line: event.start_line + 1,
name: event.event,
uuid: event.uuid,
level: event.event_level,
@ -132,50 +280,49 @@ function JobRenderService ($q, $sce, $window) {
end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length,
lineCount: lines.length,
isHost: this.isHostEvent(event),
isCollapsed: this.state.collapseAll,
counters: [event.counter],
};
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) {
info.truncatedAt = event.start_line + lines.length;
if (record.isTruncated) {
record.truncatedAt = event.start_line + lines.length;
}
if (EVENT_GROUPS.includes(event.event)) {
info.isParent = true;
record.isParent = true;
if (event.event_level === 1) {
this.parent = event.uuid;
}
if (event.parent_uuid) {
if (this.record[event.parent_uuid]) {
if (this.record[event.parent_uuid].children &&
!this.record[event.parent_uuid].children.includes(event.uuid)) {
this.record[event.parent_uuid].children.push(event.uuid);
if (this.records[event.parent_uuid]) {
if (this.records[event.parent_uuid].children &&
!this.records[event.parent_uuid].children.includes(event.uuid)) {
this.records[event.parent_uuid].children.push(event.uuid);
} else {
this.record[event.parent_uuid].children = [event.uuid];
this.records[event.parent_uuid].children = [event.uuid];
}
}
}
}
if (TIME_EVENTS.includes(event.event)) {
info.time = this.getTimestamp(event.created);
info.line++;
record.time = this.getTimestamp(event.created);
record.line++;
}
this.record[event.uuid] = info;
this.records[event.uuid] = record;
this.uuids[event.counter] = event.uuid;
return info;
};
this.getRecord = uuid => this.record[uuid];
this.deleteRecord = uuid => {
delete this.record[uuid];
return record;
};
this.getParentEvents = (uuid, list) => {
@ -183,42 +330,56 @@ function JobRenderService ($q, $sce, $window) {
// always push its parent if exists
list.push(uuid);
// if we can get grandparent in current visible lines, we also push it
if (this.record[uuid] && this.record[uuid].parents) {
list = list.concat(this.record[uuid].parents);
if (this.records[uuid] && this.records[uuid].parents) {
list = list.concat(this.records[uuid].parents);
}
return list;
};
this.createRow = (current, ln, content) => {
this.buildRowHTML = (record, ln, content) => {
let id = '';
let icon = '';
let timestamp = '';
let tdToggle = '';
let tdEvent = '';
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 || '';
if (hasAnsi(content)) {
content = ansi.toHtml(content);
}
if (current) {
if (this.createToggles && current.isParent && current.line === ln) {
id = current.uuid;
tdToggle = `<div class="at-Stdout-toggle" ng-click="vm.toggleLineExpand('${id}')"><i class="fa fa-angle-down can-toggle"></i></div>`;
if (record) {
if (this.state.toggleMode && record.isParent && record.line === ln) {
id = record.uuid;
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) {
tdEvent = `<div class="at-Stdout-event--host" ng-click="vm.showHostDetails('${current.id}', '${current.uuid}')"><span ng-non-bindable>${content}</span></div>`;
if (record.isHost) {
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) {
timestamp = `<span>${current.time}</span>`;
if (record.time && record.line === ln) {
timestamp = `<span>${record.time}</span>`;
}
if (current.parents) {
classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
if (record.parents) {
classList = record.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
}
}
@ -234,6 +395,12 @@ function JobRenderService ($q, $sce, $window) {
ln = '...';
}
if (record && record.isCollapsed) {
if (record.level === 3 || record.level === 0) {
classList += ' hidden';
}
}
return `
<div id="${id}" class="at-Stdout-row ${classList}">
${tdToggle}
@ -252,6 +419,10 @@ function JobRenderService ($q, $sce, $window) {
return `${hour}:${minute}:${second}`;
};
//
// Element Operations
//
this.remove = elements => this.requestAnimationFrame(() => elements.remove());
this.requestAnimationFrame = fn => $q(resolve => {
@ -270,19 +441,25 @@ function JobRenderService ($q, $sce, $window) {
return this.requestAnimationFrame();
};
this.clear = () => {
const elements = this.el.children();
this.removeAll = () => {
const elements = this.el.contents();
return this.remove(elements);
};
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);
};
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);
};
@ -292,7 +469,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve();
}
const result = this.transformEventGroup(events);
const result = this.prependEventGroup(events);
const html = this.trustHtml(result.html);
const newElements = angular.element(html);
@ -307,7 +484,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve();
}
const result = this.transformEventGroup(events);
const result = this.appendEventGroup(events);
const html = this.trustHtml(result.html);
const newElements = angular.element(html);
@ -318,8 +495,110 @@ function JobRenderService ($q, $sce, $window) {
};
this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(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'];

View File

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

View File

@ -1,42 +1,12 @@
/* eslint camelcase: 0 */
import {
API_MAX_PAGE_SIZE,
OUTPUT_EVENT_LIMIT,
OUTPUT_MAX_BUFFER_LENGTH,
OUTPUT_PAGE_SIZE,
} 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) {
this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage;
const { getRange, getFirst, getLast, getMaxCounter } = api;
this.init = ({ getRange, getFirst, getLast, getMaxCounter }, storage) => {
const { getHeadCounter, getTailCounter } = storage;
this.api = {
getRange,
@ -46,32 +16,20 @@ function SlidingWindowService ($q) {
};
this.storage = {
clear,
prepend,
append,
shift,
pop,
getRecord,
deleteRecord,
getHeadCounter,
getTailCounter,
};
this.hooks = {
getScrollHeight,
};
this.lines = {};
this.uuids = {};
this.chain = $q.resolve();
this.state = { head: null, tail: null };
this.cache = { first: null };
this.buffer = {
events: [],
min: 0,
max: 0,
count: 0,
};
this.cache = {
first: null
};
};
this.getBoundedRange = range => {
@ -92,273 +50,46 @@ function SlidingWindowService ($q) {
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) => {
const next = this.getNextRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain
.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;
return this.api.getRange(next);
};
this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain
.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;
return this.api.getRange(previous);
};
this.getFirst = () => {
this.chain = this.chain
.then(() => this.clear())
.then(() => {
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
return this.api.getFirst();
})
return this.api.getFirst()
.then(events => {
if (events.length === OUTPUT_PAGE_SIZE) {
this.cache.first = events;
}
return this.pushFront(events);
return $q.resolve(events);
});
return this.chain
.then(() => this.getNext());
};
this.getLast = () => {
this.chain = this.chain
.then(() => this.getFrames())
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
this.getLast = () => this.getFrames()
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
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;
};
return this.api.getLast();
});
this.pushFrames = events => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
const frames = this.buffer.events.concat(events);
const [head, tail] = this.getRange();
let min;
let max;
@ -367,7 +98,7 @@ function SlidingWindowService ($q) {
for (let i = frames.length - 1; i >= 0; i--) {
count++;
if (count > API_MAX_PAGE_SIZE) {
if (count > OUTPUT_MAX_BUFFER_LENGTH) {
frames.splice(i, 1);
count--;
@ -388,27 +119,41 @@ function SlidingWindowService ($q) {
this.buffer.max = max;
this.buffer.count = count;
if (min >= head && min <= tail + 1) {
return frames.filter(({ counter }) => counter > tail);
if (tail - head === 0) {
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.getMaxCounter = () => {
if (this.buffer.min) {
return this.buffer.min;
if (this.buffer.max && this.buffer.max > 1) {
return this.buffer.max;
}
return this.api.getMaxCounter();
};
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE);
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()];
this.getRecordCount = () => Object.keys(this.lines).length;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount();
this.isOnLastPage = () => {
if (this.buffer.min) {
return this.getTailCounter() >= this.buffer.min - 1;
}
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'];

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions
if (!vm.isSuperUser) {
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'];

View File

@ -81,10 +81,10 @@
<span>
</div>
<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 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 icon-class="fa-briefcase" route="managementJobsList" name="MANAGEMENT_JOBS"
system-admin-only="true">

View File

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

View File

@ -88,7 +88,7 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr
};
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){

View File

@ -94,7 +94,7 @@ function(scope, $state, i18n, CreateSelect2, Rest, $q, Wait, ProcessErrors) {
};
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){

View File

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

View File

@ -349,7 +349,7 @@ angular
$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();
}
});
@ -375,7 +375,7 @@ angular
$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)
if (!_.contains($location.$$url, '/login')) {
if (!_.includes($location.$$url, '/login')) {
ConfigService.getConfig().then(function() {
Timer.init().then(function(timer) {
$rootScope.sessionTimer = timer;

View File

@ -90,7 +90,7 @@ export default
if(streamConfig && streamConfig.activityStream) {
if(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 = {
or__object1__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) {
let widthToTrim = expandedBreadcrumbWidth - availableWidth;
// Sort the crumbs from biggest to smallest
let sortedCrumbs = _.sortByOrder(crumbs, ["origWidth"], ["desc"]);
let sortedCrumbs = _.orderBy(crumbs, ["origWidth"], ["desc"]);
let maxWidth;
for(let i=0; i<sortedCrumbs.length; i++) {
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) {
var keys = _.keys(form.fields);
_.each(keys, function(key) {

View File

@ -119,7 +119,7 @@ export default [
}));
$('.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();
element.remove();
}

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
init();
function init() {
Rest.setUrl(GetBasePath('projects'));
Rest.setUrl(GetBasePath('notification_templates'));
Rest.options()
.then(({data}) => {
if (!data.actions.POST) {
@ -205,7 +205,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
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))
.map(i => [i, processValue($scope[i], i, form.fields[i])]));

View File

@ -275,7 +275,7 @@ export default ['Rest', 'Wait',
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))
.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,
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',
ngIf: 'current_user.is_superuser || isOrgAdmin',
ngIf: 'current_user.is_superuser || isOrgAdmin || isNotificationAdmin',
fields: {
name: {
key: true,
@ -40,6 +40,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_success',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_success\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",
@ -51,6 +52,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_error',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_error\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",

View File

@ -5,10 +5,10 @@
*************************************************/
export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt',
'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt', '$rootScope',
'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', 'InstanceGroupsData', 'ConfigData',
function($scope, $location, $stateParams, OrgAdminLookup,
OrganizationForm, Rest, ProcessErrors, Prompt,
OrganizationForm, Rest, ProcessErrors, Prompt, $rootScope,
GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, InstanceGroupsData, ConfigData) {
let form = OrganizationForm(),
@ -26,6 +26,12 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
$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) {
if (val === false) {
$scope.canAdd = false;

View File

@ -133,7 +133,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
}
switch ($scope.scm_type.value) {
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>' +
@ -146,7 +146,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$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>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
@ -155,7 +155,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('Revision #');
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$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>' +
'<li>ssh://server.example.com/path</li></ul>' +
@ -174,7 +174,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.lookupType = 'insights_credential';
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text') + '</p>';
$scope.credRequired = false;
$scope.lookupType = 'scm_credential';

View File

@ -270,7 +270,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
}
switch ($scope.scm_type.value) {
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>' +
'<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, ' +
@ -281,7 +281,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$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>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
@ -290,7 +290,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('Revision #');
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$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>' +
'<li>ssh://server.example.com/path</li></ul>' +
@ -309,7 +309,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.lookupType = 'insights_credential';
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text');
$scope.credRequired = false;
$scope.lookupType = 'scm_credential';

View File

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

View File

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

View File

@ -310,12 +310,12 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
return [];
}
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
return defaultParams[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaultParams[key] !== null;
});
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) {
if (strippedCopy.hasOwnProperty(key)) {
let value = strippedCopy[key];
@ -336,7 +336,7 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
mergeQueryset (queryset, additional, singleSearchParam) {
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)) {
// // https://lodash.com/docs/3.10.1#each
// 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);
}
params = _.merge(params, termParams, combineSameSearches);
params = _.mergeWith(params, termParams, combineSameSearches);
});
return params;

View File

@ -102,7 +102,7 @@ function SmartSearchController (
const listName = $scope.list.name;
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');
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);
@ -254,7 +254,7 @@ function SmartSearchController (
defaults[key] = queryset[key];
}
});
const cleared = _(defaults).omit(_.isNull).value();
const cleared = _(defaults).omitBy(_.isNull).value();
delete cleared.page;
queryset = cleared;

View File

@ -744,10 +744,20 @@ function($injector, $stateExtender, $log, i18n) {
// search will think they need to be set as search tags.
var params;
if(field.sourceModel === "organization"){
params = {
page_size: '5',
role_level: 'admin_role'
};
if (form.name === "notification_template") {
// Users with admin_role role level should also have
// 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"){
params = {

View File

@ -21,7 +21,9 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
$scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$scope.team_id = id;
setScopeFields(data);
_.forEach(form.fields, (value, key) => {
$scope[key] = data[key];
});
$scope.organization_name = data.summary_fields.organization.name;
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
function processNewData(fields) {
var data = {};

View File

@ -6,8 +6,8 @@
</div>
<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>
<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" 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 && promptData.prompts.inventory.value.id"></at-tag>
</div>
<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>

View File

@ -261,7 +261,7 @@ export default
scope.maxTextError = false;
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 !== "" ){
scope.minTextError = true;
}
@ -272,7 +272,7 @@ export default
}
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 !== "" ){
scope.minTextError = true;
}
@ -283,7 +283,7 @@ export default
}
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 !== "" ){
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/);
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/);
if($.inArray(scope.default, choiceArray)===-1){
scope.invalidChoice = true;

View File

@ -7,9 +7,10 @@
export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
'$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel',
'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout',
'i18n',
function($scope, WorkflowService, GetBasePath, TemplatesService,
$state, ProcessErrors, CreateSelect2, $q, JobTemplate,
Empty, PromptService, Rest, TemplatesStrings, $timeout) {
Empty, PromptService, Rest, TemplatesStrings, $timeout, i18n) {
let promptWatcher, surveyQuestionWatcher, credentialsWatcher;
@ -301,15 +302,15 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!optionsToInclude) {
$scope.edgeTypeOptions = [
{
label: 'Always',
label: i18n._('Always'),
value: 'always'
},
{
label: 'On Success',
label: i18n._('On Success'),
value: 'success'
},
{
label: 'On Failure',
label: i18n._('On Failure'),
value: 'failure'
}
];
@ -641,6 +642,31 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!_.isEmpty($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 (
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_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_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
@ -839,19 +865,19 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) {
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) {
edgeDropdownOptions = ["always"];
}
break;
case "success":
$scope.edgeType = {label: "On Success", value: "success"};
$scope.edgeType = {label: i18n._("On Success"), value: "success"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"];
}
break;
case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"};
$scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"];
}
@ -978,7 +1004,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) {
case "always":
$scope.edgeType = {label: "Always", value: "always"};
$scope.edgeType = {label: i18n._("Always"), value: "always"};
if (
_.includes(siblingConnectionTypes, "always") &&
!_.includes(siblingConnectionTypes, "success") &&
@ -990,7 +1016,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
}
break;
case "success":
$scope.edgeType = {label: "On Success", value: "success"};
$scope.edgeType = {label: i18n._("On Success"), value: "success"};
if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always")
@ -1001,7 +1027,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
}
break;
case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"};
$scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always")
@ -1071,8 +1097,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;

View File

@ -17,7 +17,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
'$state', 'i18n', 'resolvedModels', 'resourceData',
function($scope, $rootScope, $stateParams, UserForm, Rest, ProcessErrors,
GetBasePath, Wait, CreateSelect2, $state, i18n, models, resourceData) {
for (var i = 0; i < user_type_options.length; i++) {
user_type_options[i].label = i18n._(user_type_options[i].label);
}
@ -28,12 +28,16 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
id = $stateParams.user_id,
defaultUrl = GetBasePath('users') + id,
user_obj = resourceData.data;
$scope.breadcrumb.user_name = user_obj.username;
init();
function init() {
_.forEach(form.fields, (value, key) => {
$scope[key] = user_obj[key];
});
$scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$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.canAdd = (val === false) ? false : true;
});
setScopeFields(user_obj);
}
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) {
let type = resource.summary_fields.resource_type.replace(/ /g , "_");
var id = resource.related[type].split("/")[4];
@ -152,7 +140,11 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
var processNewData = function(fields) {
var data = {};
_.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];
}
});

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": {
"version": "2.2.0-dev",
"from": "jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
@ -233,7 +238,7 @@
"angular-tz-extensions": {
"version": "0.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": {
"jquery": {
"version": "3.3.1",
@ -1496,7 +1501,15 @@
"version": "0.19.0",
"from": "cheerio@>=0.19.0 <0.20.0",
"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": {
"version": "1.7.0",
@ -5420,6 +5433,12 @@
"resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz",
"dev": true,
"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": {
"version": "0.5.7",
"from": "source-map@>=0.5.3 <0.6.0",
@ -5723,6 +5742,12 @@
"from": "inquirer@>=0.8.2 <0.9.0",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.8.5.tgz",
"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
},
"lodash": {
"version": "3.8.0",
"from": "lodash@>=3.8.0 <3.9.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz"
"version": "4.17.10",
"from": "lodash@>=4.17.10 <4.18.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz"
},
"lodash._arraycopy": {
"version": "3.0.0",
@ -6317,6 +6342,12 @@
"from": "glob@>=5.0.0 <6.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz",
"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 .",
"dev": "webpack --config build/webpack.development.js --progress",
"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": {
"angular-mocks": "~1.6.6",
@ -120,7 +121,7 @@
"jquery-ui": "^1.12.1",
"js-yaml": "^3.2.7",
"legacy-loader": "0.0.2",
"lodash": "~3.8.0",
"lodash": "~4.17.10",
"lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll",
"moment": "^2.19.4",
"ng-toast": "git+https://git@github.com/ansible/ngToast#v2.1.1",

View File

@ -236,8 +236,8 @@ msgstr ""
msgid "Add Project"
msgstr ""
#: client/src/shared/form-generator.js:1718
#: client/src/templates/job_templates/job-template.form.js:464
#: client/src/shared/form-generator.js:1731
#: client/src/templates/job_templates/job-template.form.js:468
#: client/src/templates/workflows.form.js:205
msgid "Add Survey"
msgstr ""
@ -277,12 +277,12 @@ msgstr ""
#: 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/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
msgid "Add a permission"
msgstr ""
#: client/src/shared/form-generator.js:1453
#: client/src/shared/form-generator.js:1466
msgid "Admin"
msgstr ""
@ -315,8 +315,8 @@ msgstr ""
msgid "All Jobs"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:286
#: client/src/templates/job_templates/job-template.form.js:293
#: client/src/templates/job_templates/job-template.form.js:290
#: client/src/templates/job_templates/job-template.form.js:297
msgid "Allow Provisioning Callbacks"
msgstr ""
@ -337,8 +337,8 @@ msgstr ""
#: client/src/organizations/organizations.form.js:52
#: client/src/projects/projects.form.js:207
#: 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:241
#: client/src/templates/job_templates/job-template.form.js:239
#: client/src/templates/job_templates/job-template.form.js:245
msgid "Ansible Environment"
msgstr ""
@ -434,7 +434,7 @@ msgstr ""
msgid "Associate this host with a new group"
msgstr ""
#: client/src/shared/form-generator.js:1455
#: client/src/shared/form-generator.js:1468
msgid "Auditor"
msgstr ""
@ -703,7 +703,7 @@ msgstr ""
#: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:188
#: client/src/configuration/configuration.controller.js:617
#: 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/workflow-results/workflow-results.controller.js:38
msgid "Cancel"
@ -759,7 +759,7 @@ msgstr ""
msgid "Check"
msgstr ""
#: client/src/shared/form-generator.js:1078
#: client/src/shared/form-generator.js:1087
msgid "Choose a %s"
msgstr ""
@ -844,7 +844,7 @@ msgid "Client Secret"
msgstr ""
#: client/src/scheduler/scheduler.strings.js:55
#: client/src/shared/form-generator.js:1710
#: client/src/shared/form-generator.js:1723
msgid "Close"
msgstr ""
@ -870,7 +870,7 @@ msgstr ""
#: 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/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
msgid "Completed Jobs"
msgstr ""
@ -1338,7 +1338,7 @@ msgstr ""
#: client/features/output/output.strings.js:34
#: client/features/users/tokens/tokens.strings.js:14
#: client/src/license/license.partial.html:5
#: client/src/shared/form-generator.js:1488
#: client/src/shared/form-generator.js:1501
msgid "Details"
msgstr ""
@ -1514,8 +1514,8 @@ msgstr ""
msgid "Edit Question"
msgstr ""
#: client/src/shared/form-generator.js:1722
#: client/src/templates/job_templates/job-template.form.js:471
#: client/src/shared/form-generator.js:1735
#: client/src/templates/job_templates/job-template.form.js:475
#: client/src/templates/workflows.form.js:212
msgid "Edit Survey"
msgstr ""
@ -1608,8 +1608,8 @@ msgstr ""
msgid "Email"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:299
#: client/src/templates/job_templates/job-template.form.js:304
#: client/src/templates/job_templates/job-template.form.js:303
#: client/src/templates/job_templates/job-template.form.js:308
#: client/src/templates/workflows.form.js:100
#: client/src/templates/workflows.form.js:105
msgid "Enable Concurrent Jobs"
@ -1620,8 +1620,8 @@ msgid "Enable External Logging"
msgstr ""
#: 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:280
#: client/src/templates/job_templates/job-template.form.js:279
#: client/src/templates/job_templates/job-template.form.js:284
msgid "Enable Privilege Escalation"
msgstr ""
@ -1629,7 +1629,7 @@ msgstr ""
msgid "Enable survey"
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."
msgstr ""
@ -1815,8 +1815,8 @@ msgstr ""
#: client/src/job-submission/job-submission.partial.html:165
#: client/src/partials/logviewer.html:8
#: 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:360
#: client/src/templates/job_templates/job-template.form.js:357
#: client/src/templates/job_templates/job-template.form.js:364
#: client/src/templates/workflows.form.js:83
#: client/src/templates/workflows.form.js:90
#: client/src/workflow-results/workflow-results.controller.js:122
@ -2091,8 +2091,8 @@ msgstr ""
msgid "Host (Authentication URL)"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:335
#: client/src/templates/job_templates/job-template.form.js:344
#: client/src/templates/job_templates/job-template.form.js:339
#: client/src/templates/job_templates/job-template.form.js:348
msgid "Host Config Key"
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."
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."
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."
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."
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."
msgstr ""
@ -2289,7 +2289,7 @@ msgstr ""
msgid "If enabled, simultaneous runs of this workflow job template will be allowed."
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."
msgstr ""
@ -2360,8 +2360,8 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:64
#: client/src/organizations/organizations.form.js:38
#: 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:251
#: client/src/templates/job_templates/job-template.form.js:252
#: client/src/templates/job_templates/job-template.form.js:255
msgid "Instance Groups"
msgstr ""
@ -2679,7 +2679,7 @@ msgstr ""
msgid "Last Updated"
msgstr ""
#: client/src/shared/form-generator.js:1714
#: client/src/shared/form-generator.js:1727
msgid "Launch"
msgstr ""
@ -2773,7 +2773,7 @@ msgstr ""
msgid "Live events: error connecting to the server."
msgstr ""
#: client/src/shared/form-generator.js:1992
#: client/src/shared/form-generator.js:2005
msgid "Loading..."
msgstr ""
@ -2843,6 +2843,10 @@ msgstr ""
msgid "Manual projects do not require an SCM update"
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
msgid "Maximum per-user sessions reached. Please sign in."
msgstr ""
@ -3153,7 +3157,7 @@ msgid "No recent notifications."
msgstr ""
#: 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
msgid "No records matched your search."
msgstr ""
@ -3300,7 +3304,7 @@ msgstr ""
#: client/src/notifications/notificationTemplates.form.js:453
#: 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
msgid "Options"
msgstr ""
@ -3414,7 +3418,7 @@ msgid "PLEASE ADD A SURVEY PROMPT."
msgstr ""
#: 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
msgid "PLEASE ADD ITEMS TO THIS LIST"
msgstr ""
@ -3448,7 +3452,7 @@ msgstr ""
msgid "Pagerduty subdomain"
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."
msgstr ""
@ -3531,7 +3535,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:106
#: client/src/projects/projects.form.js:247
#: 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/users/users.form.js:189
msgid "Permissions"
@ -3542,7 +3546,7 @@ msgid "Personal Access Token"
msgstr ""
#: 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:115
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."
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."
msgstr ""
#: client/src/shared/form-generator.js:1167
#: client/src/shared/form-generator.js:1180
msgid "Please enter a number greater than %d."
msgstr ""
#: client/src/shared/form-generator.js:1159
#: client/src/shared/form-generator.js:1172
msgid "Please enter a number."
msgstr ""
@ -3708,7 +3712,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:102
#: client/src/projects/projects.form.js:239
#: 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
msgid "Please save before assigning permissions."
msgstr ""
@ -3772,11 +3776,11 @@ msgstr ""
msgid "Please select Users from the list below."
msgstr ""
#: client/src/shared/form-generator.js:1200
#: client/src/shared/form-generator.js:1213
msgid "Please select a number between"
msgstr ""
#: client/src/shared/form-generator.js:1196
#: client/src/shared/form-generator.js:1209
msgid "Please select a number."
msgstr ""
@ -3784,10 +3788,10 @@ msgstr ""
msgid "Please select a value"
msgstr ""
#: client/src/shared/form-generator.js:1088
#: client/src/shared/form-generator.js:1156
#: client/src/shared/form-generator.js:1277
#: client/src/shared/form-generator.js:1385
#: client/src/shared/form-generator.js:1097
#: client/src/shared/form-generator.js:1169
#: client/src/shared/form-generator.js:1290
#: client/src/shared/form-generator.js:1398
msgid "Please select a value."
msgstr ""
@ -3799,7 +3803,7 @@ msgstr ""
msgid "Please select an organization before editing the host filter."
msgstr ""
#: client/src/shared/form-generator.js:1193
#: client/src/shared/form-generator.js:1206
msgid "Please select at least one value."
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:202
#: 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:366
#: client/src/templates/job_templates/job-template.form.js:270
#: 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:86
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."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:322
#: client/src/templates/job_templates/job-template.form.js:330
#: client/src/templates/job_templates/job-template.form.js:326
#: client/src/templates/job_templates/job-template.form.js:334
msgid "Provisioning Callback URL"
msgstr ""
@ -4492,7 +4496,7 @@ msgstr ""
#: 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/scheduler/scheduler.strings.js:57
#: client/src/shared/form-generator.js:1698
#: client/src/shared/form-generator.js:1711
msgid "Save"
msgstr ""
@ -4543,7 +4547,7 @@ msgstr ""
#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35
#: client/src/inventories-hosts/inventories/related/sources/sources.form.js:440
#: 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
msgid "Schedules"
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."
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.partial.html:20
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."
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."
msgstr ""
@ -4642,7 +4646,7 @@ msgstr ""
msgid "Select the Instance Groups for this Organization to run on."
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."
msgstr ""
@ -4709,8 +4713,8 @@ msgstr ""
#: 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:118
#: client/src/templates/job_templates/job-template.form.js:257
#: client/src/templates/job_templates/job-template.form.js:260
#: client/src/templates/job_templates/job-template.form.js:261
#: client/src/templates/job_templates/job-template.form.js:264
msgid "Show Changes"
msgstr ""
@ -4757,7 +4761,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/inventory.list.js:86
#: client/src/inventories-hosts/inventories/list/inventory-list.controller.js:76
#: 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"
msgstr ""
@ -5111,8 +5115,8 @@ msgstr ""
msgid "Textarea"
msgstr ""
#: client/src/shared/form-generator.js:1393
#: client/src/shared/form-generator.js:1399
#: client/src/shared/form-generator.js:1406
#: client/src/shared/form-generator.js:1412
msgid "That value was not found. Please enter or select a valid value."
msgstr ""
@ -5564,12 +5568,12 @@ msgstr ""
#: client/src/organizations/organizations.form.js:48
#: 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"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:310
#: client/src/templates/job_templates/job-template.form.js:315
#: client/src/templates/job_templates/job-template.form.js:314
#: client/src/templates/job_templates/job-template.form.js:319
msgid "Use Fact Cache"
msgstr ""
@ -5764,8 +5768,8 @@ msgstr ""
msgid "View Project checkout results"
msgstr ""
#: client/src/shared/form-generator.js:1726
#: client/src/templates/job_templates/job-template.form.js:455
#: client/src/shared/form-generator.js:1739
#: client/src/templates/job_templates/job-template.form.js:459
#: client/src/templates/workflows.form.js:196
msgid "View Survey"
msgstr ""
@ -5946,7 +5950,7 @@ msgstr ""
msgid "Workflow Templates"
msgstr ""
#: client/src/shared/form-generator.js:1730
#: client/src/shared/form-generator.js:1743
#: client/src/templates/workflows.form.js:222
msgid "Workflow Visualizer"
msgstr ""
@ -6032,7 +6036,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/related/groups/list/groups-list.partial.html:24
#: 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
msgid "and"
msgstr ""
@ -6144,7 +6148,7 @@ msgstr ""
msgid "organization"
msgstr ""
#: client/src/shared/form-generator.js:1076
#: client/src/shared/form-generator.js:1085
msgid "playbook"
msgstr ""

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