Merge branch 'release_3.1.0' into 4673-alert-modal-spacing

This commit is contained in:
kensible 2017-01-17 09:32:44 -05:00 committed by GitHub
commit c1a6f7b008
71 changed files with 24193 additions and 322 deletions

1
.gitignore vendored
View File

@ -106,7 +106,6 @@ reports
*.log.[0-9]
*.results
local/
*.mo
# AWX python libs populated by requirements.txt
awx/lib/.deps_built

View File

@ -559,9 +559,12 @@ messages:
fi; \
$(PYTHON) manage.py makemessages -l $(LANG) --keep-pot
# generate l10n .json .mo
languages: $(UI_DEPS_FLAG_FILE) check-po
# generate l10n .json
ui-languages: $(UI_DEPS_FLAG_FILE) check-po
$(NPM_BIN) --prefix awx/ui run languages
# generate l10n .mo
api-languages:
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
fi; \
@ -592,8 +595,7 @@ ui-devel: $(UI_DEPS_FLAG_FILE)
ui-release: $(UI_RELEASE_FLAG_FILE)
# todo: include languages target when .po deliverables are added to source control
$(UI_RELEASE_FLAG_FILE): $(UI_DEPS_FLAG_FILE)
$(UI_RELEASE_FLAG_FILE): ui-languages $(UI_DEPS_FLAG_FILE)
$(NPM_BIN) --prefix awx/ui run build-release
touch $(UI_RELEASE_FLAG_FILE)

View File

@ -13,7 +13,7 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions
from rest_framework import metadata
from rest_framework import serializers
from rest_framework.relations import RelatedField
from rest_framework.relations import RelatedField, ManyRelatedField
from rest_framework.request import clone_request
# Ansible Tower
@ -75,7 +75,7 @@ class Metadata(metadata.SimpleMetadata):
elif getattr(field, 'fields', None):
field_info['children'] = self.get_serializer_info(field)
if hasattr(field, 'choices') and not isinstance(field, RelatedField):
if not isinstance(field, (RelatedField, ManyRelatedField)) and hasattr(field, 'choices'):
field_info['choices'] = [(choice_value, choice_name) for choice_value, choice_name in field.choices.items()]
# Indicate if a field is write-only.

View File

