diff --git a/awx/ui/client/src/inventories/groups/factories/get-hosts-status-msg.factory.js b/awx/ui/client/src/inventories/groups/factories/get-hosts-status-msg.factory.js new file mode 100644 index 0000000000..19a846c414 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/factories/get-hosts-status-msg.factory.js @@ -0,0 +1,33 @@ +export default + function GetHostsStatusMsg() { + return function(params) { + var active_failures = params.active_failures, + total_hosts = params.total_hosts, + tip, failures, html_class; + + // Return values for use on host status indicator + + if (active_failures > 0) { + tip = total_hosts + ((total_hosts === 1) ? ' host' : ' hosts') + '. ' + active_failures + ' with failed jobs.'; + html_class = 'error'; + failures = true; + } else { + failures = false; + if (total_hosts === 0) { + // no hosts + tip = "Contains 0 hosts."; + html_class = 'none'; + } else { + // many hosts with 0 failures + tip = total_hosts + ((total_hosts === 1) ? ' host' : ' hosts') + '. No job failures'; + html_class = 'success'; + } + } + + return { + tooltip: tip, + failures: failures, + 'class': html_class + }; + }; + } diff --git a/awx/ui/client/src/inventories/groups/factories/get-source-type-options.factory.js b/awx/ui/client/src/inventories/groups/factories/get-source-type-options.factory.js new file mode 100644 index 0000000000..befef8a499 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/factories/get-source-type-options.factory.js @@ -0,0 +1,37 @@ +export default + function GetSourceTypeOptions(Rest, ProcessErrors, GetBasePath) { + return function(params) { + var scope = params.scope, + variable = params.variable; + + if (scope[variable] === undefined) { + scope[variable] = []; + Rest.setUrl(GetBasePath('inventory_sources')); + Rest.options() + .success(function (data) { + var i, choices = data.actions.GET.source.choices; + for (i = 0; i < choices.length; i++) { + if (choices[i][0] !== 'file') { + scope[variable].push({ + label: choices[i][1], + value: choices[i][0] + }); + } + } + scope.cloudCredentialRequired = false; + scope.$emit('sourceTypeOptionsReady'); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Failed to retrieve options for inventory_sources.source. OPTIONS status: ' + status + }); + }); + } + }; + } + +GetSourceTypeOptions.$inject = + [ 'Rest', + 'ProcessErrors', + 'GetBasePath' + ]; diff --git a/awx/ui/client/src/inventories/groups/factories/get-sync-status-msg.factory.js b/awx/ui/client/src/inventories/groups/factories/get-sync-status-msg.factory.js new file mode 100644 index 0000000000..2541abcc27 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/factories/get-sync-status-msg.factory.js @@ -0,0 +1,77 @@ +export default + function GetSyncStatusMsg(Empty) { + return function(params) { + var status = params.status, + source = params.source, + has_inventory_sources = params.has_inventory_sources, + launch_class = '', + launch_tip = 'Start sync process', + schedule_tip = 'Schedule future inventory syncs', + stat, stat_class, status_tip; + + stat = status; + stat_class = stat; + + switch (status) { + case 'never updated': + stat = 'never'; + stat_class = 'na'; + status_tip = 'Sync not performed. Click to start it now.'; + break; + case 'none': + case 'ok': + case '': + launch_class = 'btn-disabled'; + stat = 'n/a'; + stat_class = 'na'; + status_tip = 'Cloud source not configured. Click to update.'; + launch_tip = 'Cloud source not configured.'; + break; + case 'canceled': + status_tip = 'Sync canceled. Click to view log.'; + break; + case 'failed': + status_tip = 'Sync failed. Click to view log.'; + break; + case 'successful': + status_tip = 'Sync completed. Click to view log.'; + break; + case 'pending': + status_tip = 'Sync pending.'; + launch_class = "btn-disabled"; + launch_tip = "Sync pending"; + break; + case 'updating': + case 'running': + launch_class = "btn-disabled"; + launch_tip = "Sync running"; + status_tip = "Sync running. Click to view log."; + break; + } + + if (has_inventory_sources && Empty(source)) { + // parent has a source, therefore this group should not have a source + launch_class = "btn-disabled"; + status_tip = 'Managed by an external cloud source.'; + launch_tip = 'Can only be updated by running a sync on the parent group.'; + } + + if (has_inventory_sources === false && Empty(source)) { + launch_class = 'btn-disabled'; + status_tip = 'Cloud source not configured. Click to update.'; + launch_tip = 'Cloud source not configured.'; + } + + return { + "class": stat_class, + "tooltip": status_tip, + "status": stat, + "launch_class": launch_class, + "launch_tip": launch_tip, + "schedule_tip": schedule_tip + }; + }; + } + +GetSyncStatusMsg.$inject = + [ 'Empty' ]; diff --git a/awx/ui/client/src/inventories/groups/factories/groups-cancel-update.factory.js b/awx/ui/client/src/inventories/groups/factories/groups-cancel-update.factory.js new file mode 100644 index 0000000000..1447d0aa1c --- /dev/null +++ b/awx/ui/client/src/inventories/groups/factories/groups-cancel-update.factory.js @@ -0,0 +1,81 @@ +export default + function GroupsCancelUpdate(Empty, Rest, ProcessErrors, Alert, Wait, Find) { + return function(params) { + var scope = params.scope, + id = params.id, + group = params.group; + + if (scope.removeCancelUpdate) { + scope.removeCancelUpdate(); + } + scope.removeCancelUpdate = scope.$on('CancelUpdate', function (e, url) { + // Cancel the update process + Rest.setUrl(url); + Rest.post() + .success(function () { + Wait('stop'); + //Alert('Inventory Sync Cancelled', 'Request to cancel the sync process was submitted to the task manger. ' + + // 'Click the button to monitor the status.', 'alert-info'); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. POST status: ' + status + }); + }); + }); + + if (scope.removeCheckCancel) { + scope.removeCheckCancel(); + } + scope.removeCheckCancel = scope.$on('CheckCancel', function (e, last_update, current_update) { + // Check that we have access to cancelling an update + var url = (current_update) ? current_update : last_update; + url += 'cancel/'; + Rest.setUrl(url); + Rest.get() + .success(function (data) { + if (data.can_cancel) { + scope.$emit('CancelUpdate', url); + //} else { + // Wait('stop'); + // Alert('Cancel Inventory Sync', 'The sync process completed. Click the button to view ' + + // 'the latest status.', 'alert-info'); + } + else { + Wait('stop'); + } + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. GET status: ' + status + }); + }); + }); + + // Cancel the update process + if (Empty(group)) { + group = Find({ list: scope.groups, key: 'id', val: id }); + scope.selected_group_id = group.id; + } + + if (group && (group.status === 'running' || group.status === 'pending')) { + // We found the group, and there is a running update + Wait('start'); + Rest.setUrl(group.related.inventory_source); + Rest.get() + .success(function (data) { + scope.$emit('CheckCancel', data.related.last_update, data.related.current_update); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + group.related.inventory_source + ' failed. GET status: ' + status + }); + }); + } + }; + } + +GroupsCancelUpdate.$inject = + [ 'Empty', 'Rest', 'ProcessErrors', + 'Alert', 'Wait', 'Find' + ]; diff --git a/awx/ui/client/src/inventories/groups/factories/view-update-status.factory.js b/awx/ui/client/src/inventories/groups/factories/view-update-status.factory.js new file mode 100644 index 0000000000..1f3280b51c --- /dev/null +++ b/awx/ui/client/src/inventories/groups/factories/view-update-status.factory.js @@ -0,0 +1,46 @@ +export default + function ViewUpdateStatus($state, Rest, ProcessErrors, Alert, Wait, Empty, Find) { + return function(params) { + var scope = params.scope, + group_id = params.group_id, + group = Find({ list: scope.groups, key: 'id', val: group_id }); + + if (scope.removeSourceReady) { + scope.removeSourceReady(); + } + scope.removeSourceReady = scope.$on('SourceReady', function(e, source) { + + // Get the ID from the correct summary field + var update_id = (source.summary_fields.current_update) ? source.summary_fields.current_update.id : source.summary_fields.last_update.id; + + $state.go('inventorySyncStdout', {id: update_id}); + + }); + + if (group) { + if (Empty(group.source)) { + // do nothing + } else if (Empty(group.status) || group.status === "never updated") { + Alert('No Status Available', '
An inventory sync has not been performed for the selected group. Start the process by ' + + 'clicking the button.
', 'alert-info', null, null, null, null, true); + } else { + Wait('start'); + Rest.setUrl(group.related.inventory_source); + Rest.get() + .success(function (data) { + scope.$emit('SourceReady', data); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', + msg: 'Failed to retrieve inventory source: ' + group.related.inventory_source + + ' GET returned status: ' + status }); + }); + } + } + }; + } + +ViewUpdateStatus.$inject = + [ '$state', 'Rest', 'ProcessErrors', + 'Alert', 'Wait', 'Empty', 'Find' + ]; diff --git a/awx/ui/client/src/inventories/groups/groups.service.js b/awx/ui/client/src/inventories/groups/groups.service.js new file mode 100644 index 0000000000..54ed90dfc7 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/groups.service.js @@ -0,0 +1,113 @@ +export default + ['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', 'Wait', function($rootScope, Rest, GetBasePath, ProcessErrors, Wait){ + return { + stringifyParams: function(params){ + return _.reduce(params, (result, value, key) => { + return result + key + '=' + value + '&'; + }, ''); + }, + // cute abstractions via fn.bind() + url: function(){ + return ''; + }, + error: function(data, status) { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + this.url + '. GET returned: ' + status }); + }, + success: function(data){ + return data; + }, + // HTTP methods + get: function(params){ + Wait('start'); + this.url = GetBasePath('groups') + '?' + this.stringifyParams(params); + Rest.setUrl(this.url); + return Rest.get() + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + post: function(group){ + Wait('start'); + this.url = GetBasePath('groups'); + Rest.setUrl(this.url); + return Rest.post(group) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + put: function(group){ + Wait('start'); + this.url = GetBasePath('groups') + group.id; + Rest.setUrl(this.url); + return Rest.put(group) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + delete: function(id){ + Wait('start'); + this.url = GetBasePath('groups') + id; + Rest.setUrl(this.url); + return Rest.destroy() + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + getCredential: function(id){ + Wait('start'); + this.url = GetBasePath('credentials') + id; + Rest.setUrl(this.url); + return Rest.get() + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + getInventorySource: function(params){ + Wait('start'); + this.url = GetBasePath('inventory_sources') + '?' + this.stringifyParams(params); + Rest.setUrl(this.url); + return Rest.get() + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + putInventorySource: function(params, url){ + Wait('start'); + this.url = url; + Rest.setUrl(this.url); + return Rest.put(params) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + // these relationship setters could be consolidated, but verbosity makes the operation feel more clear @ controller level + associateGroup: function(group, target){ + Wait('start'); + this.url = GetBasePath('groups') + target + '/children/'; + Rest.setUrl(this.url); + return Rest.post(group) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + disassociateGroup: function(group, parent){ + Wait('start'); + this.url = GetBasePath('groups') + parent + '/children/'; + Rest.setUrl(this.url); + return Rest.post({id: group, disassociate: 1}) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + promote: function(group, inventory){ + Wait('start'); + this.url = GetBasePath('inventory') + inventory + '/groups/'; + Rest.setUrl(this.url); + return Rest.post({id: group, disassociate: 1}) + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + } + }; + }]; diff --git a/awx/ui/client/src/inventories/groups/list/build-groups-list-state.factory.js b/awx/ui/client/src/inventories/groups/list/build-groups-list-state.factory.js new file mode 100644 index 0000000000..f1af8925fd --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/build-groups-list-state.factory.js @@ -0,0 +1,99 @@ +/************************************************* +* Copyright (c) 2017 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + +export default ['InventoryGroupsList', '$stateExtender', 'templateUrl', '$injector', + function(InventoryGroupsList, $stateExtender, templateUrl, $injector){ + var val = function(field, formStateDefinition, params) { + let state, + list = field.include ? $injector.get(field.include) : field, + breadcrumbLabel = (field.iterator.replace('_', ' ') + 's').toUpperCase(), + stateConfig = { + searchPrefix: `${list.iterator}`, + name: `${formStateDefinition.name}.${list.iterator}s`, + url: `/${list.iterator}s`, + ncyBreadcrumb: { + parent: `${formStateDefinition.name}`, + label: `${breadcrumbLabel}` + }, + params: { + [list.iterator + '_search']: { + value: { order_by: field.order_by ? field.order_by : 'name' } + }, + }, + views: { + 'related': { + templateProvider: function(InventoryGroupsList, generateList, $templateRequest, $stateParams, GetBasePath) { + let list = _.cloneDeep(InventoryGroupsList); + if($stateParams && $stateParams.group) { + list.basePath = GetBasePath('groups') + _.last($stateParams.group) + '/children'; + } + else { + //reaches here if the user is on the root level group + list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/root_groups'; + } + + let html = generateList.build({ + list: list, + mode: 'edit' + }); + // Include the custom group delete modal template + return $templateRequest(templateUrl('inventories/groups/list/groups-list')).then((template) => { + return html.concat(template); + }); + }, + // controller: GroupsListController + } + }, + resolve: { + ListDefinition: () => { + return list; + }, + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$interpolate', '$rootScope', + (list, qs, $stateParams, GetBasePath, $interpolate, $rootScope) => { + // allow related list definitions to use interpolated $rootScope / $stateParams in basePath field + let path, interpolator; + if (GetBasePath(list.basePath)) { + path = GetBasePath(list.basePath); + } else { + interpolator = $interpolate(list.basePath); + path = interpolator({ $rootScope: $rootScope, $stateParams: $stateParams }); + } + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ], + inventoryData: ['InventoryManageService', '$stateParams', function(InventoryManageService, $stateParams) { + return InventoryManageService.getInventory($stateParams.inventory_id).then(res => res.data); + }] + } + }; + + if(params.controllers && params.controllers.related && params.controllers.related[field.name]) { + stateConfig.views.related.controller = params.controllers.related[field.name]; + } + else if(field.name === 'permissions') { + stateConfig.views.related.controller = 'PermissionsList'; + } + else { + // Generic controller + stateConfig.views.related.controller = ['$scope', 'ListDefinition', 'Dataset', + function($scope, list, Dataset) { + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[`${list.iterator}s`] = $scope[`${list.iterator}_dataset`].results; + } + ]; + } + + state = $stateExtender.buildDefinition(stateConfig); + // appy any default search parameters in form definition + if (field.search) { + state.params[`${field.iterator}_search`].value = _.merge(state.params[`${field.iterator}_search`].value, field.search); + } + return state; + }; + return val; + } +]; diff --git a/awx/ui/client/src/inventories/groups/list/group-list.controller.js b/awx/ui/client/src/inventories/groups/list/group-list.controller.js new file mode 100644 index 0000000000..8bc2bb8154 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/group-list.controller.js @@ -0,0 +1,236 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + export default + ['$scope', '$rootScope', '$state', '$stateParams', 'InventoryGroupsList', 'InventoryUpdate', + 'GroupManageService', 'GroupsCancelUpdate', 'ViewUpdateStatus', 'rbacUiControlService', 'GetBasePath', + 'GetSyncStatusMsg', 'GetHostsStatusMsg', 'Dataset', 'Find', 'QuerySet', 'inventoryData', + function($scope, $rootScope, $state, $stateParams, InventoryGroupsList, InventoryUpdate, + GroupManageService, GroupsCancelUpdate, ViewUpdateStatus, rbacUiControlService, GetBasePath, + GetSyncStatusMsg, GetHostsStatusMsg, Dataset, Find, qs, inventoryData){ + + let list = InventoryGroupsList; + + init(); + + function init(){ + $scope.inventory_id = $stateParams.inventory_id; + $scope.canAdhoc = inventoryData.summary_fields.user_capabilities.adhoc; + $scope.canAdd = false; + + rbacUiControlService.canAdd(GetBasePath('inventory') + $scope.inventory_id + "/groups") + .then(function(canAdd) { + $scope.canAdd = canAdd; + }); + + // Search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + // The ncy breadcrumb directive will look at this attribute when attempting to bind to the correct scope. + // In this case, we don't want to incidentally bind to this scope when editing a host or a group. See: + // https://github.com/ncuillery/angular-breadcrumb/issues/42 for a little more information on the + // problem that this solves. + $scope.ncyBreadcrumbIgnore = true; + if($state.current.name === "inventoryManage.editGroup") { + $scope.rowBeingEdited = $state.params.group_id; + $scope.listBeingEdited = "groups"; + } + + $scope.inventory_id = $stateParams.inventory_id; + _.forEach($scope[list.name], buildStatusIndicators); + + } + + function buildStatusIndicators(group){ + if (group === undefined || group === null) { + group = {}; + } + + let group_status, hosts_status; + + group_status = GetSyncStatusMsg({ + status: group.summary_fields.inventory_source.status, + has_inventory_sources: group.has_inventory_sources, + source: ( (group.summary_fields.inventory_source) ? group.summary_fields.inventory_source.source : null ) + }); + hosts_status = GetHostsStatusMsg({ + active_failures: group.hosts_with_active_failures, + total_hosts: group.total_hosts, + inventory_id: $scope.inventory_id, + group_id: group.id + }); + _.assign(group, + {status_class: group_status.class}, + {status_tooltip: group_status.tooltip}, + {launch_tooltip: group_status.launch_tip}, + {launch_class: group_status.launch_class}, + {group_schedule_tooltip: group_status.schedule_tip}, + {hosts_status_tip: hosts_status.tooltip}, + {hosts_status_class: hosts_status.class}, + {source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null}, + {status: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.status : null}); + } + + $scope.groupSelect = function(id){ + var group = $stateParams.group === undefined ? [id] : _($stateParams.group).concat(id).value(); + $state.go('inventoryManage', { + inventory_id: $stateParams.inventory_id, + group: group, + group_search: { + page_size: '20', + page: '1', + order_by: 'name', + } + }, {reload: true}); + }; + $scope.createGroup = function(){ + $state.go('inventoryManage.addGroup'); + }; + $scope.editGroup = function(id){ + $state.go('inventoryManage.editGroup', {group_id: id}); + }; + $scope.deleteGroup = function(group){ + $scope.toDelete = {}; + angular.extend($scope.toDelete, group); + if($scope.toDelete.total_groups === 0 && $scope.toDelete.total_hosts === 0) { + // This group doesn't have any child groups or hosts - the user is just trying to delete + // the group + $scope.deleteOption = "delete"; + } + $('#group-delete-modal').modal('show'); + }; + $scope.confirmDelete = function(){ + + // Bind an even listener for the modal closing. Trying to $state.go() before the modal closes + // will mean that these two things are running async and the modal may not finish closing before + // the state finishes transitioning. + $('#group-delete-modal').off('hidden.bs.modal').on('hidden.bs.modal', function () { + // Remove the event handler so that we don't end up with multiple bindings + $('#group-delete-modal').off('hidden.bs.modal'); + // Reload the inventory manage page and show that the group has been removed + $state.go('inventoryManage', null, {reload: true}); + }); + + switch($scope.deleteOption){ + case 'promote': + GroupManageService.promote($scope.toDelete.id, $stateParams.inventory_id) + .then(() => { + if (parseInt($state.params.group_id) === $scope.toDelete.id) { + $state.go("inventoryManage", null, {reload: true}); + } else { + $state.go($state.current, null, {reload: true}); + } + $('#group-delete-modal').modal('hide'); + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); + }); + break; + default: + GroupManageService.delete($scope.toDelete.id).then(() => { + if (parseInt($state.params.group_id) === $scope.toDelete.id) { + $state.go("inventoryManage", null, {reload: true}); + } else { + $state.go($state.current, null, {reload: true}); + } + $('#group-delete-modal').modal('hide'); + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); + }); + } + }; + $scope.updateGroup = function(group) { + GroupManageService.getInventorySource({group: group.id}).then(res =>InventoryUpdate({ + scope: $scope, + group_id: group.id, + url: res.data.results[0].related.update, + group_name: group.name, + group_source: res.data.results[0].source + })); + }; + + $scope.$on(`ws-jobs`, function(e, data){ + var group = Find({ list: $scope.groups, key: 'id', val: data.group_id }); + + if (group === undefined || group === null) { + group = {}; + } + + if(data.status === 'failed' || data.status === 'successful'){ + let path; + if($stateParams && $stateParams.group && $stateParams.group.length > 0) { + path = GetBasePath('groups') + _.last($stateParams.group) + '/children'; + } + else { + //reaches here if the user is on the root level group + path = GetBasePath('inventory') + $stateParams.inventory_id + '/root_groups'; + } + qs.search(path, $state.params[`${list.iterator}_search`]) + .then(function(searchResponse) { + $scope[`${list.iterator}_dataset`] = searchResponse.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + _.forEach($scope[list.name], buildStatusIndicators); + }); + } else { + var status = GetSyncStatusMsg({ + status: data.status, + has_inventory_sources: group.has_inventory_sources, + source: group.source + }); + group.status = data.status; + group.status_class = status.class; + group.status_tooltip = status.tooltip; + group.launch_tooltip = status.launch_tip; + group.launch_class = status.launch_class; + } + }); + + $scope.cancelUpdate = function (id) { + GroupsCancelUpdate({ scope: $scope, id: id }); + }; + $scope.viewUpdateStatus = function (id) { + ViewUpdateStatus({ + scope: $scope, + group_id: id + }); + }; + $scope.showFailedHosts = function() { + $state.go('inventoryManage', {failed: true}, {reload: true}); + }; + $scope.scheduleGroup = function(id) { + // Add this group's id to the array of group id's so that it gets + // added to the breadcrumb trail + var groupsArr = $stateParams.group ? $stateParams.group : []; + groupsArr.push(id); + $state.go('inventoryManage.editGroup.schedules', {group_id: id, group: groupsArr}, {reload: true}); + }; + // $scope.$parent governed by InventoryManageController, for unified multiSelect options + $scope.$on('multiSelectList.selectionChanged', (event, selection) => { + $scope.$parent.groupsSelected = selection.length > 0 ? true : false; + $scope.$parent.groupsSelectedItems = selection.selectedItems; + }); + + $scope.copyMoveGroup = function(id){ + $state.go('inventoryManage.copyMoveGroup', {group_id: id, groups: $stateParams.groups}); + }; + + var cleanUpStateChangeListener = $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams) { + if (toState.name === "inventoryManage.editGroup") { + $scope.rowBeingEdited = toParams.group_id; + $scope.listBeingEdited = "groups"; + } + else { + delete $scope.rowBeingEdited; + delete $scope.listBeingEdited; + } + }); + + // Remove the listener when the scope is destroyed to avoid a memory leak + $scope.$on('$destroy', function() { + cleanUpStateChangeListener(); + }); + + }]; diff --git a/awx/ui/client/src/inventories/groups/list/groups-list.controller.js b/awx/ui/client/src/inventories/groups/list/groups-list.controller.js new file mode 100644 index 0000000000..8bc2bb8154 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/groups-list.controller.js @@ -0,0 +1,236 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + export default + ['$scope', '$rootScope', '$state', '$stateParams', 'InventoryGroupsList', 'InventoryUpdate', + 'GroupManageService', 'GroupsCancelUpdate', 'ViewUpdateStatus', 'rbacUiControlService', 'GetBasePath', + 'GetSyncStatusMsg', 'GetHostsStatusMsg', 'Dataset', 'Find', 'QuerySet', 'inventoryData', + function($scope, $rootScope, $state, $stateParams, InventoryGroupsList, InventoryUpdate, + GroupManageService, GroupsCancelUpdate, ViewUpdateStatus, rbacUiControlService, GetBasePath, + GetSyncStatusMsg, GetHostsStatusMsg, Dataset, Find, qs, inventoryData){ + + let list = InventoryGroupsList; + + init(); + + function init(){ + $scope.inventory_id = $stateParams.inventory_id; + $scope.canAdhoc = inventoryData.summary_fields.user_capabilities.adhoc; + $scope.canAdd = false; + + rbacUiControlService.canAdd(GetBasePath('inventory') + $scope.inventory_id + "/groups") + .then(function(canAdd) { + $scope.canAdd = canAdd; + }); + + // Search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + // The ncy breadcrumb directive will look at this attribute when attempting to bind to the correct scope. + // In this case, we don't want to incidentally bind to this scope when editing a host or a group. See: + // https://github.com/ncuillery/angular-breadcrumb/issues/42 for a little more information on the + // problem that this solves. + $scope.ncyBreadcrumbIgnore = true; + if($state.current.name === "inventoryManage.editGroup") { + $scope.rowBeingEdited = $state.params.group_id; + $scope.listBeingEdited = "groups"; + } + + $scope.inventory_id = $stateParams.inventory_id; + _.forEach($scope[list.name], buildStatusIndicators); + + } + + function buildStatusIndicators(group){ + if (group === undefined || group === null) { + group = {}; + } + + let group_status, hosts_status; + + group_status = GetSyncStatusMsg({ + status: group.summary_fields.inventory_source.status, + has_inventory_sources: group.has_inventory_sources, + source: ( (group.summary_fields.inventory_source) ? group.summary_fields.inventory_source.source : null ) + }); + hosts_status = GetHostsStatusMsg({ + active_failures: group.hosts_with_active_failures, + total_hosts: group.total_hosts, + inventory_id: $scope.inventory_id, + group_id: group.id + }); + _.assign(group, + {status_class: group_status.class}, + {status_tooltip: group_status.tooltip}, + {launch_tooltip: group_status.launch_tip}, + {launch_class: group_status.launch_class}, + {group_schedule_tooltip: group_status.schedule_tip}, + {hosts_status_tip: hosts_status.tooltip}, + {hosts_status_class: hosts_status.class}, + {source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null}, + {status: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.status : null}); + } + + $scope.groupSelect = function(id){ + var group = $stateParams.group === undefined ? [id] : _($stateParams.group).concat(id).value(); + $state.go('inventoryManage', { + inventory_id: $stateParams.inventory_id, + group: group, + group_search: { + page_size: '20', + page: '1', + order_by: 'name', + } + }, {reload: true}); + }; + $scope.createGroup = function(){ + $state.go('inventoryManage.addGroup'); + }; + $scope.editGroup = function(id){ + $state.go('inventoryManage.editGroup', {group_id: id}); + }; + $scope.deleteGroup = function(group){ + $scope.toDelete = {}; + angular.extend($scope.toDelete, group); + if($scope.toDelete.total_groups === 0 && $scope.toDelete.total_hosts === 0) { + // This group doesn't have any child groups or hosts - the user is just trying to delete + // the group + $scope.deleteOption = "delete"; + } + $('#group-delete-modal').modal('show'); + }; + $scope.confirmDelete = function(){ + + // Bind an even listener for the modal closing. Trying to $state.go() before the modal closes + // will mean that these two things are running async and the modal may not finish closing before + // the state finishes transitioning. + $('#group-delete-modal').off('hidden.bs.modal').on('hidden.bs.modal', function () { + // Remove the event handler so that we don't end up with multiple bindings + $('#group-delete-modal').off('hidden.bs.modal'); + // Reload the inventory manage page and show that the group has been removed + $state.go('inventoryManage', null, {reload: true}); + }); + + switch($scope.deleteOption){ + case 'promote': + GroupManageService.promote($scope.toDelete.id, $stateParams.inventory_id) + .then(() => { + if (parseInt($state.params.group_id) === $scope.toDelete.id) { + $state.go("inventoryManage", null, {reload: true}); + } else { + $state.go($state.current, null, {reload: true}); + } + $('#group-delete-modal').modal('hide'); + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); + }); + break; + default: + GroupManageService.delete($scope.toDelete.id).then(() => { + if (parseInt($state.params.group_id) === $scope.toDelete.id) { + $state.go("inventoryManage", null, {reload: true}); + } else { + $state.go($state.current, null, {reload: true}); + } + $('#group-delete-modal').modal('hide'); + $('body').removeClass('modal-open'); + $('.modal-backdrop').remove(); + }); + } + }; + $scope.updateGroup = function(group) { + GroupManageService.getInventorySource({group: group.id}).then(res =>InventoryUpdate({ + scope: $scope, + group_id: group.id, + url: res.data.results[0].related.update, + group_name: group.name, + group_source: res.data.results[0].source + })); + }; + + $scope.$on(`ws-jobs`, function(e, data){ + var group = Find({ list: $scope.groups, key: 'id', val: data.group_id }); + + if (group === undefined || group === null) { + group = {}; + } + + if(data.status === 'failed' || data.status === 'successful'){ + let path; + if($stateParams && $stateParams.group && $stateParams.group.length > 0) { + path = GetBasePath('groups') + _.last($stateParams.group) + '/children'; + } + else { + //reaches here if the user is on the root level group + path = GetBasePath('inventory') + $stateParams.inventory_id + '/root_groups'; + } + qs.search(path, $state.params[`${list.iterator}_search`]) + .then(function(searchResponse) { + $scope[`${list.iterator}_dataset`] = searchResponse.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + _.forEach($scope[list.name], buildStatusIndicators); + }); + } else { + var status = GetSyncStatusMsg({ + status: data.status, + has_inventory_sources: group.has_inventory_sources, + source: group.source + }); + group.status = data.status; + group.status_class = status.class; + group.status_tooltip = status.tooltip; + group.launch_tooltip = status.launch_tip; + group.launch_class = status.launch_class; + } + }); + + $scope.cancelUpdate = function (id) { + GroupsCancelUpdate({ scope: $scope, id: id }); + }; + $scope.viewUpdateStatus = function (id) { + ViewUpdateStatus({ + scope: $scope, + group_id: id + }); + }; + $scope.showFailedHosts = function() { + $state.go('inventoryManage', {failed: true}, {reload: true}); + }; + $scope.scheduleGroup = function(id) { + // Add this group's id to the array of group id's so that it gets + // added to the breadcrumb trail + var groupsArr = $stateParams.group ? $stateParams.group : []; + groupsArr.push(id); + $state.go('inventoryManage.editGroup.schedules', {group_id: id, group: groupsArr}, {reload: true}); + }; + // $scope.$parent governed by InventoryManageController, for unified multiSelect options + $scope.$on('multiSelectList.selectionChanged', (event, selection) => { + $scope.$parent.groupsSelected = selection.length > 0 ? true : false; + $scope.$parent.groupsSelectedItems = selection.selectedItems; + }); + + $scope.copyMoveGroup = function(id){ + $state.go('inventoryManage.copyMoveGroup', {group_id: id, groups: $stateParams.groups}); + }; + + var cleanUpStateChangeListener = $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams) { + if (toState.name === "inventoryManage.editGroup") { + $scope.rowBeingEdited = toParams.group_id; + $scope.listBeingEdited = "groups"; + } + else { + delete $scope.rowBeingEdited; + delete $scope.listBeingEdited; + } + }); + + // Remove the listener when the scope is destroyed to avoid a memory leak + $scope.$on('$destroy', function() { + cleanUpStateChangeListener(); + }); + + }]; diff --git a/awx/ui/client/src/inventories/groups/list/groups-list.partial.html b/awx/ui/client/src/inventories/groups/list/groups-list.partial.html new file mode 100644 index 0000000000..1a02f3a515 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/groups-list.partial.html @@ -0,0 +1,79 @@ + diff --git a/awx/ui/client/src/inventories/groups/list/inventory-groups.list.js b/awx/ui/client/src/inventories/groups/list/inventory-groups.list.js new file mode 100644 index 0000000000..0536161ab9 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/inventory-groups.list.js @@ -0,0 +1,165 @@ +/************************************************* + * Copyright (c) 2017 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default { + name: 'groups', + iterator: 'group', + editTitle: '{{ inventory.name }}', + well: true, + wellOverride: true, + index: false, + hover: true, + multiSelect: true, + trackBy: 'group.id', + basePath: 'api/v2/inventories/{{$stateParams.inventory_id}}/root_groups/', + + fields: { + sync_status: { + label: '', + nosort: true, + mode: 'all', + iconOnly: true, + ngClick: 'viewUpdateStatus(group.id)', + awToolTip: "{{ group.status_tooltip }}", + dataTipWatch: "group.status_tooltip", + icon: "{{ 'fa icon-cloud-' + group.status_class }}", + ngClass: "group.status_class", + dataPlacement: "top", + columnClass: 'status-column List-staticColumn--smallStatus' + }, + failed_hosts: { + label: '', + nosort: true, + mode: 'all', + iconOnly: true, + awToolTip: "{{ group.hosts_status_tip }}", + dataPlacement: "top", + ngClick: "showFailedHosts(group)", + icon: "{{ 'fa icon-job-' + group.hosts_status_class }}", + columnClass: 'status-column List-staticColumn--smallStatus' + }, + name: { + label: 'Groups', + key: true, + ngClick: "groupSelect(group.id)", + columnClass: 'col-lg-6 col-md-6 col-sm-6 col-xs-6', + class: 'InventoryManage-breakWord', + }, + total_groups: { + nosort: true, + label: '', + type: 'badgeCount', + ngHide: 'group.total_groups == 0', + noLink: true, + awToolTip: "{{group.name | sanitize}} contains {{group.total_groups}} {{group.total_groups === 1 ? 'child' : 'children'}}" + } + }, + + actions: { + refresh: { + mode: 'all', + awToolTip: "Refresh the page", + ngClick: "refreshGroups()", + ngShow: "socketStatus == 'error'", + actionClass: 'btn List-buttonDefault', + buttonContent: 'REFRESH' + }, + launch: { + mode: 'all', + // $scope.$parent is governed by InventoryManageController, + ngDisabled: '!$parent.groupsSelected && !$parent.hostsSelected', + ngClick: '$parent.setAdhocPattern()', + awToolTip: "Select an inventory source by clicking the check box beside it. The inventory source can be a single group or host, a selection of multiple hosts, or a selection of multiple groups.", + dataTipWatch: "adhocCommandTooltip", + actionClass: 'btn List-buttonDefault', + buttonContent: 'RUN COMMANDS', + showTipWhenDisabled: true, + tooltipInnerClass: "Tooltip-wide", + ngShow: 'canAdhoc' + // TODO: set up a tip watcher and change text based on when + // things are selected/not selected. This is started and + // commented out in the inventory controller within the watchers. + // awToolTip: "{{ adhocButtonTipContents }}", + // dataTipWatch: "adhocButtonTipContents" + }, + create: { + mode: 'all', + ngClick: "createGroup()", + awToolTip: "Create a new group", + actionClass: 'btn List-buttonSubmit', + buttonContent: '+ ADD GROUP', + ngShow: 'canAdd', + dataPlacement: "top", + } + }, + + fieldActions: { + + columnClass: 'col-lg-6 col-md-6 col-sm-6 col-xs-6 text-right', + + // group_update: { + // //label: 'Sync', + // mode: 'all', + // ngClick: 'updateGroup(group)', + // awToolTip: "{{ group.launch_tooltip }}", + // dataTipWatch: "group.launch_tooltip", + // ngShow: "(group.status !== 'running' && group.status " + + // "!== 'pending' && group.status !== 'updating') && group.summary_fields.user_capabilities.start", + // ngClass: "group.launch_class", + // dataPlacement: "top", + // }, + // cancel: { + // //label: 'Cancel', + // mode: 'all', + // ngClick: "cancelUpdate(group.id)", + // awToolTip: "Cancel sync process", + // 'class': 'red-txt', + // ngShow: "(group.status == 'running' || group.status == 'pending' " + + // "|| group.status == 'updating') && group.summary_fields.user_capabilities.start", + // dataPlacement: "top", + // iconClass: "fa fa-minus-circle" + // }, + copy: { + mode: 'all', + ngClick: "copyMoveGroup(group.id)", + awToolTip: 'Copy or move group', + ngShow: "group.id > 0 && group.summary_fields.user_capabilities.copy", + dataPlacement: "top" + }, + // schedule: { + // mode: 'all', + // ngClick: "scheduleGroup(group.id)", + // awToolTip: "{{ group.group_schedule_tooltip }}", + // ngClass: "group.scm_type_class", + // dataPlacement: 'top', + // ngShow: "!(group.summary_fields.inventory_source.source === '')" + // }, + edit: { + //label: 'Edit', + mode: 'all', + ngClick: "editGroup(group.id)", + awToolTip: 'Edit group', + dataPlacement: "top", + ngShow: "group.summary_fields.user_capabilities.edit" + }, + view: { + //label: 'Edit', + mode: 'all', + ngClick: "editGroup(group.id)", + awToolTip: 'View group', + dataPlacement: "top", + ngShow: "!group.summary_fields.user_capabilities.edit" + }, + "delete": { + //label: 'Delete', + mode: 'all', + ngClick: "deleteGroup(group)", + awToolTip: 'Delete group', + dataPlacement: "top", + ngShow: "group.summary_fields.user_capabilities.delete" + } + } +}; diff --git a/awx/ui/client/src/inventories/groups/list/main.js b/awx/ui/client/src/inventories/groups/list/main.js new file mode 100644 index 0000000000..b36162d4be --- /dev/null +++ b/awx/ui/client/src/inventories/groups/list/main.js @@ -0,0 +1,15 @@ +/************************************************* + * Copyright (c) 2017 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import buildGroupListState from './build-groups-list-state.factory'; +import controller from './groups-list.controller'; +import InventoryGroupsList from './inventory-groups.list'; + +export default + angular.module('groupList', []) + .factory('buildGroupListState', buildGroupListState) + .value('InventoryGroupsList', InventoryGroupsList) + .controller('GroupsListController', controller); diff --git a/awx/ui/client/src/inventories/groups/main.js b/awx/ui/client/src/inventories/groups/main.js new file mode 100644 index 0000000000..d8af95c6f2 --- /dev/null +++ b/awx/ui/client/src/inventories/groups/main.js @@ -0,0 +1,24 @@ +/************************************************* + * Copyright (c) 2017 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import groupList from './list/main'; +import service from './groups.service'; +import GetHostsStatusMsg from './factories/get-hosts-status-msg.factory'; +import GetSourceTypeOptions from './factories/get-source-type-options.factory'; +import GetSyncStatusMsg from './factories/get-sync-status-msg.factory'; +import GroupsCancelUpdate from './factories/groups-cancel-update.factory'; +import ViewUpdateStatus from './factories/view-update-status.factory'; + +export default + angular.module('group', [ + groupList.name + ]) + .factory('GetHostsStatusMsg', GetHostsStatusMsg) + .factory('GetSourceTypeOptions', GetSourceTypeOptions) + .factory('GetSyncStatusMsg', GetSyncStatusMsg) + .factory('GroupsCancelUpdate', GroupsCancelUpdate) + .factory('ViewUpdateStatus', ViewUpdateStatus) + .service('GroupManageService', service); diff --git a/awx/ui/client/src/inventories/hosts/add/host-add.controller.js b/awx/ui/client/src/inventories/hosts/add/host-add.controller.js index 151d75401d..8e5b36ee7d 100644 --- a/awx/ui/client/src/inventories/hosts/add/host-add.controller.js +++ b/awx/ui/client/src/inventories/hosts/add/host-add.controller.js @@ -4,11 +4,11 @@ * All Rights Reserved *************************************************/ -function HostsAdd($scope) { +function HostsAdd() { console.log('inside host add'); } -export default ['$scope', HostsAdd +export default [ HostsAdd ]; diff --git a/awx/ui/client/src/inventories/hosts/smart-inventory/edit/smart-inventory-edit.controller.js b/awx/ui/client/src/inventories/hosts/smart-inventory/edit/smart-inventory-edit.controller.js index 5cf8bf1612..26d10c9234 100644 --- a/awx/ui/client/src/inventories/hosts/smart-inventory/edit/smart-inventory-edit.controller.js +++ b/awx/ui/client/src/inventories/hosts/smart-inventory/edit/smart-inventory-edit.controller.js @@ -4,11 +4,11 @@ * All Rights Reserved *************************************************/ -function SmartInventoryEdit($scope) { +function SmartInventoryEdit() { console.log('inside smart inventory add'); } -export default ['$scope', SmartInventoryEdit +export default [ SmartInventoryEdit ]; diff --git a/awx/ui/client/src/inventories/inventory.form.js b/awx/ui/client/src/inventories/inventory.form.js index f0646c9a40..91c181a006 100644 --- a/awx/ui/client/src/inventories/inventory.form.js +++ b/awx/ui/client/src/inventories/inventory.form.js @@ -1,5 +1,5 @@ /************************************************* - * Copyright (c) 2015 Ansible, Inc. + * Copyright (c) 2017 Ansible, Inc. * * All Rights Reserved *************************************************/ @@ -10,8 +10,9 @@ * @description This form is for adding/editing an inventory */ -export default ['i18n', function(i18n) { - return { +export default ['i18n', 'buildGroupListState', +function(i18n,buildGroupListState) { + return { addTitle: i18n._('NEW INVENTORY'), editTitle: '{{ inventory_name }}', @@ -133,35 +134,10 @@ export default ['i18n', function(i18n) { }, groups: { name: 'groups', - // awToolTip: i18n._('Please save before assigning permissions'), - // dataPlacement: 'top', - basePath: 'api/v2/inventories/{{$stateParams.inventory_id}}/root_groups/', - type: 'collection', + include: "InventoryGroupsList", title: i18n._('Groups'), iterator: 'group', - index: false, - open: false, - // search: { - // order_by: 'username' - // }, - actions: { - add: { - label: i18n._('Add'), - ngClick: "$state.go('.add')", - awToolTip: i18n._('Add a permission'), - actionClass: 'btn List-buttonSubmit', - buttonContent: '+ ADD', - // ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' - - } - }, - fields: { - name: { - label: i18n._('Name'), - // linkBase: 'users', - class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' - } - } + stateGeneratorFunction: buildGroupListState }, hosts: { name: 'hosts', diff --git a/awx/ui/client/src/inventories/main.js b/awx/ui/client/src/inventories/main.js index 3ae1d9d7cf..be59ca6b4e 100644 --- a/awx/ui/client/src/inventories/main.js +++ b/awx/ui/client/src/inventories/main.js @@ -5,6 +5,7 @@ *************************************************/ import host from './hosts/main'; +import group from './groups/main'; import inventoryAdd from './add/main'; import inventoryEdit from './edit/main'; import inventoryList from './list/main'; @@ -16,6 +17,7 @@ import InventoryManageService from './inventory-manage.service'; export default angular.module('inventory', [ host.name, + group.name, inventoryAdd.name, inventoryEdit.name, inventoryList.name @@ -175,7 +177,10 @@ angular.module('inventory', [ controllers: { list: 'InventoryListController', add: 'InventoryAddController', - edit: 'InventoryEditController' + edit: 'InventoryEditController', + related: { + groups: 'GroupsListController' + } }, urls: { list: '/inventories' diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 5f136d7a97..e986c685e4 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -170,7 +170,9 @@ export default ['$compile', 'Attr', 'Icon', } if (options.mode !== 'lookup' && (list.well === undefined || list.well)) { - html += `
`; + html += `
`; + html += (!list.wellOverride) ? "List-well" : ""; + html += `">`; // List actions html += "
"; html += "
"; diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index b42aaebd91..6e3a6ef426 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -9,7 +9,8 @@ * generateLookupNodes - Attaches to a form node. Builds an abstract '*.lookup' node with field-specific 'lookup.*' children e.g. {name: 'projects.add.lookup.organizations', ...} */ -export default ['$injector', '$stateExtender', '$log', 'i18n', function($injector, $stateExtender, $log, i18n) { +export default ['$injector', '$stateExtender', '$log', 'i18n', +function($injector, $stateExtender, $log, i18n) { return { /** * @ngdoc method @@ -557,7 +558,11 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto function buildListNodes(field) { let states = []; - if(field.iterator === 'notification'){ + if(field.iterator === 'group'){ + states.push(field.stateGeneratorFunction(field, formStateDefinition, params)); + states = _.flatten(states); + } + else if(field.iterator === 'notification'){ states.push(buildNotificationState(field)); states = _.flatten(states); }