Merge branch 'devel' of github.com:ansible/ansible-tower into python27_el6

Conflicts:
	packaging/rpm/ansible-tower.spec
	tools/docker-compose/start_development.sh
This commit is contained in:
Graham Mainwaring
2016-03-21 12:13:50 -04:00
138 changed files with 4454 additions and 158607 deletions

31
ISSUE_TEMPLATE.md Normal file
View File

@@ -0,0 +1,31 @@
### Summary
<!-- Briefly describe the problem. -->
### Environment
<!--
* Tower version: X.Y.Z
* Ansible version: X.Y.Z
* Operating System:
* Web Browser:
-->
### Steps To Reproduce:
<!-- For bugs, please show exactly how to reproduce the problem. For new
features, show how the feature would be used. -->
### Expected Results:
<!-- For bug reports, what did you expect to happen when running the steps
above? -->
### Actual Results:
<!-- For bug reports, what actually happened? -->
### Additional Information:
<!-- Include any links to sosreport, database dumps, screenshots or other
information. -->

View File

@@ -870,13 +870,15 @@ docker-compose-test:
cd tools && docker-compose run --rm --service-ports tower /bin/bash cd tools && docker-compose run --rm --service-ports tower /bin/bash
MACHINE?=default MACHINE?=default
docker-refresh: docker-clean:
rm -f awx/lib/.deps_built rm -f awx/lib/.deps_built
rm -rf awx/lib/site-packages
eval $$(docker-machine env $(MACHINE)) eval $$(docker-machine env $(MACHINE))
docker stop $$(docker ps -a -q) docker stop $$(docker ps -a -q)
docker rm $$(docker ps -f name=tools_tower -a -q) -docker rm $$(docker ps -f name=tools_tower -a -q)
docker rmi tools_tower -docker rmi tools_tower
docker-compose -f tools/docker-compose.yml up
docker-refresh: docker-clean docker-compose
mongo-debug-ui: mongo-debug-ui:
docker run -it --rm --name mongo-express --link tools_mongo_1:mongo -e ME_CONFIG_OPTIONS_EDITORTHEME=ambiance -e ME_CONFIG_BASICAUTH_USERNAME=admin -e ME_CONFIG_BASICAUTH_PASSWORD=password -p 8081:8081 knickers/mongo-express docker run -it --rm --name mongo-express --link tools_mongo_1:mongo -e ME_CONFIG_OPTIONS_EDITORTHEME=ambiance -e ME_CONFIG_BASICAUTH_USERNAME=admin -e ME_CONFIG_BASICAUTH_PASSWORD=password -p 8081:8081 knickers/mongo-express

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2016 Ansible, Inc. # Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
from collections import OrderedDict
# Django # Django
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404 from django.http import Http404
@@ -10,6 +12,7 @@ from django.utils.encoding import force_text
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework import metadata from rest_framework import metadata
from rest_framework import serializers from rest_framework import serializers
from rest_framework.relations import RelatedField
from rest_framework.request import clone_request from rest_framework.request import clone_request
# Ansible Tower # Ansible Tower
@@ -18,28 +21,21 @@ from awx.main.models import InventorySource, Notifier
class Metadata(metadata.SimpleMetadata): class Metadata(metadata.SimpleMetadata):
# DRF 3.3 doesn't render choices for read-only fields
#
# We want to render choices for read-only fields
#
# Note: This works in conjuction with logic in serializers.py that sets
# field property editable=True before calling DRF build_standard_field()
# Note: Consider expanding this rendering for more than just choices fields
def _render_read_only_choices(self, field, field_info):
if field_info.get('read_only') and hasattr(field, 'choices'):
field_info['choices'] = [
{
'value': choice_value,
'display_name': force_text(choice_name, strings_only=True)
}
for choice_value, choice_name in field.choices.items()
]
return field_info
def get_field_info(self, field): def get_field_info(self, field):
field_info = super(Metadata, self).get_field_info(field) field_info = OrderedDict()
if hasattr(field, 'choices') and field.choices: field_info['type'] = self.label_lookup[field]
field_info = self._render_read_only_choices(field, field_info) field_info['required'] = getattr(field, 'required', False)
text_attrs = [
'read_only', 'label', 'help_text',
'min_length', 'max_length',
'min_value', 'max_value'
]
for attr in text_attrs:
value = getattr(field, attr, None)
if value is not None and value != '':
field_info[attr] = force_text(value, strings_only=True)
# Indicate if a field has a default value. # Indicate if a field has a default value.
# FIXME: Still isn't showing all default values? # FIXME: Still isn't showing all default values?
@@ -48,21 +44,18 @@ class Metadata(metadata.SimpleMetadata):
except serializers.SkipField: except serializers.SkipField:
pass pass
if getattr(field, 'child', None):
field_info['child'] = self.get_field_info(field.child)
elif getattr(field, 'fields', None):
field_info['children'] = self.get_serializer_info(field)
if hasattr(field, 'choices') and not isinstance(field, RelatedField):
field_info['choices'] = [(choice_value, choice_name) for choice_value, choice_name in field.choices.items()]
# Indicate if a field is write-only. # Indicate if a field is write-only.
if getattr(field, 'write_only', False): if getattr(field, 'write_only', False):
field_info['write_only'] = True field_info['write_only'] = True
# Update choices to be a list of 2-tuples instead of list of dicts with
# value/display_name.
if 'choices' in field_info:
choices = []
for choice in field_info['choices']:
if isinstance(choice, dict):
choices.append((choice.get('value'), choice.get('display_name')))
else:
choices.append(choice)
field_info['choices'] = choices
# Special handling of inventory source_region choices that vary based on # Special handling of inventory source_region choices that vary based on
# selected inventory source. # selected inventory source.
if field.field_name == 'source_regions': if field.field_name == 'source_regions':

View File

@@ -483,7 +483,7 @@ class BaseFactSerializer(BaseSerializer):
def get_fields(self): def get_fields(self):
ret = super(BaseFactSerializer, self).get_fields() ret = super(BaseFactSerializer, self).get_fields()
if 'module' in ret and feature_enabled('system_tracking'): if 'module' in ret:
# TODO: the values_list may pull in a LOT of entries before the distinct is called # TODO: the values_list may pull in a LOT of entries before the distinct is called
modules = Fact.objects.all().values_list('module', flat=True).distinct() modules = Fact.objects.all().values_list('module', flat=True).distinct()
choices = [(o, o.title()) for o in modules] choices = [(o, o.title()) for o in modules]
@@ -798,6 +798,18 @@ class OrganizationSerializer(BaseSerializer):
)) ))
return res return res
def get_summary_fields(self, obj):
summary_dict = super(OrganizationSerializer, self).get_summary_fields(obj)
counts_dict = self.context.get('related_field_counts', None)
if counts_dict is not None and summary_dict is not None:
if obj.id not in counts_dict:
summary_dict['related_field_counts'] = {
'inventories': 0, 'teams': 0, 'users': 0,
'job_templates': 0, 'admins': 0, 'projects': 0}
else:
summary_dict['related_field_counts'] = counts_dict[obj.id]
return summary_dict
class ProjectOptionsSerializer(BaseSerializer): class ProjectOptionsSerializer(BaseSerializer):
@@ -1852,6 +1864,10 @@ class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer):
jobs = reverse('api:system_job_template_jobs_list', args=(obj.pk,)), jobs = reverse('api:system_job_template_jobs_list', args=(obj.pk,)),
schedules = reverse('api:system_job_template_schedules_list', args=(obj.pk,)), schedules = reverse('api:system_job_template_schedules_list', args=(obj.pk,)),
launch = reverse('api:system_job_template_launch', args=(obj.pk,)), launch = reverse('api:system_job_template_launch', args=(obj.pk,)),
notifiers_any = reverse('api:system_job_template_notifiers_any_list', args=(obj.pk,)),
notifiers_success = reverse('api:system_job_template_notifiers_success_list', args=(obj.pk,)),
notifiers_error = reverse('api:system_job_template_notifiers_error_list', args=(obj.pk,)),
)) ))
return res return res
@@ -1866,6 +1882,7 @@ class SystemJobSerializer(UnifiedJobSerializer):
if obj.system_job_template and obj.system_job_template.active: if obj.system_job_template and obj.system_job_template.active:
res['system_job_template'] = reverse('api:system_job_template_detail', res['system_job_template'] = reverse('api:system_job_template_detail',
args=(obj.system_job_template.pk,)) args=(obj.system_job_template.pk,))
res['notifications'] = reverse('api:system_job_notifications_list', args=(obj.pk,))
if obj.can_cancel or True: if obj.can_cancel or True:
res['cancel'] = reverse('api:system_job_cancel', args=(obj.pk,)) res['cancel'] = reverse('api:system_job_cancel', args=(obj.pk,))
return res return res

View File