@ -2370,7 +2370,7 @@ class WorkflowJobTemplateNodeSerializer(WorkflowNodeBaseSerializer):
if view and view.request:
request_method = view.request.method
if request_method in ['PATCH']:
obj = view.get_object()
obj = self.instance
char_prompts = copy.copy(obj.char_prompts)
char_prompts.update(self.extract_char_prompts(data))
else:
@ -2709,18 +2709,15 @@ class WorkflowJobLaunchSerializer(BaseSerializer):
variables_needed_to_start = serializers.ReadOnlyField()
survey_enabled = serializers.SerializerMethodField()
extra_vars = VerbatimField(required=False, write_only=True)
warnings = serializers.SerializerMethodField()
workflow_job_template_data = serializers.SerializerMethodField()
class Meta:
model = WorkflowJobTemplate
fields = ('can_start_without_user_input', 'extra_vars', 'warnings',
fields = ('can_start_without_user_input', 'extra_vars',
'survey_enabled', 'variables_needed_to_start',
'node_templates_missing', 'node_prompts_rejected',
'workflow_job_template_data')
def get_warnings(self, obj):
return obj.get_warnings()
def get_survey_enabled(self, obj):
if obj:
return obj.survey_enabled and 'spec' in obj.survey_spec

View File

@ -13,7 +13,7 @@ Which will act on data older than 30 days.
For `cleanup_facts`:
`{"older_than": "4w", `granularity`: "3d"}`
`{"older_than": "4w", "granularity": "3d"}`
Which will reduce the granularity of scan data to one scan per 3 days when the data is older than 4w.

View File

@ -2946,7 +2946,10 @@ class WorkflowJobTemplateLaunch(WorkflowsEnforcementMixin, RetrieveAPIView):
always_allow_superuser = False
def update_raw_data(self, data):
obj = self.get_object()
try:
obj = self.get_object()
except PermissionDenied:
return data
extra_vars = data.pop('extra_vars', None) or {}
if obj:
for v in obj.variables_needed_to_start:

View File

@ -19,8 +19,6 @@ from __future__ import (absolute_import, division, print_function)
# Python
import contextlib
import copy
import re
import sys
import uuid
@ -77,41 +75,11 @@ class BaseCallbackModule(CallbackBase):
super(BaseCallbackModule, self).__init__()
self.task_uuids = set()
def censor_result(self, res, no_log=False):
if not isinstance(res, dict):
if no_log:
return "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
return res
if res.get('_ansible_no_log', no_log):
new_res = {}
for k in self.CENSOR_FIELD_WHITELIST:
if k in res:
new_res[k] = res[k]
if k == 'cmd' and k in res:
if isinstance(res['cmd'], list):
res['cmd'] = ' '.join(res['cmd'])
if re.search(r'\s', res['cmd']):
new_res['cmd'] = re.sub(r'^(([^\s\\]|\\\s)+).*$',
r'\1 <censored>',
res['cmd'])
new_res['censored'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
res = new_res
if 'results' in res:
if isinstance(res['results'], list):
for i in xrange(len(res['results'])):
res['results'][i] = self.censor_result(res['results'][i], res.get('_ansible_no_log', no_log))
elif res.get('_ansible_no_log', False):
res['results'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
return res
@contextlib.contextmanager
def capture_event_data(self, event, **event_data):
event_data.setdefault('uuid', str(uuid.uuid4()))
if 'res' in event_data:
event_data['res'] = self.censor_result(copy.copy(event_data['res']))
if event not in self.EVENTS_WITHOUT_TASK:
task = event_data.pop('task', None)
else:
@ -258,7 +226,7 @@ class BaseCallbackModule(CallbackBase):
if task_uuid in self.task_uuids:
# FIXME: When this task UUID repeats, it means the play is using the
# free strategy, so different hosts may be running different tasks
# within a play.
# within a play.
return
self.task_uuids.add(task_uuid)
self.set_task(task)

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -985,8 +985,6 @@ class ProjectUpdateAccess(BaseAccess):
@check_superuser
def can_cancel(self, obj):
if not obj.can_cancel:
return False
if self.user == obj.created_by:
return True
# Project updates cascade delete with project, admin role descends from org admin
@ -1395,7 +1393,8 @@ class WorkflowJobTemplateNodeAccess(BaseAccess):
qs = self.model.objects.filter(
workflow_job_template__in=WorkflowJobTemplate.accessible_objects(
self.user, 'read_role'))
qs = qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes')
qs = qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes',
'unified_job_template')
return qs
def can_use_prompted_resources(self, data):

View File

@ -1002,9 +1002,8 @@ class InventorySourceOptions(BaseModel):
if r not in valid_regions and r not in invalid_regions:
invalid_regions.append(r)
if invalid_regions:
raise ValidationError(_('Invalid %(source)s region%(plural)s: %(region)s') % {
'source': self.source, 'plural': '' if len(invalid_regions) == 1 else 's',
'region': ', '.join(invalid_regions)})
raise ValidationError(_('Invalid %(source)s region: %(region)s') % {
'source': self.source, 'region': ', '.join(invalid_regions)})
return ','.join(regions)
source_vars_dict = VarsDictProperty('source_vars')
@ -1028,9 +1027,8 @@ class InventorySourceOptions(BaseModel):
if instance_filter_name not in self.INSTANCE_FILTER_NAMES:
invalid_filters.append(instance_filter)
if invalid_filters:
raise ValidationError(_('Invalid filter expression%(plural)s: %(filter)s') %
{'plural': '' if len(invalid_filters) == 1 else 's',
'filter': ', '.join(invalid_filters)})
raise ValidationError(_('Invalid filter expression: %(filter)s') %
{'filter': ', '.join(invalid_filters)})
return instance_filters
def clean_group_by(self):
@ -1047,9 +1045,8 @@ class InventorySourceOptions(BaseModel):
if c not in valid_choices and c not in invalid_choices:
invalid_choices.append(c)
if invalid_choices:
raise ValidationError(_('Invalid group by choice%(plural)s: %(choice)s') %
{'plural': '' if len(invalid_choices) == 1 else 's',
'choice': ', '.join(invalid_choices)})
raise ValidationError(_('Invalid group by choice: %(choice)s') %
{'choice': ', '.join(invalid_choices)})
return ','.join(choices)

View File

@ -134,7 +134,7 @@ class WorkflowNodeBase(CreatedModifiedModel):
scan_errors = ujt_obj._extra_job_type_errors(accepted_fields)
ignored_dict.update(scan_errors)
for fd in ['inventory', 'credential']:
if getattr(ujt_obj, fd) is None and not (ask_for_vars_dict.get(fd, False) and fd in prompts_dict):
if getattr(ujt_obj, "{}_id".format(fd)) is None and not (ask_for_vars_dict.get(fd, False) and fd in prompts_dict):
missing_dict[fd] = 'Job Template does not have this field and workflow node does not provide it'
data = {}
@ -421,18 +421,22 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
def can_start_without_user_input(self):
'''Return whether WFJT can be launched without survey passwords.'''
return not bool(self.variables_needed_to_start)
return not bool(
self.variables_needed_to_start or
self.node_templates_missing() or
self.node_prompts_rejected())
def get_warnings(self):
warning_data = {}
for node in self.workflow_job_template_nodes.all():
if node.unified_job_template is None:
warning_data[node.pk] = 'Node is missing a linked unified_job_template'
continue
def node_templates_missing(self):
return [node.pk for node in self.workflow_job_template_nodes.filter(
unified_job_template__isnull=True).all()]
def node_prompts_rejected(self):
node_list = []
for node in self.workflow_job_template_nodes.select_related('unified_job_template').all():
node_prompts_warnings = node.get_prompts_warnings()
if node_prompts_warnings:
warning_data[node.pk] = node_prompts_warnings
return warning_data
node_list.append(node.pk)
return node_list
def user_copy(self, user):
new_wfjt = self.copy_unified_jt()

View File

@ -125,6 +125,7 @@ class TestWorkflowJobTemplateNodeSerializerCharPrompts():
serializer = WorkflowJobTemplateNodeSerializer()
node = WorkflowJobTemplateNode(pk=1)
node.char_prompts = {'limit': 'webservers'}
serializer.instance = node
view = FakeView(node)
view.request = FakeRequest()
view.request.method = "PATCH"

View File

@ -154,7 +154,7 @@ STDOUT_MAX_BYTES_DISPLAY = 1048576
# Returned in the header on event api lists as a recommendation to the UI
# on how many events to display before truncating/hiding
RECOMMENDED_MAX_EVENTS_DISPLAY_HEADER = 10000
RECOMMENDED_MAX_EVENTS_DISPLAY_HEADER = 4000
# The maximum size of the ansible callback event's res data structure
# beyond this limit and the value will be removed
@ -169,6 +169,10 @@ SESSION_COOKIE_SECURE = True
# Disallow sending csrf cookies over insecure connections
CSRF_COOKIE_SECURE = True
# Limit CSRF cookies to browser sessions
CSRF_COOKIE_AGE = None
TEMPLATE_CONTEXT_PROCESSORS = ( # NOQA
'django.contrib.auth.context_processors.auth',
'django.core.context_processors.debug',

View File

@ -52,7 +52,7 @@
<div class="col-sm-6">
</div>
<div class="col-sm-6 footer-copyright">
Copyright &copy; 2016 <a href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc. All Rights Reserved.
Copyright &copy; 2017 <a href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc. All Rights Reserved.
</div>
</div>
</div>

View File

@ -1720,8 +1720,7 @@ tr td button i {
/* PW progress bar */
.pw-progress {
margin-top: 10px;
.pw-progress { margin-top: 10px;
li {
line-height: normal;

View File

@ -11,19 +11,19 @@
<!-- Don't indent this properly, you'll break the cow -->
<pre class="About-cowsay--code">
________________
/ Tower {{version_str}} \\
\\<span>{{version}}</span>/
/ Tower {{version_str}} \
\<span>{{version}}</span>/
----------------
\\ ^__^
\\ (oo)\\_______
(__) A )\\/\\
\ ^__^
\ (oo)\_______
(__) A )\/\
||----w |
|| ||
</pre>
</div>
<div class="About-modal--footer">
<img class="About-brand--redhat img-responsive" src="/static/assets/tower-logo-login.svg" />
<p class="text-right">Copyright &copy; 2016 Red Hat, Inc. <br>
<p class="text-right">Copyright &copy; 2017 Red Hat, Inc. <br>
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
</div>
</div>

View File

@ -0,0 +1,36 @@
/** @define OwnerList */
@import "./client/src/shared/branding/colors.default.less";
.OwnerList {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
.OwnerList-seeBase {
display: flex;
max-width: 100%;
color: @default-link;
text-transform: uppercase;
padding: 2px 15px;
cursor: pointer;
border-radius: 5px;
font-size: 11px;
}
.OwnerList-seeBase:hover {
color: @default-link-hov;
}
.OwnerList-seeLess {
.OwnerList-seeBase;
}
.OwnerList-seeMore {
.OwnerList-seeBase;
}
.OwnerList-Container {
margin-right: 5px;
}

View File

@ -1,5 +1,12 @@
<div ng-repeat="owner in owners_list">
<a ng-if="owner.type === 'organization'" ui-sref="organizations.edit({ organization_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
<a ng-if="owner.type === 'user'" ui-sref="users.edit({ user_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
<a ng-if="owner.type === 'team'" ui-sref="teams.edit({ team_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
</div>
<div class="OwnerList" ng-init="ownersLimit = 5; ownersLimitConst = 5; ">
<div class="OwnerList-Container" ng-repeat="owner in owners_list | limitTo:ownersLimit">
<a ng-if="owner.type === 'organization'" ui-sref="organizations.edit({ organization_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
<a ng-if="owner.type === 'user'" ui-sref="users.edit({ user_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
<a ng-if="owner.type === 'team'" ui-sref="teams.edit({ team_id: owner.id })">{{ owner.name }}{{$last ? '' : ', '}}</a>
</div>
<div class="OwnerList-seeMore" ng-show="owners_list.length > ownersLimitConst && ownersLimit == ownersLimitConst"
ng-click="ownersLimit = owners_list.length">View More</div>
<div class="OwnerList-seeLess" ng-show="owners_list.length > ownersLimitConst && ownersLimit != ownersLimitConst"
ng-click="ownersLimit = ownersLimitConst">View Less</div>
</div>

View File

@ -1,3 +1,3 @@
<footer class='Footer'>
<div class="Footer-copyright" ng-class="{'is-loggedOut' : !current_user || !current_user.username}">Copyright &copy 2016 <a class="Footer-link" href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc.</div>
<div class="Footer-copyright" ng-class="{'is-loggedOut' : !current_user || !current_user.username}">Copyright &copy 2017 <a class="Footer-link" href="http://www.redhat.com" target="_blank">Red Hat</a>, Inc.</div>
</footer>

View File

@ -24,10 +24,7 @@ export default
var langUrl = langInfo.replace('-', '_');
//gettextCatalog.debug = true;
gettextCatalog.setCurrentLanguage(langInfo);
// TODO: the line below is commented out temporarily until
// the .po files are received from the i18n team, in order to avoid
// 404 file not found console errors in dev
// gettextCatalog.loadRemote('/static/languages/' + langUrl + '.json');
gettextCatalog.loadRemote('/static/languages/' + langUrl + '.json');
};
}])
.factory('i18n', ['gettextCatalog',

View File

@ -11,7 +11,7 @@
$scope.item = group;
$scope.submitMode = $stateParams.groups === undefined ? 'move' : 'copy';
$scope['toggle_'+ list.iterator] = function(id){
$scope.toggle_row = function(id){
// toggle off anything else currently selected
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;});
// yoink the currently selected thing
@ -60,9 +60,6 @@
};
function init(){
var url = GetBasePath('inventory') + $stateParams.inventory_id + '/groups/';
url += $stateParams.group ? '?not__id__in=' + group.id + ',' + _.last($stateParams.group) : '?not__id=' + group.id;
list.basePath = url;
$scope.atRootLevel = $stateParams.group ? false : true;
// search init

View File

@ -8,10 +8,10 @@
['$scope', '$state', '$stateParams', 'generateList', 'HostManageService', 'GetBasePath', 'CopyMoveGroupList', 'host', 'Dataset',
function($scope, $state, $stateParams, GenerateList, HostManageService, GetBasePath, CopyMoveGroupList, host, Dataset){
var list = CopyMoveGroupList;
$scope.item = host;
$scope.submitMode = 'copy';
$scope['toggle_'+ list.iterator] = function(id){
$scope.toggle_row = function(id){
// toggle off anything else currently selected
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;});
// yoink the currently selected thing

View File

@ -30,8 +30,8 @@ var copyMoveGroupRoute = {
resolve: {
Dataset: ['CopyMoveGroupList', 'QuerySet', '$stateParams', 'GetBasePath', 'group',
function(list, qs, $stateParams, GetBasePath, group) {
$stateParams.copy_search.not__id__in = ($stateParams.group.length > 0 ? group.id + ',' + _.last($stateParams.group) : group.id);
let path = GetBasePath(list.name);
$stateParams.copy_search.not__id__in = ($stateParams.group && $stateParams.group.length > 0 ? group.id + ',' + _.last($stateParams.group) : group.id.toString());
let path = GetBasePath('inventory') + $stateParams.inventory_id + '/groups/';
return qs.search(path, $stateParams.copy_search);
}
],
@ -66,7 +66,7 @@ var copyMoveHostRoute = {
resolve: {
Dataset: ['CopyMoveGroupList', 'QuerySet', '$stateParams', 'GetBasePath',
function(list, qs, $stateParams, GetBasePath) {
let path = GetBasePath(list.name);
let path = GetBasePath('inventory') + $stateParams.inventory_id + '/hosts/';
return qs.search(path, $stateParams.copy_search);
}
],
@ -80,7 +80,9 @@ var copyMoveHostRoute = {
controller: CopyMoveHostsController,
},
'copyMoveList@inventoryManage.copyMoveHost': {
templateProvider: function(CopyMoveGroupList, generateList) {
templateProvider: function(CopyMoveGroupList, generateList, $stateParams, GetBasePath) {
let list = CopyMoveGroupList;
list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/hosts/';
let html = generateList.build({
list: CopyMoveGroupList,
mode: 'lookup',

View File

@ -98,9 +98,17 @@ export default {
},
// target ui-views with name@inventoryManage state
'groupsList@inventoryManage': {
templateProvider: function(InventoryGroups, generateList, $templateRequest) {
templateProvider: function(InventoryGroups, generateList, $templateRequest, $stateParams, GetBasePath) {
let list = _.cloneDeep(InventoryGroups);
if($stateParams && $stateParams.group) {
list.basePath = GetBasePath('groups') + _.last($stateParams.group) + '/children';
}
else {
//reaches here if the user is on the root level group
list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/root_groups';
}
let html = generateList.build({
list: InventoryGroups,
list: list,
mode: 'edit'
});
html = generateList.wrapPanel(html);
@ -112,9 +120,17 @@ export default {
controller: GroupsListController
},
'hostsList@inventoryManage': {
templateProvider: function(InventoryHosts, generateList) {
templateProvider: function(InventoryHosts, generateList, $stateParams, GetBasePath) {
let list = _.cloneDeep(InventoryHosts);
if($stateParams && $stateParams.group) {
list.basePath = GetBasePath('groups') + _.last($stateParams.group) + '/all_hosts';
}
else {
//reaches here if the user is on the root level group
list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/hosts';
}
let html = generateList.build({
list: InventoryHosts,
list: list,
mode: 'edit'
});
return generateList.wrapPanel(html);

View File

@ -15,7 +15,7 @@ export default [ 'templateUrl',
link: function(scope) {
// as count is changed by event data coming in,
// update the host status bar
scope.$watch('count', function(val) {
var toDestroy = scope.$watch('count', function(val) {
if (val) {
Object.keys(val).forEach(key => {
// reposition the hosts status bar by setting
@ -38,6 +38,10 @@ export default [ 'templateUrl',
.filter(key => (val[key] > 0)).length > 0);
}
});
scope.$on('$destroy', function(){
toDestroy();
});
}
};
}];

View File

@ -162,6 +162,7 @@
.JobResultsStdOut-stdoutColumn {
padding-left: 20px;
padding-right: 20px;
padding-top: 2px;
padding-bottom: 2px;
color: @default-interface-txt;
@ -171,6 +172,12 @@
width:100%;
}
.JobResultsStdOut-stdoutColumn--tooMany {
font-weight: bold;
text-transform: uppercase;
color: @default-err;
}
.JobResultsStdOut-stdoutColumn {
cursor: pointer;
}
@ -216,6 +223,11 @@
color: @default-interface-txt;
}
.JobResultsStdOut-cappedLine {
color: @b7grey;
font-style: italic;
}
@media (max-width: @breakpoint-md) {
.JobResultsStdOut-numberColumnPreload {
display: none;

View File

@ -12,6 +12,18 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
templateUrl: templateUrl('job-results/job-results-stdout/job-results-stdout'),
restrict: 'E',
link: function(scope, element) {
var toDestroy = [],
resizer,
scrollWatcher;
scope.$on('$destroy', function(){
$(window).off("resize", resizer);
$(window).off("scroll", scrollWatcher);
$(".JobResultsStdOut-stdoutContainer").off('scroll',
scrollWatcher);
toDestroy.forEach(closureFunc => closureFunc());
});
scope.stdoutContainerAvailable.resolve("container available");
// utility function used to find the top visible line and
// parent header in the pane
@ -115,9 +127,15 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
// stop iterating over the standard out
// lines once the first one has been
// found
$this = null;
return false;
}
});
}
$this = null;
});
$container = null;
return {
visLine: visItem,
@ -131,22 +149,24 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
} else {
scope.isMobile = false;
}
// watch changes to the window size
$(window).resize(function() {
resizer = function() {
// and update the isMobile var accordingly
if (window.innerWidth <= 1200 && !scope.isMobile) {
scope.isMobile = true;
} else if (window.innerWidth > 1200 & scope.isMobile) {
scope.isMobile = false;
}
});
};
// watch changes to the window size
$(window).resize(resizer);
var lastScrollTop;
var initScrollTop = function() {
lastScrollTop = 0;
};
var scrollWatcher = function() {
scrollWatcher = function() {
var st = $(this).scrollTop();
var netScroll = st + $(this).innerHeight();
var fullHeight;
@ -178,11 +198,15 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
}
lastScrollTop = st;
st = null;
netScroll = null;
fullHeight = null;
};
// update scroll watchers when isMobile changes based on
// window resize
scope.$watch('isMobile', function(val) {
toDestroy.push(scope.$watch('isMobile', function(val) {
if (val === true) {
// make sure ^ TOP always shown for mobile
scope.stdoutOverflowed = true;
@ -204,7 +228,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
$(".JobResultsStdOut-stdoutContainer").on('scroll',
scrollWatcher);
}
});
}));
// called to scroll to follow anchor
scope.followScroll = function() {
@ -237,7 +261,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
// if following becomes active, go ahead and get to the bottom
// of the standard out pane
scope.$watch('followEngaged', function(val) {
toDestroy.push(scope.$watch('followEngaged', function(val) {
// scroll to follow point if followEngaged is true
if (val) {
scope.followScroll();
@ -251,7 +275,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll',
scope.followTooltip = "Click to follow standard out as it comes in.";
}
}
});
}));
// follow button ng-click function
scope.followToggleClicked = function() {

View File

@ -34,6 +34,13 @@
<div id="topAnchor" class="JobResultsStdOut-topAnchor"></div>
<div class="JobResultsStdOut-numberColumnPreload"></div>
<div id='lineAnchor' class="JobResultsStdOut-lineAnchor"></div>
<div class="JobResultsStdOut-aLineOfStdOut"
ng-show="tooManyEvents">
<div class="JobResultsStdOut-lineNumberColumn">
<span class="JobResultsStdOut-lineExpander"> </span>
</div>
<div class="JobResultsStdOut-stdoutColumn JobResultsStdOut-stdoutColumn--tooMany">The standard output is too large to display. Please specify additional filters to narrow the standard out.</div>
</div>
<div id="followAnchor"
class="JobResultsStdOut-followAnchor">
<div class="JobResultsStdOut-toTop"

View File

@ -148,6 +148,7 @@
background-color: @default-bg;
border-radius: 5px;
color: @default-interface-txt;
margin-right: -5px;
}
.JobResults-panelRight {

View File

@ -1,5 +1,11 @@
export default ['jobData', 'jobDataOptions', 'jobLabels', 'jobFinished', 'count', '$scope', 'ParseTypeChange', 'ParseVariableString', 'jobResultsService', 'eventQueue', '$compile', '$log', 'Dataset', '$q', 'Rest', '$state', 'QuerySet', '$rootScope', 'moment',
function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, $log, Dataset, $q, Rest, $state, QuerySet, $rootScope, moment) {
var toDestroy = [];
var cancelRequests = false;
// this allows you to manage the timing of rest-call based events as
// filters are updated. see processPage for more info
var currentContext = 1;
// used for tag search
$scope.job_event_dataset = Dataset.data;
@ -66,14 +72,14 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
// update label in left pane and tooltip in right pane when the job_status
// changes
$scope.$watch('job_status', function(status) {
toDestroy.push($scope.$watch('job_status', function(status) {
if (status) {
$scope.status_label = $scope.jobOptions.status.choices
.filter(val => val[0] === status)
.map(val => val[1])[0];
$scope.status_tooltip = "Job " + $scope.status_label;
}
});
}));
// update the job_status value. Use the cached rootScope value if there
// is one. This is a workaround when the rest call for the jobData is
@ -185,7 +191,12 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
// This is where the async updates to the UI actually happen.
// Flow is event queue munging in the service -> $scope setting in here
var processEvent = function(event) {
var processEvent = function(event, context) {
// only care about filter context checking when the event comes
// as a rest call
if (context && context !== currentContext) {
return;
}
// put the event in the queue
var mungedEvent = eventQueue.populate(event);
@ -278,6 +289,9 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
.stdout)($scope.events[mungedEvent
.counter]));
}
classList = null;
putIn = null;
} else {
// this is a header or recap line, so just
// append to the bottom
@ -357,99 +371,113 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
getSkeleton(jobData.related.job_events + "?order_by=id&or__event__in=playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats");
});
var getEvents;
var processPage = function(events, context) {
// currentContext is the context of the filter when this request
// to processPage was made
//
// currentContext is the context of the filter currently
//
// if they are not the same, make sure to stop process events/
// making rest calls for next pages/etc. (you can see context is
// also passed into getEvents and processEvent and similar checks
// exist in these functions)
//
// also, if the page doesn't contain results (i.e.: the response
// returns an error), don't process the page
if (context !== currentContext || events === undefined ||
events.results === undefined) {
return;
}
events.results.forEach(event => {
// get the name in the same format as the data
// coming over the websocket
event.event_name = event.event;
delete event.event;
processEvent(event, context);
});
if (events.next && !cancelRequests) {
getEvents(events.next, context);
} else {
// put those paused events into the pane
$scope.gotPreviouslyRanEvents.resolve("");
}
};
// grab non-header recap lines
var getEvents = function(url) {
getEvents = function(url, context) {
if (context !== currentContext) {
return;
}
jobResultsService.getEvents(url)
.then(events => {
events.results.forEach(event => {
// get the name in the same format as the data
// coming over the websocket
event.event_name = event.event;
delete event.event;
processEvent(event);
});
if (events.next) {
getEvents(events.next);
} else {
// put those paused events into the pane
$scope.gotPreviouslyRanEvents.resolve("");
}
processPage(events, context);
});
};
// grab non-header recap lines
$scope.$watch('job_event_dataset', function(val) {
toDestroy.push($scope.$watch('job_event_dataset', function(val) {
eventQueue.initialize();
Object.keys($scope.events)
.forEach(v => {
// dont destroy scope events for skeleton lines
let name = $scope.events[v].event.name;
if (!(name === "playbook_on_play_start" ||
name === "playbook_on_task_start" ||
name === "playbook_on_stats")) {
$scope.events[v].$destroy();
$scope.events[v] = null;
delete $scope.events[v];
}
});
// pause websocket events from coming in to the pane
$scope.gotPreviouslyRanEvents = $q.defer();
currentContext += 1;
let context = currentContext;
$( ".JobResultsStdOut-aLineOfStdOut.not_skeleton" ).remove();
$scope.hasSkeleton.promise.then(() => {
val.results.forEach(event => {
// get the name in the same format as the data
// coming over the websocket
event.event_name = event.event;
delete event.event;
processEvent(event);
});
if (val.next) {
getEvents(val.next);
if (val.count > parseInt(val.maxEvents)) {
$(".header_task").hide();
$(".header_play").hide();
$scope.tooManyEvents = true;
} else {
// put those paused events into the pane
$scope.gotPreviouslyRanEvents.resolve("");
$(".header_task").show();
$(".header_play").show();
$scope.tooManyEvents = false;
processPage(val, context);
}
});
});
}));
// Processing of job_events messages from the websocket
$scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) {
toDestroy.push($scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) {
$q.all([$scope.gotPreviouslyRanEvents.promise,
$scope.hasSkeleton.promise]).then(() => {
var url = Dataset
.config.url.split("?")[0] +
QuerySet.encodeQueryset($state.params.job_event_search);
var noFilter = (url.split("&")
.filter(v => v.indexOf("page=") !== 0 &&
v.indexOf("/api/v1") !== 0 &&
v.indexOf("order_by=id") !== 0 &&
v.indexOf("not__event__in=playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats") !== 0).length === 0);
if(data.event_name === "playbook_on_start" ||
data.event_name === "playbook_on_play_start" ||
data.event_name === "playbook_on_task_start" ||
data.event_name === "playbook_on_stats" ||
noFilter) {
// for header and recap lines, as well as if no filters
// were added by the user, just put the line in the
// standard out pane (and increment play and task
// count)
if (data.event_name === "playbook_on_play_start") {
$scope.playCount++;
} else if (data.event_name === "playbook_on_task_start") {
$scope.taskCount++;
}
processEvent(data);
} else {
// to make sure host event/verbose lines go through a
// user defined filter, appent the id to the url, and
// make a request to the job_events endpoint with the
// id of the incoming event appended. If the event,
// is returned, put the line in the standard out pane
Rest.setUrl(`${url}&id=${data.id}`);
Rest.get()
.success(function(isHere) {
if (isHere.count) {
processEvent(data);
}
});
// put the line in the
// standard out pane (and increment play and task
// count if applicable)
if (data.event_name === "playbook_on_play_start") {
$scope.playCount++;
} else if (data.event_name === "playbook_on_task_start") {
$scope.taskCount++;
}
processEvent(data);
});
});
}));
// Processing of job-status messages from the websocket
$scope.$on(`ws-jobs`, function(e, data) {
toDestroy.push($scope.$on(`ws-jobs`, function(e, data) {
if (parseInt(data.unified_job_id, 10) ===
parseInt($scope.job.id,10)) {
// controller is defined, so set the job_status
@ -477,5 +505,19 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
// for this job. cache the socket status on root scope
$rootScope['lastSocketStatus' + data.unified_job_id] = data.status;
}
}));
$scope.$on('$destroy', function(){
$( ".JobResultsStdOut-aLineOfStdOut" ).remove();
cancelRequests = true;
eventQueue.initialize();
Object.keys($scope.events)
.forEach(v => {
$scope.events[v].$destroy();
$scope.events[v] = null;
});
$scope.events = {};
clearInterval(elapsedInterval);
toDestroy.forEach(closureFunc => closureFunc());
});
}];

View File

@ -36,9 +36,9 @@
<button class="List-actionButton
List-actionButton--delete"
data-placement="top"
ng-click="deleteJob()"
ng-show="job_status.status == 'running' ||
job_status.status=='pending' "
ng-click="cancelJob()"
ng-show="job_status == 'running' ||
job_status=='pending' "
aw-tool-tip="Cancel"
data-original-title="" title="">
<i class="fa fa-minus-circle"></i>
@ -49,8 +49,8 @@
List-actionButton--delete"
data-placement="top"
ng-click="deleteJob()"
ng-hide="job_status.status == 'running' ||
job_status.status == 'pending' "
ng-hide="job_status == 'running' ||
job_status == 'pending' "
aw-tool-tip="Delete"
data-original-title=""
title="">

View File

@ -25,6 +25,7 @@ export default {
params: {
job_event_search: {
value: {
page_size: 100,
order_by: 'id',
not__event__in: 'playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats'
},

View File

@ -171,22 +171,8 @@ function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybo
});
}
});
Rest.destroy()
.success(function() {
Wait('stop');
$('#prompt-modal').modal('hide');
})
.error(function(obj, status) {
Wait('stop');
$('#prompt-modal').modal('hide');
ProcessErrors(null, obj, status, null, {
hdr: 'Error!',
msg: `Could not cancel job.
Returned status: ${status}`
});
});
},
actionText: 'CANCEL'
actionText: 'PROCEED'
});
},
relaunchJob: function(scope) {

View File

@ -27,6 +27,7 @@ export default ['$log', 'moment', function($log, moment){
line = line.replace(/u001b/g, '');
// ansi classes
line = line.replace(/\[1;im/g, '<span class="JobResultsStdOut-cappedLine">');
line = line.replace(/\[1;31m/g, '<span class="ansi1 ansi31">');
line = line.replace(/\[0;31m/g, '<span class="ansi1 ansi31">');
line = line.replace(/\[0;32m/g, '<span class="ansi32">');
@ -185,7 +186,6 @@ export default ['$log', 'moment', function($log, moment){
data-uuid="${clickClass}">
</i>
</span>`;
// console.log(expandDom);
return expandDom;
} else {
// non-header lines don't get an expander
@ -193,10 +193,22 @@ export default ['$log', 'moment', function($log, moment){
}
},
getLineArr: function(event) {
return _
.zip(_.range(event.start_line + 1,
event.end_line + 1),
event.stdout.replace("\t", " ").split("\r\n").slice(0, -1));
let lineNums = _.range(event.start_line + 1,
event.end_line + 1);
let lines = event.stdout
.replace("\t", " ")
.split("\r\n");
if (lineNums.length > lines.length) {
let padBy = lineNums.length - lines.length;
for (let i = 0; i <= padBy; i++) {
lines.push("[1;imLine capped.[0m");
}
}
return _.zip(lineNums, lines).slice(0, -1);
},
// public function that provides the parsed stdout line, given a
// job_event

View File

@ -22,5 +22,6 @@ export default
key: true,
label: 'Target Group Name'
}
}
},
basePath: 'api/v1/inventories/{{$stateParams.inventory_id}}/groups'
});

View File

@ -18,9 +18,13 @@ export default
fields: {
name: {
key: true,
label: 'Name',
columnClass: 'col-md-11'
ngBind: 'inventory_source.summary_fields.group.name',
columnClass: 'col-md-11',
simpleTip: {
awToolTip: "Inventory: {{inventory_source.summary_fields.inventory.name}}",
dataPlacement: "top"
}
}
},

View File

@ -1 +1 @@
<aw-smart-status jobs="template.summary_fields.recent_jobs"></aw-smart-status>
<aw-smart-status jobs="template.summary_fields.recent_jobs" template-type="template.type"></aw-smart-status>

View File

@ -484,7 +484,7 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
function applyValidation(viewValue) {
basePath = GetBasePath(elm.attr('data-basePath')) || elm.attr('data-basePath');
query = elm.attr('data-query');
query = query.replace(/\:value/, encodeURI(viewValue));
query = query.replace(/\:value/, encodeURIComponent(viewValue));
Rest.setUrl(`${basePath}${query}`);
// https://github.com/ansible/ansible-tower/issues/3549
// capturing both success/failure conditions in .then() promise

View File

@ -1830,7 +1830,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// smart-search directive
html += `
<div
ng-hide="${itm}.length === 0 && (${collection.iterator}_searchTags | isEmpty)">
ng-hide="${itm}.length === 0 && (searchTags | isEmpty)">
<smart-search
django-model="${itm}"
search-size="${width}"
@ -1855,7 +1855,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += `
<div
class="row"
ng-show="${itm}.length === 0 && !(${collection.iterator}_searchTags | isEmpty)">
ng-show="${itm}.length === 0 && !(searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults">
No records matched your search.
</div>
@ -1865,7 +1865,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// Show the "no items" box when loading is done and the user isn't actively searching and there are no results
var emptyListText = (collection.emptyListText) ? collection.emptyListText : i18n._("PLEASE ADD ITEMS TO THIS LIST");
html += `<div ng-hide="is_superuser">`;
html += `<div class="List-noItems" ng-show="${itm}.length === 0 && (${collection.iterator}_searchTags | isEmpty)"> ${emptyListText} </div>`;
html += `<div class="List-noItems" ng-show="${itm}.length === 0 && (searchTags | isEmpty)"> ${emptyListText} </div>`;
html += '</div>';
html += `

View File

@ -591,6 +591,9 @@ angular.module('GeneratorHelpers', [systemStatus.name])
}
}
else {
if(field.simpleTip) {
html += `<span aw-tool-tip="${field.simpleTip.awToolTip}" data-placement=${field.simpleTip.dataPlacement}>`;
}
// Add icon:
if (field.ngShowIcon) {
html += "<i ng-show=\"" + field.ngShowIcon + "\" class=\"" + field.icon + "\"></i> ";
@ -615,6 +618,9 @@ angular.module('GeneratorHelpers', [systemStatus.name])
if (field.text) {
html += field.text;
}
if(field.simpleTip) {
html += `</span>`;
}
}
if (list.name === 'hosts' || list.name === 'groups') {

View File

@ -59,7 +59,7 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q
return `1 - ${pageSize}`;
} else {
let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1;
let ceil = floor + parseInt(pageSize);
let ceil = floor + parseInt(pageSize) < $scope.dataset.count ? floor + parseInt(pageSize) : $scope.dataset.count;
return `${floor} - ${ceil}`;
}
}

View File

@ -2,11 +2,12 @@ import directive from './smart-search.directive';
import controller from './smart-search.controller';
import service from './queryset.service';
import DjangoSearchModel from './django-search-model.class';
import smartSearchService from './smart-search.service';
export default
angular.module('SmartSearchModule', [])
.directive('smartSearch', directive)
.controller('SmartSearchController', controller)
.service('QuerySet', service)
.service('SmartSearchService', smartSearchService)
.constant('DjangoSearchModel', DjangoSearchModel);

View File

@ -1,5 +1,5 @@
export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory',
function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory) {
export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'SmartSearchService',
function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, SmartSearchService) {
return {
// kick off building a model for a specific endpoint
// this is usually a list's basePath
@ -67,29 +67,124 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
return angular.isObject(params) ? `?${queryset}` : '';
function encodeTerm(value, key){
key = key.replace(/__icontains_DEFAULT/g, "__icontains");
key = key.replace(/__search_DEFAULT/g, "__search");
if (Array.isArray(value)){
return _.map(value, (item) => `${key}=${item}`).join('&') + '&';
let concated = '';
angular.forEach(value, function(item){
if(item && typeof item === 'string') {
item = item.replace(/"|'/g, "");
}
concated += `${key}=${item}&`;
});
return concated;
}
else {
if(value && typeof value === 'string') {
value = value.replace(/"|'/g, "");
}
return `${key}=${value}&`;
}
}
},
// encodes a ui smart-search param to a django-friendly param
// operand:key:comparator:value => {operand__key__comparator: value}
encodeParam(param){
let split = param.split(':');
return {[split.slice(0,split.length -1).join('__')] : split[split.length-1]};
encodeParam(params){
// Assumption here is that we have a key and a value so the length
// of the paramParts array will be 2. [0] is the key and [1] the value
let paramParts = SmartSearchService.splitTermIntoParts(params.term);
let keySplit = paramParts[0].split('.');
let exclude = false;
let lessThanGreaterThan = paramParts[1].match(/^(>|<).*$/) ? true : false;
if(keySplit[0].match(/^-/g)) {
exclude = true;
keySplit[0] = keySplit[0].replace(/^-/, '');
}
let paramString = exclude ? "not__" : "";
let valueString = paramParts[1];
if(keySplit.length === 1) {
if(params.searchTerm && !lessThanGreaterThan) {
paramString += keySplit[0] + '__icontains_DEFAULT';
}
else if(params.relatedSearchTerm) {
paramString += keySplit[0] + '__search_DEFAULT';
}
else {
paramString += keySplit[0];
}
}
else {
paramString += keySplit.join('__');
}
if(lessThanGreaterThan) {
if(paramParts[1].match(/^>=.*$/)) {
paramString += '__gte';
valueString = valueString.replace(/^(>=)/,"");
}
else if(paramParts[1].match(/^<=.*$/)) {
paramString += '__lte';
valueString = valueString.replace(/^(<=)/,"");
}
else if(paramParts[1].match(/^<.*$/)) {
paramString += '__lt';
valueString = valueString.replace(/^(<)/,"");
}
else if(paramParts[1].match(/^>.*$/)) {
paramString += '__gt';
valueString = valueString.replace(/^(>)/,"");
}
}
return {[paramString] : valueString};
},
// decodes a django queryset param into a ui smart-search tag or set of tags
decodeParam(value, key){
let decodeParamString = function(searchString) {
if(key === 'search') {
// Don't include 'search:' in the search tag
return decodeURIComponent(`${searchString}`);
}
else {
key = key.replace(/__icontains_DEFAULT/g, "");
key = key.replace(/__search_DEFAULT/g, "");
let split = key.split('__');
let decodedParam = searchString;
let exclude = false;
if(key.startsWith('not__')) {
exclude = true;
split = split.splice(1, split.length);
}
if(key.endsWith('__gt')) {
decodedParam = '>' + decodedParam;
split = split.splice(0, split.length-1);
}
else if(key.endsWith('__lt')) {
decodedParam = '<' + decodedParam;
split = split.splice(0, split.length-1);
}
else if(key.endsWith('__gte')) {
decodedParam = '>=' + decodedParam;
split = split.splice(0, split.length-1);
}
else if(key.endsWith('__lte')) {
decodedParam = '<=' + decodedParam;
split = split.splice(0, split.length-1);
}
return exclude ? `-${split.join('.')}:${decodedParam}` : `${split.join('.')}:${decodedParam}`;
}
};
if (Array.isArray(value)){
return _.map(value, (item) => {
return `${key.split('__').join(':')}:${item}`;
return decodeParamString(item);
});
}
else {
return `${key.split('__').join(':')}:${value}`;
return decodeParamString(value);
}
},
@ -147,20 +242,33 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
Wait('start');
this.url = `${endpoint}${this.encodeQueryset(params)}`;
Rest.setUrl(this.url);
return Rest.get()
.success(this.success.bind(this))
.error(this.error.bind(this))
.finally(Wait('stop'));
.then(function(response) {
Wait('stop');
if (response
.headers('X-UI-Max-Events') !== null) {
response.data.maxEvents = response.
headers('X-UI-Max-Events');
}
return response;
})
.catch(function(response) {
Wait('stop');
this.error(response.data, response.status);
return response;
}.bind(this));
},
error(data, status) {
ProcessErrors($rootScope, data, status, null, {
hdr: 'Error!',
msg: 'Call to ' + this.url + '. GET returned: ' + status
});
},
success(data) {
return data;
},
}
};
}
];

View File

@ -186,8 +186,6 @@
.SmartSearch-keyPane {
max-height: 200px;
overflow: auto;
display: flex;
flex-wrap: wrap;
margin: 0px 0px 20px 0px;
font-size: 12px;
width: 100%;
@ -197,29 +195,11 @@
border: 1px solid @login-notice-border;
background-color: @login-notice-bg;
color: @login-notice-text;
}
.SmartSearch-relations{
margin-top: 15px;
position: relative;
}
.SmartSearch-keyRow {
width: 33%;
flex: 1 1 auto;
flex-direction: column;
margin-bottom: 15px;
padding-right: 50px;
}
// 100% rows in a modal
.modal-body .SmartSearch-keyRow{
width: 100%;
}
// `.${list.name}List` class can be used to set add custom class overrides
.groupsList .SmartSearch-keyRow, .hostsList .SmartSearch-keyRow, .PortalMode .SmartSearch-keyRow{
width: 100%;
}
.SmartSearch-keyRow:nth-child(3){
padding-right: 0px;
}
.SmartSearch-keyName {
@ -232,3 +212,30 @@
.SmartSearch-keyComparators {
flex: 1 0 auto;
}
.SmartSearch-keyPane--exitHolder {
position: absolute;
right: 10px;
top: 10px;
}
.SmartSearch-keyPane--exit {
background-color: @login-notice-bg;
}
.SmartSearch-examples {
display: flex;
}
.SmartSearch-examples--title {
margin-right: 5px;
}
.SmartSearch-examples--search {
color: @default-err;
background-color: @default-bg;
border: 1px solid @default-border;
border-radius: 5px;
padding: 0px 5px;
margin-right: 5px;
}

View File

@ -1,5 +1,5 @@
export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet',
function($stateParams, $scope, $state, QuerySet, GetBasePath, qs) {
export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet', 'SmartSearchService',
function($stateParams, $scope, $state, QuerySet, GetBasePath, qs, SmartSearchService) {
let path, relations,
// steps through the current tree of $state configurations, grabs default search params
@ -17,6 +17,7 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
$scope.searchTags = stripDefaultParams($state.params[`${$scope.iterator}_search`]);
qs.initFieldset(path, $scope.djangoModel, relations).then((data) => {
$scope.models = data.models;
$scope.options = data.options.data;
$scope.$emit(`${$scope.list.iterator}_options`, data.options);
});
}
@ -51,6 +52,16 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
return flat;
}
function setDefaults(term) {
if ($scope.list.defaultSearchParams) {
return $scope.list.defaultSearchParams(term);
} else {
return {
search: encodeURIComponent(term)
};
}
}
$scope.toggleKeyPane = function() {
$scope.showKeyPane = !$scope.showKeyPane;
};
@ -69,10 +80,37 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
// remove tag, merge new queryset, $state.go
$scope.remove = function(index) {
let removed = qs.encodeParam($scope.searchTags.splice(index, 1)[0]);
let tagToRemove = $scope.searchTags.splice(index, 1)[0];
let termParts = SmartSearchService.splitTermIntoParts(tagToRemove);
let removed;
if (termParts.length === 1) {
removed = setDefaults(tagToRemove);
}
else {
let root = termParts[0].split(".")[0].replace(/^-/, '');
let encodeParams = {
term: tagToRemove
};
if(_.has($scope.options.actions.GET, root)) {
if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') {
encodeParams.relatedSearchTerm = true;
}
else {
encodeParams.searchTerm = true;
}
removed = qs.encodeParam(encodeParams);
}
else {
removed = setDefaults(tagToRemove);
}
}
_.each(removed, (value, key) => {
if (Array.isArray(queryset[key])){
_.remove(queryset[key], (item) => item === value);
// If the array is now empty, remove that key
if(queryset[key].length === 0) {
delete queryset[key];
}
}
else {
delete queryset[key];
@ -92,26 +130,46 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', '
let params = {},
origQueryset = _.clone(queryset);
function setDefaults(term) {
// "name" and "description" are sane defaults for MOST models, but not ALL!
// defaults may be configured in ListDefinition.defaultSearchParams
if ($scope.list.defaultSearchParams) {
return $scope.list.defaultSearchParams(term);
} else {
return {
or__name__icontains: term,
or__description__icontains: term
};
}
}
// Remove leading/trailing whitespace if there is any
terms = terms.trim();
if(terms && terms !== '') {
_.forEach(terms.split(' '), (term) => {
// Split the terms up
let splitTerms = SmartSearchService.splitSearchIntoTerms(terms);
_.forEach(splitTerms, (term) => {
let termParts = SmartSearchService.splitTermIntoParts(term);
function combineSameSearches(a,b){
if (_.isArray(a)) {
return a.concat(b);
}
else {
if(a) {
return [a,b];
}
}
}
// if only a value is provided, search using default keys
if (term.split(':').length === 1) {
params = _.merge(params, setDefaults(term));
if (termParts.length === 1) {
params = _.merge(params, setDefaults(term), combineSameSearches);
} else {
params = _.merge(params, qs.encodeParam(term));
// Figure out if this is a search term
let root = termParts[0].split(".")[0].replace(/^-/, '');
if(_.has($scope.options.actions.GET, root)) {
if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') {
params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches);
}
else {
params = _.merge(params, qs.encodeParam({term: term, searchTerm: true}), combineSameSearches);
}
}
// Its not a search term or a related search term - treat it as a string
else {
params = _.merge(params, setDefaults(term), combineSameSearches);
}
}
});

View File

@ -33,25 +33,29 @@
</div>
<!-- hint key -->
<div class="SmartSearch-keyPane row" ng-repeat="model in models" ng-show="showKeyPane">
<div class="SmartSearch-keyPane--exit">
<button class="Form-exit" ng-click="toggleKeyPane()">
<div class="SmartSearch-keyPane--exitHolder">
<button class="Form-exit SmartSearch-keyPane--exit" ng-click="toggleKeyPane()">
<i class="fa fa-times-circle"></i>
</button>
</div>
<div class="SmartSearch-keyRow" ng-repeat="(key,value) in model.base">
<div class="SmartSearch-keyName">
{{ key }}
</div>
<div class="SmartSearch-keyInfo">
<div>Type: {{ value.type }}</div>
<div>Description: {{value.help_text}}</div>
<div ng-if="value.choices">
Enumerated: <span ng-repeat="choice in value.choices"> {{ choice[0] }} </span>
<div class="SmartSearch-keyRow">
<div class="SmartSearch-examples">
<div class="SmartSearch-examples--title">
<b>EXAMPLES:</b>
</div>
<div class="SmartSearch-examples--search">name:foo</div>
<div class="SmartSearch-examples--search">organization.name:Default</div>
<div class="SmartSearch-examples--search">id:>10</div>
</div>
</div>
<div class="SmartSearch-keyRow SmartSearch-relations">
<b>Searchable relationships:</b> <span ng-repeat="relation in model.related track by $index">{{ relation }}<span ng-if="$index !== Object.keys(model.related).length -1">, </span></span>
<div class="SmartSearch-keyRow">
<b>FIELDS:</b> <span ng-repeat="(key,value) in model.base">{{ key }}<span ng-if="!$last">, </span></span>
</div>
<div class="SmartSearch-keyRow">
<b>RELATED FIELDS:</b> <span ng-repeat="relation in model.related">{{ relation }}<span ng-if="!$last">, </span></span>
</div>
<div class="SmartSearch-keyRow">
<b>ADDITIONAL INFORMATION:</b> <span>For additional information on advanced search search syntax please see the Ansible Tower documentation.</span>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
export default [function() {
return {
splitSearchIntoTerms(searchString) {
return searchString.match(/(?:[^\s("')]+|"[^"]*"|'[^']*')+/g);
},
splitTermIntoParts(searchTerm) {
let breakOnColon = searchTerm.match(/(?:[^:"]+|"[^"]*")+/g);
if(breakOnColon.length > 2) {
// concat all the strings after the first one together
let stringsToJoin = breakOnColon.slice(1,breakOnColon.length);
return [breakOnColon[0], stringsToJoin.join(':')];
}
else {
return breakOnColon;
}
}
};
}];

View File

@ -15,9 +15,21 @@ export default ['$scope', '$filter',
var singleJobStatus = true;
var firstJobStatus;
var recentJobs = $scope.jobs;
var detailsBaseUrl;
if(!recentJobs){
return;
}
// unless we explicitly define a value for the template-type attribute when invoking the
// directive, assume the status icons are for a regular (non-workflow) job when building
// the details url path
if (typeof $scope.templateType !== 'undefined' && $scope.templateType === 'workflow_job_template') {
detailsBaseUrl = '/#/workflows/';
} else {
detailsBaseUrl = '/#/jobs/';
}
var sparkData =
_.sortBy(recentJobs.map(function(job) {
@ -38,6 +50,7 @@ export default ['$scope', '$filter',
data.sortDate = job.finished || "running" + data.jobId;
data.finished = $filter('longDate')(job.finished) || job.status+"";
data.status_tip = "JOB ID: " + data.jobId + "<br>STATUS: " + data.smartStatus + "<br>FINISHED: " + data.finished;
data.detailsUrl = detailsBaseUrl + data.jobId;
// If we've already determined that there are both failed and successful jobs OR if the current job in the loop is
// pending/waiting/running then we don't worry about checking for a single job status

View File

@ -9,7 +9,8 @@ export default [ 'templateUrl',
function(templateUrl) {
return {
scope: {
jobs: '='
jobs: '=',
templateType: '=?',
},
templateUrl: templateUrl('smart-status/smart-status'),
restrict: 'E',

View File

@ -1,6 +1,6 @@
<div class="SmartStatus-container">
<div ng-repeat="job in sparkArray track by $index" class='SmartStatus-iconContainer'>
<a href="#/jobs/{{ job.jobId }}"
<a ng-href="{{job.detailsUrl}}"
aw-tool-tip="{{job.status_tip}}"
data-tip-watch="job.status_tip"
aw-tip-placement="left"

View File

@ -294,7 +294,7 @@
${data.related.callback}
</strong>
</p>
<p class="break">The host configuration key is:
<p>The host configuration key is:
<strong>
${$filter('sanitize')(data.host_config_key)}
</strong>

View File

@ -430,7 +430,7 @@ export default
${data.related.callback}
</strong>
</p>
<p class="break">The host configuration key is:
<p>The host configuration key is:
<strong>
${$filter('sanitize')(data.host_config_key)}
</strong>

View File

@ -103,7 +103,8 @@ angular.module('templates', [surveyMaker.name, templatesList.name, jobTemplatesA
},
inventory_source_search: {
value: {
page_size: '5'
page_size: '5',
not__source: ''
},
squash: true,
dynamic: true

View File

@ -13,6 +13,7 @@ export default
var scope = params.scope,
id = params.id,
templateType = params.templateType,
url;
@ -35,7 +36,8 @@ export default
scope.$emit("SurveyDeleted");
} else {
url = GetBasePath('job_templates')+ id + '/survey_spec/';
let basePath = templateType === 'workflow_job_template' ? GetBasePath('workflow_job_templates') : GetBasePath('job_templates');
url = basePath + id + '/survey_spec/';
Rest.setUrl(url);
Rest.destroy()

View File

@ -52,7 +52,8 @@ export default
// and closing the modal after success
DeleteSurvey({
scope: scope,
id: id
id: id,
templateType: templateType
});
};

2963
awx/ui/po/fr.po Normal file

File diff suppressed because it is too large Load Diff

2803
awx/ui/po/ja.po Normal file

File diff suppressed because it is too large Load Diff

View File

@ -31,10 +31,14 @@ describe('parseStdoutService', () => {
unstyledLine = 'ok: [host-00]';
expect(parseStdoutService.prettify(line, unstyled)).toBe(unstyledLine);
});
it('can return empty strings', () => {
expect(parseStdoutService.prettify("")).toBe("");
});
});
describe('getLineClasses()', () => {
xit('creates a string that is used as a class', () => {
it('creates a string that is used as a class', () => {
let headerEvent = {
event_name: 'playbook_on_task_start',
event_data: {
@ -44,12 +48,15 @@ describe('parseStdoutService', () => {
};
let lineNum = 3;
let line = "TASK [setup] *******************************************************************";
let styledLine = " header_task header_task_80dd087c-268b-45e8-9aab-1083bcfd9364 play_0f667a23-d9ab-4128-a735-80566bcdbca0 line_num_3";
let styledLine = " header_task header_task_80dd087c-268b-45e8-9aab-1083bcfd9364 actual_header play_0f667a23-d9ab-4128-a735-80566bcdbca0 line_num_3";
expect(parseStdoutService.getLineClasses(headerEvent, line, lineNum)).toBe(styledLine);
});
});
describe('getStartTime()', () => {
// TODO: the problem is that the date here calls moment, and thus
// the date will be timezone'd in the string (this could be
// different based on where you are)
xit('creates returns a badge with the start time of the event', () => {
let headerEvent = {
event_name: 'playbook_on_play_start',
@ -115,6 +122,19 @@ describe('parseStdoutService', () => {
expect(returnedEvent).toEqual(expectedReturn);
});
it('deals correctly with capped lines', () => {
let mockEvent = {
start_line: 7,
end_line: 11,
stdout: "a\r\nb\r\nc..."
};
let expectedReturn = [[8, "a"],[9, "b"], [10,"c..."], [11, "[1;imLine capped.[0m"]];
let returnedEvent = parseStdoutService.getLineArr(mockEvent);
expect(returnedEvent).toEqual(expectedReturn);
});
});
describe('parseStdout()', () => {

View File

@ -3,7 +3,8 @@
describe('Service: QuerySet', () => {
let $httpBackend,
QuerySet,
Authorization;
Authorization,
SmartSearchService;
beforeEach(angular.mock.module('Tower', ($provide) =>{
// @todo: improve app source / write testing utilities for interim
@ -17,9 +18,10 @@ describe('Service: QuerySet', () => {
}));
beforeEach(angular.mock.module('RestServices'));
beforeEach(angular.mock.inject((_$httpBackend_, _QuerySet_) => {
beforeEach(angular.mock.inject((_$httpBackend_, _QuerySet_, _SmartSearchService_) => {
$httpBackend = _$httpBackend_;
QuerySet = _QuerySet_;
SmartSearchService = _SmartSearchService_;
// @todo: improve app source
// config.js / local_settings emit $http requests in the app's run block
@ -33,24 +35,27 @@ describe('Service: QuerySet', () => {
.respond(200, '');
}));
describe('fn encodeQuery', () => {
xit('null/undefined params should return an empty string', () => {
expect(QuerySet.encodeQuery(null)).toEqual('');
expect(QuerySet.encodeQuery(undefined)).toEqual('');
describe('fn encodeParam', () => {
it('should encode parameters properly', () =>{
expect(QuerySet.encodeParam({term: "name:foo", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "foo"});
expect(QuerySet.encodeParam({term: "-name:foo", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "foo"});
expect(QuerySet.encodeParam({term: "name:'foo bar'", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "'foo bar'"});
expect(QuerySet.encodeParam({term: "-name:'foo bar'", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "'foo bar'"});
expect(QuerySet.encodeParam({term: "organization:foo", relatedSearchTerm: true})).toEqual({"organization__search_DEFAULT" : "foo"});
expect(QuerySet.encodeParam({term: "-organization:foo", relatedSearchTerm: true})).toEqual({"not__organization__search_DEFAULT" : "foo"});
expect(QuerySet.encodeParam({term: "organization.name:foo", relatedSearchTerm: true})).toEqual({"organization__name" : "foo"});
expect(QuerySet.encodeParam({term: "-organization.name:foo", relatedSearchTerm: true})).toEqual({"not__organization__name" : "foo"});
expect(QuerySet.encodeParam({term: "id:11", searchTerm: true})).toEqual({"id__icontains_DEFAULT" : "11"});
expect(QuerySet.encodeParam({term: "-id:11", searchTerm: true})).toEqual({"not__id__icontains_DEFAULT" : "11"});
expect(QuerySet.encodeParam({term: "id:>11", searchTerm: true})).toEqual({"id__gt" : "11"});
expect(QuerySet.encodeParam({term: "-id:>11", searchTerm: true})).toEqual({"not__id__gt" : "11"});
expect(QuerySet.encodeParam({term: "id:>=11", searchTerm: true})).toEqual({"id__gte" : "11"});
expect(QuerySet.encodeParam({term: "-id:>=11", searchTerm: true})).toEqual({"not__id__gte" : "11"});
expect(QuerySet.encodeParam({term: "id:<11", searchTerm: true})).toEqual({"id__lt" : "11"});
expect(QuerySet.encodeParam({term: "-id:<11", searchTerm: true})).toEqual({"not__id__lt" : "11"});
expect(QuerySet.encodeParam({term: "id:<=11", searchTerm: true})).toEqual({"id__lte" : "11"});
expect(QuerySet.encodeParam({term: "-id:<=11", searchTerm: true})).toEqual({"not__id__lte" : "11"});
});
xit('should encode params to a string', () => {
let params = {
or__created_by: 'Jenkins',
or__modified_by: 'Jenkins',
and__not__status: 'success',
},
result = '?or__created_by=Jenkins&or__modified_by=Jenkins&and__not__status=success';
expect(QuerySet.encodeQuery(params)).toEqual(result);
});
});
xdescribe('fn decodeQuery', () => {
});

View File

@ -0,0 +1,43 @@
'use strict';
describe('Service: SmartSearch', () => {
let SmartSearchService;
beforeEach(angular.mock.module('Tower'));
beforeEach(angular.mock.module('SmartSearchModule'));
beforeEach(angular.mock.inject((_SmartSearchService_) => {
SmartSearchService = _SmartSearchService_;
}));
describe('fn splitSearchIntoTerms', () => {
it('should convert the search string to an array tag strings', () =>{
expect(SmartSearchService.splitSearchIntoTerms('foo')).toEqual(["foo"]);
expect(SmartSearchService.splitSearchIntoTerms('foo bar')).toEqual(["foo", "bar"]);
expect(SmartSearchService.splitSearchIntoTerms('name:foo bar')).toEqual(["name:foo", "bar"]);
expect(SmartSearchService.splitSearchIntoTerms('name:foo description:bar')).toEqual(["name:foo", "description:bar"]);
expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar"')).toEqual(["name:\"foo bar\""]);
expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar" description:"bar foo"')).toEqual(["name:\"foo bar\"", "description:\"bar foo\""]);
expect(SmartSearchService.splitSearchIntoTerms('name:"foo bar" description:"bar foo"')).toEqual(["name:\"foo bar\"", "description:\"bar foo\""]);
expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\'')).toEqual(["name:\'foo bar\'"]);
expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\' description:\'bar foo\'')).toEqual(["name:\'foo bar\'", "description:\'bar foo\'"]);
expect(SmartSearchService.splitSearchIntoTerms('name:\'foo bar\' description:\'bar foo\'')).toEqual(["name:\'foo bar\'", "description:\'bar foo\'"]);
expect(SmartSearchService.splitSearchIntoTerms('name:\"foo bar\" description:\'bar foo\'')).toEqual(["name:\"foo bar\"", "description:\'bar foo\'"]);
expect(SmartSearchService.splitSearchIntoTerms('name:\"foo bar\" foo')).toEqual(["name:\"foo bar\"", "foo"]);
});
});
describe('fn splitTermIntoParts', () => {
it('should convert the search term to a key and value', () =>{
expect(SmartSearchService.splitTermIntoParts('foo')).toEqual(["foo"]);
expect(SmartSearchService.splitTermIntoParts('foo:bar')).toEqual(["foo", "bar"]);
expect(SmartSearchService.splitTermIntoParts('foo:bar:foobar')).toEqual(["foo", "bar:foobar"]);
expect(SmartSearchService.splitTermIntoParts('name:\"foo bar\"')).toEqual(["name", "\"foo bar\""]);
expect(SmartSearchService.splitTermIntoParts('name:\"foo:bar\"')).toEqual(["name", "\"foo:bar\""]);
expect(SmartSearchService.splitTermIntoParts('name:\'foo bar\'')).toEqual(["name", "\'foo bar\'"]);
expect(SmartSearchService.splitTermIntoParts('name:\'foo:bar\'')).toEqual(["name", "\'foo:bar\'"]);
});
});
});

View File

@ -84,6 +84,10 @@ describe('Controller: WorkflowAdd', () => {
.whenGET('/api')
.respond(200, '');
$httpBackend
.whenGET(/\/static\/*/)
.respond(200, {});
TemplatesService.getLabelOptions = jasmine.createSpy('getLabelOptions').and.returnValue(getLabelsDeferred.promise);
TemplatesService.createWorkflowJobTemplate = jasmine.createSpy('createWorkflowJobTemplate').and.returnValue(createWorkflowJobTemplateDeferred.promise);

View File

@ -1,4 +1,4 @@
# Copyright (c) 2015 Ansible, Inc.
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import sos
@ -8,7 +8,8 @@ SOSREPORT_TOWER_COMMANDS = [
"ansible --version", # ansible core version
"tower-manage --version", # tower version
"supervisorctl status", # tower process status
"pip freeze", # pip package list
"/var/lib/awx/venv/tower/bin/pip freeze", # pip package list
"/var/lib/awx/venv/ansible/bin/pip freeze", # pip package list
"tree -d /var/lib/awx", # show me the dirs
"ls -ll /var/lib/awx", # check permissions
"ls -ll /etc/tower",
@ -19,9 +20,8 @@ SOSREPORT_TOWER_DIRS = [
"/etc/tower/",
"/etc/ansible/",
"/var/log/tower",
"/var/log/httpd",
"/var/log/apache2",
"/var/log/redis",
"/var/log/nginx",
"/var/log/rabbitmq",
"/var/log/supervisor",
"/var/log/syslog",
"/var/log/udev",