diff --git a/.bowerrc b/.bowerrc deleted file mode 100644 index 49a14a942b..0000000000 --- a/.bowerrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "directory": "awx/ui/client/lib" -} diff --git a/.dockerignore b/.dockerignore index 46c83b0467..f5faf1f0e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ +.git awx/ui/node_modules diff --git a/Makefile b/Makefile index 95d5cffc59..2835118cd0 100644 --- a/Makefile +++ b/Makefile @@ -231,10 +231,14 @@ clean: clean-rpm clean-deb clean-ui clean-tar clean-packer clean-bundle rm -rf awx/public rm -rf awx/lib/site-packages rm -rf dist/* + rm -rf awx/job_status + rm -rf reports + rm -f awx/awx_test.sqlite3 rm -rf tmp mkdir tmp rm -rf build $(NAME)-$(VERSION) *.egg-info find . -type f -regex ".*\.py[co]$$" -delete + find . -type d -name "__pycache__" -delete # convenience target to assert environment variables are defined guard-%: diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3012c722c6..839ebfe7c6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2682,9 +2682,10 @@ class NotificationTemplateSerializer(BaseSerializer): def to_representation(self, obj): ret = super(NotificationTemplateSerializer, self).to_representation(obj) for field in obj.notification_class.init_parameters: - if field in ret['notification_configuration'] and \ - force_text(ret['notification_configuration'][field]).startswith('$encrypted$'): - ret['notification_configuration'][field] = '$encrypted$' + config = obj.notification_configuration + if field in config and force_text(config[field]).startswith('$encrypted$'): + config[field] = '$encrypted$' + ret['notification_configuration'] = config return ret def get_related(self, obj): diff --git a/awx/api/views.py b/awx/api/views.py index 390fc0194a..06a249d60e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2332,23 +2332,20 @@ class JobTemplateSurveySpec(GenericAPIView): if not request.user.can_access(self.model, 'change', obj, None): raise PermissionDenied() - try: - obj.survey_spec = json.dumps(request.data) - except ValueError: - return Response(dict(error=_("Invalid JSON when parsing survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if "name" not in obj.survey_spec: + new_spec = request.data + if "name" not in new_spec: return Response(dict(error=_("'name' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if "description" not in obj.survey_spec: + if "description" not in new_spec: return Response(dict(error=_("'description' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if "spec" not in obj.survey_spec: + if "spec" not in new_spec: return Response(dict(error=_("'spec' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if not isinstance(obj.survey_spec["spec"], list): + if not isinstance(new_spec["spec"], list): return Response(dict(error=_("'spec' must be a list of items.")), status=status.HTTP_400_BAD_REQUEST) - if len(obj.survey_spec["spec"]) < 1: + if len(new_spec["spec"]) < 1: return Response(dict(error=_("'spec' doesn't contain any items.")), status=status.HTTP_400_BAD_REQUEST) idx = 0 variable_set = set() - for survey_item in obj.survey_spec["spec"]: + for survey_item in new_spec["spec"]: if not isinstance(survey_item, dict): return Response(dict(error=_("Survey question %s is not a json object.") % str(idx)), status=status.HTTP_400_BAD_REQUEST) if "type" not in survey_item: @@ -2365,7 +2362,8 @@ class JobTemplateSurveySpec(GenericAPIView): if "required" not in survey_item: return Response(dict(error=_("'required' missing from survey question %s.") % str(idx)), status=status.HTTP_400_BAD_REQUEST) idx += 1 - obj.save() + obj.survey_spec = new_spec + obj.save(update_fields=['survey_spec']) return Response() def delete(self, request, *args, **kwargs): diff --git a/awx/main/access.py b/awx/main/access.py index 2df99e7d26..e8dbd0fc59 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1956,7 +1956,7 @@ class LabelAccess(BaseAccess): def get_queryset(self): if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.all() - return self.model.objects.filter( + return self.model.objects.all().filter( organization__in=Organization.accessible_objects(self.user, 'read_role') ) diff --git a/awx/main/tests/functional/__init__.py b/awx/main/tests/functional/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index 450fbd8dc9..1e64222522 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -3,6 +3,7 @@ # Python import pytest +import os # Django from django.core.urlresolvers import reverse @@ -10,9 +11,17 @@ from django.core.urlresolvers import reverse # AWX from awx.conf.models import Setting +''' +Ensures that tests don't pick up dev container license file +''' +@pytest.fixture +def mock_no_license_file(mocker): + os.environ['AWX_LICENSE_FILE'] = '/does_not_exist' + return None @pytest.mark.django_db -def test_license_cannot_be_removed_via_system_settings(get, put, patch, delete, admin, enterprise_license): +def test_license_cannot_be_removed_via_system_settings(mock_no_license_file, get, put, patch, delete, admin, enterprise_license): + url = reverse('api:setting_singleton_detail', args=('system',)) response = get(url, user=admin, expect=200) assert not response.data['LICENSE'] diff --git a/awx/main/tests/functional/test_partial.py b/awx/main/tests/functional/test_partial.py index 0ab84dc901..d0b3ec6fa2 100644 --- a/awx/main/tests/functional/test_partial.py +++ b/awx/main/tests/functional/test_partial.py @@ -101,8 +101,11 @@ class TestInventoryUpdateLatestDict(): inv_src2 = InventorySource.objects.create(group=g2, update_on_launch=False, inventory=inventory) inv_src3 = InventorySource.objects.create(group=g3, update_on_launch=True, inventory=inventory) + import time iu1 = InventoryUpdate.objects.create(inventory_source=inv_src1, status='successful') + time.sleep(0.1) iu2 = InventoryUpdate.objects.create(inventory_source=inv_src2, status='waiting') + time.sleep(0.1) iu3 = InventoryUpdate.objects.create(inventory_source=inv_src3, status='waiting') return [iu1, iu2, iu3] @@ -114,7 +117,7 @@ class TestInventoryUpdateLatestDict(): inventory_updates_expected = [inventory_updates[0], inventory_updates[2]] assert 2 == len(tasks) - for i, inventory_update in enumerate(inventory_updates_expected): - assert inventory_update.id == tasks[i]['id'] - + task_ids = [task['id'] for task in tasks] + for inventory_update in inventory_updates_expected: + inventory_update.id in task_ids diff --git a/awx/main/tests/functional/test_rbac_label.py b/awx/main/tests/functional/test_rbac_label.py index 57700d17e2..d10e48ed0a 100644 --- a/awx/main/tests/functional/test_rbac_label.py +++ b/awx/main/tests/functional/test_rbac_label.py @@ -6,8 +6,9 @@ from awx.main.access import ( @pytest.mark.django_db def test_label_get_queryset_user(label, user): - access = LabelAccess(user('user', False)) - label.organization.member_role.members.add(user('user', False)) + u = user('user', False) + access = LabelAccess(u) + label.organization.member_role.members.add(u) assert access.get_queryset().count() == 1 @pytest.mark.django_db diff --git a/awx/main/tests/unit/__init__.py b/awx/main/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/unit/test_python_requirements.py b/awx/main/tests/unit/test_python_requirements.py new file mode 100644 index 0000000000..0db4f6a29d --- /dev/null +++ b/awx/main/tests/unit/test_python_requirements.py @@ -0,0 +1,48 @@ +from pip.operations import freeze +from django.conf import settings + +def test_req(): + def check_is_in(src, dests): + if src not in dests: + src2 = [src[0].replace('_', '-'), src[1]] + if src2 not in dests: + print("%s not in" % src2) + return False + else: + print("%s not in" % src) + return False + return True + + base_dir = settings.BASE_DIR + + reqs_actual = [] + xs = freeze.freeze(local_only=True, requirement=base_dir + "/../requirements/requirements.txt") + for x in xs: + if '## The following requirements were added by pip freeze' in x: + break + reqs_actual.append(x.split('==')) + + reqs_expected = [] + with open(base_dir + "/../requirements/requirements.txt") as f: + for line in f: + line.rstrip() + # TODO: process git requiremenst and use egg + if line.strip().startswith('#') or line.strip().startswith('git'): + continue + if line.startswith('-e'): + continue + line.rstrip() + reqs_expected.append(line.rstrip().split('==')) + + for r in reqs_actual: + print(r) + + not_found = [] + for r in reqs_expected: + res = check_is_in(r, reqs_actual) + if res is False: + not_found.append(r) + + raise RuntimeError("%s not found in \n\n%s" % (not_found, reqs_expected)) + + diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index d554ce8cc0..b9b7cbc7cc 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -905,6 +905,12 @@ input[type="checkbox"].checkbox-no-label { margin-top: 10px; } +.radio-group { + .radio-inline + .radio-inline { + margin-left: 0; + } +} + .checkbox-group { .radio-inline + .radio-inline, .checkbox-inline + .checkbox-inline { diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index b36f1bdd5b..89ff33dd03 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -184,6 +184,7 @@ .Form-formGroup--fullWidth { max-width: none !important; width: 100% !important; + padding-right: 0px !important; } .Form-formGroup--checkbox{ @@ -553,19 +554,24 @@ input[type='radio']:checked:before { color: @btn-txt; } -.Form-surveyButton { +.Form-primaryButton { background-color: @default-link; color: @default-bg; text-transform: uppercase; padding-left:15px; padding-right: 15px; + margin-right: 20px; } -.Form-surveyButton:hover{ +.Form-primaryButton:hover { background-color: @default-link-hov; color: @default-bg; } +.Form-primaryButton.Form-tab--disabled:hover { + background-color: @default-link; +} + .Form-formGroup--singleColumn { width: 100% !important; padding-right: 0px; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 763694f6d2..ee69342e0f 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -357,6 +357,32 @@ table, tbody { cursor: not-allowed; } +.List-dropdownButton { + border: none; +} + +.List-dropdownSuccess { + background-color: @submit-button-bg; + color: @submit-button-text; + border-color: @submit-button-bg-hov; +} + +.List-dropdownSuccess:hover, +.List-dropdownSuccess:focus { + color: @submit-button-text; + background-color: @submit-button-bg-hov; +} + +.List-dropdownCarat { + display: inline-block; + width: 0; + height: 0; + vertical-align: middle; + border-top: 4px dashed; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} + @media (max-width: 991px) { .List-searchWidget + .List-searchWidget { margin-top: 20px; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 664361123b..3174bc6b98 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -38,7 +38,6 @@ if ($basePath) { // Modules import './helpers'; -import * as forms from './forms'; import './lists'; import './widgets'; import './filters'; @@ -53,6 +52,7 @@ import inventoryScripts from './inventory-scripts/main'; import organizations from './organizations/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; +import workflowResults from './workflow-results/main'; import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; import about from './about/main'; @@ -122,6 +122,7 @@ var tower = angular.module('Tower', [ activityStream.name, footer.name, jobDetail.name, + workflowResults.name, jobSubmission.name, notifications.name, standardOut.name, @@ -168,7 +169,6 @@ var tower = angular.module('Tower', [ 'ProjectsHelper', 'CompletedJobsDefinition', 'AllJobsDefinition', - 'JobFormDefinition', 'JobSummaryDefinition', 'ParseHelper', 'ChildrenHelper', @@ -202,6 +202,9 @@ var tower = angular.module('Tower', [ 'ActivityStreamHelper', 'gettext', 'I18N', + 'WorkflowFormDefinition', + 'InventorySourcesListDefinition', + 'WorkflowMakerFormDefinition' ]) .constant('AngularScheduler.partials', urlPrefix + 'lib/angular-scheduler/lib/') diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js index 3ba3d2fa72..bc9688f4e2 100644 --- a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js +++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js @@ -22,7 +22,6 @@ export default [ 'CreateSelect2', 'GenerateForm', 'ParseTypeChange', - 'Wait', function( $scope, $state, @@ -40,8 +39,7 @@ export default [ ConfigurationUtils, CreateSelect2, GenerateForm, - ParseTypeChange, - Wait + ParseTypeChange ) { var authVm = this; diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js index 1214c994d3..38b180c25d 100644 --- a/awx/ui/client/src/configuration/configuration.controller.js +++ b/awx/ui/client/src/configuration/configuration.controller.js @@ -122,6 +122,30 @@ export default [ $scope.configDataResolve = configDataResolve; + var triggerModal = function(msg, title, buttons) { + if ($scope.removeModalReady) { + $scope.removeModalReady(); + } + $scope.removeModalReady = $scope.$on('ModalReady', function() { + // $('#lookup-save-button').attr('disabled', 'disabled'); + $('#FormModal-dialog').dialog('open'); + }); + + $('#FormModal-dialog').html(msg); + + CreateDialog({ + scope: $scope, + buttons: buttons, + width: 600, + height: 200, + minWidth: 500, + title: title, + id: 'FormModal-dialog', + resizable: false, + callback: 'ModalReady' + }); + }; + function activeTabCheck(setForm) { if(!$scope[formTracker.currentFormName()].$dirty) { active(setForm); @@ -212,7 +236,7 @@ export default [ payload[key] = $scope.configDataResolve[key].default; ConfigurationService.patchConfiguration(payload) - .then(function(data) { + .then(function() { $scope[key] = $scope.configDataResolve[key].default; }) .catch(function(error) { @@ -228,30 +252,6 @@ export default [ }); }; - var triggerModal = function(msg, title, buttons) { - if ($scope.removeModalReady) { - $scope.removeModalReady(); - } - $scope.removeModalReady = $scope.$on('ModalReady', function() { - // $('#lookup-save-button').attr('disabled', 'disabled'); - $('#FormModal-dialog').dialog('open'); - }); - - $('#FormModal-dialog').html(msg); - - CreateDialog({ - scope: $scope, - buttons: buttons, - width: 600, - height: 200, - minWidth: 500, - title: title, - id: 'FormModal-dialog', - resizable: false, - callback: 'ModalReady' - }); - }; - function clearApiErrors() { var currentForm = formDefs[formTracker.getCurrent()]; for (var fld in currentForm.fields) { @@ -332,7 +332,7 @@ export default [ var payload = {}; payload[key] = $scope[key]; ConfigurationService.patchConfiguration(payload) - .then(function(results) { + .then(function() { //TODO consider updating form values with returned data here }) .catch(function(error, status) { @@ -352,7 +352,7 @@ export default [ var resetAll = function() { Wait('start'); ConfigurationService.resetAll() - .then(function(results) { + .then(function() { populateFromApi(); $scope[formTracker.currentFormName].$setPristine(); }) diff --git a/awx/ui/client/src/configuration/configuration.service.js b/awx/ui/client/src/configuration/configuration.service.js index 0438b4501c..5c86f3beae 100644 --- a/awx/ui/client/src/configuration/configuration.service.js +++ b/awx/ui/client/src/configuration/configuration.service.js @@ -4,8 +4,8 @@ * All Rights Reserved *************************************************/ -export default ['GetBasePath', 'ProcessErrors', '$q', '$http', 'Rest', '$rootScope', '$timeout', 'Wait', - function(GetBasePath, ProcessErrors, $q, $http, Rest, $rootScope, $timeout, Wait) { +export default ['GetBasePath', 'ProcessErrors', '$q', '$http', 'Rest', + function(GetBasePath, ProcessErrors, $q, $http, Rest) { var url = GetBasePath('settings'); return { diff --git a/awx/ui/client/src/configuration/configurationUtils.service.js b/awx/ui/client/src/configuration/configurationUtils.service.js index ba6069e3f3..fe8d4f27c0 100644 --- a/awx/ui/client/src/configuration/configurationUtils.service.js +++ b/awx/ui/client/src/configuration/configurationUtils.service.js @@ -8,7 +8,7 @@ export default [ function() { return { - listToArray: function(input, key) { + listToArray: function(input) { if (input.indexOf('\n') !== -1) { //Parse multiline input return input.replace(/^\s+|\s+$/g, "").split('\n'); @@ -17,7 +17,7 @@ export default [ } }, - arrayToList: function(input, key) { + arrayToList: function(input) { var multiLineInput = false; _.each(input, function(statement) { if (statement.indexOf(',') !== -1) { @@ -40,7 +40,7 @@ export default [ return true; }, - formatPlaceholder: function(input, key) { + formatPlaceholder: function(input) { if(input !== null && typeof input === 'object') { if(Array.isArray(input)) { var multiLineInput = false; diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js index a01b225bb1..5129efb7d6 100644 --- a/awx/ui/client/src/controllers/Jobs.js +++ b/awx/ui/client/src/controllers/Jobs.js @@ -41,7 +41,7 @@ export function JobsListController($state, $rootScope, $log, $scope, $compile, $ }; $scope.relaunchJob = function(event, id) { - var list, job, typeId; + var job, typeId; try { $(event.target).tooltip('hide'); } catch (e) { @@ -84,6 +84,9 @@ export function JobsListController($state, $rootScope, $log, $scope, $compile, $ case 'inventory_update': goToJobDetails('inventorySyncStdout'); break; + case 'workflow_job': + goToJobDetails('workflowResults'); + break; } }; diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index 3a7f1b86a8..61ffb69d2e 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -114,7 +114,7 @@ UsersList.$inject = ['$scope', '$rootScope', '$stateParams', export function UsersAdd($scope, $rootScope, $stateParams, UserForm, GenerateForm, Rest, Alert, ProcessErrors, ReturnToCaller, ClearScope, - GetBasePath, ResetForm, Wait, CreateSelect2, $state, i18n) { + GetBasePath, ResetForm, Wait, CreateSelect2, $state, $location) { ClearScope(); @@ -201,7 +201,7 @@ export function UsersAdd($scope, $rootScope, $stateParams, UserForm, UsersAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'UserForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'GetBasePath', - 'ResetForm', 'Wait', 'CreateSelect2', '$state', 'i18n' + 'ResetForm', 'Wait', 'CreateSelect2', '$state', '$location' ]; export function UsersEdit($scope, $rootScope, $location, diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js index 5052e073a4..714b3c088a 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.directive.js @@ -2,8 +2,8 @@ export default [ 'InitiatePlaybookRun', 'templateUrl', - '$location', - function JobTemplatesList(InitiatePlaybookRun, templateUrl, $location) { + '$state', + function JobTemplatesList(InitiatePlaybookRun, templateUrl, $state) { return { restrict: 'E', link: link, @@ -47,7 +47,7 @@ export default }; scope.editJobTemplate = function (jobTemplateId) { - $location.path( '/job_templates/' + jobTemplateId); + $state.go('templates.editJobTemplate', {id: jobTemplateId}); }; } }]; diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html index 8114f037d4..77ba2f41ac 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html @@ -3,7 +3,7 @@

RECENTLY USED JOB TEMPLATES

- + VIEW ALL @@ -25,7 +25,7 @@ ng-class-even="'List-tableRow--evenRow'" ng-repeat = "job_template in job_templates"> - + {{ job_template.name }} @@ -53,7 +53,7 @@
-

No job templates were recently used.
- You can create a job template here.

+

No job templates were recently used.
+ You can create a job template here.

diff --git a/awx/ui/client/src/forms.js b/awx/ui/client/src/forms.js index 51cbc4a6e7..db46a6d0d8 100644 --- a/awx/ui/client/src/forms.js +++ b/awx/ui/client/src/forms.js @@ -16,7 +16,6 @@ import JobEventData from "./forms/JobEventData"; import JobSummary from "./forms/JobSummary"; import JobTemplates from "./forms/JobTemplates"; import JobVarsPrompt from "./forms/JobVarsPrompt"; -import Jobs from "./forms/Jobs"; import LogViewerOptions from "./forms/LogViewerOptions"; import LogViewerStatus from "./forms/LogViewerStatus"; import Organizations from "./forms/Organizations"; @@ -24,6 +23,8 @@ import ProjectStatus from "./forms/ProjectStatus"; import Projects from "./forms/Projects"; import Teams from "./forms/Teams"; import Users from "./forms/Users"; +import WorkflowMaker from "./forms/WorkflowMaker"; +import Workflows from "./forms/Workflows"; export @@ -39,12 +40,13 @@ export JobSummary, JobTemplates, JobVarsPrompt, - Jobs, LogViewerOptions, LogViewerStatus, Organizations, ProjectStatus, Projects, Teams, - Users + Users, + WorkflowMaker, + Workflows }; diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js index 06038da005..49692f476e 100644 --- a/awx/ui/client/src/forms/Inventories.js +++ b/awx/ui/client/src/forms/Inventories.js @@ -132,7 +132,7 @@ angular.module('InventoryFormDefinition', ['ScanJobsListDefinition']) } }, - relatedSets: function(urls) { + relatedSets: function() { return { permissions: { awToolTip: i18n._('Please save before assigning permissions'), diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 0c276777dc..00f4c5f131 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -20,10 +20,12 @@ export default addTitle: i18n._('New Job Template'), editTitle: '{{ name }}', name: 'job_template', + breadcrumbName: 'JOB TEMPLATE', basePath: 'job_templates', // the top-most node of generated state tree - stateTree: 'jobTemplates', + stateTree: 'templates', tabs: true, + activeEditState: 'templates.editJobTemplate', // (optional) array of supporting templates to ng-include inside generated html include: ['/static/partials/survey-maker-modal.html'], @@ -31,7 +33,7 @@ export default name: { label: i18n._('Name'), type: 'text', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)', required: true, column: 1 }, @@ -39,7 +41,7 @@ export default label: i18n._('Description'), type: 'text', column: 1, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, job_type: { label: i18n._('Job Type'), @@ -61,7 +63,7 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, inventory: { label: i18n._('Inventory'), @@ -85,7 +87,7 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, project: { label: i18n._('Project'), @@ -108,13 +110,13 @@ export default dataTitle: i18n._('Project'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, playbook: { label: i18n._('Playbook'), type:'select', ngOptions: 'book for book in playbook_options track by book', - ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAdd)", + ngDisabled: "(job_type.value === 'scan' && project_name === 'Default') || !(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)", id: 'playbook-select', awRequiredWhen: { reqExpression: "playbookrequired", @@ -152,7 +154,7 @@ export default variable: 'ask_credential_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, cloud_credential: { label: i18n._('Cloud Credential'), @@ -170,7 +172,7 @@ export default dataTitle: i18n._('Cloud Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, network_credential: { label: i18n._('Network Credential'), @@ -187,7 +189,7 @@ export default dataTitle: i18n._('Network Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, forks: { label: i18n._('Forks'), @@ -205,7 +207,7 @@ export default dataTitle: i18n._('Forks'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working }, limit: { label: i18n._('Limit'), @@ -221,7 +223,7 @@ export default variable: 'ask_limit_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, verbosity: { label: i18n._('Verbosity'), @@ -234,7 +236,7 @@ export default dataTitle: i18n._('Verbosity'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, job_tags: { label: i18n._('Job Tags'), @@ -252,7 +254,7 @@ export default variable: 'ask_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, skip_tags: { label: i18n._('Skip Tags'), @@ -270,7 +272,7 @@ export default variable: 'ask_skip_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, checkbox_group: { label: i18n._('Options'), @@ -285,7 +287,7 @@ export default dataTitle: i18n._('Become Privilege Escalation'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, { name: 'allow_callbacks', label: i18n._('Allow Provisioning Callbacks'), @@ -298,7 +300,7 @@ export default dataTitle: i18n._('Allow Provisioning Callbacks'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }] }, callback_url: { @@ -312,7 +314,7 @@ export default dataPlacement: 'top', dataTitle: i18n._('Provisioning Callback URL'), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, host_config_key: { label: i18n._('Host Config Key'), @@ -326,7 +328,7 @@ export default dataPlacement: 'right', dataTitle: i18n._("Host Config Key"), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, labels: { label: i18n._('Labels'), @@ -338,7 +340,7 @@ export default dataPlacement: 'right', awPopOver: i18n._("

Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.

"), dataContainer: 'body', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, variables: { label: i18n._('Extra Variables'), @@ -360,14 +362,14 @@ export default variable: 'ask_variables_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' // TODO: get working } }, buttons: { //for now always generates + + + + + + +
+
+
EDIT WORKFLOW
+
+
+ +
+
+
+
+
+
+
KEY:
+
+
+
On Success
+
+
+
+
On Fail
+
+
+
+
Always
+
+
+
P
+
Project Sync
+
+
+
I
+
Inventory Sync
+
+
+
+ TOTAL JOBS + +
+
+ +
+
+
{{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited && nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : "ADD A TEMPLATE"}}
+
Please hover over a template and click the Add button.
+
+
+
JOBS
+
PROJECT SYNC
+
INVENTORY SYNC
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index 8b0cae9269..682f1cfb01 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -13,6 +13,48 @@ export default function( Wait, $state, $scope, $rootScope, $location, GetBasePath, Rest, ProcessErrors, CheckLicense, moment, $window, ConfigService, FeaturesService, pendoService, i18n){ + + var calcDaysRemaining = function(seconds){ + // calculate the number of days remaining on the license + var duration = moment.duration(seconds, 'seconds').asDays(); + duration = Math.floor(duration); + if(duration < 0 ){ + duration = 0; + } + duration = (duration!==1) ? `${duration} Days` : `${duration} Day`; + return duration; + }; + + + var calcExpiresOn = function(days){ + // calculate the expiration date of the license + days = parseInt(days); + return moment().add(days, 'days').calendar(); + }; + + var reset = function(){ + document.getElementById('License-form').reset(); + }; + + var init = function(){ + // license/license.partial.html compares fileName + $scope.fileName = N_("No file selected."); + $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management"); + Wait('start'); + ConfigService.getConfig().then(function(config){ + $scope.license = config; + $scope.license.version = config.version.split('-')[0]; + $scope.time = {}; + $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining); + $scope.time.expiresOn = calcExpiresOn($scope.time.remaining); + $scope.valid = CheckLicense.valid($scope.license.license_info); + $scope.compliant = $scope.license.license_info.compliant; + Wait('stop'); + }); + }; + + init(); + $scope.getKey = function(event){ // Mimic HTML5 spec, show filename $scope.fileName = event.target.files[0].name; @@ -73,43 +115,5 @@ export default }); }); }; - var calcDaysRemaining = function(seconds){ - // calculate the number of days remaining on the license - var duration = moment.duration(seconds, 'seconds').asDays(); - duration = Math.floor(duration); - if(duration < 0 ){ - duration = 0; - } - duration = (duration!==1) ? `${duration} Days` : `${duration} Day`; - return duration; - }; - - - var calcExpiresOn = function(days){ - // calculate the expiration date of the license - days = parseInt(days); - return moment().add(days, 'days').calendar(); - }; - - var init = function(){ - // license/license.partial.html compares fileName - $scope.fileName = N_("No file selected."); - $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management"); - Wait('start'); - ConfigService.getConfig().then(function(config){ - $scope.license = config; - $scope.license.version = config.version.split('-')[0]; - $scope.time = {}; - $scope.time.remaining = calcDaysRemaining($scope.license.license_info.time_remaining); - $scope.time.expiresOn = calcExpiresOn($scope.time.remaining); - $scope.valid = CheckLicense.valid($scope.license.license_info); - $scope.compliant = $scope.license.license_info.compliant; - Wait('stop'); - }); - }; - var reset = function(){ - document.getElementById('License-form').reset(); - }; - init(); } - ]; +]; diff --git a/awx/ui/client/src/lists.js b/awx/ui/client/src/lists.js index 545c3189fb..f7b0288822 100644 --- a/awx/ui/client/src/lists.js +++ b/awx/ui/client/src/lists.js @@ -13,6 +13,7 @@ import Hosts from "./lists/Hosts"; import Inventories from "./lists/Inventories"; import InventoryGroups from "./lists/InventoryGroups"; import InventoryHosts from "./lists/InventoryHosts"; +import InventorySources from "./lists/InventorySources"; import JobEvents from "./lists/JobEvents"; import JobHosts from "./lists/JobHosts"; import JobTemplates from "./lists/JobTemplates"; @@ -38,6 +39,7 @@ export Inventories, InventoryGroups, InventoryHosts, + InventorySources, JobEvents, JobHosts, JobTemplates, diff --git a/awx/ui/client/src/lists/InventorySources.js b/awx/ui/client/src/lists/InventorySources.js new file mode 100644 index 0000000000..127352c72b --- /dev/null +++ b/awx/ui/client/src/lists/InventorySources.js @@ -0,0 +1,30 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + +export default + angular.module('InventorySourcesListDefinition', []) + .value('InventorySourcesList', { + + name: 'workflow_inventory_sources', + iterator: 'inventory_source', + basePath: 'inventory_sources', + listTitle: 'Inventory Sources', + index: false, + hover: true, + + fields: { + name: { + key: true, + label: 'Name', + columnClass: 'col-md-11' + } + }, + + actions: {}, + + fieldActions: {} + }); diff --git a/awx/ui/client/src/lists/JobTemplates.js b/awx/ui/client/src/lists/JobTemplates.js index e809683460..0713402775 100644 --- a/awx/ui/client/src/lists/JobTemplates.js +++ b/awx/ui/client/src/lists/JobTemplates.js @@ -10,11 +10,12 @@ export default .factory('JobTemplateList', ['i18n', function(i18n) { return { - name: 'job_templates', - iterator: 'job_template', - selectTitle: i18n._('Add Job Template'), - editTitle: i18n._('Job Templates'), - listTitle: i18n._('Job Templates'), + name: 'templates', + iterator: 'template', + basePath: 'unified_job_templates', + selectTitle: i18n._('Template'), + editTitle: i18n._('Templates'), + listTitle: i18n._('Templates'), selectInstructions: "Click on a row to select it, and click Finished when done. Use the " + "button to create a new job template.", index: false, @@ -24,7 +25,14 @@ export default name: { key: true, label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9' + columnClass: 'col-lg-2 col-md-2 col-sm-4 col-xs-9', + ngClick: "editJobTemplate(template)" + }, + type: { + label: i18n._('Type'), + searchType: 'select', + searchOptions: [], // will be set by Options call to job templates resource + columnClass: 'col-lg-2 col-md-2 col-sm-4 hidden-xs' }, description: { label: i18n._('Description'), @@ -41,73 +49,85 @@ export default label: i18n._('Labels'), type: 'labels', nosort: true, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs' + columnClass: 'List-tableCell col-lg-2 col-md-4 hidden-sm hidden-xs' } }, actions: { add: { mode: 'all', // One of: edit, select, all - ngClick: 'addJobTemplate()', - basePaths: ['job_templates'], + type: 'buttonDropdown', + basePaths: ['templates'], awToolTip: i18n._('Create a new template'), - actionClass: 'btn List-buttonSubmit', - buttonContent: i18n._('+ ADD'), - ngShow: 'canAdd' + actionClass: 'btn List-dropdownSuccess', + buttonContent: i18n._('ADD'), + options: [ + { + optionContent: 'Job Template', + optionSref: 'templates.addJobTemplate', + ngShow: 'canAddJobTemplate' + }, + { + optionContent: 'Workflow Job Template', + optionSref: 'templates.addWorkflowJobTemplate', + ngShow: 'canAddWorkflowJobTemplate' + } + ], + ngShow: 'canAddJobTemplate || canAddWorkflowJobTemplate' } }, fieldActions: { - columnClass: 'col-lg-2 col-md-3 col-sm-3 col-xs-3', + columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-3', submit: { label: i18n._('Launch'), mode: 'all', - ngClick: 'submitJob(job_template.id)', + ngClick: 'submitJob(template)', awToolTip: i18n._('Start a job using this template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.start' + ngShow: 'template.summary_fields.user_capabilities.start' }, schedule: { label: i18n._('Schedule'), mode: 'all', - ngClick: 'scheduleJob(job_template.id)', + ngClick: 'scheduleJob(template)', awToolTip: i18n._('Schedule future job template runs'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.schedule' + ngShow: 'template.summary_fields.user_capabilities.schedule' }, copy: { label: i18n._('Copy'), - 'ui-sref': 'jobTemplates.copy({id: job_template.id})', + 'ui-sref': 'templates.copy({id: template.id})', "class": 'btn-danger btn-xs', awToolTip: i18n._('Copy template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.copy' + ngShow: 'template.summary_fields.user_capabilities.copy' }, edit: { label: i18n._('Edit'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(template)", awToolTip: i18n._('Edit template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.edit' + ngShow: 'template.summary_fields.user_capabilities.edit' }, view: { label: i18n._('View'), - ngClick: "editJobTemplate(job_template.id)", + ngClick: "editJobTemplate(template.id)", awToolTip: i18n._('View template'), "class": 'btn-default btn-xs', dataPlacement: 'top', - ngShow: '!job_template.summary_fields.user_capabilities.edit' + ngShow: '!template.summary_fields.user_capabilities.edit' }, "delete": { label: i18n._('Delete'), - ngClick: "deleteJobTemplate(job_template.id, job_template.name)", + ngClick: "deleteJobTemplate(template)", "class": 'btn-danger btn-xs', awToolTip: i18n._('Delete template'), dataPlacement: 'top', - ngShow: 'job_template.summary_fields.user_capabilities.delete' + ngShow: 'template.summary_fields.user_capabilities.delete' } } };}]); diff --git a/awx/ui/client/src/lists/PortalJobTemplates.js b/awx/ui/client/src/lists/PortalJobTemplates.js index 9a9f8c0ca7..b542ac1181 100644 --- a/awx/ui/client/src/lists/PortalJobTemplates.js +++ b/awx/ui/client/src/lists/PortalJobTemplates.js @@ -23,7 +23,7 @@ export default key: true, label: i18n._('Name'), columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8', - linkTo: '/#/job_templates/{{job_template.id}}', + linkTo: '/#/templates/{{job_template.id}}' }, description: { label: i18n._('Description'), diff --git a/awx/ui/client/src/lists/Projects.js b/awx/ui/client/src/lists/Projects.js index ce478129ac..31d176bd98 100644 --- a/awx/ui/client/src/lists/Projects.js +++ b/awx/ui/client/src/lists/Projects.js @@ -11,6 +11,7 @@ export default name: 'projects', iterator: 'project', + basePath: 'projects', selectTitle: i18n._('Add Project'), editTitle: i18n._('Projects'), listTitle: i18n._('Projects'), diff --git a/awx/ui/client/src/login/authenticationServices/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo.service.js index 10cdbd33d8..ebcad0f03f 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo.service.js +++ b/awx/ui/client/src/login/authenticationServices/pendo.service.js @@ -94,10 +94,10 @@ export default }, issuePendoIdentity: function () { - var config, - options, + var options, c = ConfigService.get(), - config = c.license_info; + config = c.license_info; + config.analytics_status = c.analytics_status; config.version = c.version; config.ansible_version = c.ansible_version; @@ -114,7 +114,7 @@ export default }); } else { - $log.debug('Pendo is turned off.') + $log.debug('Pendo is turned off.'); } } }; diff --git a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js index 3d6e86ae02..d0e79872e1 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js +++ b/awx/ui/client/src/login/authenticationServices/pendo/ng-pendo.js @@ -1,3 +1,5 @@ +/* jshint ignore:start */ + /* * pendo.io Angular Module * @@ -25,7 +27,7 @@ setTimeout(waitFn, delay); } }; - + angular.module('pendolytics', []) .provider('$pendolytics', function() { diff --git a/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js deleted file mode 100644 index 82af8ade88..0000000000 --- a/awx/ui/client/src/login/authenticationServices/pendo/pendo.service.js +++ /dev/null @@ -1,152 +0,0 @@ -/************************************************* -* Copyright (c) 2015 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - - -export default - [ '$rootScope', '$pendolytics', 'Rest', 'GetBasePath', 'ProcessErrors', '$q', - 'Store', '$log', - function ($rootScope, $pendolytics, Rest, GetBasePath, ProcessErrors, $q, - Store, $log) { - return { - setPendoOptions: function (config) { - var tower_version = config.version.split('-')[0], - options = { - visitor: { - id: null, - role: null, - email: null - }, - account: { - id: null, - planLevel: config.license_type, - planPrice: config.instance_count, - creationDate: config.license_date, - trial: config.trial, - tower_version: tower_version, - ansible_version: config.ansible_version - } - }; - if(config.analytics_status === 'detailed'){ - this.setDetailed(options, config); - } - else if(config.analytics_status === 'anonymous'){ - this.setAnonymous(options); - } - return options; - - }, - - setDetailed: function(options, config) { - // Detailed mode - // VisitorId: username+hash of license_key - // AccountId: hash of license_key from license - // email: contact_email from license OR email from Tower account - - options.visitor.id = $rootScope.current_user.username + '@' + config.deployment_id; - options.account.id = config.deployment_id; - options.visitor.email = $rootScope.current_user.email; - }, - - setAnonymous: function (options) { - //Anonymous mode - // VisitorId: - // AccountId: - // email: - - options.visitor.id = 0; - options.account.id = "tower.ansible.com"; - options.visitor.email = ""; - }, - - setRole: function(options) { - var deferred = $q.defer(); - if($rootScope.current_user.is_superuser === true){ - options.visitor.role = 'admin'; - deferred.resolve(options); - } - else{ - var url = GetBasePath('users') + $rootScope.current_user.id + '/admin_of_organizations/'; - Rest.setUrl(url); - var promise = Rest.get(); - promise.then(function (response) { - if(response.data.count > 0 ) { - options.visitor.role = "orgadmin"; - deferred.resolve(options); - } - else { - options.visitor.role = "user"; - deferred.resolve(options); - } - }); - promise.catch(function (response) { - ProcessErrors($rootScope, response.data, response.status, null, { - hdr: 'Error!', - msg: 'Failed to get inventory name. GET returned status: ' + - response.status }); - deferred.reject('Could not resolve pendo role.'); - }); - } - return deferred.promise; - }, - - getConfig: function () { - var config = Store('license'), - deferred = $q.defer(); - if(_.isEmpty(config)){ - var url = GetBasePath('config'); - Rest.setUrl(url); - var promise = Rest.get(); - promise.then(function (response) { - config = response.data.license_info; - config.analytics_status = response.data.analytics_status; - config.version = response.data.version; - config.ansible_version = response.data.ansible_version; - if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){ - $pendolytics.bootstrap(); - deferred.resolve(config); - } - else { - deferred.reject('Pendo is turned off.'); - } - }); - promise.catch(function (response) { - ProcessErrors($rootScope, response.data, response.status, null, { - hdr: 'Error!', - msg: 'Failed to get inventory name. GET returned status: ' + - response.status }); - deferred.reject('Could not resolve pendo config.'); - }); - } - else if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){ - $pendolytics.bootstrap(); - deferred.resolve(config); - } - else { - deferred.reject('Pendo is turned off.'); - } - return deferred.promise; - }, - - issuePendoIdentity: function () { - var that = this; - this.getConfig().then(function(config){ - var options = that.setPendoOptions(config); - that.setRole(options).then(function(options){ - $log.debug('Pendo status is '+ config.analytics_status + '. Object below:'); - $log.debug(options); - $pendolytics.identify(options); - }, function(reason){ - // reject function for setRole - $log.debug(reason); - }); - }, function(reason){ - // reject function for getConfig - $log.debug(reason); - }); - } - }; - } -]; diff --git a/awx/ui/client/src/main-menu/main-menu.partial.html b/awx/ui/client/src/main-menu/main-menu.partial.html index 558d733197..398488add7 100644 --- a/awx/ui/client/src/main-menu/main-menu.partial.html +++ b/awx/ui/client/src/main-menu/main-menu.partial.html @@ -29,10 +29,10 @@ + href="/#/templates" + ng-class="{'is-currentRoute' : isCurrentState('templates')}"> - JOB TEMPLATES + TEMPLATES + ng-class="{'is-currentRoute' : isCurrentState('templates'), 'is-loggedOut' : !current_user || !current_user.username}"> - JOB TEMPLATES + TEMPLATES { + $scope.$on('selectedOrDeselected', ()=>{ throw {name: 'NotYetImplemented'}; }); } diff --git a/awx/ui/client/src/organizations/linkout/main.js b/awx/ui/client/src/organizations/linkout/main.js index 0701b87b96..e6d31a156c 100644 --- a/awx/ui/client/src/organizations/linkout/main.js +++ b/awx/ui/client/src/organizations/linkout/main.js @@ -1,4 +1,3 @@ -import routes from './organizations-linkout.route'; import AddUsers from './addUsers/main'; export default angular.module('organizationsLinkout', [AddUsers.name]); diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 207ab2d8ec..c22b3c1049 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -4,7 +4,6 @@ * All Rights Reserved *************************************************/ -import { templateUrl } from '../../shared/template-url/template-url.factory'; import OrganizationsAdmins from './controllers/organizations-admins.controller'; import OrganizationsInventories from './controllers/organizations-inventories.controller'; import OrganizationsJobTemplates from './controllers/organizations-job-templates.controller'; diff --git a/awx/ui/client/src/partials/job-template-smart-status.html b/awx/ui/client/src/partials/job-template-smart-status.html index a9c6365970..1c45c5fe36 100644 --- a/awx/ui/client/src/partials/job-template-smart-status.html +++ b/awx/ui/client/src/partials/job-template-smart-status.html @@ -1 +1 @@ - + diff --git a/awx/ui/client/src/scheduler/main.js b/awx/ui/client/src/scheduler/main.js index 9474c44dce..7cb521f3c2 100644 --- a/awx/ui/client/src/scheduler/main.js +++ b/awx/ui/client/src/scheduler/main.js @@ -23,7 +23,7 @@ export default // job templates $stateExtender.addState({ name: 'jobTemplateSchedules', - route: '/job_templates/:job_template_id/schedules', + route: '/templates/job_template/:id/schedules', templateUrl: templateUrl("scheduler/scheduler"), controller: 'schedulerListController', data: { @@ -32,7 +32,7 @@ export default activityStreamId: 'id' }, ncyBreadcrumb: { - parent: 'jobTemplates.edit', + parent: 'templates.editJobTemplate', label: 'SCHEDULES' } }); @@ -57,9 +57,45 @@ export default } }); + // workflows + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules', + route: '/templates/workflow_job_template/:id/schedules', + templateUrl: templateUrl("scheduler/scheduler"), + controller: 'schedulerController', + data: { + activityStream: true, + activityStreamTarget: 'job_template', + activityStreamId: 'id' + }, + ncyBreadcrumb: { + parent: 'templates.editWorkflowJobTemplate', + label: 'SCHEDULES' + } + }); + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules.add', + route: '/add', + templateUrl: templateUrl("scheduler/schedulerForm"), + controller: 'schedulerAddController', + ncyBreadcrumb: { + parent: 'workflowJobTemplateSchedules', + label: 'CREATE SCHEDULE' + } + }); + $stateExtender.addState({ + name: 'workflowJobTemplateSchedules.edit', + route: '/:schedule_id', + templateUrl: templateUrl("scheduler/schedulerForm"), + controller: 'schedulerEditController', + ncyBreadcrumb: { + parent: 'workflowJobTemplateSchedules', + label: '{{schedule_obj.name}}' + } + }); // projects $stateExtender.addState({ - searchPrefix: 'schedule', + searchPrefix: 'schedule', name: 'projectSchedules', route: '/projects/:id/schedules', data: { diff --git a/awx/ui/client/src/scheduler/schedulerList.controller.js b/awx/ui/client/src/scheduler/schedulerList.controller.js index 4400eef5c4..b532c6d6dd 100644 --- a/awx/ui/client/src/scheduler/schedulerList.controller.js +++ b/awx/ui/client/src/scheduler/schedulerList.controller.js @@ -90,9 +90,8 @@ export default [ }); }; - base = $location.path().replace(/^\//, '').split('/')[0]; - console.log(base) + if (base === 'management_jobs') { $scope.base = base = 'system_job_templates'; } diff --git a/awx/ui/client/src/shared/Modal.js b/awx/ui/client/src/shared/Modal.js index 897b4b1a73..e37defa900 100644 --- a/awx/ui/client/src/shared/Modal.js +++ b/awx/ui/client/src/shared/Modal.js @@ -65,6 +65,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) forms = _.chain([params.form]).flatten().compact().value(), buttons, id = params.id, + position = (params.position === undefined) ? { my: "center", at: "center", of: window } : params.position, x, y, wh, ww; function updateButtonStatus(isValid) { @@ -91,7 +92,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) // Set modal dimensions based on viewport width ww = $(document).width(); - wh = $('body').height(); + wh = $(document).height(); x = (width > ww) ? ww - 10 : width; y = (height > wh) ? wh - 10 : height; @@ -108,6 +109,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) resizable: resizable, draggable: draggable, dialogClass: dialogClass, + position: position, create: function () { // Fix the close button $('.ui-dialog[aria-describedby="' + id + '"]').find('.ui-dialog-titlebar button').empty().attr({'class': 'close'}).html(''); diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index b996f16607..dc5f01bf18 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -164,12 +164,21 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Not a very good way to do this // Form sub-states expect to target ui-views related@stateName & modal@stateName // Also wraps mess of generated HTML in a .Panel - wrapPanel(html){ - return `
- ${html} -
-
-
`; + wrapPanel(html, ignorePanel){ + if(ignorePanel) { + return `
+ ${html} +
+
+
`; + } + else { + return `
+ ${html} +
+
+
`; + } }, inject: function (form, options) { @@ -377,7 +386,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // html = GenerateForm.buildHTML(JobVarsPromptForm, { mode: 'edit', modal: true, scope: scope }); this.mode = options.mode; - this.modal = (options.modal) ? true : false; + //this.modal = (options.modal) ? true : false; this.setForm(form); return this.build(options); }, @@ -728,7 +737,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if ((!field.readonly) || (field.readonly && options.mode === 'edit')) { - if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock') { + if((field.excludeMode === undefined || field.excludeMode !== options.mode) && field.type !== 'alertblock' && field.type !== 'workflow-chart') { html += "
Please select a value.
\n"; @@ -1433,8 +1449,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Generate HTML. Do NOT call this function directly. Called by inject(). Returns an HTML // string to be injected into the current view. // - var btn, button, fld, field, html = '', i, section, group, - tab, sectionShow, offset, width,ngDisabled, itm; + var btn, button, fld, field, html = '', section, group, + sectionShow, offset, width,ngDisabled, itm; // title and exit button if(!(this.form.showHeader !== undefined && this.form.showHeader === false)) { @@ -1473,14 +1489,14 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += ""; //end of Form-header } - if (!_.isEmpty(this.form.related)) { + if (!_.isEmpty(this.form.related) || !_.isEmpty(this.form.relatedButtons)) { var collection, details = i18n._('Details'); - html += `
`; + html += "
"; if(this.mode === "edit"){ html += `
` + + `ng-class="{'is-selected': $state.is('${this.form.activeEditState}') || $state.is('${this.form.stateTree}.edit') || $state.$current.data.formChildState }">` + `${details}
`; for (itm in this.form.related) { @@ -1499,6 +1515,45 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } html += `}">${(collection.title || collection.editTitle)}
`; } + + for (itm in this.form.relatedButtons) { + button = this.form.relatedButtons[itm]; + + // Build button HTML + html += "
";//tabHolder } - if(!_.isEmpty(this.form.related) && this.mode === "edit"){ - html += `
`; + if(!_.isEmpty(this.form.related) && this.mode === "edit"){// TODO: either include $state.is('templates.editWorkflowJobTemplate') or figure out something else to do here + html += `
`; } html += "
- +
+ + +
+ + +
diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.directive.js b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js index 5950363d3c..588573bb27 100644 --- a/awx/ui/client/src/shared/lookup/lookup-modal.directive.js +++ b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js @@ -1,4 +1,4 @@ -export default ['templateUrl', '$compile', function(templateUrl, $compile) { +export default ['templateUrl', function(templateUrl) { return { restrict: 'E', replace: true, @@ -29,6 +29,9 @@ export default ['templateUrl', '$compile', function(templateUrl, $compile) { $scope.$parent[list.iterator] = $scope.selection[list.iterator].id; $state.go('^'); }; + $scope.cancelForm = function() { + $state.go('^'); + }; }] }; }]; diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.partial.html b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html index 7840d25217..5371e8d454 100644 --- a/awx/ui/client/src/shared/lookup/lookup-modal.partial.html +++ b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html @@ -6,7 +6,7 @@
-
@@ -16,7 +16,7 @@
diff --git a/awx/ui/client/src/shared/main.js b/awx/ui/client/src/shared/main.js index 78bc5d19f9..8db9dd8172 100644 --- a/awx/ui/client/src/shared/main.js +++ b/awx/ui/client/src/shared/main.js @@ -10,7 +10,6 @@ import lookupModal from './lookup/main'; import smartSearch from './smart-search/main'; import paginate from './paginate/main'; import columnSort from './column-sort/main'; -import title from './title.directive'; import lodashAsPromised from './lodash-as-promised'; import stringFilters from './string-filters/main'; import truncatedText from './truncated-text.directive'; diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js index b8d0a4ecf5..f0276bdebd 100644 --- a/awx/ui/client/src/shared/paginate/paginate.controller.js +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -50,9 +50,9 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q } function calcDataRange() { - if ($scope.current() == 1 && $scope.dataset.count < parseInt(pageSize)) { + if ($scope.current() === 1 && $scope.dataset.count < parseInt(pageSize)) { return `1 - ${$scope.dataset.count}`; - } else if ($scope.current() == 1) { + } else if ($scope.current() === 1) { return `1 - ${pageSize}`; } else { let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1; diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 12057c1a90..1296a1394b 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -1,5 +1,5 @@ -export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'GetBasePath', - function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, GetBasePath) { +export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', + function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory) { return { // kick off building a model for a specific endpoint // this is usually a list's basePath diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index 1af67cb6ef..64933f8fbd 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -50,13 +50,13 @@ export default $log.debug('Websocket Error Logged: ' + error); //log errors }; - self.socket.onconnecting = function (event) { + self.socket.onconnecting = function () { self.checkStatus(); $log.debug('Websocket reconnecting'); needsResubscribing = true; }; - self.socket.onclose = function (event) { + self.socket.onclose = function () { self.checkStatus(); $log.debug(`Websocket disconnected`); }; diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index 0c4112aa7b..b76c5b5a36 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -9,8 +9,6 @@ * generateLookupNodes - Attaches to a form node. Builds an abstract '*.lookup' node with field-specific 'lookup.*' children e.g. {name: 'projects.add.lookup.organizations', ...} */ -import { templateUrl } from './template-url/template-url.factory'; - export default ['$injector', '$stateExtender', '$log', function($injector, $stateExtender, $log) { return { /** @@ -140,12 +138,14 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat let formNode, states = []; switch (mode) { case 'add': + // breadcrumbName necessary for resources that are more than one word like + // job templates. form.name can't have spaces in it or it busts form gen formNode = $stateExtender.buildDefinition({ name: params.name || `${params.parent}.add`, url: params.url || '/add', ncyBreadcrumb: { [params.parent ? 'parent' : null]: `${params.parent}`, - label: `CREATE ${form.name}` + label: `CREATE ${form.breadcrumbName || form.name}` }, views: { 'form': { @@ -370,7 +370,7 @@ export default ['$injector', '$stateExtender', '$log', function($injector, $stat // a lookup field's basePath takes precedence over generic list definition's basePath, if supplied data: { basePath: field.basePath || null, - lookup: true + formChildState: true }, params: { [field.sourceModel + '_search']: { diff --git a/awx/ui/client/src/standard-out/standard-out.controller.js b/awx/ui/client/src/standard-out/standard-out.controller.js index 663d165cf3..ffed821d7d 100644 --- a/awx/ui/client/src/standard-out/standard-out.controller.js +++ b/awx/ui/client/src/standard-out/standard-out.controller.js @@ -52,7 +52,7 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams, $scope.created_by = data.summary_fields.created_by; $scope.project_name = (data.summary_fields.project) ? data.summary_fields.project.name : ''; $scope.inventory_name = (data.summary_fields.inventory) ? data.summary_fields.inventory.name : ''; - $scope.job_template_url = '/#/job_templates/' + data.unified_job_template; + $scope.job_template_url = '/#/templates/' + data.unified_job_template; $scope.inventory_url = ($scope.inventory_name && data.inventory) ? '/#/inventories/' + data.inventory : ''; $scope.project_url = ($scope.project_name && data.project) ? '/#/projects/' + data.project : ''; $scope.credential_name = (data.summary_fields.credential) ? data.summary_fields.credential.name : ''; diff --git a/awx/ui/client/src/workflow-results/main.js b/awx/ui/client/src/workflow-results/main.js new file mode 100644 index 0000000000..7faf366fc9 --- /dev/null +++ b/awx/ui/client/src/workflow-results/main.js @@ -0,0 +1,16 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowStatusBar from './workflow-status-bar/main'; +import route from './workflow-results.route.js'; +import workflowResultsService from './workflow-results.service'; + +export default + angular.module('workflowResults', [workflowStatusBar.name]) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]) + .service('workflowResultsService', workflowResultsService); diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less new file mode 100644 index 0000000000..edd5412d2d --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.block.less @@ -0,0 +1,112 @@ +@import '../shared/branding/colors.less'; +@import '../shared/branding/colors.default.less'; +@import '../shared/layouts/one-plus-two.less'; + +@breakpoint-md: 1200px; +@breakpoint-sm: 623px; + +.WorkflowResults { + .OnePlusTwo-container(100%, @breakpoint-md); + + &.fullscreen { + .WorkflowResults-rightSide { + max-width: 100%; + } + } +} + +.WorkflowResults-leftSide { + .OnePlusTwo-left--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; +} + +.WorkflowResults-rightSide { + .OnePlusTwo-right--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; + + @media (max-width: @breakpoint-md - 1px) { + padding-right: 15px; + } +} + +.WorkflowResults-stdoutActionButton--active { + display: none; + visibility: hidden; + flex:none; + width:0px; + padding-right: 0px; +} + +.WorkflowResults-panelHeader { + display: flex; + height: 30px; +} + +.WorkflowResults-panelHeaderText { + color: @default-interface-txt; + flex: 1 0 auto; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} + +.WorkflowResults-resultRow { + width: 100%; + display: flex; + padding-bottom: 10px; + flex-wrap: wrap; +} + +.WorkflowResults-resultRow--variables { + flex-direction: column; +} + +.WorkflowResults-resultRowLabel { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 14px; + font-weight: normal!important; + width: 30%; + margin-right: 20px; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} + +.WorkflowResults-resultRowLabel--fullWidth { + width: 100%; + margin-right: 0px; +} + +.WorkflowResults-resultRowText { + width: ~"calc(70% - 20px)"; + flex: 1 0 auto; + text-transform: none; + word-wrap: break-word; +} + +.WorkflowResults-resultRowText--fullWidth { + width: 100%; +} + +.WorkflowResults-statusResultIcon { + padding-left: 0px; + padding-right: 10px; +} + +.WorkflowResults-badgeRow { + display: flex; + align-items: center; + margin-right: 5px; +} + +.WorkflowResults-badgeTitle{ + color: @default-interface-txt; + font-size: 14px; + margin-right: 10px; + font-weight: normal; + text-transform: uppercase; + margin-left: 20px; +} diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js new file mode 100644 index 0000000000..d3f6eb4da9 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -0,0 +1,188 @@ +export default ['workflowData', + 'workflowResultsService', + 'workflowDataOptions', + 'jobLabels', + 'workflowNodes', + '$scope', + 'ParseTypeChange', + 'ParseVariableString', + function(workflowData, + workflowResultsService, + workflowDataOptions, + jobLabels, + workflowNodes, + $scope, + ParseTypeChange, + ParseVariableString + ) { + var getTowerLinks = function() { + var getTowerLink = function(key) { + if ($scope.workflow.related[key]) { + return '/#/' + $scope.workflow.related[key] + .split('api/v1/')[1]; + } + else { + return null; + } + }; + + $scope.workflow_template_link = '/#/templates/workflow_job_template/'+$scope.workflow.workflow_job_template; + $scope.created_by_link = getTowerLink('created_by'); + $scope.cloud_credential_link = getTowerLink('cloud_credential'); + $scope.network_credential_link = getTowerLink('network_credential'); + }; + + var getTowerLabels = function() { + var getTowerLabel = function(key) { + if ($scope.workflowOptions && $scope.workflowOptions[key]) { + return $scope.workflowOptions[key].choices + .filter(val => val[0] === $scope.workflow[key]) + .map(val => val[1])[0]; + } else { + return null; + } + }; + + $scope.status_label = getTowerLabel('status'); + $scope.type_label = getTowerLabel('job_type'); + $scope.verbosity_label = getTowerLabel('verbosity'); + }; + + // var getTotalHostCount = function(count) { + // return Object + // .keys(count).reduce((acc, i) => acc += count[i], 0); + // }; + + // put initially resolved request data on scope + $scope.workflow = workflowData; + $scope.workflow_nodes = workflowNodes; + $scope.workflowOptions = workflowDataOptions.actions.GET; + $scope.labels = jobLabels; + + // turn related api browser routes into tower routes + getTowerLinks(); + + // use options labels to manipulate display of details + getTowerLabels(); + + // set up a read only code mirror for extra vars + $scope.variables = ParseVariableString($scope.workflow.extra_vars); + $scope.parseType = 'yaml'; + ParseTypeChange({ scope: $scope, + field_id: 'pre-formatted-variables', + readOnly: true }); + + // Click binding for the expand/collapse button on the standard out log + $scope.stdoutFullScreen = false; + $scope.toggleStdoutFullscreen = function() { + $scope.stdoutFullScreen = !$scope.stdoutFullScreen; + }; + + $scope.deleteJob = function() { + workflowResultsService.deleteJob($scope.workflow); + }; + + $scope.cancelJob = function() { + workflowResultsService.cancelJob($scope.workflow); + }; + + $scope.relaunchJob = function() { + workflowResultsService.relaunchJob($scope); + }; + + $scope.stdoutArr = []; + + // EVENT STUFF BELOW + + // just putting the event queue on scope so it can be inspected in the + // console + // $scope.event_queue = eventQueue.queue; + // $scope.defersArr = eventQueue.populateDefers; + + // 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) { + // // put the event in the queue + // eventQueue.populate(event).then(mungedEvent => { + // // make changes to ui based on the event returned from the queue + // if (mungedEvent.changes) { + // mungedEvent.changes.forEach(change => { + // // we've got a change we need to make to the UI! + // // update the necessary scope and make the change + // if (change === 'startTime' && !$scope.workflow.start) { + // $scope.workflow.start = mungedEvent.startTime; + // } + // + // if (change === 'count' && !$scope.countFinished) { + // // for all events that affect the host count, + // // update the status bar as well as the host + // // count badge + // $scope.count = mungedEvent.count; + // $scope.hostCount = getTotalHostCount(mungedEvent + // .count); + // } + // + // if (change === 'playCount') { + // $scope.playCount = mungedEvent.playCount; + // } + // + // if (change === 'taskCount') { + // $scope.taskCount = mungedEvent.taskCount; + // } + // + // if (change === 'finishedTime' && !$scope.workflow.finished) { + // $scope.workflow.finished = mungedEvent.finishedTime; + // } + // + // if (change === 'countFinished') { + // // the playbook_on_stats event actually lets + // // us know that we don't need to iteratively + // // look at event to update the host counts + // // any more. + // $scope.countFinished = true; + // } + // + // if(change === 'stdout'){ + // angular + // .element(".JobResultsStdOut-stdoutContainer") + // .append($compile(mungedEvent + // .stdout)($scope)); + // } + // }); + // } + // + // // the changes have been processed in the ui, mark it in the queue + // eventQueue.markProcessed(event); + // }); + // }; + + // PULL! grab completed event data and process each event + // TODO: implement retry logic in case one of these requests fails + // var getEvents = function(url) { + // workflowResultsService.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; + // processEvent(event); + // }); + // if (events.next) { + // getEvents(events.next); + // } + // }); + // }; + // getEvents($scope.job.related.job_events); + + // // Processing of job_events messages from the websocket + // $scope.$on(`ws-job_events-${$scope.workflow.id}`, function(e, data) { + // processEvent(data); + // }); + + // Processing of job-status messages from the websocket + $scope.$on(`ws-jobs`, function(e, data) { + if (parseInt(data.unified_job_id, 10) === parseInt($scope.workflow.id,10)) { + $scope.workflow.status = data.status; + } + }); +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html new file mode 100644 index 0000000000..9612719221 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -0,0 +1,236 @@ +
+
+
+ + +
+
+ + +
+
+ RESULTS +
+ + +
+ + + + + + + + + +
+
+ + +
+ + +
+ +
+ {{ workflow.started | longDate }} +
+
+ + +
+ +
+ {{ (workflow.finished | + longDate) || "Not Finished" }} +
+
+ + +
+ + +
+ + +
+ +
+ Workflow Job +
+
+ + + + + + +
+ + +
+ + +
+ +
+
+
+
+ {{ label }} +
+
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + + {{ workflow.name }} +
+ + +
+ +
+ Total Jobs +
+ + {{ workflow_nodes.length || 0}} + + + +
+ Elapsed +
+ + {{ job.elapsed * 1000 }} + +
+ + +
+ + + + + + + + + +
+
+ + +
+ +
+
+
diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js new file mode 100644 index 0000000000..4d15d50777 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.route.js @@ -0,0 +1,114 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../shared/template-url/template-url.factory'; + +import workflowResultsController from './workflow-results.controller'; + +export default { + name: 'workflowResults', + url: '/workflows/:id', + ncyBreadcrumb: { + parent: 'jobs', + label: '{{ job.id }} - {{ job.name }}' + }, + data: { + socket: { + "groups":{ + "jobs": ["status_changed", "summary"], + // not sure if you're gonna need to use job_events + // or if y'all will come up w/ a new socket group specifically + // for workflows + // "job_events": [] + } + } + }, + templateUrl: templateUrl('workflow-results/workflow-results'), + controller: workflowResultsController, + resolve: { + // the GET for the particular workflow + workflowData: ['Rest', 'GetBasePath', '$stateParams', '$q', '$state', 'Alert', function(Rest, GetBasePath, $stateParams, $q, $state, Alert) { + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id); + var defer = $q.defer(); + Rest.get() + .then(function(data) { + defer.resolve(data.data); + }, function(data) { + defer.reject(data); + + if (data.status === 404) { + Alert('Job Not Found', 'Cannot find job.', 'alert-info'); + } else if (data.status === 403) { + Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info'); + } + + $state.go('jobs'); + }); + return defer.promise; + }], + // after the GET for the job, this helps us keep the status bar from + // flashing as rest data comes in. Provides the list of workflow nodes + workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) { + var defer = $q.defer(); + Rest.setUrl(workflowData.related.workflow_nodes); + Rest.get() + .success(function(data) { + defer.resolve(data.results); + }) + .error(function() { + // TODO: handle this + //defer.resolve(data); + }); + return defer.promise; + }], + // GET for the particular jobs labels to be displayed in the + // left-hand pane + jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + var getNext = function(data, arr, resolve) { + Rest.setUrl(data.next); + Rest.get() + .success(function (data) { + if (data.next) { + getNext(data, arr.concat(data.results), resolve); + } else { + resolve.resolve(arr.concat(data.results) + .map(val => val.name)); + } + }); + }; + + var seeMoreResolve = $q.defer(); + + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id + '/labels/'); + Rest.get() + .success(function(data) { + if (data.next) { + getNext(data, data.results, seeMoreResolve); + } else { + seeMoreResolve.resolve(data.results + .map(val => val.name)); + } + }); + + return seeMoreResolve.promise; + }], + // OPTIONS request for the workflow. Used to make things like the + // verbosity data in the left-hand pane prettier than just an + // integer + workflowDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + Rest.setUrl(GetBasePath('workflow_jobs') + $stateParams.id); + var defer = $q.defer(); + Rest.options() + .then(function(data) { + defer.resolve(data.data); + }, function(data) { + defer.reject(data); + }); + return defer.promise; + }] + } + +}; diff --git a/awx/ui/client/src/workflow-results/workflow-results.service.js b/awx/ui/client/src/workflow-results/workflow-results.service.js new file mode 100644 index 0000000000..cf7ba70af8 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-results.service.js @@ -0,0 +1,108 @@ +/************************************************* +* Copyright (c) 2016 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + + +export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun) { + var val = { + deleteJob: function(workflow) { + Prompt({ + hdr: 'Delete Job', + body: `
+ Are you sure you want to delete the workflow below? +
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(workflow.url); + Rest.destroy() + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + $state.go('jobs'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not delete job. + Returned status: ${status}` + }); + }); + }, + actionText: 'DELETE' + }); + }, + cancelJob: function(workflow) { + var doCancel = function() { + Rest.setUrl(workflow.url + 'cancel'); + Rest.post({}) + .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 workflow. + Returned status: ${status}` + }); + }); + }; + + Prompt({ + hdr: 'Cancel Workflow', + body: `
+ Are you sure you want to cancel the workflow below? +
+
+ #${workflow.id} ${$filter('sanitize')(workflow.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(workflow.url + 'cancel'); + Rest.get() + .success(function(data) { + if (data.can_cancel === true) { + doCancel(); + } else { + $('#prompt-modal').modal('hide'); + ProcessErrors(null, data, null, null, { + hdr: 'Error!', + msg: `Job has completed, + unabled to be canceled.` + }); + } + }); + 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 workflow. + Returned status: ${status}` + }); + }); + }, + actionText: 'CANCEL' + }); + }, + relaunchJob: function(scope) { + InitiatePlaybookRun({ scope: scope, id: scope.workflow.id, + relaunch: true }); + } + }; + return val; +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/main.js b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js new file mode 100644 index 0000000000..251258fc70 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowStatusBar from './workflow-status-bar.directive'; + +export default + angular.module('workflowStatusBarDirective', []) + .directive('workflowStatusBar', workflowStatusBar); diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less new file mode 100644 index 0000000000..38e57d4883 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.block.less @@ -0,0 +1,80 @@ +@import '../../shared/branding/colors.default.less'; + +.WorkflowStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; + margin-top: 10px; +} + +.WorkflowStatusBar-ok, +.WorkflowStatusBar-changed, +.WorkflowStatusBar-unreachable, +.WorkflowStatusBar-failures, +.WorkflowStatusBar-skipped, +.WorkflowStatusBar-noData { + height: 15px; + border-top: 5px solid @default-bg; + border-bottom: 5px solid @default-bg; +} + +.WorkflowStatusBar-ok { + background-color: @default-succ; + display: flex; + flex: 0 0 auto; +} + +.WorkflowStatusBar-changed { + background-color: @default-warning; + flex: 0 0 auto; +} + +.WorkflowStatusBar-unreachable { + background-color: @default-unreachable; + flex: 0 0 auto; +} + +.WorkflowStatusBar-failures { + background-color: @default-err; + flex: 0 0 auto; +} + +.WorkflowStatusBar-skipped { + background-color: @default-link; + flex: 0 0 auto; +} + +.WorkflowStatusBar-noData { + background-color: @default-icon-hov; + flex: 1 0 auto; +} + +.WorkflowStatusBar-tooltipLabel { + text-transform: uppercase; + margin-right: 15px; +} + +.WorkflowStatusBar-tooltipBadge { + border-radius: 5px; +} + +.WorkflowStatusBar-tooltipBadge--ok { + background-color: @default-succ; +} + +.WorkflowStatusBar-tooltipBadge--unreachable { + background-color: @default-unreachable; +} + +.WorkflowStatusBar-tooltipBadge--skipped { + background-color: @default-link; +} + +.WorkflowStatusBar-tooltipBadge--changed { + background-color: @default-warning; +} + +.WorkflowStatusBar-tooltipBadge--failures { + background-color: @default-err; + +} diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js new file mode 100644 index 0000000000..a6899eb0da --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.directive.js @@ -0,0 +1,43 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +// import WorkflowStatusBarController from './host-status-bar.controller'; +export default [ 'templateUrl', + function(templateUrl) { + return { + scope: true, + templateUrl: templateUrl('workflow-results/workflow-status-bar/workflow-status-bar'), + restrict: 'E', + // controller: standardOutLogController, + link: function(scope) { + // as count is changed by event data coming in, + // update the host status bar + scope.$watch('count', function(val) { + if (val) { + Object.keys(val).forEach(key => { + // reposition the hosts status bar by setting + // the various flex values to the count of + // those hosts + $(`.WorkflowStatusBar-${key}`) + .css('flex', `${val[key]} 0 auto`); + + // set the tooltip to give how many hosts of + // each type + if (val[key] > 0) { + scope[`${key}CountTip`] = `${key}${val[key]}`; + } + }); + + // if there are any hosts that have finished, don't + // show default grey bar + scope.hostsFinished = (Object + .keys(val) + .filter(key => (val[key] > 0)).length > 0); + } + }); + } + }; +}]; diff --git a/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html new file mode 100644 index 0000000000..e0efddc7b6 --- /dev/null +++ b/awx/ui/client/src/workflow-results/workflow-status-bar/workflow-status-bar.partial.html @@ -0,0 +1,26 @@ +
+
+
+
+
+
+
+
diff --git a/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js new file mode 100644 index 0000000000..5d542622cd --- /dev/null +++ b/awx/ui/tests/spec/job-templates/job-templates-list.controller-test.js @@ -0,0 +1,269 @@ +'use strict'; + +describe('Controller: JobTemplatesList', () => { + // Setup + let scope, + rootScope, + state, + JobTemplatesListController, + ClearScope, + GetChoices, + Alert, + Prompt, + InitiatePlaybookRun, + rbacUiControlService, + canAddDeferred, + q, + JobTemplateService, + deleteWorkflowJobTemplateDeferred, + deleteJobTemplateDeferred, + Dataset; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + state = jasmine.createSpyObj('state', [ + '$get', + 'transitionTo', + 'go' + ]); + + state.params = { + id: 1 + }; + + rbacUiControlService = { + canAdd: function(){ + return angular.noop; + } + }; + + JobTemplateService = { + deleteWorkflowJobTemplate: function(){ + return angular.noop; + }, + deleteJobTemplate: function(){ + return angular.noop; + } + }; + + Dataset = { + data: { + results: [] + } + }; + + ClearScope = jasmine.createSpy('ClearScope'); + GetChoices = jasmine.createSpy('GetChoices'); + Alert = jasmine.createSpy('Alert'); + Prompt = jasmine.createSpy('Prompt').and.callFake(function(args) { + args.action(); + }); + InitiatePlaybookRun = jasmine.createSpy('InitiatePlaybookRun'); + + $provide.value('ClearScope', ClearScope); + $provide.value('GetChoices', GetChoices); + $provide.value('Alert', Alert); + $provide.value('Prompt', Prompt); + $provide.value('state', state); + $provide.value('InitiatePlaybookRun', InitiatePlaybookRun); + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, $q, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _Prompt_, _InitiatePlaybookRun_) => { + scope = $rootScope.$new(); + rootScope = $rootScope; + q = $q; + state = _state_; + ClearScope = _ClearScope_; + GetChoices = _GetChoices_; + Alert = _Alert_; + Prompt = _Prompt_; + InitiatePlaybookRun = _InitiatePlaybookRun_; + canAddDeferred = q.defer(); + deleteWorkflowJobTemplateDeferred = q.defer(); + deleteJobTemplateDeferred = q.defer(); + + rbacUiControlService.canAdd = jasmine.createSpy('canAdd').and.returnValue(canAddDeferred.promise); + + JobTemplateService.deleteWorkflowJobTemplate = jasmine.createSpy('deleteWorkflowJobTemplate').and.returnValue(deleteWorkflowJobTemplateDeferred.promise); + JobTemplateService.deleteJobTemplate = jasmine.createSpy('deleteJobTemplate').and.returnValue(deleteJobTemplateDeferred.promise); + + JobTemplatesListController = $controller('JobTemplatesListController', { + $scope: scope, + $rootScope: rootScope, + $state: state, + ClearScope: ClearScope, + GetChoices: GetChoices, + Alert: Alert, + Prompt: Prompt, + InitiatePlaybookRun: InitiatePlaybookRun, + rbacUiControlService: rbacUiControlService, + JobTemplateService: JobTemplateService, + Dataset: Dataset + }); + })); + + describe('scope.editJobTemplate()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.editJobTemplate(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to edit template', 'Template parameter is missing'); + }); + + it('should transition to templates.editJobTemplate when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(state.transitionTo).toHaveBeenCalledWith('templates.editJobTemplate', {job_template_id: 1}); + }); + + it('should transition to templates.templates.editWorkflowJobTemplate when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(state.transitionTo).toHaveBeenCalledWith('templates.editWorkflowJobTemplate', {workflow_job_template_id: 1}); + }); + + it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Some Other Type", + id: 1 + }; + + scope.editJobTemplate(testTemplate); + expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while routing to edit.'); + }); + + }); + + describe('scope.deleteJobTemplate()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.deleteJobTemplate(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to delete template', 'Template parameter is missing'); + }); + + it('should call Prompt if template param is present', ()=>{ + + var testTemplate = { + id: 1, + name: "Test Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(Prompt).toHaveBeenCalled(); + }); + + it('should call JobTemplateService.deleteWorkflowJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{ + // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in + // which is how we access the private function in the controller + + var testTemplate = { + id: 1, + name: "Test Template", + type: "Workflow Job Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(JobTemplateService.deleteWorkflowJobTemplate).toHaveBeenCalled(); + }); + + it('should call JobTemplateService.deleteJobTemplate when the user takes affirmative action on the delete modal and type = "Workflow Job Template"', ()=>{ + // Note that Prompt has been mocked up above to immediately call the callback function that gets passed in + // which is how we access the private function in the controller + + var testTemplate = { + id: 1, + name: "Test Template", + type: "Job Template" + }; + + scope.deleteJobTemplate(testTemplate); + expect(JobTemplateService.deleteJobTemplate).toHaveBeenCalled(); + }); + + }); + + describe('scope.submitJob()', () => { + + it('should call Alert when template param is not present', ()=>{ + scope.submitJob(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to launch template', 'Template parameter is missing'); + }); + + it('should call InitiatePlaybookRun when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.submitJob(testTemplate); + expect(InitiatePlaybookRun).toHaveBeenCalled(); + }); + + xit('should call [something] when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.submitJob(testTemplate); + expect([something]).toHaveBeenCalled(); + }); + + it('should call Alert when type is not "Job Template" or "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Some Other Type", + id: 1 + }; + + scope.submitJob(testTemplate); + expect(Alert).toHaveBeenCalledWith('Error: Unable to determine template type', 'We were unable to determine this template\'s type while launching.'); + }); + + }); + + describe('scope.scheduleJob()', () => { + + it('should transition to jobTemplateSchedules when type is "Job Template"', ()=>{ + + var testTemplate = { + type: "Job Template", + id: 1 + }; + + scope.scheduleJob(testTemplate); + expect(state.go).toHaveBeenCalledWith('jobTemplateSchedules', {id: 1}); + }); + + it('should transition to workflowJobTemplateSchedules when type is "Workflow Job Template"', ()=>{ + + var testTemplate = { + type: "Workflow Job Template", + id: 1 + }; + + scope.scheduleJob(testTemplate); + expect(state.go).toHaveBeenCalledWith('workflowJobTemplateSchedules', {id: 1}); + }); + + it('should call Alert when template param is not present', ()=>{ + scope.scheduleJob(); + expect(Alert).toHaveBeenCalledWith('Error: Unable to schedule job', 'Template parameter is missing'); + }); + + }); + +}); diff --git a/awx/ui/tests/spec/workflows/workflow-add.controller-test.js b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js new file mode 100644 index 0000000000..a631ac66ff --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-add.controller-test.js @@ -0,0 +1,161 @@ +'use strict'; + +describe('Controller: WorkflowAdd', () => { + // Setup + let scope, + state, + WorkflowAdd, + ClearScope, + Alert, + GenerateForm, + JobTemplateService, + q, + getLabelsDeferred, + createWorkflowJobTemplateDeferred, + httpBackend, + ProcessErrors, + CreateSelect2, + Wait, + ParseTypeChange, + ToJSON; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + state = jasmine.createSpyObj('state', [ + '$get', + 'transitionTo', + 'go' + ]); + + GenerateForm = jasmine.createSpyObj('GenerateForm', [ + 'inject', + 'reset', + 'clearApiErrors', + 'applyDefaults' + ]); + + JobTemplateService = { + getLabelOptions: function(){ + return angular.noop; + }, + createWorkflowJobTemplate: function(){ + return angular.noop; + } + }; + + ClearScope = jasmine.createSpy('ClearScope'); + Alert = jasmine.createSpy('Alert'); + ProcessErrors = jasmine.createSpy('ProcessErrors'); + CreateSelect2 = jasmine.createSpy('CreateSelect2'); + Wait = jasmine.createSpy('Wait'); + ParseTypeChange = jasmine.createSpy('ParseTypeChange'); + ToJSON = jasmine.createSpy('ToJSON'); + + $provide.value('ClearScope', ClearScope); + $provide.value('Alert', Alert); + $provide.value('GenerateForm', GenerateForm); + $provide.value('state', state); + $provide.value('ProcessErrors', ProcessErrors); + $provide.value('CreateSelect2', CreateSelect2); + $provide.value('Wait', Wait); + $provide.value('ParseTypeChange', ParseTypeChange); + $provide.value('ToJSON', ToJSON); + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, $q, $httpBackend, _state_, _ConfigService_, _ClearScope_, _GetChoices_, _Alert_, _GenerateForm_, _ProcessErrors_, _CreateSelect2_, _Wait_, _ParseTypeChange_, _ToJSON_) => { + scope = $rootScope.$new(); + state = _state_; + q = $q; + ClearScope = _ClearScope_; + Alert = _Alert_; + GenerateForm = _GenerateForm_; + httpBackend = $httpBackend; + ProcessErrors = _ProcessErrors_; + CreateSelect2 = _CreateSelect2_; + Wait = _Wait_; + getLabelsDeferred = q.defer(); + createWorkflowJobTemplateDeferred = q.defer(); + ParseTypeChange = _ParseTypeChange_; + ToJSON = _ToJSON_; + + JobTemplateService.getLabelOptions = jasmine.createSpy('getLabelOptions').and.returnValue(getLabelsDeferred.promise); + JobTemplateService.createWorkflowJobTemplate = jasmine.createSpy('createWorkflowJobTemplate').and.returnValue(createWorkflowJobTemplateDeferred.promise); + + WorkflowAdd = $controller('WorkflowAdd', { + $scope: scope, + $state: state, + ClearScope: ClearScope, + Alert: Alert, + GenerateForm: GenerateForm, + JobTemplateService: JobTemplateService, + ProcessErrors: ProcessErrors, + CreateSelect2: CreateSelect2, + Wait: Wait, + ParseTypeChange: ParseTypeChange, + ToJSON + }); + })); + + it('should call ClearScope', ()=>{ + expect(ClearScope).toHaveBeenCalled(); + }); + + it('should get/set the label options and select2-ify the input', ()=>{ + // Resolve JobTemplateService.getLabelsForJobTemplate + getLabelsDeferred.resolve({ + foo: "bar" + }); + // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it + httpBackend.expectGET('/static/config.js').respond(200); + scope.$digest(); + expect(scope.labelOptions).toEqual({ + foo: "bar" + }); + expect(CreateSelect2).toHaveBeenCalledWith({ + element:'#workflow_job_template_labels', + multiple: true, + addNew: true + }); + }); + + it('should call ProcessErrors when getLabelsForJobTemplate returns a rejected promise', ()=>{ + // Reject JobTemplateService.getLabelsForJobTemplate + getLabelsDeferred.reject({ + data: "mockedData", + status: 400 + }); + // We expect the digest cycle to fire off this call to /static/config.js so we go ahead and handle it + httpBackend.expectGET('/static/config.js').respond(200); + scope.$digest(); + expect(ProcessErrors).toHaveBeenCalled(); + }); + + describe('scope.formSave()', () => { + + it('should call JobTemplateService.createWorkflowJobTemplate', ()=>{ + scope.name = "Test Workflow"; + scope.description = "This is a test description"; + scope.formSave(); + expect(JobTemplateService.createWorkflowJobTemplate).toHaveBeenCalledWith({ + name: "Test Workflow", + description: "This is a test description", + labels: undefined, + organization: undefined, + variables: undefined, + extra_vars: undefined + }); + }); + + }); + + describe('scope.formCancel()', () => { + + it('should transition to templates', ()=>{ + scope.formCancel(); + expect(state.transitionTo).toHaveBeenCalledWith('templates'); + }); + + }); + +}); diff --git a/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js new file mode 100644 index 0000000000..0486356249 --- /dev/null +++ b/awx/ui/tests/spec/workflows/workflow-maker.controller-test.js @@ -0,0 +1,58 @@ +'use strict'; + +describe('Controller: WorkflowMaker', () => { + // Setup + let scope, + WorkflowMakerController, + WorkflowHelpService; + + beforeEach(angular.mock.module('Tower')); + beforeEach(angular.mock.module('jobTemplates', ($provide) => { + + WorkflowHelpService = jasmine.createSpyObj('WorkflowHelpService', [ + 'closeDialog', + 'addPlaceholderNode', + 'getSiblingConnectionTypes' + ]); + + $provide.value('WorkflowHelpService', WorkflowHelpService); + + })); + + beforeEach(angular.mock.inject( ($rootScope, $controller, _WorkflowHelpService_) => { + scope = $rootScope.$new(); + scope.treeData = { + data: { + id: 1, + canDelete: false, + canEdit: false, + canAddTo: true, + isStartNode: true, + unifiedJobTemplate: { + name: "Workflow Launch" + }, + children: [], + deletedNodes: [], + totalNodes: 0 + }, + nextIndex: 2 + }; + WorkflowHelpService = _WorkflowHelpService_; + + WorkflowMakerController = $controller('WorkflowMakerController', { + $scope: scope, + WorkflowHelpService: WorkflowHelpService + }); + + })); + + describe('scope.saveWorkflowMaker()', () => { + + it('should close the dialog', ()=>{ + scope.saveWorkflowMaker(); + expect(WorkflowHelpService.closeDialog).toHaveBeenCalled(); + }); + + }); + +}); diff --git a/awx/ui/webpack.config.js b/awx/ui/webpack.config.js index 78ee691d9c..0b279d7fbf 100644 --- a/awx/ui/webpack.config.js +++ b/awx/ui/webpack.config.js @@ -123,6 +123,7 @@ var release = { 'jsyaml': 'js-yaml', 'jsonlint': 'codemirror.jsonlint' }), + new webpack.DefinePlugin({ $ENV: {} }), new webpack.optimize.CommonsChunkPlugin('vendor', 'tower.vendor.js'), new webpack.optimize.UglifyJsPlugin({ mangle: false diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 072eeed380..b57a325194 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,7 +2,7 @@ git+https://github.com/ansible/ansiconv.git@tower_1.0.0#egg=ansiconv amqp==1.4.9 anyjson==0.3.3 appdirs==1.4.0 -asgi_amqp==0.3.1 +asgi-amqp==0.3.1 azure==2.0.0rc5 Babel==2.2.0 baron==0.6.2 @@ -10,11 +10,11 @@ billiard==3.3.0.16 boto==2.43.0 celery==3.1.23 cffi==1.7.0 -channels==0.17.2 +channels==0.17.3 cliff==1.15.0 cmd2==0.6.8 daphne==0.15.0 -d2to1==0.2.11 # TODO: Still needed? +d2to1==0.2.11 defusedxml==0.4.1 Django==1.8.16 debtcollector==1.2.0 @@ -36,7 +36,7 @@ django-taggit==0.17.6 git+https://github.com/ansible/dm.xmlsec.binding.git@master#egg=dm.xmlsec.binding dogpile.core==0.4.1 funcsigs==0.4 -gevent==1.1 +gevent==1.1.0 gevent-websocket==0.9.5 git+https://github.com/ansible/django-qsstats-magic.git@tower_0.7.2#egg=django-qsstats-magic greenlet==0.4.9 diff --git a/tools/docker-compose/unit-tests/Dockerfile b/tools/docker-compose/unit-tests/Dockerfile index dc4a10bcc4..4658c5d32e 100644 --- a/tools/docker-compose/unit-tests/Dockerfile +++ b/tools/docker-compose/unit-tests/Dockerfile @@ -1,39 +1,13 @@ -# FROM gcr.io/ansible-tower-engineering/tower_devel:latest -FROM centos +FROM gcr.io/ansible-tower-engineering/tower_devel:latest -RUN yum install -y epel-release +RUN yum install -y bzip2 gcc-c++ -RUN yum install -y \ - bzip2 \ - python-pip \ - python-virtualenv \ - make \ - swig \ - git \ - libffi-devel \ - openssl-devel \ - libxml2-devel \ - xmlsec1-devel \ - xmlsec1-openssl-devel \ - gcc \ - gcc-c++ \ - /usr/bin/pg_config \ - openldap-devel \ - postgresql-devel \ - rabbitmq-server \ - libtool-ltdl-devel \ - fontconfig +# We need to install dependencies somewhere other than /ansible-tower. +# Anything in /ansible-tower will be overwritten by the bind-mount. +# We switch the WORKDIR to /ansible-tower further down. +WORKDIR "/tmp/" -# NOTE: The following steps work for tower-3.0.0 -# RUN curl -LO https://rpm.nodesource.com/pub_0.12/el/7/x86_64/nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm -# RUN yum install -y nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm -# RUN rm nodejs-0.12.9-1nodesource.el7.centos.x86_64.rpm - -# Remove the 2 lines below and uncomment the 3 lines above to build -# RPMs with the old JS build system. -RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - - -RUN yum install -y nodejs +# Build front-end deps # https://github.com/npm/npm/issues/9863 RUN cd $(npm root -g)/npm \ @@ -42,41 +16,15 @@ RUN cd $(npm root -g)/npm \ RUN npm install -g npm@3.10.7 -# We need to install dependencies somewhere other than /ansible-tower. -# Anything in /ansible-tower will be overwritten by the bind-mount. -# We switch the WORKDIR to /ansible-tower further down. -WORKDIR "/tmp/ansible-tower" - -# Copy requirements files -# NOTE: '*' is not used as it invalidates docker caching -COPY requirements/requirements.txt requirements/ -COPY requirements/requirements_ansible.txt requirements/ -COPY requirements/requirements_dev.txt requirements/ -COPY requirements/requirements_jenkins.txt requirements/ - - -# Copy __init__.py so the Makefile can retrieve `awx.__version__` -COPY awx/__init__.py awx/ - -# Copy Makefile -COPY Makefile . - -# Install tower runtime virtualenvs -ENV SWIG_FEATURES="-cpperraswarn -includeall -I/usr/include/openssl" -RUN make requirements - -# Install tower test requirements -ENV VENV_BASE="" -RUN make requirements_jenkins - -# Build front-end deps COPY awx/ui/package.json awx/ui/ RUN npm set progress=false +# Copy __init__.py so the Makefile can retrieve `awx.__version__` +COPY awx/__init__.py awx/ RUN make ui-deps -WORKDIR "/ansible-tower" +WORKDIR "/tower_devel" # This entrypoint script takes care of moving the node_modules # into the bind-mount, then exec's to whatever was passed as the CMD. diff --git a/tools/docker-compose/unit-tests/docker-compose.yml b/tools/docker-compose/unit-tests/docker-compose.yml index 9e59aadd5f..243726ae8d 100644 --- a/tools/docker-compose/unit-tests/docker-compose.yml +++ b/tools/docker-compose/unit-tests/docker-compose.yml @@ -11,4 +11,4 @@ services: TEST_DIRS: awx/main/tests/unit awx/main/tests/functional command: ["make test"] volumes: - - ../../../:/ansible-tower + - ../../../:/tower_devel diff --git a/tools/docker-compose/unit-tests/entrypoint b/tools/docker-compose/unit-tests/entrypoint index 0fcf42881b..cb7fbe142e 100644 --- a/tools/docker-compose/unit-tests/entrypoint +++ b/tools/docker-compose/unit-tests/entrypoint @@ -1,7 +1,7 @@ #!/bin/bash set -e -mkdir -p /ansible-tower/awx/ui/ -mv -n /tmp/ansible-tower/awx/ui/node_modules /ansible-tower/awx/ui +mkdir -p /tower_devel/awx/ui/ +mv -n /tmp/awx/ui/node_modules /tmp/awx/ui/.deps_built /tower_devel/awx/ui exec $@