@@ -218,12 +218,16 @@ system_job_template_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/launch/$', 'system_job_template_launch'), url(r'^(?P<pk>[0-9]+)/launch/$', 'system_job_template_launch'),
url(r'^(?P<pk>[0-9]+)/jobs/$', 'system_job_template_jobs_list'), url(r'^(?P<pk>[0-9]+)/jobs/$', 'system_job_template_jobs_list'),
url(r'^(?P<pk>[0-9]+)/schedules/$', 'system_job_template_schedules_list'), url(r'^(?P<pk>[0-9]+)/schedules/$', 'system_job_template_schedules_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'system_job_template_notifiers_any_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'system_job_template_notifiers_error_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'system_job_template_notifiers_success_list'),
) )
system_job_urls = patterns('awx.api.views', system_job_urls = patterns('awx.api.views',
url(r'^$', 'system_job_list'), url(r'^$', 'system_job_list'),
url(r'^(?P<pk>[0-9]+)/$', 'system_job_detail'), url(r'^(?P<pk>[0-9]+)/$', 'system_job_detail'),
url(r'^(?P<pk>[0-9]+)/cancel/$', 'system_job_cancel'), url(r'^(?P<pk>[0-9]+)/cancel/$', 'system_job_cancel'),
url(r'^(?P<pk>[0-9]+)/notifications/$', 'system_job_notifications_list'),
) )
notifier_urls = patterns('awx.api.views', notifier_urls = patterns('awx.api.views',

View File

@@ -32,6 +32,7 @@ from django.http import HttpResponse
# Django REST Framework # Django REST Framework
from rest_framework.exceptions import PermissionDenied, ParseError from rest_framework.exceptions import PermissionDenied, ParseError
from rest_framework.parsers import FormParser
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
@@ -53,7 +54,7 @@ from social.backends.utils import load_backends
# AWX # AWX
from awx.main.task_engine import TaskSerializer, TASK_FILE, TEMPORARY_TASK_FILE from awx.main.task_engine import TaskSerializer, TASK_FILE, TEMPORARY_TASK_FILE
from awx.main.tasks import mongodb_control, send_notifications from awx.main.tasks import send_notifications
from awx.main.access import get_user_queryset from awx.main.access import get_user_queryset
from awx.main.ha import is_ha_environment from awx.main.ha import is_ha_environment
from awx.api.authentication import TaskAuthentication, TokenGetAuthentication from awx.api.authentication import TaskAuthentication, TokenGetAuthentication
@@ -273,7 +274,6 @@ class ApiV1ConfigView(APIView):
# Only stop mongod if license removal succeeded # Only stop mongod if license removal succeeded
if has_error is None: if has_error is None:
mongodb_control.delay('stop')
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
else: else:
return Response({"error": "Failed to remove license (%s)" % has_error}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Failed to remove license (%s)" % has_error}, status=status.HTTP_400_BAD_REQUEST)
@@ -614,6 +614,78 @@ class OrganizationList(ListCreateAPIView):
# Okay, create the organization as usual. # Okay, create the organization as usual.
return super(OrganizationList, self).create(request, *args, **kwargs) return super(OrganizationList, self).create(request, *args, **kwargs)
def get_serializer_context(self, *args, **kwargs):
full_context = super(OrganizationList, self).get_serializer_context(*args, **kwargs)
if self.request is None:
return full_context
db_results = {}
org_qs = self.request.user.get_queryset(self.model)
org_id_list = org_qs.values('id')
if len(org_id_list) == 0:
if self.request.method == 'POST':
full_context['related_field_counts'] = {}
return full_context
inv_qs = self.request.user.get_queryset(Inventory)
project_qs = self.request.user.get_queryset(Project)
user_qs = self.request.user.get_queryset(User)
# Produce counts of Foreign Key relationships
db_results['inventories'] = inv_qs\
.values('organization').annotate(Count('organization')).order_by('organization')
db_results['teams'] = self.request.user.get_queryset(Team)\
.values('organization').annotate(Count('organization')).order_by('organization')
# TODO: When RBAC branch merges, change this to project relationship
JT_reference = 'inventory__organization'
# Extra filter is applied on the inventory, because this catches
# the case of deleted (and purged) inventory
db_results['job_templates'] = self.request.user.get_queryset(JobTemplate)\
.filter(inventory__in=inv_qs)\
.values(JT_reference).annotate(Count(JT_reference))\
.order_by(JT_reference)
# Produce counts of m2m relationships
db_results['projects'] = Organization.projects.through.objects\
.filter(project__in=project_qs, organization__in=org_qs)\
.values('organization')\
.annotate(Count('organization')).order_by('organization')
# TODO: When RBAC branch merges, change these to role relation
db_results['users'] = Organization.users.through.objects\
.filter(user__in=user_qs, organization__in=org_qs)\
.values('organization')\
.annotate(Count('organization')).order_by('organization')
db_results['admins'] = Organization.admins.through.objects\
.filter(user__in=user_qs, organization__in=org_qs)\
.values('organization')\
.annotate(Count('organization')).order_by('organization')
count_context = {}
for org in org_id_list:
org_id = org['id']
count_context[org_id] = {
'inventories': 0, 'teams': 0, 'users': 0, 'job_templates': 0,
'admins': 0, 'projects': 0}
for res in db_results:
if res == 'job_templates':
org_reference = JT_reference
else:
org_reference = 'organization'
for entry in db_results[res]:
org_id = entry[org_reference]
if org_id in count_context:
count_context[org_id][res] = entry['%s__count' % org_reference]
full_context['related_field_counts'] = count_context
return full_context
class OrganizationDetail(RetrieveUpdateDestroyAPIView): class OrganizationDetail(RetrieveUpdateDestroyAPIView):
model = Organization model = Organization
@@ -1270,7 +1342,17 @@ class HostActivityStreamList(SubListAPIView):
qs = self.request.user.get_queryset(self.model) qs = self.request.user.get_queryset(self.model)
return qs.filter(Q(host=parent) | Q(inventory=parent.inventory)) return qs.filter(Q(host=parent) | Q(inventory=parent.inventory))
class HostFactVersionsList(ListAPIView, ParentMixin): class SystemTrackingEnforcementMixin(APIView):
'''
Use check_permissions instead of initial() because it's in the OPTION's path as well
'''
def check_permissions(self, 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)
class HostFactVersionsList(ListAPIView, ParentMixin, SystemTrackingEnforcementMixin):
model = Fact model = Fact
serializer_class = FactVersionSerializer serializer_class = FactVersionSerializer
@@ -1278,10 +1360,6 @@ class HostFactVersionsList(ListAPIView, ParentMixin):
new_in_220 = True new_in_220 = True
def get_queryset(self): def get_queryset(self):
if not feature_enabled("system_tracking"):
raise LicenseForbids("Your license does not permit use "
"of system tracking.")
from_spec = self.request.query_params.get('from', None) from_spec = self.request.query_params.get('from', None)
to_spec = self.request.query_params.get('to', None) to_spec = self.request.query_params.get('to', None)
module_spec = self.request.query_params.get('module', None) module_spec = self.request.query_params.get('module', None)
@@ -1299,7 +1377,7 @@ class HostFactVersionsList(ListAPIView, ParentMixin):
queryset = self.get_queryset() or [] queryset = self.get_queryset() or []
return Response(dict(results=self.serializer_class(queryset, many=True).data)) return Response(dict(results=self.serializer_class(queryset, many=True).data))
class HostFactCompareView(SubDetailAPIView): class HostFactCompareView(SubDetailAPIView, SystemTrackingEnforcementMixin):
model = Fact model = Fact
new_in_220 = True new_in_220 = True
@@ -1307,11 +1385,6 @@ class HostFactCompareView(SubDetailAPIView):
serializer_class = FactSerializer serializer_class = FactSerializer
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
# Sanity check: Does the license allow system tracking?
if not feature_enabled('system_tracking'):
raise LicenseForbids('Your license does not permit use '
'of system tracking.')
datetime_spec = request.query_params.get('datetime', None) datetime_spec = request.query_params.get('datetime', None)
module_spec = request.query_params.get('module', "ansible") module_spec = request.query_params.get('module', "ansible")
datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now()
@@ -1906,8 +1979,11 @@ class JobTemplateSurveySpec(GenericAPIView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
if not obj.survey_enabled: # Sanity check: Are surveys available on this license?
return Response(status=status.HTTP_404_NOT_FOUND) # If not, do not allow them to be used.
if not feature_enabled('surveys'):
raise LicenseForbids('Your license does not allow '
'adding surveys.')
return Response(obj.survey_spec) return Response(obj.survey_spec)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -2004,6 +2080,7 @@ class JobTemplateCallback(GenericAPIView):
model = JobTemplate model = JobTemplate
permission_classes = (JobTemplateCallbackPermission,) permission_classes = (JobTemplateCallbackPermission,)
serializer_class = EmptySerializer serializer_class = EmptySerializer
parser_classes = api_settings.DEFAULT_PARSER_CLASSES + [FormParser]
@csrf_exempt @csrf_exempt
@transaction.non_atomic_requests @transaction.non_atomic_requests
@@ -2218,6 +2295,27 @@ class SystemJobTemplateJobsList(SubListAPIView):
relationship = 'jobs' relationship = 'jobs'
parent_key = 'system_job_template' parent_key = 'system_job_template'
class SystemJobTemplateNotifiersAnyList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = SystemJobTemplate
relationship = 'notifiers_any'
class SystemJobTemplateNotifiersErrorList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = SystemJobTemplate
relationship = 'notifiers_error'
class SystemJobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = SystemJobTemplate
relationship = 'notifiers_success'
class JobList(ListCreateAPIView): class JobList(ListCreateAPIView):
model = Job model = Job
@@ -2898,6 +2996,12 @@ class SystemJobCancel(RetrieveAPIView):
else: else:
return self.http_method_not_allowed(request, *args, **kwargs) return self.http_method_not_allowed(request, *args, **kwargs)
class SystemJobNotificationsList(SubListAPIView):
model = Notification
serializer_class = NotificationSerializer
parent_model = SystemJob
relationship = 'notifications'
class UnifiedJobTemplateList(ListAPIView): class UnifiedJobTemplateList(ListAPIView):

View File

@@ -1,2 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved

View File

@@ -35,7 +35,7 @@ def _get_db_monkeypatched(cls):
password=settings.MONGO_PASSWORD, password=settings.MONGO_PASSWORD,
tz_aware=settings.USE_TZ) tz_aware=settings.USE_TZ)
register_key_transform(get_db()) register_key_transform(get_db())
except ConnectionError: except (ConnectionError, AttributeError):
logger.info('Failed to establish connect to MongoDB') logger.info('Failed to establish connect to MongoDB')
return get_db(cls._meta.get("db_alias", "default")) return get_db(cls._meta.get("db_alias", "default"))

View File

@@ -1,8 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from __future__ import absolute_import
from .models import * # noqa
from .utils import * # noqa
from .base import * # noqa

View File

@@ -1,223 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from __future__ import absolute_import
from django.utils.timezone import now
# Django
from django.conf import settings
import django
# MongoEngine
from mongoengine.connection import get_db, ConnectionError
# AWX
from awx.fact.models.fact import * # noqa
TEST_FACT_ANSIBLE = {
"ansible_swapfree_mb" : 4092,
"ansible_default_ipv6" : {
},
"ansible_distribution_release" : "trusty",
"ansible_system_vendor" : "innotek GmbH",
"ansible_os_family" : "Debian",
"ansible_all_ipv4_addresses" : [
"192.168.1.145"
],
"ansible_lsb" : {
"release" : "14.04",
"major_release" : "14",
"codename" : "trusty",
"id" : "Ubuntu",
"description" : "Ubuntu 14.04.2 LTS"
},
}
TEST_FACT_PACKAGES = [
{
"name": "accountsservice",
"architecture": "amd64",
"source": "apt",
"version": "0.6.35-0ubuntu7.1"
},
{
"name": "acpid",
"architecture": "amd64",
"source": "apt",
"version": "1:2.0.21-1ubuntu2"
},
{
"name": "adduser",
"architecture": "all",
"source": "apt",
"version": "3.113+nmu3ubuntu3"
},
]
TEST_FACT_SERVICES = [
{
"source" : "upstart",
"state" : "waiting",
"name" : "ureadahead-other",
"goal" : "stop"
},
{
"source" : "upstart",
"state" : "running",
"name" : "apport",
"goal" : "start"
},
{
"source" : "upstart",
"state" : "waiting",
"name" : "console-setup",
"goal" : "stop"
},
]
class MongoDBRequired(django.test.TestCase):
def setUp(self):
# Drop mongo database
try:
self.db = get_db()
self.db.connection.drop_database(settings.MONGO_DB)
except ConnectionError:
self.skipTest('MongoDB connection failed')
class BaseFactTestMixin(MongoDBRequired):
pass
class BaseFactTest(BaseFactTestMixin, MongoDBRequired):
pass
# TODO: for now, we relate all hosts to a single inventory
class FactScanBuilder(object):
def __init__(self):
self.facts_data = {}
self.hostname_data = []
self.inventory_id = 1
self.host_objs = []
self.fact_objs = []
self.version_objs = []
self.timestamps = []
self.epoch = now().replace(year=2015, microsecond=0)
def set_epoch(self, epoch):
self.epoch = epoch
def add_fact(self, module, facts):
self.facts_data[module] = facts
def add_hostname(self, hostname):
self.hostname_data.append(hostname)
def build(self, scan_count, host_count):
if len(self.facts_data) == 0:
raise RuntimeError("No fact data to build populate scans. call add_fact()")
if (len(self.hostname_data) > 0 and len(self.hostname_data) != host_count):
raise RuntimeError("Registered number of hostnames %d does not match host_count %d" % (len(self.hostname_data), host_count))
if len(self.hostname_data) == 0:
self.hostname_data = ['hostname_%s' % i for i in range(0, host_count)]
self.host_objs = [FactHost(hostname=hostname, inventory_id=self.inventory_id).save() for hostname in self.hostname_data]
for i in range(0, scan_count):
scan = {}
scan_version = {}
timestamp = self.epoch.replace(year=self.epoch.year - i, microsecond=0)
for module in self.facts_data:
fact_objs = []
version_objs = []
for host in self.host_objs:
(fact_obj, version_obj) = Fact.add_fact(timestamp=timestamp,
host=host,
module=module,
fact=self.facts_data[module])
fact_objs.append(fact_obj)
version_objs.append(version_obj)
scan[module] = fact_objs
scan_version[module] = version_objs
self.fact_objs.append(scan)
self.version_objs.append(scan_version)
self.timestamps.append(timestamp)
def get_scan(self, index, module=None):
res = None
res = self.fact_objs[index]
if module:
res = res[module]
return res
def get_scans(self, index_start=None, index_end=None):
if index_start is None:
index_start = 0
if index_end is None:
index_end = len(self.fact_objs)
return self.fact_objs[index_start:index_end]
def get_scan_version(self, index, module=None):
res = None
res = self.version_objs[index]
if module:
res = res[module]
return res
def get_scan_versions(self, index_start=None, index_end=None):
if index_start is None:
index_start = 0
if index_end is None:
index_end = len(self.version_objs)
return self.version_objs[index_start:index_end]
def get_hostname(self, index):
return self.host_objs[index].hostname
def get_hostnames(self, index_start=None, index_end=None):
if index_start is None:
index_start = 0
if index_end is None:
index_end = len(self.host_objs)
return [self.host_objs[i].hostname for i in range(index_start, index_end)]
def get_inventory_id(self):
return self.inventory_id
def set_inventory_id(self, inventory_id):
self.inventory_id = inventory_id
def get_host(self, index):
return self.host_objs[index]
def get_hosts(self, index_start=None, index_end=None):
if index_start is None:
index_start = 0
if index_end is None:
index_end = len(self.host_objs)
return self.host_objs[index_start:index_end]
def get_scan_count(self):
return len(self.fact_objs)
def get_host_count(self):
return len(self.host_objs)
def get_timestamp(self, index):
return self.timestamps[index]
def get_timestamps(self, index_start=None, index_end=None):
if not index_start:
index_start = 0
if not index_end:
len(self.timestamps)
return self.timestamps[index_start:index_end]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from __future__ import absolute_import
from .fact import * # noqa

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from __future__ import absolute_import
from .fact_simple import * # noqa
from .fact_transform_pymongo import * # noqa
from .fact_transform import * # noqa
from .fact_get_single_facts import * # noqa

View File

@@ -1,96 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from __future__ import absolute_import
# Django
# AWX
from awx.fact.models.fact import * # noqa
from awx.fact.tests.base import BaseFactTest, FactScanBuilder, TEST_FACT_PACKAGES
__all__ = ['FactGetSingleFactsTest', 'FactGetSingleFactsMultipleScansTest',]
class FactGetSingleFactsTest(BaseFactTest):
def setUp(self):
super(FactGetSingleFactsTest, self).setUp()
self.builder = FactScanBuilder()
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.add_fact('nested', TEST_FACT_PACKAGES)
self.builder.build(scan_count=1, host_count=20)
def check_query_results(self, facts_known, facts):
self.assertIsNotNone(facts)
self.assertEqual(len(facts_known), len(facts), "More or less facts found than expected")
# Ensure only 'acpid' is returned
for fact in facts:
self.assertEqual(len(fact.fact), 1)
self.assertEqual(fact.fact[0]['name'], 'acpid')
# Transpose facts to a dict with key id
count = 0
facts_dict = {}
for fact in facts:
count += 1
facts_dict[fact.id] = fact
self.assertEqual(count, len(facts_known))
# For each fact that we put into the database on setup,
# we should find that fact in the result set returned
for fact_known in facts_known:
key = fact_known.id
self.assertIn(key, facts_dict)
self.assertEqual(len(facts_dict[key].fact), 1)
def check_query_results_nested(self, facts):
self.assertIsNotNone(facts)
for fact in facts:
self.assertEqual(len(fact.fact), 1)
self.assertEqual(fact.fact['nested'][0]['name'], 'acpid')
def test_single_host(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(0, 1), 'name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.check_query_results(self.builder.get_scan(0, 'packages')[:1], facts)
def test_all(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(), 'name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.check_query_results(self.builder.get_scan(0, 'packages'), facts)
def test_subset_hosts(self):
host_count = (self.builder.get_host_count() / 2)
facts = Fact.get_single_facts(self.builder.get_hostnames(0, host_count), 'name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.check_query_results(self.builder.get_scan(0, 'packages')[:host_count], facts)
def test_get_single_facts_nested(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(), 'nested.name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.check_query_results_nested(facts)
class FactGetSingleFactsMultipleScansTest(BaseFactTest):
def setUp(self):
super(FactGetSingleFactsMultipleScansTest, self).setUp()
self.builder = FactScanBuilder()
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.build(scan_count=10, host_count=10)
def test_1_host(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(0, 1), 'name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.assertEqual(len(facts), 1)
self.assertEqual(facts[0], self.builder.get_scan(0, 'packages')[0])
def test_multiple_hosts(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(0, 3), 'name', 'acpid', self.builder.get_timestamp(0), 'packages')
self.assertEqual(len(facts), 3)
for i, fact in enumerate(facts):
self.assertEqual(fact, self.builder.get_scan(0, 'packages')[i])
def test_middle_of_timeline(self):
facts = Fact.get_single_facts(self.builder.get_hostnames(0, 3), 'name', 'acpid', self.builder.get_timestamp(4), 'packages')
self.assertEqual(len(facts), 3)
for i, fact in enumerate(facts):
self.assertEqual(fact, self.builder.get_scan(4, 'packages')[i])

View File

@@ -1,127 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from __future__ import absolute_import
import os
import json
# Django
from django.utils.timezone import now
from dateutil.relativedelta import relativedelta
# AWX
from awx.fact.models.fact import * # noqa
from awx.fact.tests.base import BaseFactTest, FactScanBuilder, TEST_FACT_PACKAGES
__all__ = ['FactHostTest', 'FactTest', 'FactGetHostVersionTest', 'FactGetHostTimelineTest']
# damn you python 2.6
def timedelta_total_seconds(timedelta):
return (
timedelta.microseconds + 0.0 +
(timedelta.seconds + timedelta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
class FactHostTest(BaseFactTest):
def test_create_host(self):
host = FactHost(hostname='hosty', inventory_id=1)
host.save()
host = FactHost.objects.get(hostname='hosty', inventory_id=1)
self.assertIsNotNone(host, "Host added but not found")
self.assertEqual('hosty', host.hostname, "Gotten record hostname does not match expected hostname")
self.assertEqual(1, host.inventory_id, "Gotten record inventory_id does not match expected inventory_id")
# Ensure an error is raised for .get() that doesn't match a record.
def test_get_host_id_no_result(self):
host = FactHost(hostname='hosty', inventory_id=1)
host.save()
self.assertRaises(FactHost.DoesNotExist, FactHost.objects.get, hostname='doesnotexist', inventory_id=1)
class FactTest(BaseFactTest):
def setUp(self):
super(FactTest, self).setUp()
def test_add_fact(self):
timestamp = now().replace(microsecond=0)
host = FactHost(hostname="hosty", inventory_id=1).save()
(f_obj, v_obj) = Fact.add_fact(host=host, timestamp=timestamp, module='packages', fact=TEST_FACT_PACKAGES)
f = Fact.objects.get(id=f_obj.id)
v = FactVersion.objects.get(id=v_obj.id)
self.assertEqual(f.id, f_obj.id)
self.assertEqual(f.module, 'packages')
self.assertEqual(f.fact, TEST_FACT_PACKAGES)
self.assertEqual(f.timestamp, timestamp)
# host relationship created
self.assertEqual(f.host.id, host.id)
# version created and related
self.assertEqual(v.id, v_obj.id)
self.assertEqual(v.timestamp, timestamp)
self.assertEqual(v.host.id, host.id)
self.assertEqual(v.fact.id, f_obj.id)
self.assertEqual(v.fact.module, 'packages')
# Note: Take the failure of this with a grain of salt.
# The test almost entirely depends on the specs of the system running on.
def test_add_fact_performance_4mb_file(self):
timestamp = now().replace(microsecond=0)
host = FactHost(hostname="hosty", inventory_id=1).save()
from awx.fact import tests
with open('%s/data/file_scan.json' % os.path.dirname(os.path.realpath(tests.__file__))) as f:
data = json.load(f)
t1 = now()
(f_obj, v_obj) = Fact.add_fact(host=host, timestamp=timestamp, module='packages', fact=data)
t2 = now()
diff = timedelta_total_seconds(t2 - t1)
print("add_fact save time: %s (s)" % diff)
# Note: 20 is realllly high. This should complete in < 2 seconds
self.assertLessEqual(diff, 20)
Fact.objects.get(id=f_obj.id)
FactVersion.objects.get(id=v_obj.id)
class FactGetHostVersionTest(BaseFactTest):
def setUp(self):
super(FactGetHostVersionTest, self).setUp()
self.builder = FactScanBuilder()
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.build(scan_count=2, host_count=1)
def test_get_host_version_exact_timestamp(self):
fact_known = self.builder.get_scan(0, 'packages')[0]
fact = Fact.get_host_version(hostname=self.builder.get_hostname(0), inventory_id=self.builder.get_inventory_id(), timestamp=self.builder.get_timestamp(0), module='packages')
self.assertIsNotNone(fact)
self.assertEqual(fact_known, fact)
def test_get_host_version_lte_timestamp(self):
timestamp = self.builder.get_timestamp(0) + relativedelta(days=1)
fact_known = self.builder.get_scan(0, 'packages')[0]
fact = Fact.get_host_version(hostname=self.builder.get_hostname(0), inventory_id=self.builder.get_inventory_id(), timestamp=timestamp, module='packages')
self.assertIsNotNone(fact)
self.assertEqual(fact_known, fact)
def test_get_host_version_none(self):
timestamp = self.builder.get_timestamp(0) - relativedelta(years=20)
fact = Fact.get_host_version(hostname=self.builder.get_hostname(0), inventory_id=self.builder.get_inventory_id(), timestamp=timestamp, module='packages')
self.assertIsNone(fact)
class FactGetHostTimelineTest(BaseFactTest):
def setUp(self):
super(FactGetHostTimelineTest, self).setUp()
self.builder = FactScanBuilder()
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.build(scan_count=20, host_count=1)
def test_get_host_timeline_ok(self):
timestamps = Fact.get_host_timeline(hostname=self.builder.get_hostname(0), inventory_id=self.builder.get_inventory_id(), module='packages')
self.assertIsNotNone(timestamps)
self.assertEqual(len(timestamps), self.builder.get_scan_count())
for i in range(0, self.builder.get_scan_count()):
self.assertEqual(timestamps[i], self.builder.get_timestamp(i))

View File

@@ -1,120 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from __future__ import absolute_import
from datetime import datetime
# Django
from django.conf import settings
# Pymongo
import pymongo
# AWX
from awx.fact.models.fact import * # noqa
from awx.fact.tests.base import BaseFactTest
__all__ = ['FactTransformTest', 'FactTransformUpdateTest',]
TEST_FACT_PACKAGES_WITH_DOTS = [
{
"name": "acpid3.4",
"version": "1:2.0.21-1ubuntu2",
"deeper.key": "some_value"
},
{
"name": "adduser.2",
"source": "apt",
"version": "3.113+nmu3ubuntu3"
},
{
"what.ever." : {
"shallowish.key": "some_shallow_value"
}
}
]
TEST_FACT_PACKAGES_WITH_DOLLARS = [
{
"name": "acpid3$4",
"version": "1:2.0.21-1ubuntu2",
"deeper.key": "some_value"
},
{
"name": "adduser$2",
"source": "apt",
"version": "3.113+nmu3ubuntu3"
},
{
"what.ever." : {
"shallowish.key": "some_shallow_value"
}
}
]
class FactTransformTest(BaseFactTest):
def setUp(self):
super(FactTransformTest, self).setUp()
# TODO: get host settings from config
self.client = pymongo.MongoClient('localhost', 27017)
self.db2 = self.client[settings.MONGO_DB]
self.timestamp = datetime.now().replace(microsecond=0)
def setup_create_fact_dot(self):
self.host = FactHost(hostname='hosty', inventory_id=1).save()
self.f = Fact(timestamp=self.timestamp, module='packages', fact=TEST_FACT_PACKAGES_WITH_DOTS, host=self.host)
self.f.save()
def setup_create_fact_dollar(self):
self.host = FactHost(hostname='hosty', inventory_id=1).save()
self.f = Fact(timestamp=self.timestamp, module='packages', fact=TEST_FACT_PACKAGES_WITH_DOLLARS, host=self.host)
self.f.save()
def test_fact_with_dot_serialized(self):
self.setup_create_fact_dot()
q = {
'_id': self.f.id
}
# Bypass mongoengine and pymongo transform to get record
f_dict = self.db2['fact'].find_one(q)
self.assertIn('what\uff0Eever\uff0E', f_dict['fact'][2])
def test_fact_with_dot_serialized_pymongo(self):
#self.setup_create_fact_dot()
host = FactHost(hostname='hosty', inventory_id=1).save()
f = self.db['fact'].insert({
'hostname': 'hosty',
'fact': TEST_FACT_PACKAGES_WITH_DOTS,
'timestamp': self.timestamp,
'host': host.id,
'module': 'packages',
})
q = {
'_id': f
}
# Bypass mongoengine and pymongo transform to get record
f_dict = self.db2['fact'].find_one(q)
self.assertIn('what\uff0Eever\uff0E', f_dict['fact'][2])
def test_fact_with_dot_deserialized_pymongo(self):
self.setup_create_fact_dot()
q = {
'_id': self.f.id
}
f_dict = self.db['fact'].find_one(q)
self.assertIn('what.ever.', f_dict['fact'][2])
def test_fact_with_dot_deserialized(self):
self.setup_create_fact_dot()
f = Fact.objects.get(id=self.f.id)
self.assertIn('what.ever.', f.fact[2])
class FactTransformUpdateTest(BaseFactTest):
pass

View File

@@ -1,96 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from __future__ import absolute_import
from datetime import datetime
# Django
from django.conf import settings
# Pymongo
import pymongo
# AWX
from awx.fact.models.fact import * # noqa
from awx.fact.tests.base import BaseFactTest
__all__ = ['FactSerializePymongoTest', 'FactDeserializePymongoTest',]
class FactPymongoBaseTest(BaseFactTest):
def setUp(self):
super(FactPymongoBaseTest, self).setUp()
# TODO: get host settings from config
self.client = pymongo.MongoClient('localhost', 27017)
self.db2 = self.client[settings.MONGO_DB]
def _create_fact(self):
fact = {}
fact[self.k] = self.v
q = {
'hostname': 'blah'
}
h = self.db['fact_host'].insert(q)
q = {
'host': h,
'module': 'blah',
'timestamp': datetime.now(),
'fact': fact
}
f = self.db['fact'].insert(q)
return f
def check_transform(self, id):
raise RuntimeError("Must override")
def create_dot_fact(self):
self.k = 'this.is.a.key'
self.v = 'this.is.a.value'
self.k_uni = 'this\uff0Eis\uff0Ea\uff0Ekey'
return self._create_fact()
def create_dollar_fact(self):
self.k = 'this$is$a$key'
self.v = 'this$is$a$value'
self.k_uni = 'this\uff04is\uff04a\uff04key'
return self._create_fact()
class FactSerializePymongoTest(FactPymongoBaseTest):
def check_transform(self, id):
q = {
'_id': id
}
f = self.db2.fact.find_one(q)
self.assertIn(self.k_uni, f['fact'])
self.assertEqual(f['fact'][self.k_uni], self.v)
# Ensure key . are being transformed to the equivalent unicode into the database
def test_key_transform_dot(self):
f = self.create_dot_fact()
self.check_transform(f)
# Ensure key $ are being transformed to the equivalent unicode into the database
def test_key_transform_dollar(self):
f = self.create_dollar_fact()
self.check_transform(f)
class FactDeserializePymongoTest(FactPymongoBaseTest):
def check_transform(self, id):
q = {
'_id': id
}
f = self.db.fact.find_one(q)
self.assertIn(self.k, f['fact'])
self.assertEqual(f['fact'][self.k], self.v)
def test_key_transform_dot(self):
f = self.create_dot_fact()
self.check_transform(f)
def test_key_transform_dollar(self):
f = self.create_dollar_fact()
self.check_transform(f)

View File

@@ -1,6 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from __future__ import absolute_import
from .dbtransform import * # noqa

View File

@@ -1,113 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
from django.test import TestCase
# AWX
from awx.fact.models.fact import * # noqa
from awx.fact.utils.dbtransform import KeyTransform
#__all__ = ['DBTransformTest', 'KeyTransformUnitTest']
__all__ = ['KeyTransformUnitTest']
class KeyTransformUnitTest(TestCase):
def setUp(self):
super(KeyTransformUnitTest, self).setUp()
self.key_transform = KeyTransform([('.', '\uff0E'), ('$', '\uff04')])
def test_no_replace(self):
value = {
"a_key_with_a_dict" : {
"key" : "value",
"nested_key_with_dict": {
"nested_key_with_value" : "deep_value"
}
}
}
data = self.key_transform.transform_incoming(value, None)
self.assertEqual(data, value)
data = self.key_transform.transform_outgoing(value, None)
self.assertEqual(data, value)
def test_complex(self):
value = {
"a.key.with.a.dict" : {
"key" : "value",
"nested.key.with.dict": {
"nested.key.with.value" : "deep_value"
}
}
}
value_transformed = {
"a\uff0Ekey\uff0Ewith\uff0Ea\uff0Edict" : {
"key" : "value",
"nested\uff0Ekey\uff0Ewith\uff0Edict": {
"nested\uff0Ekey\uff0Ewith\uff0Evalue" : "deep_value"
}
}
}
data = self.key_transform.transform_incoming(value, None)
self.assertEqual(data, value_transformed)
data = self.key_transform.transform_outgoing(value_transformed, None)
self.assertEqual(data, value)
def test_simple(self):
value = {
"a.key" : "value"
}
value_transformed = {
"a\uff0Ekey" : "value"
}
data = self.key_transform.transform_incoming(value, None)
self.assertEqual(data, value_transformed)
data = self.key_transform.transform_outgoing(value_transformed, None)
self.assertEqual(data, value)
def test_nested_dict(self):
value = {
"a.key.with.a.dict" : {
"nested.key." : "value"
}
}
value_transformed = {
"a\uff0Ekey\uff0Ewith\uff0Ea\uff0Edict" : {
"nested\uff0Ekey\uff0E" : "value"
}
}
data = self.key_transform.transform_incoming(value, None)
self.assertEqual(data, value_transformed)
data = self.key_transform.transform_outgoing(value_transformed, None)
self.assertEqual(data, value)
def test_array(self):
value = {
"a.key.with.an.array" : [
{
"key.with.dot" : "value"
}
]
}
value_transformed = {
"a\uff0Ekey\uff0Ewith\uff0Ean\uff0Earray" : [
{
"key\uff0Ewith\uff0Edot" : "value"
}
]
}
data = self.key_transform.transform_incoming(value, None)
self.assertEqual(data, value_transformed)
data = self.key_transform.transform_outgoing(value_transformed, None)
self.assertEqual(data, value)
'''
class DBTransformTest(BaseTest, MongoDBRequired):
'''

View File

@@ -1,28 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
from django.conf import settings
from mongoengine import connect
from mongoengine.connection import ConnectionError
from pymongo.errors import AutoReconnect
def test_mongo_connection():
# Connect to Mongo
try:
# Sanity check: If we have intentionally invalid settings, then we
# know we cannot connect.
if settings.MONGO_HOST == NotImplemented:
raise ConnectionError
# Attempt to connect to the MongoDB database.
db = connect(settings.MONGO_DB,
host=settings.MONGO_HOST,
port=int(settings.MONGO_PORT),
username=settings.MONGO_USERNAME,
password=settings.MONGO_PASSWORD,
tz_aware=settings.USE_TZ)
db[settings.MONGO_DB].command('ping')
return True
except (ConnectionError, AutoReconnect):
return False

View File

@@ -67,12 +67,12 @@ class FactCacheReceiver(object):
self.timestamp = datetime.fromtimestamp(date_key, None) self.timestamp = datetime.fromtimestamp(date_key, None)
# Update existing Fact entry # Update existing Fact entry
fact_obj = Fact.get_host_fact(host_obj.id, module_name, self.timestamp) try:
if fact_obj: fact_obj = Fact.objects.get(host__id=host_obj.id, module=module_name, timestamp=self.timestamp)
fact_obj.facts = facts fact_obj.facts = facts
fact_obj.save() fact_obj.save()
logger.info('Updated existing fact <%s>' % (fact_obj.id)) logger.info('Updated existing fact <%s>' % (fact_obj.id))
else: except Fact.DoesNotExist:
# Create new Fact entry # Create new Fact entry
fact_obj = Fact.add_fact(host_obj.id, module_name, self.timestamp, facts) fact_obj = Fact.add_fact(host_obj.id, module_name, self.timestamp, facts)
logger.info('Created new fact <fact_id, module> <%s, %s>' % (fact_obj.id, module_name)) logger.info('Created new fact <fact_id, module> <%s, %s>' % (fact_obj.id, module_name))

View File

@@ -108,6 +108,8 @@ class SimpleDAG(object):
return "inventory_update" return "inventory_update"
elif type(obj) == ProjectUpdate: elif type(obj) == ProjectUpdate:
return "project_update" return "project_update"
elif type(obj) == SystemJob:
return "system_job"
return "unknown" return "unknown"
def get_dependencies(self, obj): def get_dependencies(self, obj):

View File

@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('taggit', '0002_auto_20150616_2121'), ('taggit', '0002_auto_20150616_2121'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0002_v300_changes'), ('main', '0002_v300_tower_settings_changes'),
] ]
operations = [ operations = [

View File

@@ -8,7 +8,7 @@ import jsonbfield.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('main', '0003_v300_changes'), ('main', '0003_v300_notification_changes'),
] ]
operations = [ operations = [

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from awx.main.migrations import _system_tracking as system_tracking
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0004_v300_fact_changes'),
]
operations = [
migrations.RunPython(system_tracking.migrate_facts),
]

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from django.utils.timezone import now
from awx.api.license import feature_enabled
def create_system_job_templates(apps, schema_editor):
'''
Create default system job templates if not present. Create default schedules
only if new system job templates were created (i.e. new database).
'''
SystemJobTemplate = apps.get_model('main', 'SystemJobTemplate')
ContentType = apps.get_model('contenttypes', 'ContentType')
sjt_ct = ContentType.objects.get_for_model(SystemJobTemplate)
now_dt = now()
now_str = now_dt.strftime('%Y%m%dT%H%M%SZ')
sjt, created = SystemJobTemplate.objects.get_or_create(
job_type='cleanup_jobs',
defaults=dict(
name='Cleanup Job Details',
description='Remove job history older than X days',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,
),
)
if created:
sjt.schedules.create(
name='Cleanup Job Schedule',
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU' % now_str,
description='Automatically Generated Schedule',
enabled=True,
extra_data={'days': '120'},
created=now_dt,
modified=now_dt,
)
sjt, created = SystemJobTemplate.objects.get_or_create(
job_type='cleanup_deleted',
defaults=dict(
name='Cleanup Deleted Data',
description='Remove deleted object history older than X days',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,
),
)
if created:
sjt.schedules.create(
name='Cleanup Deleted Data Schedule',
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO' % now_str,
description='Automatically Generated Schedule',
enabled=True,
extra_data={'days': '30'},
created=now_dt,
modified=now_dt,
)
sjt, created = SystemJobTemplate.objects.get_or_create(
job_type='cleanup_activitystream',
defaults=dict(
name='Cleanup Activity Stream',
description='Remove activity stream history older than X days',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,
),
)
if created:
sjt.schedules.create(
name='Cleanup Activity Schedule',
rrule='DTSTART:%s RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU' % now_str,
description='Automatically Generated Schedule',
enabled=True,
extra_data={'days': '355'},
created=now_dt,
modified=now_dt,
)
sjt, created = SystemJobTemplate.objects.get_or_create(
job_type='cleanup_facts',
defaults=dict(
name='Cleanup Fact Details',
description='Remove system tracking history',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,
),
)
if created and feature_enabled('system_tracking', bypass_database=True):
sjt.schedules.create(
name='Cleanup Fact Schedule',
rrule='DTSTART:%s RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1' % now_str,
description='Automatically Generated Schedule',
enabled=True,
extra_data={'older_than': '120d', 'granularity': '1w'},
created=now_dt,
modified=now_dt,
)
class Migration(migrations.Migration):
dependencies = [
('main', '0005_v300_migrate_facts'),
]
operations = [
migrations.RunPython(create_system_job_templates, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,47 @@
from awx.fact.models import FactVersion
from mongoengine.connection import ConnectionError
from pymongo.errors import OperationFailure
from django.conf import settings
def drop_system_tracking_db():
try:
db = FactVersion._get_db()
db.connection.drop_database(settings.MONGO_DB)
except ConnectionError:
# TODO: Log this. Not a deal-breaker. Just let the user know they
# may need to manually drop/delete the database.
pass
except OperationFailure:
# TODO: This means the database was up but something happened when we tried to query it
pass
def migrate_facts(apps, schema_editor):
Fact = apps.get_model('main', "Fact")
Host = apps.get_model('main', "Host")
try:
n = FactVersion.objects.all().count()
except ConnectionError:
# TODO: Let the user know about the error. Likely this is
# a new install and we just don't need to do this
return (0, 0)
except OperationFailure:
# TODO: This means the database was up but something happened when we tried to query it
return (0, 0)
migrated_count = 0
not_migrated_count = 0
for factver in FactVersion.objects.all():
fact_obj = factver.fact
try:
host = Host.objects.only('id').get(inventory__id=factver.host.inventory_id, name=factver.host.hostname)
Fact.objects.create(host_id=host.id, timestamp=fact_obj.timestamp, module=fact_obj.module, facts=fact_obj.fact).save()
migrated_count += 1
except Host.DoesNotExist:
# TODO: Log this. No host was found to migrate the facts to.
# This isn't a hard error. Just something the user would want to know.
not_migrated_count += 1
drop_system_tracking_db()
return (migrated_count, not_migrated_count)

View File

@@ -1065,6 +1065,13 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
def cache_timeout_blocked(self): def cache_timeout_blocked(self):
return False return False
@property
def notifiers(self):
base_notifiers = Notifier.objects.filter(active=True)
error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors__in=[self]))
success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success__in=[self]))
any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any__in=[self]))
return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers))
class SystemJob(UnifiedJob, SystemJobOptions): class SystemJob(UnifiedJob, SystemJobOptions):

View File

@@ -393,9 +393,11 @@ def activity_stream_associate(sender, instance, **kwargs):
obj2_id = entity_acted obj2_id = entity_acted
obj2_actual = obj2.objects.get(id=obj2_id) obj2_actual = obj2.objects.get(id=obj2_id)
object2 = camelcase_to_underscore(obj2.__name__) object2 = camelcase_to_underscore(obj2.__name__)
# Skip recording any inventory source changes here. # Skip recording any inventory source, or system job template changes here.
if isinstance(obj1, InventorySource) or isinstance(obj2_actual, InventorySource): if isinstance(obj1, InventorySource) or isinstance(obj2_actual, InventorySource):
continue continue
if isinstance(obj1, SystemJobTemplate) or isinstance(obj2_actual, SystemJobTemplate):
continue
activity_entry = ActivityStream( activity_entry = ActivityStream(
operation=action, operation=action,
object1=object1, object1=object1,

View File

@@ -14,7 +14,6 @@ import pipes
import re import re
import shutil import shutil
import stat import stat
import subprocess
import tempfile import tempfile
import thread import thread
import time import time
@@ -53,7 +52,6 @@ from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL
from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url,
ignore_inventory_computed_fields, emit_websocket_notification, ignore_inventory_computed_fields, emit_websocket_notification,
check_proot_installed, build_proot_temp_dir, wrap_args_with_proot) check_proot_installed, build_proot_temp_dir, wrap_args_with_proot)
from awx.fact.utils.connection import test_mongo_connection
__all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate',
'RunAdHocCommand', 'handle_work_error', 'handle_work_success', 'RunAdHocCommand', 'handle_work_error', 'handle_work_success',
@@ -181,30 +179,6 @@ def notify_task_runner(metadata_dict):
queue = FifoQueue('tower_task_manager') queue = FifoQueue('tower_task_manager')
queue.push(metadata_dict) queue.push(metadata_dict)
@task()
def mongodb_control(cmd):
# Sanity check: Do not send arbitrary commands.
if cmd not in ('start', 'stop'):
raise ValueError('Only "start" and "stop" are allowed.')
# Either start or stop mongo, as requested.
p = subprocess.Popen('sudo service mongod %s' % cmd, shell=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate()
p.wait()
# Check to make sure the stop actually succeeded
p = subprocess.Popen('pidof mongod', shell=True)
shutdown_failed = p.wait() == 0
# If there was an error, log it.
if err:
logger.error(err)
if cmd == 'stop' and shutdown_failed:
p = subprocess.Popen('sudo mongod --shutdown -f /etc/mongod.conf', shell=True)
p.wait()
@task(bind=True) @task(bind=True)
def handle_work_success(self, result, task_actual): def handle_work_success(self, result, task_actual):
if task_actual['type'] == 'project_update': if task_actual['type'] == 'project_update':
@@ -227,6 +201,11 @@ def handle_work_success(self, result, task_actual):
instance_name = instance.module_name instance_name = instance.module_name
notifiers = [] # TODO: Ad-hoc commands need to notify someone notifiers = [] # TODO: Ad-hoc commands need to notify someone
friendly_name = "AdHoc Command" friendly_name = "AdHoc Command"
elif task_actual['type'] == 'system_job':
instance = SystemJob.objects.get(id=task_actual['id'])
instance_name = instance.system_job_template.name
notifiers = instance.system_job_template.notifiers
friendly_name = "System Job"
else: else:
return return
notification_body = instance.notification_data() notification_body = instance.notification_data()
@@ -234,7 +213,8 @@ def handle_work_success(self, result, task_actual):
task_actual['id'], task_actual['id'],
instance_name, instance_name,
notification_body['url']) notification_body['url'])
send_notifications.delay([n.generate_notification(notification_subject, notification_body) notification_body['friendly_name'] = friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notifiers.get('success', []) + notifiers.get('any', []))], for n in set(notifiers.get('success', []) + notifiers.get('any', []))],
job_id=task_actual['id']) job_id=task_actual['id'])
@@ -269,6 +249,11 @@ def handle_work_error(self, task_id, subtasks=None):
instance_name = instance.module_name instance_name = instance.module_name
notifiers = [] notifiers = []
friendly_name = "AdHoc Command" friendly_name = "AdHoc Command"
elif task_actual['type'] == 'system_job':
instance = SystemJob.objects.get(id=task_actual['id'])
instance_name = instance.system_job_template.name
notifiers = instance.system_job_template.notifiers
friendly_name = "System Job"
else: else:
# Unknown task type # Unknown task type
break break
@@ -957,11 +942,6 @@ class RunJob(BaseTask):
''' '''
return getattr(tower_settings, 'AWX_PROOT_ENABLED', False) return getattr(tower_settings, 'AWX_PROOT_ENABLED', False)
def pre_run_hook(self, job, **kwargs):
if job.job_type == PERM_INVENTORY_SCAN:
if not test_mongo_connection():
raise RuntimeError("Fact Scan Database is offline")
def post_run_hook(self, job, **kwargs): def post_run_hook(self, job, **kwargs):
''' '''
Hook for actions to run after job/task has completed. Hook for actions to run after job/task has completed.

View File

@@ -11,6 +11,7 @@ import shutil
import sys import sys
import tempfile import tempfile
import time import time
import urllib
from multiprocessing import Process from multiprocessing import Process
from subprocess import Popen from subprocess import Popen
import re import re
@@ -463,6 +464,8 @@ class BaseTestMixin(QueueTestMixin, MockCommonlySlowTestMixin):
response = method(url, json.dumps(data), 'application/json') response = method(url, json.dumps(data), 'application/json')
elif data_type == 'yaml': elif data_type == 'yaml':
response = method(url, yaml.safe_dump(data), 'application/yaml') response = method(url, yaml.safe_dump(data), 'application/yaml')
elif data_type == 'form':
response = method(url, urllib.urlencode(data), 'application/x-www-form-urlencoded')
else: else:
self.fail('Unsupported data_type %s' % data_type) self.fail('Unsupported data_type %s' % data_type)
else: else:

View File

@@ -16,6 +16,9 @@ from django.utils import timezone
def mock_feature_enabled(feature, bypass_database=None): def mock_feature_enabled(feature, bypass_database=None):
return True return True
def mock_feature_disabled(feature, bypass_database=None):
return False
def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), get_params={}, host_count=1): def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), get_params={}, host_count=1):
hosts = hosts(host_count=host_count) hosts = hosts(host_count=host_count)
fact_scans(fact_scans=3, timestamp_epoch=epoch) fact_scans(fact_scans=3, timestamp_epoch=epoch)
@@ -42,8 +45,33 @@ def check_response_facts(facts_known, response):
assert timestamp_apiformat(fact_known.timestamp) == response.data['results'][i]['timestamp'] assert timestamp_apiformat(fact_known.timestamp) == response.data['results'][i]['timestamp']
check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module) check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module)
def check_system_tracking_feature_forbidden(response):
assert 402 == response.status_code
assert 'Your license does not permit use of system tracking.' == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_get(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = get(url, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_options(hosts, options, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = options(url, None, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.license_feature
def test_no_facts_db(hosts, get, user): def test_no_facts_db(hosts, get, user):
hosts = hosts(host_count=1) hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
@@ -72,6 +100,7 @@ def test_basic_fields(hosts, fact_scans, get, user):
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.license_feature
def test_basic_options_fields(hosts, fact_scans, options, user): def test_basic_options_fields(hosts, fact_scans, options, user):
hosts = hosts(host_count=1) hosts = hosts(host_count=1)
fact_scans(fact_scans=1) fact_scans(fact_scans=1)
@@ -79,9 +108,6 @@ def test_basic_options_fields(hosts, fact_scans, options, user):
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = options(url, None, user('admin', True), pk=hosts[0].id) response = options(url, None, user('admin', True), pk=hosts[0].id)
import json
print(json.dumps(response.data))
assert 'related' in response.data['actions']['GET'] assert 'related' in response.data['actions']['GET']
assert 'module' in response.data['actions']['GET'] assert 'module' in response.data['actions']['GET']
assert ("ansible", "Ansible") in response.data['actions']['GET']['module']['choices'] assert ("ansible", "Ansible") in response.data['actions']['GET']['module']['choices']

View File

@@ -9,6 +9,9 @@ from django.utils import timezone
def mock_feature_enabled(feature, bypass_database=None): def mock_feature_enabled(feature, bypass_database=None):
return True return True
def mock_feature_disabled(feature, bypass_database=None):
return False
# TODO: Consider making the fact_scan() fixture a Class, instead of a function, and move this method into it # TODO: Consider making the fact_scan() fixture a Class, instead of a function, and move this method into it
def find_fact(facts, host_id, module_name, timestamp): def find_fact(facts, host_id, module_name, timestamp):
for f in facts: for f in facts:
@@ -26,6 +29,30 @@ def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), module_name
fact_known = find_fact(facts, hosts[0].id, module_name, epoch) fact_known = find_fact(facts, hosts[0].id, module_name, epoch)
return (fact_known, response) return (fact_known, response)
def check_system_tracking_feature_forbidden(response):
assert 402 == response.status_code
assert 'Your license does not permit use of system tracking.' == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_get(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_options(hosts, options, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = options(url, None, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) @mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db @pytest.mark.django_db
def test_no_fact_found(hosts, get, user): def test_no_fact_found(hosts, get, user):

View File

@@ -0,0 +1,144 @@
import pytest
from django.core.urlresolvers import reverse
@pytest.fixture
def resourced_organization(organization, project, team, inventory, user):
admin_user = user('test-admin', True)
member_user = user('org-member')
# Associate one resource of every type with the organization
organization.users.add(member_user)
organization.admins.add(admin_user)
organization.projects.add(project)
# organization.teams.create(name='org-team')
# inventory = organization.inventories.create(name="associated-inv")
project.jobtemplates.create(name="test-jt",
description="test-job-template-desc",
inventory=inventory,
playbook="test_playbook.yml")
return organization
@pytest.mark.django_db
def test_org_counts_admin(resourced_organization, user, get):
# Check that all types of resources are counted by a superuser
external_admin = user('admin', True)
response = get(reverse('api:organization_list', args=[]), external_admin)
assert response.status_code == 200
counts = response.data['results'][0]['summary_fields']['related_field_counts']
assert counts == {
'users': 1,
'admins': 1,
'job_templates': 1,
'projects': 1,
'inventories': 1,
'teams': 1
}
@pytest.mark.django_db
def test_org_counts_member(resourced_organization, get):
# Check that a non-admin user can only see the full project and
# user count, consistent with the RBAC rules
member_user = resourced_organization.users.get(username='org-member')
response = get(reverse('api:organization_list', args=[]), member_user)
assert response.status_code == 200
counts = response.data['results'][0]['summary_fields']['related_field_counts']
assert counts == {
'users': 1, # User can see themselves
'admins': 0,
'job_templates': 0,
'projects': 1, # Projects are shared with all the organization
'inventories': 0,
'teams': 0
}
@pytest.mark.django_db
def test_new_org_zero_counts(user, post):
# Check that a POST to the organization list endpoint returns
# correct counts, including the new record
org_list_url = reverse('api:organization_list', args=[])
post_response = post(url=org_list_url, data={'name': 'test organization',
'description': ''}, user=user('admin', True))
assert post_response.status_code == 201
new_org_list = post_response.render().data
counts_dict = new_org_list['summary_fields']['related_field_counts']
assert counts_dict == {
'users': 0,
'admins': 0,
'job_templates': 0,
'projects': 0,
'inventories': 0,
'teams': 0
}
@pytest.mark.django_db
def test_two_organizations(resourced_organization, organizations, user, get):
# Check correct results for two organizations are returned
external_admin = user('admin', True)
organization_zero = organizations(1)[0]
response = get(reverse('api:organization_list', args=[]), external_admin)
assert response.status_code == 200
org_id_full = resourced_organization.id
org_id_zero = organization_zero.id
counts = {}
for i in range(2):
org_id = response.data['results'][i]['id']
counts[org_id] = response.data['results'][i]['summary_fields']['related_field_counts']
assert counts[org_id_full] == {
'users': 1,
'admins': 1,
'job_templates': 1,
'projects': 1,
'inventories': 1,
'teams': 1
}
assert counts[org_id_zero] == {
'users': 0,
'admins': 0,
'job_templates': 0,
'projects': 0,
'inventories': 0,
'teams': 0
}
@pytest.mark.django_db
def test_JT_associated_with_project(organizations, project, user, get):
# Check that adding a project to an organization gets the project's JT
# included in the organization's JT count
external_admin = user('admin', True)
two_orgs = organizations(2)
organization = two_orgs[0]
other_org = two_orgs[1]
unrelated_inv = other_org.inventories.create(name='not-in-organization')
project.jobtemplates.create(name="test-jt",
description="test-job-template-desc",
inventory=unrelated_inv,
playbook="test_playbook.yml")
organization.projects.add(project)
response = get(reverse('api:organization_list', args=[]), external_admin)
assert response.status_code == 200
org_id = organization.id
counts = {}
for i in range(2):
working_id = response.data['results'][i]['id']
counts[working_id] = response.data['results'][i]['summary_fields']['related_field_counts']
assert counts[org_id] == {
'users': 0,
'admins': 0,
'job_templates': 1,
'projects': 1,
'inventories': 0,
'teams': 0
}

View File

@@ -3,6 +3,7 @@
# Python # Python
import pytest import pytest
import mock
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from datetime import timedelta from datetime import timedelta
@@ -15,6 +16,12 @@ from awx.main.management.commands.cleanup_facts import CleanupFacts, Command
from awx.main.models.fact import Fact from awx.main.models.fact import Fact
from awx.main.models.inventory import Host from awx.main.models.inventory import Host
def mock_feature_enabled(feature, bypass_database=None):
return True
def mock_feature_disabled(feature, bypass_database=None):
return False
@pytest.mark.django_db @pytest.mark.django_db
def test_cleanup_granularity(fact_scans, hosts): def test_cleanup_granularity(fact_scans, hosts):
epoch = timezone.now() epoch = timezone.now()
@@ -88,6 +95,16 @@ def test_cleanup_logic(fact_scans, hosts):
timestamp_pivot -= granularity timestamp_pivot -= granularity
assert fact.timestamp == timestamp_pivot assert fact.timestamp == timestamp_pivot
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_feature_disabled(mocker):
cmd = Command()
with pytest.raises(CommandError) as err:
cmd.handle(None)
assert 'The System Tracking feature is not enabled for your Tower instance' in err.value
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db @pytest.mark.django_db
def test_parameters_ok(mocker): def test_parameters_ok(mocker):
run = mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run') run = mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
@@ -158,6 +175,7 @@ def test_string_time_to_timestamp_invalid():
res = cmd.string_time_to_timestamp(kv['time']) res = cmd.string_time_to_timestamp(kv['time'])
assert res is None assert res is None
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db @pytest.mark.django_db
def test_parameters_fail(mocker): def test_parameters_fail(mocker):
# Mock run() just in case, but it should never get called because an error should be thrown # Mock run() just in case, but it should never get called because an error should be thrown

View File

@@ -0,0 +1,84 @@
# Python
import pytest
from datetime import timedelta
# Django
from django.utils import timezone
from django.conf import settings
# AWX
from awx.fact.models.fact import Fact, FactHost
# MongoEngine
from mongoengine.connection import ConnectionError
@pytest.fixture(autouse=True)
def mongo_db(request):
marker = request.keywords.get('mongo_db', None)
if marker:
# Drop mongo database
try:
db = Fact._get_db()
db.connection.drop_database(settings.MONGO_DB)
except ConnectionError:
raise
@pytest.fixture
def inventories(organization):
def rf(inventory_count=1):
invs = []
for i in xrange(0, inventory_count):
inv = organization.inventories.create(name="test-inv-%d" % i, description="test-inv-desc")
invs.append(inv)
return invs
return rf
'''
hosts naming convension should align with hosts_mongo
'''
@pytest.fixture
def hosts(organization):
def rf(host_count=1, inventories=[]):
hosts = []
for inv in inventories:
for i in xrange(0, host_count):
name = '%s-host-%s' % (inv.name, i)
host = inv.hosts.create(name=name)
hosts.append(host)
return hosts
return rf
@pytest.fixture
def hosts_mongo(organization):
def rf(host_count=1, inventories=[]):
hosts = []
for inv in inventories:
for i in xrange(0, host_count):
name = '%s-host-%s' % (inv.name, i)
(host, created) = FactHost.objects.get_or_create(hostname=name, inventory_id=inv.id)
hosts.append(host)
return hosts
return rf
@pytest.fixture
def fact_scans(organization, fact_ansible_json, fact_packages_json, fact_services_json):
def rf(fact_scans=1, inventories=[], timestamp_epoch=timezone.now()):
facts_json = {}
facts = []
module_names = ['ansible', 'services', 'packages']
facts_json['ansible'] = fact_ansible_json
facts_json['packages'] = fact_packages_json
facts_json['services'] = fact_services_json
for inv in inventories:
for host_obj in FactHost.objects.filter(inventory_id=inv.id):
timestamp_current = timestamp_epoch
for i in xrange(0, fact_scans):
for module_name in module_names:
facts.append(Fact.add_fact(timestamp_current, facts_json[module_name], host_obj, module_name))
timestamp_current += timedelta(days=1)
return facts
return rf

View File

@@ -0,0 +1,79 @@
import pytest
import datetime
from django.apps import apps
from awx.main.models.inventory import Host
from awx.main.models.fact import Fact
from awx.main.migrations import _system_tracking as system_tracking
from awx.fact.models.fact import Fact as FactMongo
from awx.fact.models.fact import FactVersion, FactHost
def micro_to_milli(micro):
return micro - (((int)(micro / 1000)) * 1000)
@pytest.mark.django_db
@pytest.mark.mongo_db
def test_migrate_facts(inventories, hosts, hosts_mongo, fact_scans):
inventory_objs = inventories(2)
hosts(2, inventory_objs)
hosts_mongo(2, inventory_objs)
facts_known = fact_scans(2, inventory_objs)
(migrated_count, not_migrated_count) = system_tracking.migrate_facts(apps, None)
# 4 hosts w/ 2 fact scans each, 3 modules each scan
assert migrated_count == 24
assert not_migrated_count == 0
for fact_mongo, fact_version in facts_known:
host = Host.objects.get(inventory_id=fact_mongo.host.inventory_id, name=fact_mongo.host.hostname)
t = fact_mongo.timestamp - datetime.timedelta(microseconds=micro_to_milli(fact_mongo.timestamp.microsecond))
fact = Fact.objects.filter(host_id=host.id, timestamp=t, module=fact_mongo.module)
assert len(fact) == 1
assert fact[0] is not None
@pytest.mark.django_db
@pytest.mark.mongo_db
def test_migrate_facts_hostname_does_not_exist(inventories, hosts, hosts_mongo, fact_scans):
inventory_objs = inventories(2)
host_objs = hosts(1, inventory_objs)
hosts_mongo(2, inventory_objs)
facts_known = fact_scans(2, inventory_objs)
(migrated_count, not_migrated_count) = system_tracking.migrate_facts(apps, None)
assert migrated_count == 12
assert not_migrated_count == 12
for fact_mongo, fact_version in facts_known:
# Facts that don't match the only host will not be migrated
if fact_mongo.host.hostname != host_objs[0].name:
continue
host = Host.objects.get(inventory_id=fact_mongo.host.inventory_id, name=fact_mongo.host.hostname)
t = fact_mongo.timestamp - datetime.timedelta(microseconds=micro_to_milli(fact_mongo.timestamp.microsecond))
fact = Fact.objects.filter(host_id=host.id, timestamp=t, module=fact_mongo.module)
assert len(fact) == 1
assert fact[0] is not None
@pytest.mark.django_db
@pytest.mark.mongo_db
def test_drop_system_tracking_db(inventories, hosts, hosts_mongo, fact_scans):
inventory_objs = inventories(1)
hosts_mongo(1, inventory_objs)
fact_scans(1, inventory_objs)
assert FactMongo.objects.all().count() > 0
assert FactVersion.objects.all().count() > 0
assert FactHost.objects.all().count() > 0
system_tracking.drop_system_tracking_db()
assert FactMongo.objects.all().count() == 0
assert FactVersion.objects.all().count() == 0
assert FactHost.objects.all().count() == 0

View File

@@ -803,6 +803,21 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
self.assertEqual(job.hosts.count(), 1) self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host) self.assertEqual(job.hosts.all()[0], host)
# Create the job itself using URL-encoded form data instead of JSON.
result = self.post(url, data, expect=202, remote_addr=host_ip, data_type='form')
# Establish that we got back what we expect, and made the changes
# that we expect.
self.assertTrue('Location' in result.response, result.response)
self.assertEqual(jobs_qs.count(), 2)
job = jobs_qs[0]
self.assertEqual(urlparse.urlsplit(result.response['Location']).path,
job.get_absolute_url())
self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name)
self.assertEqual(job.hosts.count(), 1)
self.assertEqual(job.hosts.all()[0], host)
# Run the callback job again with extra vars and verify their presence # Run the callback job again with extra vars and verify their presence
data.update(dict(extra_vars=dict(key="value"))) data.update(dict(extra_vars=dict(key="value")))
result = self.post(url, data, expect=202, remote_addr=host_ip) result = self.post(url, data, expect=202, remote_addr=host_ip)
@@ -853,9 +868,9 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
if host_ip: if host_ip:
break break
self.assertTrue(host) self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 2)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 3) self.assertEqual(jobs_qs.count(), 3)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 4)
job = jobs_qs[0] job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback') self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name) self.assertEqual(job.limit, host.name)
@@ -878,9 +893,9 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
if host_ip: if host_ip:
break break
self.assertTrue(host) self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 3)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 4) self.assertEqual(jobs_qs.count(), 4)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 5)
job = jobs_qs[0] job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback') self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name) self.assertEqual(job.limit, host.name)
@@ -892,9 +907,9 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
host_qs = host_qs.filter(variables__icontains='ansible_ssh_host') host_qs = host_qs.filter(variables__icontains='ansible_ssh_host')
host = host_qs[0] host = host_qs[0]
host_ip = host.variables_dict['ansible_ssh_host'] host_ip = host.variables_dict['ansible_ssh_host']
self.assertEqual(jobs_qs.count(), 4)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 5) self.assertEqual(jobs_qs.count(), 5)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 6)
job = jobs_qs[0] job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback') self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, host.name) self.assertEqual(job.limit, host.name)
@@ -926,9 +941,9 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase):
host_ip = list(ips)[0] host_ip = list(ips)[0]
break break
self.assertTrue(host) self.assertTrue(host)
self.assertEqual(jobs_qs.count(), 5)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 6) self.assertEqual(jobs_qs.count(), 6)
self.post(url, data, expect=202, remote_addr=host_ip)
self.assertEqual(jobs_qs.count(), 7)
job = jobs_qs[0] job = jobs_qs[0]
self.assertEqual(job.launch_type, 'callback') self.assertEqual(job.launch_type, 'callback')
self.assertEqual(job.limit, ':&'.join([job_template.limit, host.name])) self.assertEqual(job.limit, ':&'.join([job_template.limit, host.name]))

View File

@@ -44,7 +44,8 @@
@import "text-label.less"; @import "text-label.less";
@import "./bootstrap-datepicker.less"; @import "./bootstrap-datepicker.less";
@import "awx/ui/client/src/shared/branding/colors.default.less"; @import "awx/ui/client/src/shared/branding/colors.default.less";
// Bootstrap default overrides
@import "awx/ui/client/src/shared/bootstrap-settings.less";
/* Bootstrap fix that's causing a right margin to appear /* Bootstrap fix that's causing a right margin to appear
whenver a modal is opened */ whenver a modal is opened */
body.modal-open { body.modal-open {
@@ -919,15 +920,11 @@ input[type="checkbox"].checkbox-no-label {
/* Display list actions next to search widget */ /* Display list actions next to search widget */
.list-actions { .list-actions {
text-align: right; text-align: right;
button { .fa-lg {
margin-left: 4px; vertical-align: -8%;
} }
.fa-lg {
vertical-align: -8%;
}
} }
.jqui-accordion { .jqui-accordion {
@@ -1952,11 +1949,6 @@ tr td button i {
} }
} }
button.dropdown-toggle,
.input-group-btn {
z-index: 1;
}
#login-modal-body { #login-modal-body {
padding-bottom: 5px; padding-bottom: 5px;
} }

View File

@@ -214,9 +214,7 @@
} }
#job-detail-container { #job-detail-container {
position: relative;
padding-left: 15px;
padding-right: 7px;
.well { .well {
overflow: hidden; overflow: hidden;
} }

View File

@@ -143,7 +143,6 @@ table, tbody {
.List-header { .List-header {
display: flex; display: flex;
height: 34px;
align-items: center; align-items: center;
} }
@@ -151,7 +150,7 @@ table, tbody {
align-items: center; align-items: center;
flex: 1 0 auto; flex: 1 0 auto;
display: flex; display: flex;
margin-top: -2px; height: 34px;
} }
.List-titleBadge { .List-titleBadge {
@@ -172,15 +171,22 @@ table, tbody {
text-transform: uppercase; text-transform: uppercase;
} }
.List-actions { .List-actionHolder {
justify-content: flex-end; justify-content: flex-end;
display: flex; display: flex;
height: 34px;
}
.List-actions {
margin-top: -10px; margin-top: -10px;
}
.List-auxAction + .List-actions {
margin-left: 10px; margin-left: 10px;
} }
.List-auxAction { .List-auxAction {
justify-content: flex-end; align-items: center;
display: flex; display: flex;
} }
@@ -188,6 +194,10 @@ table, tbody {
width: 175px; width: 175px;
} }
.List-action:not(.ng-hide) ~ .List-action:not(.ng-hide) {
margin-left: 10px;
}
.List-buttonSubmit { .List-buttonSubmit {
background-color: @submit-button-bg; background-color: @submit-button-bg;
color: @submit-button-text; color: @submit-button-text;
@@ -352,3 +362,25 @@ table, tbody {
display: block; display: block;
font-size: 13px; font-size: 13px;
} }
@media (max-width: 991px) {
.List-searchWidget + .List-searchWidget {
margin-top: 20px;
}
}
@media (max-width: 600px) {
.List-header {
flex-direction: column;
align-items: stretch;
}
.List-actionHolder {
justify-content: flex-start;
align-items: center;
flex: 1 0 auto;
margin-top: 12px;
}
.List-well {
margin-top: 20px;
}
}

View File

@@ -1,20 +1,20 @@
export default export default
['$scope', '$state', 'CheckLicense', function($scope, $state, CheckLicense){ ['$scope', '$state', 'CheckLicense', function($scope, $state, CheckLicense){
var processVersion = function(version){ var processVersion = function(version){
// prettify version & calculate padding // prettify version & calculate padding
// e,g 3.0.0-0.git201602191743/ -> 3.0.0 // e,g 3.0.0-0.git201602191743/ -> 3.0.0
var split = version.split('-')[0] var split = version.split('-')[0]
var spaces = Math.floor((16-split.length)/2), var spaces = Math.floor((16-split.length)/2),
paddedStr = ""; paddedStr = "";
for(var i=0; i<=spaces; i++){ for(var i=0; i<=spaces; i++){
paddedStr = paddedStr +" "; paddedStr = paddedStr +" ";
} }
paddedStr = paddedStr + split; paddedStr = paddedStr + split;
for(var j = paddedStr.length; j<16; j++){ for(var j = paddedStr.length; j<16; j++){
paddedStr = paddedStr + " "; paddedStr = paddedStr + " ";
} }
return paddedStr return paddedStr
} };
var init = function(){ var init = function(){
CheckLicense.get() CheckLicense.get()
.then(function(res){ .then(function(res){
@@ -23,9 +23,9 @@ export default
$('#about-modal').modal('show'); $('#about-modal').modal('show');
}); });
}; };
var back = function(){ $('#about-modal').on('hidden.bs.modal', function () {
$state.go('setup'); $state.go('setup');
} });
init(); init();
} }
]; ];

View File

@@ -3,7 +3,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<img class="About-brand--ansible img-responsive" src="/static/assets/ansible_tower_logo_minimalc.png" /> <img class="About-brand--ansible img-responsive" src="/static/assets/ansible_tower_logo_minimalc.png" />
<button type="button" class="close About-close" ng-click="back()"> <button data-dismiss="modal" type="button" class="close About-close">
<span class="fa fa-times-circle"></span> <span class="fa fa-times-circle"></span>
</button> </button>
</div> </div>

View File

@@ -27,9 +27,11 @@ import {JobsListController} from './controllers/Jobs';
import {PortalController} from './controllers/Portal'; import {PortalController} from './controllers/Portal';
import systemTracking from './system-tracking/main'; import systemTracking from './system-tracking/main';
import inventoryScripts from './inventory-scripts/main'; import inventoryScripts from './inventory-scripts/main';
import organizations from './organizations/main';
import permissions from './permissions/main'; import permissions from './permissions/main';
import managementJobs from './management-jobs/main'; import managementJobs from './management-jobs/main';
import jobDetail from './job-detail/main'; import jobDetail from './job-detail/main';
import notifications from './notifications/main';
// modules // modules
import about from './about/main'; import about from './about/main';
@@ -46,10 +48,12 @@ import login from './login/main';
import activityStream from './activity-stream/main'; import activityStream from './activity-stream/main';
import standardOut from './standard-out/main'; import standardOut from './standard-out/main';
import lookUpHelper from './lookup/main'; import lookUpHelper from './lookup/main';
import {JobTemplatesList, JobTemplatesAdd, JobTemplatesEdit} from './controllers/JobTemplates'; import JobTemplates from './job-templates/main';
import {ScheduleEditController} from './controllers/Schedules'; import {ScheduleEditController} from './controllers/Schedules';
import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects'; import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects';
import {OrganizationsList, OrganizationsAdd, OrganizationsEdit} from './controllers/Organizations'; import OrganizationsList from './organizations/list/organizations-list.controller';
import OrganizationsAdd from './organizations/add/organizations-add.controller';
import OrganizationsEdit from './organizations/edit/organizations-edit.controller';
import {InventoriesList, InventoriesAdd, InventoriesEdit, InventoriesManage} from './controllers/Inventories'; import {InventoriesList, InventoriesAdd, InventoriesEdit, InventoriesManage} from './controllers/Inventories';
import {AdminsList} from './controllers/Admins'; import {AdminsList} from './controllers/Admins';
import {UsersList, UsersAdd, UsersEdit} from './controllers/Users'; import {UsersList, UsersAdd, UsersEdit} from './controllers/Users';
@@ -64,7 +68,6 @@ import './shared/directives';
import './shared/filters'; import './shared/filters';
import './shared/InventoryTree'; import './shared/InventoryTree';
import './shared/Socket'; import './shared/Socket';
import './job-templates/main';
import './shared/features/main'; import './shared/features/main';
import './login/authenticationServices/pendo/ng-pendo'; import './login/authenticationServices/pendo/ng-pendo';
import footer from './footer/main'; import footer from './footer/main';
@@ -76,7 +79,7 @@ __deferLoadIfEnabled();
/*#endif#*/ /*#endif#*/
var tower = angular.module('Tower', [ var tower = angular.module('Tower', [
// 'ngAnimate', //'ngAnimate',
'ngSanitize', 'ngSanitize',
'ngCookies', 'ngCookies',
about.name, about.name,
@@ -85,6 +88,7 @@ var tower = angular.module('Tower', [
browserData.name, browserData.name,
systemTracking.name, systemTracking.name,
inventoryScripts.name, inventoryScripts.name,
organizations.name,
permissions.name, permissions.name,
managementJobs.name, managementJobs.name,
setupMenu.name, setupMenu.name,
@@ -98,7 +102,9 @@ var tower = angular.module('Tower', [
activityStream.name, activityStream.name,
footer.name, footer.name,
jobDetail.name, jobDetail.name,
notifications.name,
standardOut.name, standardOut.name,
JobTemplates.name,
'templates', 'templates',
'Utilities', 'Utilities',
'OrganizationFormDefinition', 'OrganizationFormDefinition',
@@ -295,52 +301,6 @@ var tower = angular.module('Tower', [
} }
}). }).
state('jobTemplates', {
url: '/job_templates',
templateUrl: urlPrefix + 'partials/job_templates.html',
controller: JobTemplatesList,
data: {
activityStream: true,
activityStreamTarget: 'job_template'
},
ncyBreadcrumb: {
label: "JOB TEMPLATES"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('jobTemplates.add', {
url: '/add',
templateUrl: urlPrefix + 'partials/job_templates.html',
controller: JobTemplatesAdd,
ncyBreadcrumb: {
parent: "jobTemplates",
label: "CREATE JOB TEMPLATE"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('jobTemplates.edit', {
url: '/:template_id',
templateUrl: urlPrefix + 'partials/job_templates.html',
controller: JobTemplatesEdit,
data: {
activityStreamId: 'template_id'
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('projects', { state('projects', {
url: '/projects', url: '/projects',
templateUrl: urlPrefix + 'partials/projects.html', templateUrl: urlPrefix + 'partials/projects.html',
@@ -456,28 +416,6 @@ var tower = angular.module('Tower', [
} }
}). }).
state('inventoryJobTemplateAdd', {
url: '/inventories/:inventory_id/job_templates/add',
templateUrl: urlPrefix + 'partials/job_templates.html',
controller: JobTemplatesAdd,
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('inventoryJobTemplateEdit', {
url: '/inventories/:inventory_id/job_templates/:template_id',
templateUrl: urlPrefix + 'partials/job_templates.html',
controller: JobTemplatesEdit,
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('inventoryManage', { state('inventoryManage', {
url: '/inventories/:inventory_id/manage?groups', url: '/inventories/:inventory_id/manage?groups',
templateUrl: urlPrefix + 'partials/inventory-manage.html', templateUrl: urlPrefix + 'partials/inventory-manage.html',
@@ -494,61 +432,6 @@ var tower = angular.module('Tower', [
} }
}). }).
state('organizations', {
url: '/organizations',
templateUrl: urlPrefix + 'partials/organizations.html',
controller: OrganizationsList,
data: {
activityStream: true,
activityStreamTarget: 'organization'
},
ncyBreadcrumb: {
parent: function($scope) {
$scope.$parent.$emit("ReloadOrgListView");
return "setup";
},
label: "ORGANIZATIONS"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('organizations.add', {
url: '/add',
templateUrl: urlPrefix + 'partials/organizations.crud.html',
controller: OrganizationsAdd,
ncyBreadcrumb: {
parent: "organizations",
label: "CREATE ORGANIZATION"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('organizations.edit', {
url: '/:organization_id',
templateUrl: urlPrefix + 'partials/organizations.crud.html',
controller: OrganizationsEdit,
data: {
activityStreamId: 'organization_id'
},
ncyBreadcrumb: {
parent: "organizations",
label: "{{name}}"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('organizationAdmins', { state('organizationAdmins', {
url: '/organizations/:organization_id/admins', url: '/organizations/:organization_id/admins',
templateUrl: urlPrefix + 'partials/organizations.html', templateUrl: urlPrefix + 'partials/organizations.html',
@@ -882,13 +765,13 @@ var tower = angular.module('Tower', [
}]); }]);
}]) }])
.run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', '$state', 'CheckLicense', .run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', '$state', 'CheckLicense',
'$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket',
'LoadConfig', 'Store', 'ShowSocketHelp', 'pendoService', 'LoadConfig', 'Store', 'ShowSocketHelp', 'pendoService',
function ( function (
$q, $compile, $cookieStore, $rootScope, $log, $state, CheckLicense, $q, $compile, $cookieStore, $rootScope, $log, $state, CheckLicense,
$location, Authorization, LoadBasePaths, Timer, ClearScope, Socket, $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket,
LoadConfig, Store, ShowSocketHelp, pendoService) LoadConfig, Store, ShowSocketHelp, pendoService)
{ {
var sock; var sock;
@@ -975,7 +858,7 @@ var tower = angular.module('Tower', [
$log.debug("sending status to standard out"); $log.debug("sending status to standard out");
$rootScope.$emit('JobStatusChange-jobStdout', data); $rootScope.$emit('JobStatusChange-jobStdout', data);
} else if ($state.is('jobDetail')) { } if ($state.is('jobDetail')) {
$rootScope.$emit('JobStatusChange-jobDetails', data); $rootScope.$emit('JobStatusChange-jobDetails', data);
} else if ($state.is('dashboard')) { } else if ($state.is('dashboard')) {
$rootScope.$emit('JobStatusChange-home', data); $rootScope.$emit('JobStatusChange-home', data);

View File

@@ -922,7 +922,7 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
generateList.inject(InventoryGroups, { generateList.inject(InventoryGroups, {
mode: 'edit', mode: 'edit',
id: 'group-list-container', id: 'group-list-container',
searchSize: 'col-lg-6 col-md-6 col-sm-6', searchSize: 'col-lg-6 col-md-6 col-sm-6 col-xs-12',
scope: $scope scope: $scope
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,382 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name controllers.function:Organizations
* @description This controller's for the Organizations page
*/
export function OrganizationsList($stateParams, $scope, $rootScope, $location,
$log, $compile, Rest, PaginateWidget, PaginateInit, SearchInit, OrganizationList, Alert, Prompt, ClearScope, ProcessErrors, GetBasePath, Wait,
$state) {
ClearScope();
var defaultUrl = GetBasePath('organizations'),
list = OrganizationList,
pageSize = $scope.orgCount;
PaginateInit({
scope: $scope,
list: list,
url: defaultUrl,
pageSize: pageSize,
});
SearchInit({
scope: $scope,
list: list,
url: defaultUrl,
});
$scope.search(list.iterator);
$scope.PaginateWidget = PaginateWidget({
iterator: list.iterator,
set: 'organizations'
});
var paginationContainer = $('#pagination-container');
paginationContainer.html($scope.PaginateWidget);
$compile(paginationContainer.contents())($scope)
var parseCardData = function (cards) {
return cards.map(function (card) {
var val = {};
val.name = card.name;
val.id = card.id;
if (card.id + "" === cards.activeCard) {
val.isActiveCard = true;
}
val.description = card.description || undefined;
val.links = [];
val.links.push({
href: card.related.users,
name: "USERS"
});
val.links.push({
href: card.related.teams,
name: "TEAMS"
});
val.links.push({
href: card.related.inventories,
name: "INVENTORIES"
});
val.links.push({
href: card.related.projects,
name: "PROJECTS"
});
val.links.push({
href: card.related.job_templates,
name: "JOB TEMPLATES"
});
val.links.push({
href: card.related.admins,
name: "ADMINS"
});
return val;
});
};
var getOrganization = function (id) {
Rest.setUrl(defaultUrl);
Rest.get()
.success(function (data) {
data.results.activeCard = id;
$scope.orgCount = data.count;
$scope.orgCards = parseCardData(data.results);
Wait("stop");
})
.error(function (data, status) {
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + defaultUrl + ' failed. DELETE returned status: ' + status });
});
};
$scope.$on("ReloadOrgListView", function() {
if ($state.$current.self.name === "organizations") {
delete $scope.activeCard;
if ($scope.orgCards) {
$scope.orgCards = $scope.orgCards.map(function (card) {
delete card.isActiveCard;
return card;
});
}
$scope.hideListHeader = false;
}
});
$scope.$on("ReloadOrganzationCards", function(e, id) {
$scope.activeCard = id;
getOrganization(id);
});
$scope.$on("HideOrgListHeader", function() {
$scope.hideListHeader = true;
});
$scope.$on("ShowOrgListHeader", function() {
$scope.hideListHeader = false;
});
getOrganization();
$rootScope.flashMessage = null;
if ($scope.removePostRefresh) {
$scope.removePostRefresh();
}
$scope.removePostRefresh = $scope.$on('PostRefresh', function () {
// Cleanup after a delete
Wait('stop');
$('#prompt-modal').modal('hide');
});
$scope.addOrganization = function () {
$state.transitionTo('organizations.add');
};
$scope.editOrganization = function (id) {
$scope.activeCard = id;
$state.transitionTo('organizations.edit', {organization_id: id});
};
$scope.deleteOrganization = function (id, name) {
var action = function () {
$('#prompt-modal').modal('hide');
Wait('start');
var url = defaultUrl + id + '/';
Rest.setUrl(url);
Rest.destroy()
.success(function () {
if ($state.current.name !== "organzations") {
$state.transitionTo("organizations");
}
$scope.$emit("ReloadOrganzationCards");
})
.error(function (data, status) {
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status });
});
};
Prompt({
hdr: 'Delete',
body: '<div class="Prompt-bodyQuery">Are you sure you want to delete the organization below?</div><div class="Prompt-bodyTarget">' + name + '</div>',
action: action,
actionText: 'DELETE'
});
};
}
OrganizationsList.$inject = ['$stateParams', '$scope', '$rootScope',
'$location', '$log', '$compile', 'Rest', 'PaginateWidget', 'PaginateInit', 'SearchInit', 'OrganizationList', 'Alert', 'Prompt', 'ClearScope',
'ProcessErrors', 'GetBasePath', 'Wait',
'$state'
];
export function OrganizationsAdd($scope, $rootScope, $compile, $location, $log,
$stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors,
ClearScope, GetBasePath, ReturnToCaller, Wait, $state) {
ClearScope();
// Inject dynamic view
var generator = GenerateForm,
form = OrganizationForm,
base = $location.path().replace(/^\//, '').split('/')[0];
generator.inject(form, { mode: 'add', related: false, scope: $scope});
generator.reset();
$scope.$emit("HideOrgListHeader");
// Save
$scope.formSave = function () {
generator.clearApiErrors();
Wait('start');
var url = GetBasePath(base);
url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : '';
Rest.setUrl(url);
Rest.post({ name: $scope.name, description: $scope.description })
.success(function (data) {
Wait('stop');
$scope.$emit("ReloadOrganzationCards", data.id);
if (base === 'organizations') {
$rootScope.flashMessage = "New organization successfully created!";
$location.path('/organizations/' + data.id);
} else {
ReturnToCaller(1);
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to add new organization. Post returned status: ' + status });
});
};
$scope.formCancel = function () {
$scope.$emit("ReloadOrganzationCards");
$scope.$emit("ShowOrgListHeader");
$state.transitionTo('organizations');
};
}
OrganizationsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location',
'$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert',
'ProcessErrors', 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'Wait',
'$state'
];
export function OrganizationsEdit($scope, $rootScope, $compile, $location, $log,
$stateParams, OrganizationForm, GenerateForm, Rest, Alert, ProcessErrors,
RelatedSearchInit, RelatedPaginateInit, Prompt, ClearScope, GetBasePath,
Wait, $state) {
ClearScope();
// Inject dynamic view
var form = OrganizationForm,
generator = GenerateForm,
defaultUrl = GetBasePath('organizations'),
base = $location.path().replace(/^\//, '').split('/')[0],
master = {},
id = $stateParams.organization_id,
relatedSets = {};
$scope.$emit("HideOrgListHeader");
$scope.$emit("ReloadOrganzationCards", id);
$scope.organization_id = id;
generator.inject(form, { mode: 'edit', related: true, scope: $scope});
generator.reset();
// After the Organization is loaded, retrieve each related set
if ($scope.organizationLoadedRemove) {
$scope.organizationLoadedRemove();
}
$scope.organizationLoadedRemove = $scope.$on('organizationLoaded', function () {
for (var set in relatedSets) {
$scope.search(relatedSets[set].iterator);
}
Wait('stop');
});
// Retrieve detail record and prepopulate the form
Wait('start');
Rest.setUrl(defaultUrl + id + '/');
Rest.get()
.success(function (data) {
var fld, related, set;
$scope.organization_name = data.name;
for (fld in form.fields) {
if (data[fld]) {
$scope[fld] = data[fld];
master[fld] = data[fld];
}
}
related = data.related;
for (set in form.related) {
if (related[set]) {
relatedSets[set] = {
url: related[set],
iterator: form.related[set].iterator
};
}
}
// Initialize related search functions. Doing it here to make sure relatedSets object is populated.
RelatedSearchInit({ scope: $scope, form: form, relatedSets: relatedSets });
RelatedPaginateInit({ scope: $scope, relatedSets: relatedSets });
$scope.$emit('organizationLoaded');
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to retrieve organization: ' + $stateParams.id + '. GET status: ' + status });
});
// Save changes to the parent
$scope.formSave = function () {
var fld, params = {};
generator.clearApiErrors();
Wait('start');
for (fld in form.fields) {
params[fld] = $scope[fld];
}
Rest.setUrl(defaultUrl + id + '/');
Rest.put(params)
.success(function (data) {
Wait('stop');
$scope.organization_name = $scope.name;
master = params;
$rootScope.flashMessage = "Your changes were successfully saved!";
$scope.$emit("ReloadOrganzationCards", data.id);
})
.error(function (data, status) {
ProcessErrors($scope, data, status, OrganizationForm, { hdr: 'Error!',
msg: 'Failed to update organization: ' + id + '. PUT status: ' + status });
});
};
$scope.formCancel = function () {
$scope.$emit("ReloadOrganzationCards");
$scope.$emit("ShowOrgListHeader");
$state.transitionTo('organizations');
};
// Related set: Add button
$scope.add = function (set) {
$rootScope.flashMessage = null;
$location.path('/' + base + '/' + $stateParams.organization_id + '/' + set);
};
// Related set: Edit button
$scope.edit = function (set, id) {
$rootScope.flashMessage = null;
$location.path('/' + set + '/' + id);
};
// Related set: Delete button
$scope['delete'] = function (set, itm_id, name, title) {
$rootScope.flashMessage = null;
var action = function () {
Wait('start');
var url = defaultUrl + $stateParams.organization_id + '/' + set + '/';
Rest.setUrl(url);
Rest.post({ id: itm_id, disassociate: 1 })
.success(function () {
$('#prompt-modal').modal('hide');
$scope.search(form.related[set].iterator);
})
.error(function (data, status) {
$('#prompt-modal').modal('hide');
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. POST returned status: ' + status });
});
};
Prompt({
hdr: 'Delete',
body: '<div class="Prompt-bodyQuery">Are you sure you want to remove the ' + title + ' below from ' + $scope.name + '?</div><div class="Prompt-bodyTarget">' + name + '</div>',
action: action,
actionText: 'DELETE'
});
};
}
OrganizationsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location',
'$log', '$stateParams', 'OrganizationForm', 'GenerateForm', 'Rest', 'Alert',
'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', 'Prompt',
'ClearScope', 'GetBasePath', 'Wait', '$state'
];

View File

@@ -1,12 +1,11 @@
/** @define DashboardCounts */ /** @define DashboardCounts */
.Footer { .Footer {
height: 40px; height: 40px;
background-color: #f6f6f6; background-color: #f6f6f6;
color: #848992; color: #848992;
width: 100%; width: 100%;
z-index: 1040; z-index: 1040;
position: fixed; position: absolute;
right: 0; right: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;

View File

@@ -4,5 +4,5 @@
<img id="footer-logo" alt="Red Hat, Inc. | Ansible, Inc." class="Footer-logoImage" src="/static/assets/footer-logo.png"> <img id="footer-logo" alt="Red Hat, Inc. | Ansible, Inc." class="Footer-logoImage" src="/static/assets/footer-logo.png">
</div> </div>
</a> </a>
<div class="Footer-copyright" ng-class="{'is-loggedOut' : !$root.current_user.username}">Copyright &copy 2015 <a class="Footer-link" href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc. All Rights Reserved.</div> <div class="Footer-copyright" ng-class="{'is-loggedOut' : !$root.current_user.username}">Copyright &copy 2016 <a class="Footer-link" href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc.</div>
</footer> </footer>

View File

@@ -221,29 +221,6 @@ export default
dataTitle: 'Prompt for Extra Variables', dataTitle: 'Prompt for Extra Variables',
dataContainer: "body" dataContainer: "body"
}, },
survey_enabled: {
label: 'Enable Survey',
type: 'checkbox',
addRequired: false,
editRequird: false,
awFeature: 'surveys',
ngChange: "surveyEnabled()",
ngHide: "job_type.value === 'scan'",
column: 2,
awPopOver: "<p>If checked, user will be prompted at job launch with a series of questions related to the job.</p>",
dataPlacement: 'right',
dataTitle: 'Enable Survey',
dataContainer: "body"
},
create_survey: {
type: 'custom',
column: 2,
ngHide: "job_type.value === 'scan'" ,
control: '<button type="button" class="btn btn-sm btn-primary" id="job_templates_create_survey_btn" ng-show="survey_enabled" ng-click="addSurvey()"><i class="fa fa-pencil"></i> Create Survey</button>'+
'<button style="display:none;" type="button" class="btn btn-sm btn-primary" id="job_templates_edit_survey_btn" ng-show="survey_enabled" ng-click="editSurvey()"><i class="fa fa-pencil"></i> Edit Survey</button>'+
'<button style="display:none;margin-left:5px" type="button" class="btn btn-sm btn-primary" id="job_templates_delete_survey_btn" ng-show="survey_enabled" ng-click="deleteSurvey()"><i class="fa fa-trash-o"></i> Delete Survey</button>'+
'<div class="error ng-hide" id="job-template-survey-error" ng-show="invalid_survey">A survey is enabled but it does not exist. Create a survey or uncheck the Enable Survey box to disable the survey. </div>'
},
become_enabled: { become_enabled: {
label: 'Enable Privilege Escalation', label: 'Enable Privilege Escalation',
type: 'checkbox', type: 'checkbox',
@@ -294,6 +271,13 @@ export default
dataPlacement: 'right', dataPlacement: 'right',
dataTitle: "Host Config Key", dataTitle: "Host Config Key",
dataContainer: "body" dataContainer: "body"
},
survey: {
type: 'custom',
column: 2,
ngHide: "job_type.value === 'scan'" ,
control: '<button type="button" class="btn btn-sm Form-buttonDefault" id="job_templates_create_survey_btn" ng-show="!survey_exists" ng-click="addSurvey()">ADD SURVEY</button>'+
'<button type="button" class="btn btn-sm Form-buttonDefault" id="job_templates_edit_survey_btn" ng-show="survey_exists" ng-click="editSurvey()">EDIT SURVEY</button>'
} }
}, },

View File

@@ -229,7 +229,7 @@ angular.module('HostsHelper', [ 'RestServices', 'Utilities', listGenerator.name,
generator = GenerateList; generator = GenerateList;
// Inject the list html // Inject the list html
generator.inject(InventoryHosts, { scope: host_scope, mode: 'edit', id: 'host-list-container', searchSize: 'col-lg-6 col-md-6 col-sm-6' }); generator.inject(InventoryHosts, { scope: host_scope, mode: 'edit', id: 'host-list-container', searchSize: 'col-lg-6 col-md-6 col-sm-6 col-xs-12' });
// Load data // Load data
HostsReload({ scope: host_scope, group_id: group_id, inventory_id: inventory_id, parent_scope: group_scope, pageSize: pageSize }); HostsReload({ scope: host_scope, group_id: group_id, inventory_id: inventory_id, parent_scope: group_scope, pageSize: pageSize });
@@ -487,7 +487,7 @@ angular.module('HostsHelper', [ 'RestServices', 'Utilities', listGenerator.name,
minWidth: 400, minWidth: 400,
title: 'Host Properties', title: 'Host Properties',
id: 'host-modal-dialog', id: 'host-modal-dialog',
clonseOnEscape: false, closeOnEscape: false,
form: form_scope.host_form, form: form_scope.host_form,
onClose: function() { onClose: function() {
Wait('stop'); Wait('stop');

View File

@@ -235,7 +235,7 @@ export default
} }
if (newActivePlay) { if (newActivePlay) {
scope.activePlay = newActivePlay; scope.activePlay = newActivePlay;
scope.jobData.plays[scope.activePlay].playActiveClass = 'List-tableRow--selected'; scope.jobData.plays[scope.activePlay].playActiveClass = 'JobDetail-tableRow--selected';
} }
} }
}; };
@@ -265,7 +265,7 @@ export default
} }
if (newActiveTask) { if (newActiveTask) {
scope.activeTask = newActiveTask; scope.activeTask = newActiveTask;
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'List-tableRow--selected'; scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'JobDetail-tableRow--selected';
} }
} }
}; };
@@ -793,7 +793,7 @@ export default
scope.selectedPlay = id; scope.selectedPlay = id;
scope.plays.forEach(function(play, idx) { scope.plays.forEach(function(play, idx) {
if (play.id === scope.selectedPlay) { if (play.id === scope.selectedPlay) {
scope.plays[idx].playActiveClass = 'List-tableRow--selected'; scope.plays[idx].playActiveClass = 'JobDetail-tableRow--selected';
} }
else { else {
scope.plays[idx].playActiveClass = ''; scope.plays[idx].playActiveClass = '';
@@ -940,7 +940,7 @@ export default
scope.selectedTask = id; scope.selectedTask = id;
scope.tasks.forEach(function(task, idx) { scope.tasks.forEach(function(task, idx) {
if (task.id === scope.selectedTask) { if (task.id === scope.selectedTask) {
scope.tasks[idx].taskActiveClass = 'List-tableRow--selected'; scope.tasks[idx].taskActiveClass = 'JobDetail-tableRow--selected';
} }
else { else {
scope.tasks[idx].taskActiveClass = ''; scope.tasks[idx].taskActiveClass = '';
@@ -1142,8 +1142,7 @@ export default
.factory('DrawGraph', ['DonutChart', function(DonutChart) { .factory('DrawGraph', ['DonutChart', function(DonutChart) {
return function(params) { return function(params) {
var scope = params.scope, var scope = params.scope,
resize = params.resize, graph_data = [];
width, height, svg_height, svg_width, svg_radius, graph_data = [];
// Ready the data // Ready the data
if (scope.host_summary.ok) { if (scope.host_summary.ok) {
@@ -1192,7 +1191,9 @@ export default
element = $("#graph-section"), element = $("#graph-section"),
colors, total,job_detail_chart; colors, total,job_detail_chart;
colors = ['#60D66F', '#FF9900','#FF0000','#ff5850']; colors = _.map(dataset, function(d){
return d.color;
});
total = d3.sum(dataset.map(function(d) { total = d3.sum(dataset.map(function(d) {
return d.value; return d.value;
})); }));
@@ -1221,6 +1222,7 @@ export default
"font-weight":400, "font-weight":400,
"src": "url(/static/assets/OpenSans-Regular.ttf)" "src": "url(/static/assets/OpenSans-Regular.ttf)"
}); });
d3.select(element.find(".nv-label text")[0]) d3.select(element.find(".nv-label text")[0])
.attr("class", "DashboardGraphs-hostStatusLabel--successful") .attr("class", "DashboardGraphs-hostStatusLabel--successful")
.style({ .style({
@@ -1228,7 +1230,7 @@ export default
"text-anchor": "start", "text-anchor": "start",
"font-size": "16px", "font-size": "16px",
"text-transform" : "uppercase", "text-transform" : "uppercase",
"fill" : '#3CB878', "fill" : colors[0],
"src": "url(/static/assets/OpenSans-Regular.ttf)" "src": "url(/static/assets/OpenSans-Regular.ttf)"
}); });
d3.select(element.find(".nv-label text")[1]) d3.select(element.find(".nv-label text")[1])
@@ -1238,7 +1240,7 @@ export default
"text-anchor" : "end !imporant", "text-anchor" : "end !imporant",
"font-size": "16px", "font-size": "16px",
"text-transform" : "uppercase", "text-transform" : "uppercase",
"fill" : "#FF9900", "fill" : colors[1],
"src": "url(/static/assets/OpenSans-Regular.ttf)" "src": "url(/static/assets/OpenSans-Regular.ttf)"
}); });
d3.select(element.find(".nv-label text")[2]) d3.select(element.find(".nv-label text")[2])
@@ -1248,7 +1250,7 @@ export default
"text-anchor" : "end !imporant", "text-anchor" : "end !imporant",
"font-size": "16px", "font-size": "16px",
"text-transform" : "uppercase", "text-transform" : "uppercase",
"fill" : "#FF0000", "fill" : colors[2],
"src": "url(/static/assets/OpenSans-Regular.ttf)" "src": "url(/static/assets/OpenSans-Regular.ttf)"
}); });
d3.select(element.find(".nv-label text")[3]) d3.select(element.find(".nv-label text")[3])
@@ -1258,7 +1260,7 @@ export default
"text-anchor" : "end !imporant", "text-anchor" : "end !imporant",
"font-size": "16px", "font-size": "16px",
"text-transform" : "uppercase", "text-transform" : "uppercase",
"fill" : "#ff5850", "fill" : colors[3],
"src": "url(/static/assets/OpenSans-Regular.ttf)" "src": "url(/static/assets/OpenSans-Regular.ttf)"
}); });
return job_detail_chart; return job_detail_chart;

View File

@@ -92,17 +92,7 @@ angular.module('JobTemplatesHelper', ['Utilities'])
} else { } else {
scope[fld] = data[fld]; scope[fld] = data[fld];
if(fld ==='survey_enabled'){ if(fld ==='survey_enabled'){
// $scope.$emit('EnableSurvey', fld); if(!Empty(data.summary_fields.survey)) {
$('#job_templates_survey_enabled_chbox').attr('checked', scope[fld]);
if(Empty(data.summary_fields.survey)) {
$('#job_templates_delete_survey_btn').hide();
$('#job_templates_edit_survey_btn').hide();
$('#job_templates_create_survey_btn').show();
}
else{
$('#job_templates_delete_survey_btn').show();
$('#job_templates_edit_survey_btn').show();
$('#job_templates_create_survey_btn').hide();
scope.survey_exists = true; scope.survey_exists = true;
} }
} }

View File

@@ -32,14 +32,14 @@ export default
// Which page are we on? // Which page are we on?
if (Empty(next) && previous) { if (Empty(next) && previous) {
// no next page, but there is a previous page // no next page, but there is a previous page
scope[iterator + '_page'] = parseInt(previous.match(/page=\d+/)[0].replace(/page=/, '')) + 1; scope[iterator + '_page'] = scope[iterator + '_num_pages'];
} else if (next && Empty(previous)) { } else if (next && Empty(previous)) {
// next page available, but no previous page // next page available, but no previous page
scope[iterator + '_page'] = 1; scope[iterator + '_page'] = 1;
$('#'+iterator+'-pagination #pagination-links li:eq(1)').attr('class', 'disabled'); $('#'+iterator+'-pagination #pagination-links li:eq(1)').attr('class', 'disabled');
} else if (next && previous) { } else if (next && previous) {
// we're in between next and previous // we're in between next and previous
scope[iterator + '_page'] = parseInt(previous.match(/page=\d+/)[0].replace(/page=/, '')) + 1; scope[iterator + '_page'] = /page=\d+/.test(previous) ? parseInt(previous.match(/page=(\d+)/)[1]) + 1 : 2;
} }
// Calc the range of up to 10 pages to show // Calc the range of up to 10 pages to show

View File

@@ -139,7 +139,7 @@ export default
minWidth: 400, minWidth: 400,
title: 'Inventory Properties', title: 'Inventory Properties',
id: 'inventory-edit-modal-dialog', id: 'inventory-edit-modal-dialog',
clonseOnEscape: false, closeOnEscape: false,
form: form_scope.inventory_form, form: form_scope.inventory_form,
onClose: function() { onClose: function() {
Wait('stop'); Wait('stop');

View File

@@ -3,7 +3,19 @@
@import '../shared/branding/colors.less'; @import '../shared/branding/colors.less';
@import '../shared/branding/colors.default.less'; @import '../shared/branding/colors.default.less';
.JobDetail{
display: flex;
flex-direction: row;
}
.JobDetail-leftSide{ .JobDetail-leftSide{
flex: 1 0 auto;
width: 50%;
padding-right: 20px;
}
.JobDetail-rightSide{
flex: 1 0 auto;
width: 50%; width: 50%;
} }
@@ -49,6 +61,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
padding-top: 25px;
} }
.JobDetail-resultRow{ .JobDetail-resultRow{
@@ -56,6 +69,10 @@
display: flex; display: flex;
} }
.JobDetail-resultRowLabel{
text-transform: uppercase;
}
.JobDetail-resultRow label{ .JobDetail-resultRow label{
color: @default-interface-txt; color: @default-interface-txt;
font-size: 14px; font-size: 14px;
@@ -64,14 +81,26 @@
} }
.JobDetail-resultRow--variables{ .JobDetail-resultRow--variables{
width: 90%; width: 100%;
display: block; display: flex;
flex-direction: column;
padding-left:15px;
}
.JobDetail-extraVars{
text-transform: none;
}
.JobDetail-extraVarsLabel{
margin-left:-15px;
padding-bottom: 15px;
} }
.JobDetail-resultRowText{ .JobDetail-resultRowText{
width: 40%; width: 40%;
flex: 1 0 auto; flex: 1 0 auto;
padding:0px; padding:0px;
text-transform: none;
} }
.JobDetail-searchHeaderRow{ .JobDetail-searchHeaderRow{
@@ -101,7 +130,7 @@
.JobDetail-tableToggle.active{ .JobDetail-tableToggle.active{
background-color: @default-link; background-color: @default-link;
border: 1px solid @default-link; border: 1px solid @default-link;
color: @toggle-selected-text; color: @default-bg;
} }
.JobDetail-tableToggle--left{ .JobDetail-tableToggle--left{
@@ -127,7 +156,27 @@
padding-left: 10px; padding-left: 10px;
} }
.JobDetail-tableRow--selected,
.JobDetail-tableRow--selected > :first-child{
border-left: 5px solid @list-row-select-bord;
}
.JobDetail-tableRow--selected > :first-child > .JobDetail-statusIcon{
margin-left: -5px;
}
.JobDetail-statusIcon--results{
padding-left: 0px;
padding-right: 10px;
}
.JobDetail-graphSection{ .JobDetail-graphSection{
height: 320px; height: 320px;
width:100%; width:100%;
} }
.JobDetail-stdoutActionButton--active{
flex:none;
width:0px;
padding-right: 0px;
}

View File

@@ -1,5 +1,5 @@
/************************************************* /*************************************************
* Copyright (c) 2015 Ansible, Inc. * Copyright (c) 2016 Ansible, Inc.
* *
* All Rights Reserved * All Rights Reserved
*************************************************/ *************************************************/
@@ -12,23 +12,22 @@
export default export default
[ '$location', '$rootScope', '$filter', '$scope', '$compile', [ '$location', '$rootScope', '$filter', '$scope', '$compile',
'$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'Rest', '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait',
'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed',
'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList', 'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList',
'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer',
'EventViewer', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer',
'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit', 'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit',
'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels',
'EditSchedule', 'ParseTypeChange', 'EditSchedule', 'ParseTypeChange', 'JobDetailService', 'EventViewer',
function( function(
$location, $rootScope, $filter, $scope, $compile, $stateParams, $location, $rootScope, $filter, $scope, $compile, $stateParams,
$log, ClearScope, GetBasePath, Wait, Rest, ProcessErrors, $log, ClearScope, GetBasePath, Wait, ProcessErrors,
SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph, SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph,
LoadHostSummary, ReloadHostSummaryList, JobIsFinished, LoadHostSummary, ReloadHostSummaryList, JobIsFinished,
SetTaskStyles, DigestEvent, UpdateDOM, EventViewer, DeleteJob, SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob,
PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts,
HostsEdit, ParseVariableString, GetChoices, fieldChoices, HostsEdit, ParseVariableString, GetChoices, fieldChoices,
fieldLabels, EditSchedule, ParseTypeChange fieldLabels, EditSchedule, ParseTypeChange, JobDetailService, EventViewer
) { ) {
ClearScope(); ClearScope();
@@ -43,6 +42,7 @@ export default
scope.plays = []; scope.plays = [];
scope.parseType = 'yaml'; scope.parseType = 'yaml';
scope.previousTaskFailed = false; scope.previousTaskFailed = false;
$scope.stdoutFullScreen = false;
scope.$watch('job_status', function(job_status) { scope.$watch('job_status', function(job_status) {
if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") { if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") {
@@ -202,7 +202,7 @@ export default
scope.processing = false; scope.processing = false;
scope.lessStatus = false; scope.lessStatus = false;
scope.lessDetail = false; scope.lessDetail = false;
scope.lessEvents = false; scope.lessEvents = true;
scope.host_summary = {}; scope.host_summary = {};
scope.host_summary.ok = 0; scope.host_summary.ok = 0;
@@ -282,15 +282,15 @@ export default
scope.removeInitialLoadComplete(); scope.removeInitialLoadComplete();
} }
scope.removeInitialLoadComplete = scope.$on('InitialLoadComplete', function() { scope.removeInitialLoadComplete = scope.$on('InitialLoadComplete', function() {
var url;
Wait('stop'); Wait('stop');
if (JobIsFinished(scope)) { if (JobIsFinished(scope)) {
scope.liveEventProcessing = false; // signal that event processing is over and endless scroll scope.liveEventProcessing = false; // signal that event processing is over and endless scroll
scope.pauseLiveEvents = false; // should be enabled scope.pauseLiveEvents = false; // should be enabled
url = scope.job.related.job_events + '?event=playbook_on_stats'; var params = {
Rest.setUrl(url); event: 'playbook_on_stats'
Rest.get() };
JobDetailService.getRelatedJobEvents(scope.job.id, params)
.success(function(data) { .success(function(data) {
if (data.results.length > 0) { if (data.results.length > 0) {
LoadHostSummary({ LoadHostSummary({
@@ -326,11 +326,11 @@ export default
} }
scope.removeHostSummaries = scope.$on('LoadHostSummaries', function() { scope.removeHostSummaries = scope.$on('LoadHostSummaries', function() {
if(scope.job){ if(scope.job){
var url = scope.job.related.job_host_summaries + '?'; var params = {
url += '&page_size=' + scope.hostSummariesMaxRows + '&order=host_name'; page_size: scope.hostSummariesMaxRows,
order: 'host_name'
Rest.setUrl(url); };
Rest.get() JobDetailService.getJobHostSummaries(scope.job.id, params)
.success(function(data) { .success(function(data) {
scope.next_host_summaries = data.next; scope.next_host_summaries = data.next;
if (data.results.length > 0) { if (data.results.length > 0) {
@@ -356,10 +356,6 @@ export default
}; };
}); });
scope.$emit('InitialLoadComplete'); scope.$emit('InitialLoadComplete');
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
}); });
} }
@@ -372,17 +368,17 @@ export default
if (scope.activeTask) { if (scope.activeTask) {
var play = scope.jobData.plays[scope.activePlay], var play = scope.jobData.plays[scope.activePlay],
task, // = play.tasks[scope.activeTask], task;
url;
if(play){ if(play){
task = play.tasks[scope.activeTask]; task = play.tasks[scope.activeTask];
} }
if (play && task) { if (play && task) {
url = scope.job.related.job_events + '?parent=' + task.id + '&'; var params = {
url += 'event__startswith=runner&page_size=' + scope.hostResultsMaxRows + '&order=host_name,counter'; parent: task.id,
event__startswith: 'runner',
Rest.setUrl(url); page_size: scope.hostResultsMaxRows
Rest.get() };
JobDetailService.getRelatedJobEvents(scope.job.id, params)
.success(function(data) { .success(function(data) {
var idx, event, status, status_text, item, msg; var idx, event, status, status_text, item, msg;
if (data.results.length > 0) { if (data.results.length > 0) {
@@ -449,10 +445,6 @@ export default
} }
} }
scope.$emit('LoadHostSummaries'); scope.$emit('LoadHostSummaries');
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
}); });
} else { } else {
scope.$emit('LoadHostSummaries'); scope.$emit('LoadHostSummaries');
@@ -467,14 +459,15 @@ export default
} }
scope.removeLoadTasks = scope.$on('LoadTasks', function() { scope.removeLoadTasks = scope.$on('LoadTasks', function() {
if (scope.activePlay) { if (scope.activePlay) {
var play = scope.jobData.plays[scope.activePlay], url; var play = scope.jobData.plays[scope.activePlay];
if (play) { if (play) {
url = scope.job.url + 'job_tasks/?event_id=' + play.id; var params = {
url += '&page_size=' + scope.tasksMaxRows + '&order=id'; event_id: play.id,
page_size: scope.tasksMaxRows,
Rest.setUrl(url); order: 'id'
Rest.get() }
JobDetailService.getJobTasks(scope.job.id, params)
.success(function(data) { .success(function(data) {
scope.next_tasks = data.next; scope.next_tasks = data.next;
if (data.results.length > 0) { if (data.results.length > 0) {
@@ -557,7 +550,7 @@ export default
}); });
}); });
if (scope.activeTask && scope.jobData.plays[scope.activePlay] && scope.jobData.plays[scope.activePlay].tasks[scope.activeTask]) { if (scope.activeTask && scope.jobData.plays[scope.activePlay] && scope.jobData.plays[scope.activePlay].tasks[scope.activeTask]) {
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'List-tableRow--selected'; scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'JobDetail-tableRow--selected';
} }
scope.$emit('LoadHosts'); scope.$emit('LoadHosts');
}) })
@@ -584,12 +577,10 @@ export default
scope.host_summary.failed = 0; scope.host_summary.failed = 0;
scope.host_summary.total = 0; scope.host_summary.total = 0;
scope.jobData.plays = {}; scope.jobData.plays = {};
var params = {
var url = scope.job.url + 'job_plays/?order_by=id'; order_by: 'id'
url += '&page_size=' + scope.playsMaxRows + '&order_by=id'; };
JobDetailService.getJobPlays(scope.job.id, params)
Rest.setUrl(url);
Rest.get()
.success( function(data) { .success( function(data) {
scope.next_plays = data.next; scope.next_plays = data.next;
if (data.results.length > 0) { if (data.results.length > 0) {
@@ -677,13 +668,9 @@ export default
scope.host_summary.failed; scope.host_summary.failed;
}); });
if (scope.activePlay && scope.jobData.plays[scope.activePlay]) { if (scope.activePlay && scope.jobData.plays[scope.activePlay]) {
scope.jobData.plays[scope.activePlay].playActiveClass = 'List-tableRow--selected'; scope.jobData.plays[scope.activePlay].playActiveClass = 'JobDetail-tableRow--selected';
} }
scope.$emit('LoadTasks', events_url); scope.$emit('LoadTasks', events_url);
})
.error( function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
}); });
}); });
@@ -701,8 +688,7 @@ export default
scope.LoadHostSummaries = true; scope.LoadHostSummaries = true;
// Load the job record // Load the job record
Rest.setUrl(GetBasePath('jobs') + job_id + '/'); JobDetailService.getJob(job_id)
Rest.get()
.success(function(data) { .success(function(data) {
var i; var i;
scope.job = data; scope.job = data;
@@ -981,11 +967,11 @@ export default
scope.toggleLessStatus = function() { scope.toggleLessStatus = function() {
if (!scope.lessStatus) { if (!scope.lessStatus) {
$('#job-status-form .toggle-show').slideUp(200); $('#job-status-form').slideUp(200);
scope.lessStatus = true; scope.lessStatus = true;
} }
else { else {
$('#job-status-form .toggle-show').slideDown(200); $('#job-status-form').slideDown(200);
scope.lessStatus = false; scope.lessStatus = false;
} }
}; };
@@ -1009,6 +995,7 @@ export default
else { else {
$('#events-summary').slideDown(200); $('#events-summary').slideDown(200);
scope.lessEvents = false; scope.lessEvents = false;
DrawGraph({scope:scope});
} }
}; };
@@ -1175,8 +1162,7 @@ export default
if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_plays) { if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_plays) {
$('#playsMoreRows').fadeIn(); $('#playsMoreRows').fadeIn();
scope.playsLoading = true; scope.playsLoading = true;
Rest.setUrl(scope.next_plays); JobDetailService.getNextPage(scope.next_plays)
Rest.get()
.success( function(data) { .success( function(data) {
scope.next_plays = data.next; scope.next_plays = data.next;
data.results.forEach(function(event, idx) { data.results.forEach(function(event, idx) {
@@ -1241,8 +1227,7 @@ export default
if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_tasks) { if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_tasks) {
$('#tasksMoreRows').fadeIn(); $('#tasksMoreRows').fadeIn();
scope.tasksLoading = true; scope.tasksLoading = true;
Rest.setUrl(scope.next_tasks); JobDetailService.getNextPage(scope.next_tasks)
Rest.get()
.success(function(data) { .success(function(data) {
scope.next_tasks = data.next; scope.next_tasks = data.next;
data.results.forEach(function(event, idx) { data.results.forEach(function(event, idx) {
@@ -1313,8 +1298,7 @@ export default
if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_results) { if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_results) {
$('#hostResultsMoreRows').fadeIn(); $('#hostResultsMoreRows').fadeIn();
scope.hostResultsLoading = true; scope.hostResultsLoading = true;
Rest.setUrl(scope.next_host_results); JobDetailService.getNextPage(scope.next_host_results)
Rest.get()
.success(function(data) { .success(function(data) {
scope.next_host_results = data.next; scope.next_host_results = data.next;
data.results.forEach(function(row) { data.results.forEach(function(row) {
@@ -1385,8 +1369,7 @@ export default
// check for more hosts when user scrolls to bottom of host summaries list... // check for more hosts when user scrolls to bottom of host summaries list...
if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_summaries) { if (((!scope.liveEventProcessing) || (scope.liveEventProcessing && scope.pauseLiveEvents)) && scope.next_host_summaries) {
scope.hostSummariesLoading = true; scope.hostSummariesLoading = true;
Rest.setUrl(scope.next_host_summaries); JobDetailService.getNextPage(scope.next_host_summaries)
Rest.get()
.success(function(data) { .success(function(data) {
scope.next_host_summaries = data.next; scope.next_host_summaries = data.next;
data.results.forEach(function(row) { data.results.forEach(function(row) {
@@ -1432,16 +1415,10 @@ export default
$scope.$emit('LoadJob'); $scope.$emit('LoadJob');
}; };
scope.editHost = function(id) { // Click binding for the expand/collapse button on the standard out log
HostsEdit({ $scope.toggleStdoutFullscreen = function() {
host_scope: scope, $scope.stdoutFullScreen = !$scope.stdoutFullScreen;
group_scope: null, }
host_id: id,
inventory_id: scope.job.inventory,
mode: 'edit', // 'add' or 'edit'
selected_group_id: null
});
};
scope.editSchedule = function() { scope.editSchedule = function() {
// We need to get the schedule's ID out of the related links // We need to get the schedule's ID out of the related links

View File

@@ -1,238 +1,406 @@
<div class="tab-pane" id="jobs-detail"> <div class="tab-pane" id="jobs-detail">
<div ng-cloak id="htmlTemplate"> <div ng-cloak id="htmlTemplate" class="JobDetail">
<div class="row" style="position: relative;">
<div id="job-detail-container" class="JobDetail-leftSide"> <!--beginning of job-detail-container (left side) -->
<div id="job-results-panel" class="JobDetail-resultsContainer Panel"> <div id="job-detail-container" class="JobDetail-leftSide" ng-class="{'JobDetail-stdoutActionButton--active': stdoutFullScreen}">
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer"> <!--beginning of results-->
<a class="JobDetail-panelHeaderText" ng-show="lessStatus" href="" ng-click="toggleLessStatus()"> <div id="job-results-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
RESULTS<i class="JobDetail-expandArrow fa fa-caret-left"></i> <div class="JobDetail-panelHeader">
</a> <div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="!lessStatus" href="" ng-click="toggleLessStatus()"> <a class="JobDetail-panelHeaderText" ng-show="lessStatus" href="" ng-click="toggleLessStatus()">
RESULTS<i class="JobDetail-expandArrow fa fa-caret-down"></i> RESULTS<i class="JobDetail-expandArrow fa fa-caret-left"></i>
</a> </a>
</div> <a class="JobDetail-panelHeaderText" ng-show="!lessStatus" href="" ng-click="toggleLessStatus()">
<div class="JobDetail-actions"> RESULTS<i class="JobDetail-expandArrow fa fa-caret-down"></i>
<button id="submit-action" class="List-actionButton JobDetail-launchButton" data-placement="top" mode="all" ng-click="relaunchJob()" aw-tool-tip="Start a job using this template" data-original-title="" title=""><i class="fa fa-rocket"></i> </button> </a>
<button id="delete-action" class="List-actionButton List-actionButton--delete JobDetail-launchButton" data-placement="top" ng-click="deleteJobTemplate(job_template.id, job_template.name)" aw-tool-tip="Delete template" data-original-title="" title=""><i class="fa fa-trash-o"></i> </button>
</div>
</div> </div>
<div class="JobDetail-actions">
<div class="form-horizontal JobDetail-resultsDetails" role="form" id="job-status-form"> <button id="relaunch-job-button" class="List-actionButton JobDetail-launchButton" data-placement="top" mode="all" ng-click="relaunchJob()" aw-tool-tip="Relaunch using the same parameters" data-original-title="" title=""><i class="fa fa-rocket"></i> </button>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started"> <button id="cancel-job-button" class="List-actionButton List-actionButton--delete JobDetail-launchButton" data-placement="top" ng-click="deleteJob()" ng-show="job_status.status == 'running' || job_status.status=='pending' " aw-tool-tip="Cancel" data-original-title="" title=""><i class="fa fa-minus-circle"></i> </button>
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Status</label> <button id="delete-job-button" class="List-actionButton List-actionButton--delete JobDetail-launchButton" data-placement="top" ng-click="deleteJob()" ng-hide="job_status.status == 'running' || job_status.status == 'pending' " aw-tool-tip="Delete" data-original-title="" title=""><i class="fa fa-trash-o"></i> </button>
<div class="JobDetail-resultRowText"><i class="fa icon-job-{{ job_status.status }}"></i> {{ job_status.status_label }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.explanation">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 col-xs-12">Explanation</label>
<div class="JobDetail-resultRowText col-lg-10 col-md-10 col-sm-10 col-xs-9 job_status_explanation"
ng-show="!previousTaskFailed" ng-bind-html="job_status.explanation"></div>
<div class="JobDetail-resultRowText col-lg-10 col-md-10 col-sm-10 col-xs-9 job_status_explanation"
ng-show="previousTaskFailed">Previous Task Failed
<a
href=""
id="explanation_help"
aw-pop-over="{{ task_detail }}"
aw-pop-over-watch="task_detail"
data-placement="bottom"
data-container="body" class="help-link" over-title="Failure Detail"
title=""
tabindex="-1">
<i class="fa fa-question-circle">
</i>
</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.traceback">
<label class="col-lg-2 col-md-12 col-sm-12 col-xs-12">Results Traceback</label>
<div class="JobDetail-resultRowText col-lg-10 col-md-12 col-sm-12 col-xs-12 job_status_traceback" ng-bind-html="job_status.traceback"></div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_template_name">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Template</label>
<div class="JobDetail-resultRowText">
<a href="{{ job_template_url }}" aw-tool-tip="Edit the job template" data-placement="top">{{ job_template_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Started</label>
<div class="JobDetail-resultRowText">{{ job_status.started | date:'MM/dd/yy HH:mm:ss' }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_type">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Job Type</label>
<div class="JobDetail-resultRowText">{{ job_type }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Finished</label>
<div class="JobDetail-resultRowText">{{ job_status.finished | date:'MM/dd/yy HH:mm:ss' }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="created_by">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Launched By</label>
<div class="JobDetail-resultRowText">
<a href="{{ users_url }}" aw-tool-tip="Edit the User" data-placement="top">{{ created_by }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Elapsed</label>
<div class="JobDetail-resultRowText">{{ job_status.elapsed }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="scheduled_by">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Launched By</label>
<div class="JobDetail-resultRowText">
<a href aw-tool-tip="Edit the Schedule" data-placement="top" ng-click="editSchedule()">{{scheduled_by}}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="inventory_name">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Inventory</label>
<div class="JobDetail-resultRowText">
<a href="{{ inventory_url }}" aw-tool-tip="Edit the inventory" data-placement="top">{{ inventory_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="project_name">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Project</label>
<div class="JobDetail-resultRowText">
<a href="{{ project_url }}" aw-tool-tip="Edit the project" data-placement="top">{{ project_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.playbook">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Playbook</label>
<div class="JobDetail-resultRowText">{{ job.playbook }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="credential_name">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Machine Credential</label>
<div class="JobDetail-resultRowText JobDetail-resultRowText">
<a href="{{ credential_url }}" aw-tool-tip="Edit the credential" data-placement="top">{{ credential_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="cloud_credential_name">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Cloud Credential</label>
<div class="JobDetail-resultRowText">
<a href="{{ cloud_credential_url }}" aw-tool-tip="Edit the credential" data-placement="top">{{ cloud_credential_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.forks">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Forks</label>
<div class="JobDetail-resultRowText">{{ job.forks }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.limit">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Limit</label>
<div class="JobDetail-resultRowText">{{ job.limit }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="verbosity">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Verbosity</label>
<div class="JobDetail-resultRowText">{{ verbosity }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.job_tags">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Job Tags</label>
<div class="JobDetail-resultRowText">{{ job.job_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow JobDetail-resultRow--variables toggle-show" ng-show="variables">
<label class="col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Extra Variables</label>
<textarea rows="6" ng-model="variables" name="variables" id="pre-formatted-variables"></textarea>
</div>
</div> </div>
</div> </div>
<!--- JobDetail-results---------------------------------------------->
<div id="job-detail-panel" class="JobDetail-resultsContainer Panel"> <div class="form-horizontal JobDetail-resultsDetails" role="form" id="job-status-form">
<div class="JobDetail-panelHeader"> <div class="form-group JobDetail-resultRow toggle-show">
<div class="JobDetail-expandContainer"> <label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Status</label>
<a class="JobDetail-panelHeaderText" ng-show="lessDetail" href="" ng-click="toggleLessDetail()"> <div class="JobDetail-resultRowText"><i class="JobDetail-statusIcon--results fa icon-job-{{ job_status.status }}"></i> {{ job_status.status_label }}</div>
DETAILS<i class="JobDetail-expandArrow fa fa-caret-left"></i> </div>
</a>
<a class="JobDetail-panelHeaderText" ng-show="!lessDetail" href="" ng-click="toggleLessDetail()"> <div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.explanation">
DETAILS<i class="JobDetail-expandArrow fa fa-caret-down"></i> <label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 col-xs-12">Explanation</label>
<div class="JobDetail-resultRowText col-lg-10 col-md-10 col-sm-10 col-xs-9 job_status_explanation"
ng-show="!previousTaskFailed" ng-bind-html="job_status.explanation"></div>
<div class="JobDetail-resultRowText col-lg-10 col-md-10 col-sm-10 col-xs-9 job_status_explanation"
ng-show="previousTaskFailed">Previous Task Failed
<a
href=""
id="explanation_help"
aw-pop-over="{{ task_detail }}"
aw-pop-over-watch="task_detail"
data-placement="bottom"
data-container="body" class="help-link" over-title="Failure Detail"
title=""
tabindex="-1">
<i class="fa fa-question-circle">
</i>
</a> </a>
</div> </div>
</div> </div>
<div id="job-detail-details"> <div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.traceback">
<div id="play-section"> <label class="JobDetail-resultRowLabel col-lg-2 col-md-12 col-sm-12 col-xs-12">Results Traceback</label>
<div class="JobDetail-searchHeaderRow"> <div class="JobDetail-resultRowText col-lg-10 col-md-12 col-sm-12 col-xs-12 job_status_traceback" ng-bind-html="job_status.traceback"></div>
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_play_name" ng-model="search_play_name" placeholder="Play Name" ng-keypress="searchPlaysKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchPlaysEnabled" ng-click="searchPlays()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchPlaysEnabled" ng-click="search_play_name=''; searchPlays()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterPlayStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div id="plays-table-header" class="table-header">
<table class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-7 col-md-6 col-sm-6 col-xs-4">Plays</th>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Started</th>
<th class="List-tableHeader JobDetail-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Elapsed</th>
</tr>
</thead>
</table>
</div>
<div id="plays-table-detail" class="table-detail" lr-infinite-scroll="playsScrollDown"
scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-repeat="play in plays" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-class="play.playActiveClass" ng-click="selectPlay(play.id, $event)">
<td class="List-tableCell col-lg-7 col-md-6 col-sm-6 col-xs-4 status-column" aw-tool-tip="{{ play.status_tip }}" data-tip-watch="play.status_tip" data-placement="top"><i class="JobDetail-statusIcon fa icon-job-{{ play.status }}"></i>{{ play.name }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3">{{ play.created | date: 'HH:mm:ss' }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3" aw-tool-tip="{{ play.finishedTip }}" data-tip-watch="play.finishedTip"
data-placement="top">{{ play.elapsed }}</td>
</tr>
<tr ng-show="plays.length === 0 && waiting">
<td colspan="4" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="plays.length === 0 && playsLoading && !waiting">
<td colspan="4" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="plays.length === 0 && !playsLoading && !waiting">
<td colspan="4" class="col-lg-12 loading-info">No matching plays</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="playsMoreRows"><i class="fa fa-cog fa-spin"></i></div>
</div> </div>
<!-- end of plays section-->
<div id="task-section" class="section" > <div class="form-group JobDetail-resultRow toggle-show" ng-show="job_template_name">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Template</label>
<div class="JobDetail-resultRowText">
<a href="{{ job_template_url }}" aw-tool-tip="Edit the job template" data-placement="top">{{ job_template_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Started</label>
<div class="JobDetail-resultRowText">{{ job_status.started | date:'MM/dd/yy HH:mm:ss' }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_type">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Job Type</label>
<div class="JobDetail-resultRowText">{{ job_type }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Finished</label>
<div class="JobDetail-resultRowText">{{ job_status.finished | date:'MM/dd/yy HH:mm:ss' }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="created_by">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Launched By</label>
<div class="JobDetail-resultRowText">
<a href="{{ users_url }}" aw-tool-tip="Edit the User" data-placement="top">{{ created_by }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job_status.started">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Elapsed</label>
<div class="JobDetail-resultRowText">{{ job_status.elapsed }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="scheduled_by">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Launched By</label>
<div class="JobDetail-resultRowText">
<a href aw-tool-tip="Edit the Schedule" data-placement="top" ng-click="editSchedule()">{{scheduled_by}}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="inventory_name">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Inventory</label>
<div class="JobDetail-resultRowText">
<a href="{{ inventory_url }}" aw-tool-tip="Edit the inventory" data-placement="top">{{ inventory_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="project_name">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Project</label>
<div class="JobDetail-resultRowText">
<a href="{{ project_url }}" aw-tool-tip="Edit the project" data-placement="top">{{ project_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.playbook">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Playbook</label>
<div class="JobDetail-resultRowText">{{ job.playbook }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="credential_name">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Machine Credential</label>
<div class="JobDetail-resultRowText JobDetail-resultRowText">
<a href="{{ credential_url }}" aw-tool-tip="Edit the credential" data-placement="top">{{ credential_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="cloud_credential_name">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Cloud Credential</label>
<div class="JobDetail-resultRowText">
<a href="{{ cloud_credential_url }}" aw-tool-tip="Edit the credential" data-placement="top">{{ cloud_credential_name }}</a>
</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.forks">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Forks</label>
<div class="JobDetail-resultRowText">{{ job.forks }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.limit">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Limit</label>
<div class="JobDetail-resultRowText">{{ job.limit }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="verbosity">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Verbosity</label>
<div class="JobDetail-resultRowText">{{ verbosity }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.job_tags">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Job Tags</label>
<div class="JobDetail-resultRowText">{{ job.job_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow JobDetail-resultRow--variables toggle-show" ng-show="variables">
<label class="JobDetail-resultRowLabel JobDetail-extraVarsLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Extra Variables</label>
<textarea rows="6" ng-model="variables" name="variables" class="JobDetail-extraVars" id="pre-formatted-variables"></textarea>
</div>
</div>
</div>
<!--- end of results-->
<!--beginning of details-->
<div id="job-detail-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="lessDetail" href="" ng-click="toggleLessDetail()">
DETAILS<i class="JobDetail-expandArrow fa fa-caret-left"></i>
</a>
<a class="JobDetail-panelHeaderText" ng-show="!lessDetail" href="" ng-click="toggleLessDetail()">
DETAILS<i class="JobDetail-expandArrow fa fa-caret-down"></i>
</a>
</div>
</div>
<div id="job-detail-details">
<div id="play-section">
<div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_play_name" ng-model="search_play_name" placeholder="Play Name" ng-keypress="searchPlaysKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchPlaysEnabled" ng-click="searchPlays()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchPlaysEnabled" ng-click="search_play_name=''; searchPlays()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterPlayStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div id="plays-table-header" class="table-header">
<table class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-7 col-md-6 col-sm-6 col-xs-4">Plays</th>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Started</th>
<th class="List-tableHeader JobDetail-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Elapsed</th>
</tr>
</thead>
</table>
</div>
<div id="plays-table-detail" class="table-detail" lr-infinite-scroll="playsScrollDown"
scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-repeat="play in plays" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-class="play.playActiveClass" ng-click="selectPlay(play.id, $event)">
<td class="List-tableCell col-lg-7 col-md-6 col-sm-6 col-xs-4 status-column" aw-tool-tip="{{ play.status_tip }}" data-tip-watch="play.status_tip" data-placement="top"><i class="JobDetail-statusIcon fa icon-job-{{ play.status }}"></i>{{ play.name }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3">{{ play.created | date: 'HH:mm:ss' }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3" aw-tool-tip="{{ play.finishedTip }}" data-tip-watch="play.finishedTip"
data-placement="top">{{ play.elapsed }}</td>
</tr>
<tr ng-show="plays.length === 0 && waiting">
<td colspan="4" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="plays.length === 0 && playsLoading && !waiting">
<td colspan="4" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="plays.length === 0 && !playsLoading && !waiting">
<td colspan="4" class="col-lg-12 loading-info">No matching plays</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="playsMoreRows">
<i class="fa fa-cog fa-spin"></i>
</div>
</div>
<!-- end of plays section of details-->
<div id="task-section" class="section" >
<div class="JobDetail-searchHeaderRow"> <div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group"> <div class="JobDetail-searchContainer form-group">
<div class="search-name"> <div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_task_name" ng-model="search_task_name" placeholder="Task Name" ng-keypress="searchTasksKeyPress($event)" > <input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_task_name" ng-model="search_task_name" placeholder="Task Name" ng-keypress="searchTasksKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchTasksEnabled" ng-click="searchTasks()"><i class="fa fa-search"></i></a> <a class="List-searchInputIcon search-icon" ng-show="searchTasksEnabled" ng-click="searchTasks()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchTasksEnabled" ng-click="search_task_name=''; searchTasks()"><i class="fa fa-times"></i></a> <a class="List-searchInputIcon search-icon" ng-show="!searchTasksEnabled" ng-click="search_task_name=''; searchTasks()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterTaskStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div class="table-header">
<table id="tasks-table-header" class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-3 col-md-3 col-sm-6 col-xs-4">Tasks</th>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Started</th>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Elapsed</th>
<th class="List-tableHeader JobDetail-tableHeader col-lg-4 col-md-3 hidden-xs hidden-sm">Host Status</th>
</tr>
</thead>
</table>
</div>
<div id="tasks-table-detail" class="table-detail" lr-infinite-scroll="tasksScrollDown"
scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="task in taskList = (tasks) track by $index" ng-class="task.taskActiveClass" ng-click="selectTask(task.id)">
<td class="List-tableCell col-lg-3 col-md-3 col-sm-6 col-xs-4 status-column" aw-tool-tip="{{ task.status_tip }}"
data-tip-watch="task.status_tip" data-placement="top"><i class="JobDetail-statusIcon fa icon-job-{{ task.status }}"></i>{{ task.name }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3">{{ task.created | date: 'HH:mm:ss' }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3" aw-tool-tip="{{ task.finishedTip }}" data-tip-watch="task.finishedTip"
data-placement="top">{{ task.elapsed }}</td>
<td class="List-tableCell col-lg-4 col-md-3 hidden-sm hidden-xs">
<div>
<a href="" id="{{ task.id }}-successful-bar" aw-tool-tip="{{ task.successfulCountTip }}" data-tip-watch="task.successfulCountTip" data-placement="top" ng-style="task.successfulStyle">
<span class="badge successful-hosts">{{ task.successfulCount }}</span>
</a>
<a href="" id="{{ task.id }}-changed-bar" aw-tool-tip="{{ task.changedCountTip }}" data-tip-watch="task.changedCountTip" data-placement="top" ng-style="task.changedStyle">
<span class="badge changed-hosts">{{ task.changedCount }}</span>
</a>
<a href="" id="{{ task.id }}-skipped-bar" aw-tool-tip="{{ task.skippedCountTip }}" data-tip-watch="task.skippedCountTip" data-placement="top" ng-style="task.skippedStyle">
<span class="badge skipped-hosts">{{ task.skippedCount }}</span>
</a>
<a href="" id="{{ task.id }}-failed-bar" aw-tool-tip="{{ task.failedCountTip }}" data-tip-watch="task.failedCountTip" data-placement="top" ng-style="task.failedStyle">
<span class="badge failed-hosts">{{ task.failedCount }}</span>
</a>
<a href="" id="{{ task.id }}-unreachable-bar" aw-tool-tip="{{ task.unreachableCountTip }}" data-tip-watch="task.unreachableCountTip" data-placement="top" ng-style="task.unreachableStyle">
<span class="badge unreachable-hosts">{{ task.unreachableCount }}</span>
</a>
<a href="" id="{{ task.id }}-missing-bar" aw-tool-tip="{{ task.missingCountTip }}" data-tip-watch="task.missingCountTip" data-placement="top" ng-style="task.missingStyle">
<span class="badge missing-hosts">{{ task.missingCount }}</span>
</a>
<div class="no-matching-hosts inner-bar" id="{{ task.id }}-{{ task.play_id }}-no-matching-hosts-bar" aw-tool-tip="No matching hosts were found." data-placement="top" style="width: 100%;" ng-show="task.status === 'no-matching-hosts'">
No matching hosts.
</div>
</div>
</td>
</tr>
<tr ng-show="taskList.length === 0 && waiting">
<td colspan="5" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="taskList.length === 0 && tasksLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="taskList.length === 0 && !tasksLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">No matching tasks</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="tasksMoreRows"><i class="fa fa-cog fa-spin"></i></div>
</div><!-- section -->
<!--end of tasks section of details-->
<div id="task-hosts-section" class="section">
<div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_host_name" ng-model="search_host_name" placeholder="Host Name" ng-keypress="searchHostsKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchHostsEnabled" ng-click="searchHosts()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchHostsEnabled" ng-click="search_host_name=''; searchHosts()"><i class="fa fa-times"></i></a>
</div> </div>
</div> </div>
<div class="JobDetail-tableToggleContainer form-group"> <div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterTaskStatus"> <div class="btn-group" aw-toggle-button data-after-toggle="filterHostStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div class="table-header" id="hosts-table-header">
<table class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-4 col-md-3 col-sm-3 col-xs-3">Hosts</th>
<th class="List-tableHeader col-lg-3 col-md-4 col-sm-3 col-xs-3">Item</th>
<th class="List-tableHeader JobDetail-tableHeader col-lg-3 col-md-4 col-sm-3 col-xs-3">Message</th>
</tr>
</thead>
</table>
</div>
<div id="hosts-table-detail" class="table-detail" lr-infinite-scroll="hostResultsScrollDown" scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="result in results = (hostResults) track by $index">
<td class="List-tableCell col-lg-4 col-md-3 col-sm-3 col-xs-3 status-column"><a ng-click="viewHostResults(result.id)" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a></td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3 item-column">{{ result.item }}</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3">{{ result.msg }}</td>
</tr>
<tr ng-show="results.length === 0 && waiting">
<td colspan="5" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="results.length === 0 && hostResultsLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="results.length === 0 && !hostResultsLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">No matching host events</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="hostResultsMoreRows"><i class="fa fa-cog fa-spin"></i></div>
</div>
<!--end of hosts section of details-->
</div>
</div>
<!--end of details-->
</div>
<!--end of job-detail-container (left side)-->
<!--beginning of stdout-->
<div class="JobDetail-rightSide">
<!--beginning of events summary-->
<div id="events-summary-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="lessEvents" href="" ng-click="toggleLessEvents()">
EVENT SUMMARY<i class="JobDetail-expandArrow fa fa-caret-left"></i>
</a>
<a class="JobDetail-panelHeaderText" ng-show="!lessEvents" href="" ng-click="toggleLessEvents()">
EVENT SUMMARY<i class="JobDetail-expandArrow fa fa-caret-down"></i>
</a>
</div>
</div>
<div id="events-summary" style="display:none">
<div id="hosts-summary-section" class="section">
<div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_host_summary_name" ng-model="search_host_summary_name" placeholder="Host Name" ng-keypress="searchHostSummaryKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchHostSummaryEnabled" ng-click="searchHostSummary()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchHostSummaryEnabled" ng-click="search_host_summary_name=''; searchHostSummary()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterHostSummaryStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button> <button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button> <button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div> </div>
@@ -240,186 +408,29 @@
</div> </div>
<div class="table-header"> <div class="table-header">
<table id="tasks-table-header" class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-3 col-md-3 col-sm-6 col-xs-4">Tasks</div>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Started</th>
<th class="List-tableHeader col-lg-2 col-md-2 col-sm-2 col-xs-3">Elapsed</th>
<th class="List-tableHeader JobDetail-tableHeader col-lg-4 col-md-3 hidden-xs hidden-sm">Host Status</th>
</tr>
</thead>
</table>
</div>
<div id="tasks-table-detail" class="table-detail" lr-infinite-scroll="tasksScrollDown"
scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="task in taskList = (tasks) track by $index" ng-class="task.taskActiveClass" ng-click="selectTask(task.id)">
<td class="List-tableCell col-lg-3 col-md-3 col-sm-6 col-xs-4 status-column" aw-tool-tip="{{ task.status_tip }}"
data-tip-watch="task.status_tip" data-placement="top"><i class="JobDetail-statusIcon fa icon-job-{{ task.status }}"></i>{{ task.name }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3">{{ task.created | date: 'HH:mm:ss' }}</td>
<td class="List-tableCell col-lg-2 col-md-2 col-sm-2 col-xs-3" aw-tool-tip="{{ task.finishedTip }}" data-tip-watch="task.finishedTip"
data-placement="top">{{ task.elapsed }}</td>
<td class="List-tableCell col-lg-4 col-md-3 hidden-sm hidden-xs">
<div>
<a href="" id="{{ task.id }}-successful-bar" aw-tool-tip="{{ task.successfulCountTip }}" data-tip-watch="task.successfulCountTip" data-placement="top" ng-style="task.successfulStyle">
<span class="badge successful-hosts">{{ task.successfulCount }}</span>
</a>
<a href="" id="{{ task.id }}-changed-bar" aw-tool-tip="{{ task.changedCountTip }}" data-tip-watch="task.changedCountTip" data-placement="top" ng-style="task.changedStyle">
<span class="badge changed-hosts">{{ task.changedCount }}</span>
</a>
<a href="" id="{{ task.id }}-skipped-bar" aw-tool-tip="{{ task.skippedCountTip }}" data-tip-watch="task.skippedCountTip" data-placement="top" ng-style="task.skippedStyle">
<span class="badge skipped-hosts">{{ task.skippedCount }}</span>
</a>
<a href="" id="{{ task.id }}-failed-bar" aw-tool-tip="{{ task.failedCountTip }}" data-tip-watch="task.failedCountTip" data-placement="top" ng-style="task.failedStyle">
<span class="badge failed-hosts">{{ task.failedCount }}</span>
</a>
<a href="" id="{{ task.id }}-unreachable-bar" aw-tool-tip="{{ task.unreachableCountTip }}" data-tip-watch="task.unreachableCountTip" data-placement="top" ng-style="task.unreachableStyle">
<span class="badge unreachable-hosts">{{ task.unreachableCount }}</span>
</a>
<a href="" id="{{ task.id }}-missing-bar" aw-tool-tip="{{ task.missingCountTip }}" data-tip-watch="task.missingCountTip" data-placement="top" ng-style="task.missingStyle">
<span class="badge missing-hosts">{{ task.missingCount }}</span>
</a>
<div class="no-matching-hosts inner-bar" id="{{ task.id }}-{{ task.play_id }}-no-matching-hosts-bar" aw-tool-tip="No matching hosts were found." data-placement="top" style="width: 100%;" ng-show="task.status === 'no-matching-hosts'">
No matching hosts.
</div>
</div>
</td>
</tr>
<tr ng-show="taskList.length === 0 && waiting">
<td colspan="5" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="taskList.length === 0 && tasksLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="taskList.length === 0 && !tasksLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">No matching tasks</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="tasksMoreRows"><i class="fa fa-cog fa-spin"></i></div>
</div><!-- section -->
<div id="task-hosts-section" class="section">
<div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_host_name" ng-model="search_host_name" placeholder="Host Name" ng-keypress="searchHostsKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchHostsEnabled" ng-click="searchHosts()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchHostsEnabled" ng-click="search_host_name=''; searchHosts()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterHostStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div class="table-header" id="hosts-table-header">
<table class="table table-condensed"> <table class="table table-condensed">
<thead> <thead>
<tr> <tr>
<th class="List-tableHeader col-lg-3 col-md-3 col-sm-3 col-xs-3">Hosts</th> <th class="List-tableHeader col-lg-6 col-md-6 col-sm-6 col-xs-6">Hosts</th>
<th class="List-tableHeader col-lg-3 col-md-4 col-sm-3 col-xs-3">Item</th> <th class="List-tableHeader JobDetail-tableHeader col-lg-6 col-md-5 col-sm-5 col-xs-5">Completed Tasks</th>
<th class="List-tableHeader col-lg-3 col-md-4 col-sm-3 col-xs-3">Message</th>
<th class="List-tableHeader col-lg-2 col-md-1 col-sm-1 col-xs-1">Actions</th>
</tr> </tr>
</thead> </thead>
</table> </table>
</div> </div>
<div id="hosts-table-detail" class="table-detail" lr-infinite-scroll="hostResultsScrollDown" scroll-threshold="10" time-threshold="500">
<table class="table">
<tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="result in results = (hostResults) track by $index">
<td class="List-tableCell col-lg-3 col-md-3 col-sm-3 col-xs-3 status-column"><a href="" ng-click="viewHostResults(result.id)" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a></td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3 item-column">{{ result.item }}</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3">{{ result.msg }}</td>
<td class="List-actionButtonCell List-tableCell col-lg-1 col-md-1 col-sm-1 col-xs-1">
<button class="List-actionButton " ng-show="result.host_id" data-placement="top" ng-click="editHost(result.host_id)" aw-tool-tip="Edit host" data-original-title="" title=""><i class="fa fa-pencil"></i> </button>
</td>
</tr>
<tr ng-show="results.length === 0 && waiting">
<td colspan="5" class="col-lg-12 loading-info">Waiting...</td>
</tr>
<tr ng-show="results.length === 0 && hostResultsLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">Loading...</td>
</tr>
<tr ng-show="results.length === 0 && !hostResultsLoading && !waiting">
<td colspan="5" class="col-lg-12 loading-info">No matching host events</td>
</tr>
</tbody>
</table>
</div>
<div class="scroll-spinner" id="hostResultsMoreRows"><i class="fa fa-cog fa-spin"></i></div>
</div>
</div>
</div>
<div id="events-summary-panel" class="JobDetail-resultsContainer Panel">
<div id="summary-well-top-section">
<div id="hide-summary-button" style="display: hidden;">
<a href="" class="btn btn-xs btn-primary" ng-click="toggleSummary('hide')" aw-tool-tip="Hide summary" data-placement="top"><i class="fa fa-arrow-circle-right"></i></a>
</div>
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="lessEvents" href="" ng-click="toggleLessEvents()">
EVENT SUMMARY<i class="JobDetail-expandArrow fa fa-caret-left"></i>
</a>
<a class="JobDetail-panelHeaderText" ng-show="!lessEvents" href="" ng-click="toggleLessEvents()">
EVENT SUMMARY<i class="JobDetail-expandArrow fa fa-caret-down"></i>
</a>
</div>
</div>
<div id="events-summary">
<div id="hosts-summary-section" class="section">
<div class="JobDetail-searchHeaderRow">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<input type="text" class="JobDetail-searchInput form-control List-searchInput" id="search_host_summary_name" ng-model="search_host_summary_name" placeholder="Host Name" ng-keypress="searchHostSummaryKeyPress($event)" >
<a class="List-searchInputIcon search-icon" ng-show="searchHostSummaryEnabled" ng-click="searchHostSummary()"><i class="fa fa-search"></i></a>
<a class="List-searchInputIcon search-icon" ng-show="!searchHostSummaryEnabled" ng-click="search_host_summary_name=''; searchHostSummary()"><i class="fa fa-times"></i></a>
</div>
</div>
<div class="JobDetail-tableToggleContainer form-group">
<div class="btn-group" aw-toggle-button data-after-toggle="filterHostSummaryStatus">
<button class="JobDetail-tableToggle btn btn-xs btn-primary active">All</button>
<button class="JobDetail-tableToggle btn btn-xs btn-default">Failed</button>
</div>
</div>
</div>
<div class="table-header">
<table class="table table-condensed">
<thead>
<tr>
<th class="List-tableHeader col-lg-5 col-md-6 col-sm-6 col-xs-6">Hosts</th>
<th class="List-tableHeader col-lg-5 col-md-5 col-sm-5 col-xs-5">Completed Tasks</th>
<th class="List-tableHeader col-lg-2 col-md-1 col-sm-1 col-xs-1">Actions</th>
</tr>
</thead>
</table>
</div>
<div id="hosts-summary-table" class="table-detail" lr-infinite-scroll="hostSummariesScrollDown" scroll-threshold="10" time-threshold="500"> <div id="hosts-summary-table" class="table-detail" lr-infinite-scroll="hostSummariesScrollDown" scroll-threshold="10" time-threshold="500">
<table class="table"> <table class="table">
<tbody> <tbody>
<tr class="List-tableRow" ng-repeat="host in summaryList = (hosts) track by $index" id="{{ host.id }}" ng-class-even="'List-tableRow--evenRow'" ng-class-odd="'List-tableRow--oddRow'"> <tr class="List-tableRow" ng-repeat="host in summaryList = (hosts) track by $index" id="{{ host.id }}" ng-class-even="'List-tableRow--evenRow'" ng-class-odd="'List-tableRow--oddRow'">
<td class="List-tableCell name col-lg-6 col-md-6 col-sm-6 col-xs-6"><a href="" ng-click="hostEventsViewer(host.id, host.name)" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a></td> <td class="List-tableCell name col-lg-6 col-md-6 col-sm-6 col-xs-6">
<td class="List-tableCell col-lg-5 col-md-5 col-sm-5 col-xs-5 badge-column"> <a href="" ng-click="hostEventsViewer(host.id, host.name)" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a>
</td>
<td class="List-tableCell col-lg-6 col-md-5 col-sm-5 col-xs-5 badge-column">
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'ok')" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a> <a href="" ng-click="hostEventsViewer(host.id, host.name, 'ok')" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'changed')" aw-tool-tip="{{ host.changedTip }}" data-tip-watch="host.changedTip" data-placement="top" ng-hide="host.changed == 0"><span class="badge changed-hosts">{{ host.changed }}</span></a> <a href="" ng-click="hostEventsViewer(host.id, host.name, 'changed')" aw-tool-tip="{{ host.changedTip }}" data-tip-watch="host.changedTip" data-placement="top" ng-hide="host.changed == 0"><span class="badge changed-hosts">{{ host.changed }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'unreachable')" aw-tool-tip="{{ host.unreachableTip }}" data-tip-watch="host.unreachableTip" data-placement="top" ng-hide="host.unreachable == 0"><span class="badge unreachable-hosts">{{ host.unreachable }}</span></a> <a href="" ng-click="hostEventsViewer(host.id, host.name, 'unreachable')" aw-tool-tip="{{ host.unreachableTip }}" data-tip-watch="host.unreachableTip" data-placement="top" ng-hide="host.unreachable == 0"><span class="badge unreachable-hosts">{{ host.unreachable }}</span></a>
<a href="" ng-click="hostEventsViewer(host.id, host.name, 'failed')" aw-tool-tip="{{ host.failedTip }}" data-tip-watch="host.failedTip" data-placement="top" ng-hide="host.failed == 0"><span class="badge failed-hosts">{{ host.failed }}</span></a></td> <a href="" ng-click="hostEventsViewer(host.id, host.name, 'failed')" aw-tool-tip="{{ host.failedTip }}" data-tip-watch="host.failedTip" data-placement="top" ng-hide="host.failed == 0"><span class="badge failed-hosts">{{ host.failed }}</span></a>
<td class="List-actionButtonCell List-tableCell col-lg-2 col-md-1 col-sm-1 col-xs-1">
<button class="List-actionButton " ng-show="host.id" data-placement="top" ng-click="editHost(host.id)" aw-tool-tip="Edit host" data-original-title="" title=""><i class="fa fa-pencil"></i></button>
</td> </td>
</tr> </tr>
<tr ng-show="summaryList.length === 0 && waiting"> <tr ng-show="summaryList.length === 0 && waiting">
<td colspan="5" class="col-lg-12 loading-info">Waiting...</td> <td colspan="5" class="col-lg-12 loading-info">Waiting...</td>
@@ -433,19 +444,42 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="scroll-spinner" id="hostSummariesMoreRows"><i class="fa fa-cog fa-spin"></i></div>
<div class="scroll-spinner" id="hostSummariesMoreRows">
<i class="fa fa-cog fa-spin"></i>
</div>
</div><!-- section --> </div><!-- section -->
<div id="graph-section" class="JobDetail-graphSection">
<svg width="100%" height="100%"></svg> <div id="graph-section" class="JobDetail-graphSection">
<svg width="100%" height="100%"></svg>
</div>
</div>
<!--end of events summary-->
</div>
<!-- end of events summary-->
<div class="JobDetail-stdoutPanel Panel">
<div class="StandardOut-panelHeader">
<div class="StandardOut-panelHeaderText">STANDARD OUT</div>
<div class="StandardOut-panelHeaderActions">
<button class="StandardOut-actionButton" aw-tool-tip="Toggle Output" data-placement="top" ng-class="{'StandardOut-actionButton--active': stdoutFullScreen}" ng-click="toggleStdoutFullscreen()">
<i class="fa fa-arrows-alt"></i>
</button>
<a href="/api/v1/jobs/{{ job.id }}/stdout?format=txt_download&token={{ token }}">
<button class="StandardOut-actionButton" aw-tool-tip="Download Output" data-placement="top">
<i class="fa fa-download"></i>
</button>
</a>
</div> </div>
</div> </div>
</div> <standard-out-log stdout-endpoint="job.related.stdout"></standard-out-log>
</div> <!--end of job-detail-container--> </div>
</div><!-- col-md-5 -->
</div> </div>
<!--end of stdout-->
</div> </div>
</div>
<div ng-include="'/static/partials/eventviewer.html'"></div> <div ng-include="'/static/partials/eventviewer.html'"></div>

View File

@@ -0,0 +1,118 @@
export default
['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', function($rootScope, Rest, GetBasePath, ProcessErrors){
return {
/*
For ES6
it might be useful to set some default params here, e.g.
getJobHostSummaries: function(id, page_size=200, order='host_name'){}
without ES6, we'd have to supply defaults like this:
this.page_size = params.page_size ? params.page_size : 200;
*/
// GET events related to a job run
// e.g.
// ?event=playbook_on_stats
// ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter
getRelatedJobEvents: function(id, params){
var url = GetBasePath('jobs');
url = url + id + '/job_events/?';
Object.keys(params).forEach(function(key, index) {
// the API is tolerant of extra ampersands
// ?&event=playbook_on_start == ?event=playbook_on_stats
url = url + '&' + key + '=' + params[key];
});
Rest.setUrl(url);
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
},
// GET job host summaries related to a job run
// e.g. ?page_size=200&order=host_name
getJobHostSummaries: function(id, params){
var url = GetBasePath('jobs');
url = url + id + '/job_host_summaries/?'
Object.keys(params).forEach(function(key, index) {
// the API is tolerant of extra ampersands
url = url + '&' + key + '=' + params[key];
});
Rest.setUrl(url);
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
},
// GET job plays related to a job run
// e.g. ?page_size=200
getJobPlays: function(id, params){
var url = GetBasePath('jobs');
url = url + id + '/job_plays/?';
Object.keys(params).forEach(function(key, index) {
// the API is tolerant of extra ampersands
url = url + '&' + key + '=' + params[key];
});
Rest.setUrl(url);
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
},
getJobTasks: function(id, params){
var url = GetBasePath('jobs');
url = url + id + '/job_tasks/?';
Object.keys(params).forEach(function(key, index) {
// the API is tolerant of extra ampersands
url = url + '&' + key + '=' + params[key];
});
Rest.setUrl(url);
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
},
getJob: function(id){
var url = GetBasePath('jobs');
url = url + id;
Rest.setUrl(url);
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
},
// GET next set of paginated results
// expects 'next' param returned by the API e.g.
// "/api/v1/jobs/51/job_plays/?order_by=id&page=2&page_size=1"
getNextPage: function(url){
return Rest.get()
.success(function(data){
return data
})
.error(function(data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
}
}
}
];

View File

@@ -6,10 +6,12 @@
import route from './job-detail.route'; import route from './job-detail.route';
import controller from './job-detail.controller'; import controller from './job-detail.controller';
import service from './job-detail.service';
export default export default
angular.module('jobDetail', []) angular.module('jobDetail', [])
.controller('JobDetailController', controller) .controller('JobDetailController', controller)
.service('JobDetailService', service)
.run(['$stateExtender', function($stateExtender) { .run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(route); $stateExtender.addState(route);
}]); }]);

View File

@@ -0,0 +1,19 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'inventoryJobTemplateAdd',
url: '/inventories/:inventory_id/job_templates/add',
templateUrl: templateUrl('job-templates/add/job-templates-add'),
controller: 'JobTemplatesAdd',
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
};

View File

@@ -0,0 +1,437 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ 'Refresh', '$filter', '$scope', '$rootScope', '$compile',
'$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm',
'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope',
'GetBasePath', 'InventoryList', 'CredentialList', 'ProjectList',
'LookUpInit', 'md5Setup', 'ParseTypeChange', 'Wait', 'Empty', 'ToJSON',
'CallbackHelpInit', 'initSurvey', 'Prompt', 'GetChoices', '$state',
'CreateSelect2',
function(
Refresh, $filter, $scope, $rootScope, $compile,
$location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert,
ProcessErrors, ReturnToCaller, ClearScope, GetBasePath, InventoryList,
CredentialList, ProjectList, LookUpInit, md5Setup, ParseTypeChange, Wait,
Empty, ToJSON, CallbackHelpInit, SurveyControllerInit, Prompt, GetChoices,
$state, CreateSelect2
) {
ClearScope();
// Inject dynamic view
var defaultUrl = GetBasePath('job_templates'),
form = JobTemplateForm(),
generator = GenerateForm,
master = {},
CloudCredentialList = {},
selectPlaybook, checkSCMStatus,
callback,
base = $location.path().replace(/^\//, '').split('/')[0],
context = (base === 'job_templates') ? 'job_template' : 'inv';
CallbackHelpInit({ scope: $scope });
$scope.can_edit = true;
generator.inject(form, { mode: 'add', related: false, scope: $scope });
callback = function() {
// Make sure the form controller knows there was a change
$scope[form.name + '_form'].$setDirty();
};
$scope.mode = "add";
$scope.parseType = 'yaml';
ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback });
$scope.playbook_options = [];
$scope.allow_callbacks = false;
generator.reset();
md5Setup({
scope: $scope,
master: master,
check_field: 'allow_callbacks',
default_val: false
});
LookUpInit({
scope: $scope,
form: form,
current_item: ($stateParams.inventory_id !== undefined) ? $stateParams.inventory_id : null,
list: InventoryList,
field: 'inventory',
input_type: "radio"
});
// Clone the CredentialList object for use with cloud_credential. Cloning
// and changing properties to avoid collision.
jQuery.extend(true, CloudCredentialList, CredentialList);
CloudCredentialList.name = 'cloudcredentials';
CloudCredentialList.iterator = 'cloudcredential';
SurveyControllerInit({
scope: $scope,
parent_scope: $scope
});
if ($scope.removeLookUpInitialize) {
$scope.removeLookUpInitialize();
}
$scope.removeLookUpInitialize = $scope.$on('lookUpInitialize', function () {
LookUpInit({
url: GetBasePath('credentials') + '?cloud=true',
scope: $scope,
form: form,
current_item: null,
list: CloudCredentialList,
field: 'cloud_credential',
hdr: 'Select Cloud Credential',
input_type: 'radio'
});
LookUpInit({
url: GetBasePath('credentials') + '?kind=ssh',
scope: $scope,
form: form,
current_item: null,
list: CredentialList,
field: 'credential',
hdr: 'Select Machine Credential',
input_type: "radio"
});
});
var selectCount = 0;
if ($scope.removeChoicesReady) {
$scope.removeChoicesReady();
}
$scope.removeChoicesReady = $scope.$on('choicesReadyVerbosity', function () {
selectCount++;
if (selectCount === 2) {
var verbosity;
// this sets the default options for the selects as specified by the controller.
for (verbosity in $scope.verbosity_options) {
if ($scope.verbosity_options[verbosity].isDefault) {
$scope.verbosity = $scope.verbosity_options[verbosity];
}
}
$scope.job_type = $scope.job_type_options[$scope.job_type_field.default];
// if you're getting to the form from the scan job section on inventories,
// set the job type select to be scan
if ($stateParams.inventory_id) {
// This means that the job template form was accessed via inventory prop's
// This also means the job is a scan job.
$scope.job_type.value = 'scan';
$scope.jobTypeChange();
$scope.inventory = $stateParams.inventory_id;
Rest.setUrl(GetBasePath('inventory') + $stateParams.inventory_id + '/');
Rest.get()
.success(function (data) {
$scope.inventory_name = data.name;
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to lookup inventory: ' + data.id + '. GET returned status: ' + status });
});
}
CreateSelect2({
element:'#job_templates_job_type',
multiple: false
});
CreateSelect2({
element:'#playbook-select',
multiple: false
});
CreateSelect2({
element:'#job_templates_verbosity',
multiple: false
});
$scope.$emit('lookUpInitialize');
}
});
// setup verbosity options select
GetChoices({
scope: $scope,
url: defaultUrl,
field: 'verbosity',
variable: 'verbosity_options',
callback: 'choicesReadyVerbosity'
});
// setup job type options select
GetChoices({
scope: $scope,
url: defaultUrl,
field: 'job_type',
variable: 'job_type_options',
callback: 'choicesReadyVerbosity'
});
// Update playbook select whenever project value changes
selectPlaybook = function (oldValue, newValue) {
var url;
if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){
$scope.playbook_options = ['Default'];
$scope.playbook = 'Default';
Wait('stop');
}
else if (oldValue !== newValue) {
if ($scope.project) {
Wait('start');
url = GetBasePath('projects') + $scope.project + '/playbooks/';
Rest.setUrl(url);
Rest.get()
.success(function (data) {
var i, opts = [];
for (i = 0; i < data.length; i++) {
opts.push(data[i]);
}
$scope.playbook_options = opts;
Wait('stop');
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to get playbook list for ' + url + '. GET returned status: ' + status });
});
}
}
};
$scope.jobTypeChange = function(){
if($scope.job_type){
if($scope.job_type.value === 'scan'){
$scope.toggleScanInfo();
}
else if($scope.project_name === "Default"){
$scope.project_name = null;
$scope.playbook_options = [];
// $scope.playbook = 'null';
$scope.job_templates_form.playbook.$setPristine();
}
}
};
$scope.toggleScanInfo = function() {
$scope.project_name = 'Default';
if($scope.project === null){
selectPlaybook();
}
else {
$scope.project = null;
}
};
// Detect and alert user to potential SCM status issues
checkSCMStatus = function (oldValue, newValue) {
if (oldValue !== newValue && !Empty($scope.project)) {
Rest.setUrl(GetBasePath('projects') + $scope.project + '/');
Rest.get()
.success(function (data) {
var msg;
switch (data.status) {
case 'failed':
msg = "The selected project has a <em>failed</em> status. Review the project's SCM settings" +
" and run an update before adding it to a template.";
break;
case 'never updated':
msg = 'The selected project has a <em>never updated</em> status. You will need to run a successful' +
' update in order to selected a playbook. Without a valid playbook you will not be able ' +
' to save this template.';
break;
case 'missing':
msg = 'The selected project has a status of <em>missing</em>. Please check the server and make sure ' +
' the directory exists and file permissions are set correctly.';
break;
}
if (msg) {
Alert('Warning', msg, 'alert-info', null, null, null, null, true);
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to get project ' + $scope.project + '. GET returned status: ' + status });
});
}
};
// $scope.selectPlaybookUnregister = $scope.$watch('project_name', function (newval, oldval) {
// selectPlaybook(oldval, newval);
// checkSCMStatus(oldval, newval);
// });
// Register a watcher on project_name
if ($scope.selectPlaybookUnregister) {
$scope.selectPlaybookUnregister();
}
$scope.selectPlaybookUnregister = $scope.$watch('project', function (newValue, oldValue) {
if (newValue !== oldValue) {
selectPlaybook(oldValue, newValue);
checkSCMStatus();
}
});
LookUpInit({
scope: $scope,
form: form,
current_item: null,
list: ProjectList,
field: 'project',
input_type: "radio",
autopopulateLookup: (context === 'inv') ? false : true
});
if ($scope.removeSurveySaved) {
$scope.rmoveSurveySaved();
}
$scope.removeSurveySaved = $scope.$on('SurveySaved', function() {
Wait('stop');
$scope.survey_exists = true;
$scope.invalid_survey = false;
});
function saveCompleted() {
$state.go('jobTemplates', null, {reload: true});
}
if ($scope.removeTemplateSaveSuccess) {
$scope.removeTemplateSaveSuccess();
}
$scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) {
Wait('stop');
if (data.related && data.related.callback) {
Alert('Callback URL', '<p>Host callbacks are enabled for this template. The callback URL is:</p>'+
'<p style="padding: 10px 0;"><strong>' + $scope.callback_server_path + data.related.callback + '</strong></p>'+
'<p>The host configuration key is: <strong>' + $filter('sanitize')(data.host_config_key) + '</strong></p>', 'alert-info', saveCompleted, null, null, null, true);
}
else {
saveCompleted();
}
});
// Save
$scope.formSave = function () {
$scope.invalid_survey = false;
if ($scope.removeGatherFormFields) {
$scope.removeGatherFormFields();
}
$scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) {
generator.clearApiErrors();
Wait('start');
data = {};
var fld;
try {
for (fld in form.fields) {
if (form.fields[fld].type === 'select' && fld !== 'playbook') {
data[fld] = $scope[fld].value;
} else {
if (fld !== 'variables') {
data[fld] = $scope[fld];
}
}
}
data.extra_vars = ToJSON($scope.parseType, $scope.variables, true);
if(data.job_type === 'scan' && $scope.default_scan === true){
data.project = "";
data.playbook = "";
}
Rest.setUrl(defaultUrl);
Rest.post(data)
.success(function(data) {
$scope.$emit('templateSaveSuccess', data);
$scope.addedItem = data.id;
Refresh({
scope: $scope,
set: 'job_templates',
iterator: 'job_template',
url: $scope.current_url
});
if(data.survey_enabled===true){
//once the job template information is saved we submit the survey info to the correct endpoint
var url = data.url+ 'survey_spec/';
Rest.setUrl(url);
Rest.post({ name: $scope.survey_name, description: $scope.survey_description, spec: $scope.survey_questions })
.success(function () {
Wait('stop');
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to add new survey. Post returned status: ' + status });
});
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to add new job template. POST returned status: ' + status
});
});
} catch (err) {
Wait('stop');
Alert("Error", "Error parsing extra variables. Parser returned: " + err);
}
});
if ($scope.removePromptForSurvey) {
$scope.removePromptForSurvey();
}
$scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() {
var action = function () {
// $scope.$emit("GatherFormFields");
Wait('start');
$('#prompt-modal').modal('hide');
$scope.addSurvey();
};
Prompt({
hdr: 'Incomplete Survey',
body: '<div class="Prompt-bodyQuery">Do you want to create a survey before proceeding?</div>',
action: action
});
});
// users can't save a survey with a scan job
if($scope.job_type.value === "scan" && $scope.survey_enabled === true){
$scope.survey_enabled = false;
}
if($scope.survey_enabled === true && $scope.survey_exists!==true){
// $scope.$emit("PromptForSurvey");
// The original design for this was a pop up that would prompt the user if they wanted to create a
// survey, because they had enabled one but not created it yet. We switched this for now so that
// an error message would be displayed by the survey buttons that tells the user to add a survey or disabled
// surveys.
$scope.invalid_survey = true;
return;
} else {
$scope.$emit("GatherFormFields");
}
};
$scope.formCancel = function () {
$state.transitionTo('jobTemplates');
};
}
];

View File

@@ -0,0 +1,5 @@
<div class="tab-pane" id="job_templates_create">
<div ui-view></div>
<div ng-cloak id="htmlTemplate" class="Panel"></div>
<div id="survey-modal-dialog"></div>
</div>

View File

@@ -0,0 +1,23 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'jobTemplates.add',
url: '/add',
templateUrl: templateUrl('job-templates/add/job-templates-add'),
controller: 'JobTemplatesAdd',
ncyBreadcrumb: {
parent: "jobTemplates",
label: "CREATE JOB TEMPLATE"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
};

View File

@@ -0,0 +1,17 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import jobTemplateAddRoute from './job-templates-add.route';
import inventoryJobTemplateAddRoute from './inventory-job-templates-add.route';
import controller from './job-templates-add.controller';
export default
angular.module('jobTemplatesAdd', [])
.controller('JobTemplatesAdd', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(jobTemplateAddRoute);
$stateExtender.addState(inventoryJobTemplateAddRoute);
}]);

View File

@@ -0,0 +1,32 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ 'Wait', '$state', '$scope', 'jobTemplateCopyService',
'ProcessErrors', 'GetBasePath',
function(Wait, $state, $scope, jobTemplateCopyService,
ProcessErrors, GetBasePath){
// GETs the job_template to copy
// POSTs a new job_template
// routes to JobTemplates.edit when finished
var init = function(){
Wait('start');
jobTemplateCopyService.get($state.params.id)
.success(function(res){
jobTemplateCopyService.set(res)
.success(function(res){
Wait('stop');
$state.go('jobTemplates.edit', {template_id: res.id, copied: true}, {reload: true});
});
})
.error(function(res, status){
ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
});
};
init();
}
];

View File

@@ -0,0 +1,13 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'jobTemplates.copy',
route: '/:id/copy',
controller: 'jobTemplateCopyController'
}

View File

@@ -0,0 +1,38 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
['$rootScope', 'Rest', 'ProcessErrors', 'GetBasePath', 'moment',
function($rootScope, Rest, ProcessErrors, GetBasePath, moment){
return {
get: function(id){
var defaultUrl = GetBasePath('job_templates') + '?id=' + id;
Rest.setUrl(defaultUrl);
return Rest.get()
.success(function(res){
return res
})
.error(function(res, status){
ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
});
},
set: function(data){
var defaultUrl = GetBasePath('job_templates');
Rest.setUrl(defaultUrl);
data.results[0].name = data.results[0].name + ' ' + moment().format('h:mm:ss a'); // 2:49:11 pm
return Rest.post(data.results[0])
.success(function(res){
return res
})
.error(function(res, status){
ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
});
}
}
}
];

View File

@@ -0,0 +1,17 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import controller from './job-templates-copy.controller';
import route from './job-templates-copy.route';
import service from './job-templates-copy.service';
export default
angular.module('jobTemplates.copy', [])
.service('jobTemplateCopyService', service)
.controller('jobTemplateCopyController', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(route)
}]);

View File

@@ -4,23 +4,15 @@
* All Rights Reserved * All Rights Reserved
*************************************************/ *************************************************/
var rest, getBasePath; export default ['Rest', 'GetBasePath', function(Rest, GetBasePath){
return {
deleteJobTemplate: function(id){
var url = GetBasePath('job_templates');
export default url = url + id;
[ 'Rest',
'GetBasePath', Rest.setUrl(url);
function(_rest, _getBasePath) { return Rest.destroy();
rest = _rest;
getBasePath = _getBasePath;
return deleteJobTemplate;
} }
]; }
}]
function deleteJobTemplate(id) {
var url = getBasePath('job_templates');
url = url + id;
rest.setUrl(url);
return rest.destroy();
}

View File

@@ -0,0 +1,22 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'inventoryJobTemplateEdit',
url: '/inventories/:inventory_id/job_templates/:template_id',
templateUrl: templateUrl('job-templates/edit/job-templates-edit'),
controller: 'JobTemplatesEdit',
data: {
activityStreamId: 'template_id'
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
};

View File

@@ -0,0 +1,560 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name controllers.function:JobTemplatesEdit
* @description This controller's for Job Template Edit
*/
export default
[ '$filter', '$scope', '$rootScope', '$compile',
'$location', '$log', '$stateParams', 'JobTemplateForm', 'GenerateForm',
'Rest', 'Alert', 'ProcessErrors', 'RelatedSearchInit',
'RelatedPaginateInit','ReturnToCaller', 'ClearScope', 'InventoryList',
'CredentialList', 'ProjectList', 'LookUpInit', 'GetBasePath', 'md5Setup',
'ParseTypeChange', 'JobStatusToolTip', 'FormatDate', 'Wait',
'Empty', 'Prompt', 'ParseVariableString', 'ToJSON',
'SchedulesControllerInit', 'JobsControllerInit', 'JobsListUpdate',
'GetChoices', 'SchedulesListInit', 'SchedulesList', 'CallbackHelpInit',
'PlaybookRun' , 'initSurvey', '$state', 'CreateSelect2',
function(
$filter, $scope, $rootScope, $compile,
$location, $log, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert,
ProcessErrors, RelatedSearchInit, RelatedPaginateInit, ReturnToCaller,
ClearScope, InventoryList, CredentialList, ProjectList, LookUpInit,
GetBasePath, md5Setup, ParseTypeChange, JobStatusToolTip, FormatDate, Wait,
Empty, Prompt, ParseVariableString, ToJSON, SchedulesControllerInit,
JobsControllerInit, JobsListUpdate, GetChoices, SchedulesListInit,
SchedulesList, CallbackHelpInit, PlaybookRun, SurveyControllerInit, $state,
CreateSelect2
) {
ClearScope();
var defaultUrl = GetBasePath('job_templates'),
generator = GenerateForm,
form = JobTemplateForm(),
base = $location.path().replace(/^\//, '').split('/')[0],
master = {},
id = $stateParams.template_id,
relatedSets = {},
checkSCMStatus, getPlaybooks, callback,
choicesCount = 0;
CallbackHelpInit({ scope: $scope });
SchedulesList.well = false;
generator.inject(form, { mode: 'edit', related: true, scope: $scope });
$scope.mode = 'edit';
$scope.parseType = 'yaml';
$scope.showJobType = false;
SurveyControllerInit({
scope: $scope,
parent_scope: $scope,
id: id
});
callback = function() {
// Make sure the form controller knows there was a change
$scope[form.name + '_form'].$setDirty();
};
$scope.playbook_options = null;
$scope.playbook = null;
generator.reset();
getPlaybooks = function (project) {
var url;
if($scope.job_type.value === 'scan' && $scope.project_name === "Default"){
$scope.playbook_options = ['Default'];
$scope.playbook = 'Default';
Wait('stop');
}
else if (!Empty(project)) {
url = GetBasePath('projects') + project + '/playbooks/';
Wait('start');
Rest.setUrl(url);
Rest.get()
.success(function (data) {
var i;
$scope.playbook_options = [];
for (i = 0; i < data.length; i++) {
$scope.playbook_options.push(data[i]);
if (data[i] === $scope.playbook) {
$scope.job_templates_form.playbook.$setValidity('required', true);
}
}
if ($scope.playbook) {
$scope.$emit('jobTemplateLoadFinished');
} else {
Wait('stop');
}
})
.error(function () {
Wait('stop');
Alert('Missing Playbooks', 'Unable to retrieve the list of playbooks for this project. Choose a different ' +
' project or make the playbooks available on the file system.', 'alert-info');
});
}
else {
Wait('stop');
}
};
$scope.jobTypeChange = function(){
if($scope.job_type){
if($scope.job_type.value === 'scan'){
$scope.toggleScanInfo();
}
else if($scope.project_name === "Default"){
$scope.project_name = null;
$scope.playbook_options = [];
// $scope.playbook = 'null';
$scope.job_templates_form.playbook.$setPristine();
}
}
};
$scope.toggleScanInfo = function() {
$scope.project_name = 'Default';
if($scope.project === null){
getPlaybooks();
}
else {
$scope.project = null;
}
};
// Detect and alert user to potential SCM status issues
checkSCMStatus = function () {
if (!Empty($scope.project)) {
Wait('start');
Rest.setUrl(GetBasePath('projects') + $scope.project + '/');
Rest.get()
.success(function (data) {
var msg;
switch (data.status) {
case 'failed':
msg = "The selected project has a <em>failed</em> status. Review the project's SCM settings" +
" and run an update before adding it to a template.";
break;
case 'never updated':
msg = 'The selected project has a <em>never updated</em> status. You will need to run a successful' +
' update in order to selected a playbook. Without a valid playbook you will not be able ' +
' to save this template.';
break;
case 'missing':
msg = 'The selected project has a status of <em>missing</em>. Please check the server and make sure ' +
' the directory exists and file permissions are set correctly.';
break;
}
Wait('stop');
if (msg) {
Alert('Warning', msg, 'alert-info', null, null, null, null, true);
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to get project ' + $scope.project +
'. GET returned status: ' + status });
});
}
};
if ($scope.removerelatedschedules) {
$scope.removerelatedschedules();
}
$scope.removerelatedschedules = $scope.$on('relatedschedules', function() {
SchedulesListInit({
scope: $scope,
list: SchedulesList,
choices: null,
related: true
});
});
// Register a watcher on project_name. Refresh the playbook list on change.
if ($scope.watchProjectUnregister) {
$scope.watchProjectUnregister();
}
$scope.watchProjectUnregister = $scope.$watch('project', function (newValue, oldValue) {
if (newValue !== oldValue) {
getPlaybooks($scope.project);
checkSCMStatus();
}
});
// Turn off 'Wait' after both cloud credential and playbook list come back
if ($scope.removeJobTemplateLoadFinished) {
$scope.removeJobTemplateLoadFinished();
}
$scope.removeJobTemplateLoadFinished = $scope.$on('jobTemplateLoadFinished', function () {
CreateSelect2({
element:'#job_templates_job_type',
multiple: false
});
CreateSelect2({
element:'#playbook-select',
multiple: false
});
CreateSelect2({
element:'#job_templates_verbosity',
multiple: false
});
for (var set in relatedSets) {
$scope.search(relatedSets[set].iterator);
}
SchedulesControllerInit({
scope: $scope,
parent_scope: $scope,
iterator: 'schedule'
});
});
// Set the status/badge for each related job
if ($scope.removeRelatedCompletedJobs) {
$scope.removeRelatedCompletedJobs();
}
$scope.removeRelatedCompletedJobs = $scope.$on('relatedcompleted_jobs', function () {
JobsControllerInit({
scope: $scope,
parent_scope: $scope,
iterator: form.related.completed_jobs.iterator
});
JobsListUpdate({
scope: $scope,
parent_scope: $scope,
list: form.related.completed_jobs
});
});
if ($scope.cloudCredentialReadyRemove) {
$scope.cloudCredentialReadyRemove();
}
$scope.cloudCredentialReadyRemove = $scope.$on('cloudCredentialReady', function (e, name) {
var CloudCredentialList = {};
$scope.cloud_credential_name = name;
master.cloud_credential_name = name;
// Clone the CredentialList object for use with cloud_credential. Cloning
// and changing properties to avoid collision.
jQuery.extend(true, CloudCredentialList, CredentialList);
CloudCredentialList.name = 'cloudcredentials';
CloudCredentialList.iterator = 'cloudcredential';
LookUpInit({
url: GetBasePath('credentials') + '?cloud=true',
scope: $scope,
form: form,
current_item: $scope.cloud_credential,
list: CloudCredentialList,
field: 'cloud_credential',
hdr: 'Select Cloud Credential',
input_type: "radio"
});
$scope.$emit('jobTemplateLoadFinished');
});
// Retrieve each related set and populate the playbook list
if ($scope.jobTemplateLoadedRemove) {
$scope.jobTemplateLoadedRemove();
}
$scope.jobTemplateLoadedRemove = $scope.$on('jobTemplateLoaded', function (e, related_cloud_credential, masterObject, relatedSets) {
var dft, set;
master = masterObject;
getPlaybooks($scope.project);
for (set in relatedSets) {
$scope.search(relatedSets[set].iterator);
}
dft = ($scope.host_config_key === "" || $scope.host_config_key === null) ? false : true;
md5Setup({
scope: $scope,
master: master,
check_field: 'allow_callbacks',
default_val: dft
});
ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback });
if (related_cloud_credential) {
Rest.setUrl(related_cloud_credential);
Rest.get()
.success(function (data) {
$scope.$emit('cloudCredentialReady', data.name);
})
.error(function (data, status) {
ProcessErrors($scope, data, status, null, {hdr: 'Error!',
msg: 'Failed to related cloud credential. GET returned status: ' + status });
});
} else {
// No existing cloud credential
$scope.$emit('cloudCredentialReady', null);
}
});
Wait('start');
if ($scope.removeEnableSurvey) {
$scope.removeEnableSurvey();
}
$scope.removeEnableSurvey = $scope.$on('EnableSurvey', function(fld) {
$('#job_templates_survey_enabled_chbox').attr('checked', $scope[fld]);
Rest.setUrl(defaultUrl + id+ '/survey_spec/');
Rest.get()
.success(function (data) {
if(data && data.name){
$scope.survey_exists = true;
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, {
hdr: 'Error!',
msg: 'Failed to retrieve job template: ' + $stateParams.template_id + '. GET status: ' + status
});
});
});
if ($scope.removeSurveySaved) {
$scope.rmoveSurveySaved();
}
$scope.removeSurveySaved = $scope.$on('SurveySaved', function() {
Wait('stop');
$scope.survey_exists = true;
$scope.invalid_survey = false;
});
if ($scope.removeLoadJobs) {
$scope.rmoveLoadJobs();
}
$scope.removeLoadJobs = $scope.$on('LoadJobs', function() {
$scope.fillJobTemplate();
});
if ($scope.removeChoicesReady) {
$scope.removeChoicesReady();
}
$scope.removeChoicesReady = $scope.$on('choicesReady', function() {
choicesCount++;
if (choicesCount === 4) {
$scope.$emit('LoadJobs');
}
});
GetChoices({
scope: $scope,
url: GetBasePath('unified_jobs'),
field: 'status',
variable: 'status_choices',
callback: 'choicesReady'
});
GetChoices({
scope: $scope,
url: GetBasePath('unified_jobs'),
field: 'type',
variable: 'type_choices',
callback: 'choicesReady'
});
// setup verbosity options lookup
GetChoices({
scope: $scope,
url: defaultUrl,
field: 'verbosity',
variable: 'verbosity_options',
callback: 'choicesReady'
});
// setup job type options lookup
GetChoices({
scope: $scope,
url: defaultUrl,
field: 'job_type',
variable: 'job_type_options',
callback: 'choicesReady'
});
function saveCompleted() {
$state.go('jobTemplates', null, {reload: true});
}
if ($scope.removeTemplateSaveSuccess) {
$scope.removeTemplateSaveSuccess();
}
$scope.removeTemplateSaveSuccess = $scope.$on('templateSaveSuccess', function(e, data) {
Wait('stop');
if ($scope.allow_callbacks && ($scope.host_config_key !== master.host_config_key || $scope.callback_url !== master.callback_url)) {
if (data.related && data.related.callback) {
Alert('Callback URL', '<p>Host callbacks are enabled for this template. The callback URL is:</p>'+
'<p style="padding: 10px 0;"><strong>' + $scope.callback_server_path + data.related.callback + '</strong></p>'+
'<p>The host configuration key is: <strong>' + $filter('sanitize')(data.host_config_key) + '</strong></p>', 'alert-info', saveCompleted, null, null, null, true);
}
else {
saveCompleted();
}
}
else {
saveCompleted();
}
});
// Save changes to the parent
$scope.formSave = function () {
$scope.invalid_survey = false;
if ($scope.removeGatherFormFields) {
$scope.removeGatherFormFields();
}
$scope.removeGatherFormFields = $scope.$on('GatherFormFields', function(e, data) {
generator.clearApiErrors();
Wait('start');
data = {};
var fld;
try {
// Make sure we have valid variable data
data.extra_vars = ToJSON($scope.parseType, $scope.variables, true);
if(data.extra_vars === undefined ){
throw 'undefined variables';
}
for (fld in form.fields) {
if (form.fields[fld].type === 'select' && fld !== 'playbook') {
data[fld] = $scope[fld].value;
} else {
if (fld !== 'variables' && fld !== 'callback_url') {
data[fld] = $scope[fld];
}
}
}
Rest.setUrl(defaultUrl + id + '/');
Rest.put(data)
.success(function (data) {
$scope.$emit('templateSaveSuccess', data);
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to update job template. PUT returned status: ' + status });
});
} catch (err) {
Wait('stop');
Alert("Error", "Error parsing extra variables. Parser returned: " + err);
}
});
if ($scope.removePromptForSurvey) {
$scope.removePromptForSurvey();
}
$scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() {
var action = function () {
// $scope.$emit("GatherFormFields");
Wait('start');
$('#prompt-modal').modal('hide');
$scope.addSurvey();
};
Prompt({
hdr: 'Incomplete Survey',
body: '<div class="Prompt-bodyQuery">Do you want to create a survey before proceeding?</div>',
action: action
});
});
// users can't save a survey with a scan job
if($scope.job_type.value === "scan" && $scope.survey_enabled === true){
$scope.survey_enabled = false;
}
if($scope.survey_enabled === true && $scope.survey_exists!==true){
// $scope.$emit("PromptForSurvey");
// The original design for this was a pop up that would prompt the user if they wanted to create a
// survey, because they had enabled one but not created it yet. We switched this for now so that
// an error message would be displayed by the survey buttons that tells the user to add a survey or disabled
// surveys.
$scope.invalid_survey = true;
return;
} else {
$scope.$emit("GatherFormFields");
}
};
$scope.formCancel = function () {
// the form was just copied in the previous state, it's safe to destroy on cancel
if ($state.params.copied){
var defaultUrl = GetBasePath('job_templates') + $state.params.template_id;
Rest.setUrl(defaultUrl);
Rest.destroy()
.success(function(res){
$state.go('jobTemplates', null, {reload: true, notify:true});
})
.error(function(res, status){
ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
});
}
else {
$state.go('jobTemplates');
}
};
// Related set: Add button
$scope.add = function (set) {
$rootScope.flashMessage = null;
$location.path('/' + base + '/' + $stateParams.template_id + '/' + set);
};
// Related set: Edit button
$scope.edit = function (set, id) {
$rootScope.flashMessage = null;
$location.path('/' + set + '/' + id);
};
// Launch a job using the selected template
$scope.launch = function() {
if ($scope.removePromptForSurvey) {
$scope.removePromptForSurvey();
}
$scope.removePromptForSurvey = $scope.$on('PromptForSurvey', function() {
var action = function () {
// $scope.$emit("GatherFormFields");
Wait('start');
$('#prompt-modal').modal('hide');
$scope.addSurvey();
};
Prompt({
hdr: 'Incomplete Survey',
body: '<div class="Prompt-bodyQuery">Do you want to create a survey before proceeding?</div>',
action: action
});
});
if($scope.survey_enabled === true && $scope.survey_exists!==true){
$scope.$emit("PromptForSurvey");
}
else {
PlaybookRun({
scope: $scope,
id: id
});
}
};
}
];

View File

@@ -0,0 +1,5 @@
<div class="tab-pane" id="job_templates_edit">
<div ui-view></div>
<div ng-cloak id="htmlTemplate" class="Panel"></div>
<div id="survey-modal-dialog"></div>
</div>

View File

@@ -0,0 +1,25 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'jobTemplates.edit',
url: '/:template_id',
templateUrl: templateUrl('job-templates/edit/job-templates-edit'),
controller: 'JobTemplatesEdit',
data: {
activityStreamId: 'template_id'
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
},
params: {
copied: null
}
};

View File

@@ -0,0 +1,17 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import jobTemplateEditRoute from './job-templates-edit.route';
import inventoryJobTemplateEditRoute from './inventory-job-templates-edit.route';
import controller from './job-templates-edit.controller';
export default
angular.module('jobTemplatesEdit', [])
.controller('JobTemplatesEdit', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(jobTemplateEditRoute);
$stateExtender.addState(inventoryJobTemplateEditRoute);
}]);

View File

@@ -0,0 +1,105 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ '$scope', '$rootScope', '$location', '$log',
'$stateParams', 'Rest', 'Alert', 'JobTemplateList', 'generateList',
'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope',
'ProcessErrors', 'GetBasePath', 'JobTemplateForm', 'CredentialList',
'LookUpInit', 'PlaybookRun', 'Wait', '$compile',
'$state',
function(
$scope, $rootScope, $location, $log,
$stateParams, Rest, Alert, JobTemplateList, GenerateList, Prompt,
SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors,
GetBasePath, JobTemplateForm, CredentialList, LookUpInit, PlaybookRun,
Wait, $compile, $state
) {
ClearScope();
var list = JobTemplateList,
defaultUrl = GetBasePath('job_templates'),
view = GenerateList,
base = $location.path().replace(/^\//, '').split('/')[0],
mode = (base === 'job_templates') ? 'edit' : 'select';
view.inject(list, { mode: mode, scope: $scope });
$rootScope.flashMessage = null;
if ($scope.removePostRefresh) {
$scope.removePostRefresh();
}
$scope.removePostRefresh = $scope.$on('PostRefresh', function () {
// Cleanup after a delete
Wait('stop');
$('#prompt-modal').modal('hide');
});
SearchInit({
scope: $scope,
set: 'job_templates',
list: list,
url: defaultUrl
});
PaginateInit({
scope: $scope,
list: list,
url: defaultUrl
});
// Called from Inventories tab, host failed events link:
if ($stateParams.name) {
$scope[list.iterator + 'SearchField'] = 'name';
$scope[list.iterator + 'SearchValue'] = $stateParams.name;
$scope[list.iterator + 'SearchFieldLabel'] = list.fields.name.label;
}
$scope.search(list.iterator);
$scope.addJobTemplate = function () {
$state.transitionTo('jobTemplates.add');
};
$scope.editJobTemplate = function (id) {
$state.transitionTo('jobTemplates.edit', {template_id: id});
};
$scope.deleteJobTemplate = function (id, name) {
var action = function () {
$('#prompt-modal').modal('hide');
Wait('start');
var url = defaultUrl + id + '/';
Rest.setUrl(url);
Rest.destroy()
.success(function () {
$scope.search(list.iterator);
})
.error(function (data) {
Wait('stop');
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status });
});
};
Prompt({
hdr: 'Delete',
body: '<div class="Prompt-bodyQuery">Are you sure you want to delete the job template below?</div><div class="Prompt-bodyTarget">' + name + '</div>',
action: action,
actionText: 'DELETE'
});
};
$scope.submitJob = function (id) {
PlaybookRun({ scope: $scope, id: id });
};
$scope.scheduleJob = function (id) {
$state.go('jobTemplateSchedules', {id: id});
};
}
];

View File

@@ -0,0 +1,7 @@
<div class="tab-pane" id="job_templates">
<div ui-view></div>
<div ng-cloak id="htmlTemplate" class="Panel"></div>
<div ng-include="'/static/partials/schedule_dialog.html'"></div>
<div ng-include="'/static/partials/logviewer.html'"></div>
</div>

View File

@@ -0,0 +1,26 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'jobTemplates',
url: '/job_templates',
templateUrl: templateUrl('job-templates/list/job-templates-list'),
controller: 'JobTemplatesList',
data: {
activityStream: true,
activityStreamTarget: 'job_template'
},
ncyBreadcrumb: {
label: "JOB TEMPLATES"
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
};

View File

@@ -0,0 +1,15 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import route from './job-templates-list.route';
import controller from './job-templates-list.controller';
export default
angular.module('jobTemplatesList', [])
.controller('JobTemplatesList', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(route);
}]);

View File

@@ -5,10 +5,15 @@
*************************************************/ *************************************************/
import deleteJobTemplate from './delete-job-template.service'; import deleteJobTemplate from './delete-job-template.service';
import surveyMaker from './survey-maker/main'; import surveyMaker from './survey-maker/main';
import jobTemplatesList from './list/main';
import jobTemplatesAdd from './add/main';
import jobTemplatesEdit from './edit/main';
import jobTemplatesCopy from './copy/main';
export default export default
angular.module('jobTemplates', angular.module('jobTemplates',
[ surveyMaker.name [surveyMaker.name, jobTemplatesList.name, jobTemplatesAdd.name,
]) jobTemplatesEdit.name, jobTemplatesCopy.name])
.service('deleteJobTemplate', deleteJobTemplate); .service('deleteJobTemplate', deleteJobTemplate);

View File

@@ -10,30 +10,30 @@ export default
element, element,
target = (mode==='survey-taker') ? 'password-modal' : "survey-modal-dialog", target = (mode==='survey-taker') ? 'password-modal' : "survey-modal-dialog",
buttons = [{ buttons = [{
"label": "Cancel", "label": "Cancel",
"onClick": function() { "onClick": function() {
scope.cancelSurvey(this); scope.cancelSurvey(this);
}, },
"icon": "fa-times", "icon": "fa-times",
"class": "btn btn-default", "class": "btn btn-default",
"id": "survey-close-button" "id": "survey-close-button"
},{ },{
"label": (mode==='survey-taker') ? "Launch" : "Save" , "label": (mode==='survey-taker') ? "Launch" : "Save" ,
"onClick": function() { "onClick": function() {
setTimeout(function(){ setTimeout(function(){
scope.$apply(function(){ scope.$apply(function(){
if(mode==='survey-taker'){ if(mode==='survey-taker'){
scope.$emit('SurveyTakerCompleted'); scope.$emit('SurveyTakerCompleted');
} else{ } else{
scope.saveSurvey(); scope.saveSurvey();
} }
});
}); });
}); },
}, "icon": (mode==='survey-taker') ? "fa-rocket" : "fa-check",
"icon": (mode==='survey-taker') ? "fa-rocket" : "fa-check", "class": "btn btn-primary",
"class": "btn btn-primary", "id": "survey-save-button"
"id": "survey-save-button" }];
}];
CreateDialog({ CreateDialog({
id: target, id: target,
@@ -84,4 +84,3 @@ ShowFactory.$inject =
'Empty', 'Empty',
'$compile' '$compile'
]; ];

View File

@@ -20,6 +20,16 @@
display: block; display: block;
width: 100%; width: 100%;
} }
.License-submit--success.ng-hide-add, .License-submit--success.ng-hide-remove {
transition: all ease-in-out 0.5s;
}
.License-submit--success{
opacity: 1;
transition: all ease-in-out 0.5s;
}
.License-submit--success.ng-hide{
opacity: 0;
}
.License-eula textarea{ .License-eula textarea{
width: 100%; width: 100%;
height: 300px; height: 300px;
@@ -33,6 +43,9 @@
.License-field{ .License-field{
.OnePlusTwo-left--detailsRow; .OnePlusTwo-left--detailsRow;
} }
.License-field + .License-field {
margin-top: 20px;
}
.License-greenText{ .License-greenText{
color: @submit-button-bg; color: @submit-button-bg;
} }
@@ -40,16 +53,16 @@
color: #d9534f; color: #d9534f;
} }
.License-fields{ .License-fields{
.OnePlusTwo-left--details; .OnePlusTwo-left--details;
} }
.License-details { .License-details {
.OnePlusTwo-left--panel(600px); .OnePlusTwo-left--panel(650px);
} }
.License-titleText { .License-titleText {
.OnePlusTwo-panelHeader; .OnePlusTwo-panelHeader;
} }
.License-management{ .License-management{
.OnePlusTwo-right--panel(600px); .OnePlusTwo-right--panel(650px);
} }
.License-submit--container{ .License-submit--container{
height: 33px; height: 33px;
@@ -59,8 +72,25 @@
margin: 0 10px 0 0; margin: 0 10px 0 0;
} }
.License-file--container { .License-file--container {
margin: 20px 0 20px 0;
input[type=file] { input[type=file] {
display: none; display: none;
} }
} }
.License-upgradeText {
margin: 20px 0px;
}
.License-body {
margin-top: 25px;
}
.License-subTitleText {
text-transform: uppercase;
margin: 20px 0px 5px 0px;
color: @default-interface-txt;
}
.License-helperText {
color: @default-interface-txt;
}
.License-input--fake{
border-top-right-radius: 4px !important;
border-bottom-right-radius: 4px !important;
}

View File

@@ -5,9 +5,9 @@
*************************************************/ *************************************************/
export default export default
[ 'Wait', '$state', '$scope', '$location', [ 'Wait', '$state', '$scope', '$rootScope', '$location',
'GetBasePath', 'Rest', 'ProcessErrors', 'CheckLicense', 'moment', 'GetBasePath', 'Rest', 'ProcessErrors', 'CheckLicense', 'moment',
function( Wait, $state, $scope, $location, function( Wait, $state, $scope, $rootScope, $location,
GetBasePath, Rest, ProcessErrors, CheckLicense, moment){ GetBasePath, Rest, ProcessErrors, CheckLicense, moment){
$scope.getKey = function(event){ $scope.getKey = function(event){
// Mimic HTML5 spec, show filename // Mimic HTML5 spec, show filename
@@ -16,9 +16,19 @@ export default
var raw = new FileReader(); var raw = new FileReader();
// readAsFoo runs async // readAsFoo runs async
raw.onload = function(){ raw.onload = function(){
$scope.newLicense.file = JSON.parse(raw.result); try {
$scope.newLicense.file = JSON.parse(raw.result);
}
catch(err) {
ProcessErrors($rootScope, null, null, null, {msg: 'Invalid file format. Please upload valid JSON.'});
}
}
try {
raw.readAsText(event.target.files[0]);
}
catch(err) {
ProcessErrors($rootScope, null, null, null, {msg: 'Invalid file format. Please upload valid JSON.'});
} }
raw.readAsText(event.target.files[0]);
}; };
// HTML5 spec doesn't provide a way to customize file input css // HTML5 spec doesn't provide a way to customize file input css
// So we hide the default input, show our own, and simulate clicks to the hidden input // So we hide the default input, show our own, and simulate clicks to the hidden input
@@ -33,6 +43,11 @@ export default
reset(); reset();
init(); init();
$scope.success = true; $scope.success = true;
// for animation purposes
var successTimeout = setTimeout(function(){
$scope.success = false;
clearTimeout(successTimeout);
}, 4000);
}); });
}; };
var calcDaysRemaining = function(ms){ var calcDaysRemaining = function(ms){
@@ -51,6 +66,7 @@ export default
CheckLicense.get() CheckLicense.get()
.then(function(res){ .then(function(res){
$scope.license = res.data; $scope.license = res.data;
$scope.license.version = res.data.version.split('-')[0];
$scope.time = {}; $scope.time = {};
$scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining); $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining);
$scope.time.expiresOn = calcExpiresOn($scope.time.remaining); $scope.time.expiresOn = calcExpiresOn($scope.time.remaining);

View File

@@ -5,95 +5,98 @@
<div class="License-fields"> <div class="License-fields">
<div class="License-field"> <div class="License-field">
<div class="License-field--label">License</div> <div class="License-field--label">License</div>
<div class="License-field--content"> <div class="License-field--content">
<span ng-show='valid'><i class="fa fa-circle License-greenText"></i> Valid</span> <span ng-show='valid'><i class="fa fa-circle License-greenText"></i> Valid</span>
<span ng-show='invalid'><i class="fa fa-circle License-redText"></i> Invalid</span> <span ng-show='invalid'><i class="fa fa-circle License-redText"></i> Invalid</span>
</div>
</div>
<div class="License-field">
<div class="License-field--label">Version</div>
<div class="License-field--content">
{{license.version}}
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">License Type</div> <div class="License-field--label">Version</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.license_type}} {{license.version || "No result found"}}
</div> </div>
</div>
<div class="License-field">
<div class="License-field--label">License Type</div>
<div class="License-field--content">
{{license.license_info.license_type || "No result found"}}
</div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">Subscription</div> <div class="License-field--label">Subscription</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.subscription_name}} {{license.license_info.subscription_name || "No result found"}}
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">License Key</div> <div class="License-field--label">License Key</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.license_key}} {{license.license_info.license_key || "No result found"}}
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">Expires On</div> <div class="License-field--label">Expires On</div>
<div class="License-field--content"> <div class="License-field--content">
{{time.expiresOn}} {{time.expiresOn}}
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">Time Remaining</div> <div class="License-field--label">Time Remaining</div>
<div class="License-field--content"> <div class="License-field--content">
{{time.remaining}} Day {{time.remaining}} Days
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">Hosts Available</div> <div class="License-field--label">Hosts Available</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.available_instances}} {{license.license_info.available_instances || "No result found"}}
</div> </div>
</div> </div>
<div class="License-field"> <div class="License-field">
<div class="License-field--label">Hosts Used</div> <div class="License-field--label">Hosts Used</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.current_instances}} {{license.license_info.current_instances || "No result found"}}
</div> </div>
</div> </div>
<div class="License-field License-greenText"> <div class="License-field License-greenText">
<div class="License-field--label">Hosts Remaining</div> <div class="License-field--label">Hosts Remaining</div>
<div class="License-field--content"> <div class="License-field--content">
{{license.license_info.free_instances}} {{license.license_info.free_instances || "No result found"}}
</div> </div>
</div> </div>
</div> </div>
<p>If you are ready to upgrade, please contact us by clicking the button below</p> <div class="License-upgradeText">If you are ready to upgrade, please contact us by clicking the button below</div>
<a href="https://www.ansible.com/renew" target="_blank"><button class="btn btn-default">Upgrade</button></a> <a href="https://www.ansible.com/renew" target="_blank"><button class="btn btn-default">Upgrade</button></a>
</div> </div>
</div> </div>
<div class="License-management"> <div class="License-management">
<div class="Panel"> <div class="Panel">
<div class="License-titleText">License Management</div> <div class="License-titleText">License Management</div>
<p>Choose your license file, agree to the End User License Agreement, and click submit.</p> <div class="License-body">
<form id="License-form" name="license"> <p class="License-helperText">Choose your license file, agree to the End User License Agreement, and click submit.</p>
<div class="input-group License-file--container"> <form id="License-form" name="license">
<span class="btn btn-default input-group-addon" ng-click="fakeClick()">Browse...</span> <div class="License-subTitleText prepend-asterisk"> License File</div>
<input class="form-control" ng-disabled="true" placeholder="{{fileName}}" /> <div class="input-group License-file--container">
<input id="License-file" class="form-control" type="file" file-on-change="getKey"/> <span class="btn btn-default input-group-addon" ng-click="fakeClick()">Browse...</span>
</div> <input class="form-control License-input--fake" ng-disabled="true" placeholder="{{fileName}}" />
<div class="License-titleText prepend-asterisk"> End User License Agreement</div> <input id="License-file" class="form-control" type="file" file-on-change="getKey"/>
<div class="form-group License-eula"> </div>
<textarea class="form-control">{{license.eula}} <div class="License-subTitleText prepend-asterisk"> End User License Agreement</div>
</textarea> <div class="form-group License-eula">
</div> <textarea class="form-control">{{license.eula}}
<div class="form-group"> </textarea>
<div class="checkbox"> </div>
<div class="License-details--label"><input type="checkbox" ng-model="newLicense.eula" required> I agree to the End User License Agreement</div> <div class="form-group">
<div class="License-submit--container pull-right"> <div class="checkbox">
<span ng-hide="success == null || false" class="License-greenText License-submit--success pull-left">Save successful!</span> <div class="License-details--label"><input type="checkbox" ng-model="newLicense.eula" required> I agree to the End User License Agreement</div>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="newLicense.file.license_key == null || newLicense.eula == null">Submit</button> <div class="License-submit--container pull-right">
<span ng-show="success == true" class="License-greenText License-submit--success pull-left">Save successful!</span>
<button ng-click="submit()" class="btn btn-success pull-right" ng-disabled="newLicense.file.license_key == null || newLicense.eula == null">Submit</button>
</div>
</div> </div>
</div> </div>
</div> </form>
</form> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -71,7 +71,7 @@ export default
}, },
copy: { copy: {
label: 'Copy', label: 'Copy',
ngClick: "copyJobTemplate(job_template.id, job_template.name)", 'ui-sref': 'jobTemplates.copy({id: job_template.id})',
"class": 'btn-danger btn-xs', "class": 'btn-danger btn-xs',
awToolTip: 'Copy template', awToolTip: 'Copy template',
dataPlacement: 'top', dataPlacement: 'top',

View File

@@ -58,8 +58,7 @@ export default
}, },
copy: { copy: {
label: 'Copy', label: 'Copy',
ngClick: "copyJobTemplate(job_template.id, job_template.name)", 'ui-sref': 'jobTemplates.copy({id: job_template.id})', "class": 'btn-danger btn-xs',
"class": 'btn-danger btn-xs',
awToolTip: 'Copy template', awToolTip: 'Copy template',
dataPlacement: 'top', dataPlacement: 'top',
ngHide: 'job_template.summary_fields.can_copy===false' ngHide: 'job_template.summary_fields.can_copy===false'

View File

@@ -19,4 +19,8 @@
.List-tableCell { .List-tableCell {
color: @default-interface-txt; color: @default-interface-txt;
} }
&.ui-dialog-content {
overflow-x: hidden;
}
} }

View File

@@ -177,6 +177,7 @@ export default ['Rest', 'ProcessErrors', 'generateList',
minWidth: 500, minWidth: 500,
title: hdr, title: hdr,
id: 'LookupModal-dialog', id: 'LookupModal-dialog',
resizable: false,
onClose: function() { onClose: function() {
setTimeout(function() { setTimeout(function() {
scope.$apply(function() { scope.$apply(function() {

View File

@@ -0,0 +1,67 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ '$rootScope', 'pagination', '$compile','SchedulerInit', 'Rest', 'Wait',
'notificationsFormObject', 'ProcessErrors', 'GetBasePath', 'Empty',
'GenerateForm', 'SearchInit' , 'PaginateInit',
'LookUpInit', 'OrganizationList', '$scope', '$state',
function(
$rootScope, pagination, $compile, SchedulerInit, Rest, Wait,
notificationsFormObject, ProcessErrors, GetBasePath, Empty,
GenerateForm, SearchInit, PaginateInit,
LookUpInit, OrganizationList, $scope, $state
) {
var scope = $scope,
generator = GenerateForm,
form = notificationsFormObject,
url = GetBasePath('notifications');
generator.inject(form, {
mode: 'add' ,
scope:scope,
related: false
});
generator.reset();
LookUpInit({
url: GetBasePath('organization'),
scope: scope,
form: form,
list: OrganizationList,
field: 'organization',
input_type: 'radio'
});
// Save
scope.formSave = function () {
generator.clearApiErrors();
Wait('start');
Rest.setUrl(url);
Rest.post({
name: scope.name,
description: scope.description,
organization: scope.organization,
script: scope.script
})
.success(function (data) {
$rootScope.addedItem = data.id;
$state.go('inventoryScripts', {}, {reload: true});
Wait('stop');
})
.error(function (data, status) {
ProcessErrors(scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to add new inventory script. POST returned status: ' + status });
});
};
scope.formCancel = function () {
$state.transitionTo('inventoryScripts');
};
}
];

View File

@@ -0,0 +1,3 @@
<div class="tab-pane" id="notifications_add">
<div ng-cloak id="htmlTemplate" class="Panel"></div>
</div>

View File

@@ -0,0 +1,23 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'notifications.add',
route: '/add',
templateUrl: templateUrl('notifications/add/add'),
controller: 'notificationsAddController',
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
},
ncyBreadcrumb: {
parent: 'notifications',
label: 'Create Notification'
}
};

View File

@@ -0,0 +1,15 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import route from './add.route';
import controller from './add.controller';
export default
angular.module('notificationsAdd', [])
.controller('notificationsAddController', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(route);
}]);

View File

@@ -0,0 +1,97 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ 'Rest', 'Wait',
'notificationsFormObject', 'ProcessErrors', 'GetBasePath',
'GenerateForm', 'SearchInit' , 'PaginateInit',
'LookUpInit', 'OrganizationList', 'inventory_script',
'$scope', '$state',
function(
Rest, Wait,
notificationsFormObject, ProcessErrors, GetBasePath,
GenerateForm, SearchInit, PaginateInit,
LookUpInit, OrganizationList, inventory_script,
$scope, $state
) {
var generator = GenerateForm,
id = inventory_script.id,
form = notificationsFormObject,
master = {},
url = GetBasePath('notifications');
$scope.inventory_script = inventory_script;
generator.inject(form, {
mode: 'edit' ,
scope:$scope,
related: false,
activityStream: false
});
generator.reset();
LookUpInit({
url: GetBasePath('organization'),
scope: $scope,
form: form,
// hdr: "Select Custom Inventory",
list: OrganizationList,
field: 'organization',
input_type: 'radio'
});
// Retrieve detail record and prepopulate the form
Wait('start');
Rest.setUrl(url + id+'/');
Rest.get()
.success(function (data) {
var fld;
for (fld in form.fields) {
if (data[fld]) {
$scope[fld] = data[fld];
master[fld] = data[fld];
}
if (form.fields[fld].sourceModel && data.summary_fields &&
data.summary_fields[form.fields[fld].sourceModel]) {
$scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] =
data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField];
master[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] =
data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField];
}
}
Wait('stop');
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to retrieve inventory script: ' + id + '. GET status: ' + status });
});
$scope.formSave = function () {
generator.clearApiErrors();
Wait('start');
Rest.setUrl(url+ id+'/');
Rest.put({
name: $scope.name,
description: $scope.description,
organization: $scope.organization,
script: $scope.script
})
.success(function () {
$state.transitionTo('inventoryScriptsList');
Wait('stop');
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, { hdr: 'Error!',
msg: 'Failed to add new inventory script. PUT returned status: ' + status });
});
};
$scope.formCancel = function () {
$state.transitionTo('inventoryScripts');
};
}
];

View File

@@ -0,0 +1,3 @@
<div class="tab-pane" id="notficiations_edit">
<div ng-cloak id="htmlTemplate" class="Panel"></div>
</div>

View File

@@ -0,0 +1,23 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
export default {
name: 'notifications.edit',
route: '/edit',
templateUrl: templateUrl('notifications/edit/edit'),
controller: 'notificationsEditController',
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
},
ncyBreadcrumb: {
parent: 'notifications',
label: 'Edit Notification'
}
};

View File

@@ -0,0 +1,15 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import route from './edit.route';
import controller from './edit.controller';
export default
angular.module('notificationsEdit', [])
.controller('notificationsEditController', controller)
.run(['$stateExtender', function($stateExtender) {
$stateExtender.addState(route);
}]);

View File

@@ -0,0 +1,83 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ '$rootScope','Wait', 'generateList', 'notificationsListObject',
'GetBasePath' , 'SearchInit' , 'PaginateInit',
'Rest' , 'ProcessErrors', 'Prompt', '$state',
function(
$rootScope,Wait, GenerateList, notificationsListObject,
GetBasePath, SearchInit, PaginateInit,
Rest, ProcessErrors, Prompt, $state
) {
var scope = $rootScope.$new(),
defaultUrl = GetBasePath('notifications'),
list = notificationsListObject,
view = GenerateList;
view.inject( list, {
mode: 'edit',
scope: scope
});
// SearchInit({
// scope: scope,
// set: 'notifications',
// list: list,
// url: defaultUrl
// });
//
// if ($rootScope.addedItem) {
// scope.addedItem = $rootScope.addedItem;
// delete $rootScope.addedItem;
// }
// PaginateInit({
// scope: scope,
// list: list,
// url: defaultUrl
// });
//
// scope.search(list.iterator);
scope.editNotification = function(){
$state.transitionTo('notifications.edit',{
inventory_script_id: this.inventory_script.id,
inventory_script: this.inventory_script
});
};
scope.deleteNotification = function(id, name){
var action = function () {
$('#prompt-modal').modal('hide');
Wait('start');
var url = defaultUrl + id + '/';
Rest.setUrl(url);
Rest.destroy()
.success(function () {
scope.search(list.iterator);
})
.error(function (data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status });
});
};
var bodyHtml = '<div class="Prompt-bodyQuery">Are you sure you want to delete the inventory script below?</div><div class="Prompt-bodyTarget">' + name + '</div>';
Prompt({
hdr: 'Delete',
body: bodyHtml,
action: action,
actionText: 'DELETE'
});
};
scope.addNotification = function(){
$state.transitionTo('notifications.add');
};
}
];

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