diff --git a/openstack_dashboard/api/microversions.py b/openstack_dashboard/api/microversions.py index 795dee1580..a8939510c6 100644 --- a/openstack_dashboard/api/microversions.py +++ b/openstack_dashboard/api/microversions.py @@ -31,7 +31,8 @@ MICROVERSION_FEATURES = { "locked_attribute": ["2.9", "2.42"], "instance_description": ["2.19", "2.42"], "remote_console_mks": ["2.8", "2.53"], - "servergroup_soft_policies": ["2.15", "2.60"] + "servergroup_soft_policies": ["2.15", "2.60"], + "servergroup_user_info": ["2.13", "2.60"] }, "cinder": { "consistency_groups": ["2.0", "3.10"], diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 22381c2348..f5b8ed63c6 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -943,6 +943,13 @@ def server_group_delete(request, servergroup_id): novaclient(request).server_groups.delete(servergroup_id) +@profiler.trace +def server_group_get(request, servergroup_id): + microversion = get_microversion(request, "servergroup_user_info") + return novaclient(request, version=microversion).server_groups.get( + servergroup_id) + + @profiler.trace def service_list(request, binary=None): return novaclient(request).services.list(binary=binary) diff --git a/openstack_dashboard/api/rest/nova.py b/openstack_dashboard/api/rest/nova.py index 7d47da3b23..a5fbdca969 100644 --- a/openstack_dashboard/api/rest/nova.py +++ b/openstack_dashboard/api/rest/nova.py @@ -460,6 +460,14 @@ class ServerGroup(generic.View): """ api.nova.server_group_delete(request, servergroup_id) + @rest_utils.ajax() + def get(self, request, servergroup_id): + """Get a specific server group + + http://localhost/api/nova/servergroups/1 + """ + return api.nova.server_group_get(request, servergroup_id).to_dict() + @urls.register class ServerMetadata(generic.View): diff --git a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js index 2253596919..abd9aeeb0a 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.js @@ -52,6 +52,7 @@ createServer: createServer, getServer: getServer, getServers: getServers, + getServerGroup: getServerGroup, getServerGroups: getServerGroups, createServerGroup: createServerGroup, deleteServerGroup: deleteServerGroup, @@ -318,6 +319,21 @@ }); } + /** + * @name getServerGroup + * @description + * Get a single server group by ID + * @param {string} id + * Specifies the id of the server group to request. + * @returns {Object} The result of the API call + */ + function getServerGroup(id) { + return apiService.get('/api/nova/servergroups/' + id) + .error(function () { + toastService.add('error', gettext('Unable to retrieve the server group.')); + }); + } + /** * @name getServerGroups * @description diff --git a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js index 418021cc7e..956b0823e0 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/nova.service.spec.js @@ -296,6 +296,15 @@ "path": "/api/nova/servers/", "error": "Unable to retrieve instances." }, + { + "func": "getServerGroup", + "method": "get", + "path": "/api/nova/servergroups/17", + "error": "Unable to retrieve the server group.", + "testInput": [ + '17' + ] + }, { "func": 'getServerGroups', "method": 'get', diff --git a/openstack_dashboard/static/app/core/server_groups/details/details.module.js b/openstack_dashboard/static/app/core/server_groups/details/details.module.js new file mode 100644 index 0000000000..1afec60cb4 --- /dev/null +++ b/openstack_dashboard/static/app/core/server_groups/details/details.module.js @@ -0,0 +1,54 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + 'use strict'; + + /** + * @ngdoc overview + * @ngname horizon.app.core.server_groups.details + * + * @description + * Provides details features for server groups. + */ + angular + .module('horizon.app.core.server_groups.details', [ + 'horizon.framework.conf', + 'horizon.app.core' + ]) + .run(registerServerGroupDetails); + + registerServerGroupDetails.$inject = [ + 'horizon.app.core.server_groups.basePath', + 'horizon.app.core.server_groups.resourceType', + 'horizon.app.core.server_groups.service', + 'horizon.framework.conf.resource-type-registry.service' + ]; + + function registerServerGroupDetails( + basePath, + serverGroupResourceType, + serverGroupService, + registry + ) { + registry.getResourceType(serverGroupResourceType) + .setLoadFunction(serverGroupService.getServerGroupPromise) + .detailsViews.append({ + id: 'serverGroupDetailsOverview', + name: gettext('Overview'), + template: basePath + 'details/overview.html' + }); + } + +})(); diff --git a/openstack_dashboard/static/app/core/server_groups/details/details.module.spec.js b/openstack_dashboard/static/app/core/server_groups/details/details.module.spec.js new file mode 100644 index 0000000000..2ba56e9552 --- /dev/null +++ b/openstack_dashboard/static/app/core/server_groups/details/details.module.spec.js @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + 'use strict'; + + describe('server group details module', function() { + it('should exist', function() { + expect(angular.module('horizon.app.core.server_groups.details')).toBeDefined(); + }); + + var registry, resource; + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.app.core.server_groups')); + beforeEach(inject(function($injector) { + registry = $injector.get('horizon.framework.conf.resource-type-registry.service'); + })); + + it('should be loaded', function() { + resource = registry.getResourceType('OS::Nova::ServerGroup'); + expect(resource.detailsViews[0].id).toBe('serverGroupDetailsOverview'); + }); + }); +})(); diff --git a/openstack_dashboard/static/app/core/server_groups/details/overview.controller.js b/openstack_dashboard/static/app/core/server_groups/details/overview.controller.js new file mode 100644 index 0000000000..a2a08269f7 --- /dev/null +++ b/openstack_dashboard/static/app/core/server_groups/details/overview.controller.js @@ -0,0 +1,107 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + "use strict"; + + angular + .module('horizon.app.core.server_groups') + .controller('ServerGroupOverviewController', ServerGroupOverviewController); + + ServerGroupOverviewController.$inject = [ + '$scope', + 'horizon.app.core.openstack-service-api.nova', + 'horizon.app.core.openstack-service-api.userSession', + 'horizon.app.core.server_groups.resourceType', + 'horizon.app.core.server_groups.service', + 'horizon.framework.conf.resource-type-registry.service' + ]; + + function ServerGroupOverviewController( + $scope, + nova, + userSession, + serverGroupResourceTypeCode, + serverGroupsService, + registry + ) { + var ctrl = this; + + nova.isFeatureSupported( + 'servergroup_user_info').then(onGetServerGroupProperties); + + function onGetServerGroupProperties(response) { + var properties = ['id', 'name', 'policy']; + if (response.data) { + properties.splice(2, 0, 'project_id', 'user_id'); + } + ctrl.properties = properties; + } + + ctrl.resourceType = registry.getResourceType(serverGroupResourceTypeCode); + ctrl.tableConfig = { + selectAll: false, + expand: false, + trackId: 'id', + columns: [ + { + id: 'name', + title: gettext('Instance Name'), + priority: 1, + sortDefault: true, + urlFunction: serverGroupsService.getInstanceDetailsPath + }, + { + id: 'id', + title: gettext('Instance ID'), + priority: 1 + } + ] + }; + + $scope.context.loadPromise.then(onGetServerGroup); + + function onGetServerGroup(servergroup) { + ctrl.servergroup = servergroup.data; + if (ctrl.servergroup.members.length) { + // The server group members only contains the instance id, + // does not contain other information of the instance, we + // need to get the list of instances and then get the specified + // instance from the list based on id. + nova.getServers().then(function getServer(servers) { + var members = []; + ctrl.servergroup.members.forEach(function(member) { + for (var i = 0; i < servers.data.items.length; i++) { + var server = servers.data.items[i]; + if (member === server.id) { + members.push(server); + break; + } + } + }); + ctrl.members = members; + }); + } else { + ctrl.members = []; + } + + userSession.get().then(setProject); + + function setProject(session) { + ctrl.projectId = session.project_id; + } + } + } + +})(); diff --git a/openstack_dashboard/static/app/core/server_groups/details/overview.controller.spec.js b/openstack_dashboard/static/app/core/server_groups/details/overview.controller.spec.js new file mode 100644 index 0000000000..83881e9289 --- /dev/null +++ b/openstack_dashboard/static/app/core/server_groups/details/overview.controller.spec.js @@ -0,0 +1,80 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + 'use strict'; + + describe('server group overview controller', function() { + var $controller, $q, $timeout, nova, session; + var sessionObj = {project_id: '10'}; + + beforeEach(module('horizon.app.core.server_groups')); + beforeEach(module('horizon.framework.conf')); + beforeEach(inject(function($injector) { + $controller = $injector.get('$controller'); + $q = $injector.get('$q'); + $timeout = $injector.get('$timeout'); + session = $injector.get('horizon.app.core.openstack-service-api.userSession'); + nova = $injector.get('horizon.app.core.openstack-service-api.nova'); + })); + + it('sets ctrl.members when server group members length === 0', inject(function() { + var deferred = $q.defer(); + var serverGroupDeferred = $q.defer(); + var serversDeferred = $q.defer(); + var sessionDeferred = $q.defer(); + deferred.resolve({data: { data: true}}); + serverGroupDeferred.resolve({data: {members: ['1', '2']}}); + serversDeferred.resolve({data: { items: [{'id': '1'}, {'id': '2'}]}}); + sessionDeferred.resolve(sessionObj); + spyOn(nova, 'isFeatureSupported').and.returnValue(deferred.promise); + spyOn(nova, 'getServerGroup').and.returnValue(serverGroupDeferred.promise); + spyOn(nova, 'getServers').and.returnValue(serversDeferred.promise); + spyOn(session, 'get').and.returnValue(sessionDeferred.promise); + var ctrl = $controller('ServerGroupOverviewController', + { + '$scope': {context: {loadPromise: serverGroupDeferred.promise}} + } + ); + $timeout.flush(); + expect(ctrl.properties).toBeDefined(); + expect(ctrl.members[0]).toEqual({'id': '1'}); + expect(ctrl.projectId).toBe(sessionObj.project_id); + })); + + it('sets ctrl.members when server group members length > 0', inject(function() { + var deferred = $q.defer(); + var serverGroupDeferred = $q.defer(); + var serversDeferred = $q.defer(); + var sessionDeferred = $q.defer(); + deferred.resolve({data: { data: true}}); + serverGroupDeferred.resolve({data: {members: []}}); + serversDeferred.resolve({data: { items: [{'id': '1'}, {'id': '2'}]}}); + sessionDeferred.resolve(sessionObj); + spyOn(nova, 'isFeatureSupported').and.returnValue(deferred.promise); + spyOn(nova, 'getServerGroup').and.returnValue(serverGroupDeferred.promise); + spyOn(nova, 'getServers').and.returnValue(serversDeferred.promise); + spyOn(session, 'get').and.returnValue(sessionDeferred.promise); + var ctrl = $controller('ServerGroupOverviewController', + { + '$scope': {context: {loadPromise: serverGroupDeferred.promise}} + } + ); + $timeout.flush(); + expect(ctrl.properties).toBeDefined(); + expect(ctrl.members).toEqual([]); + expect(ctrl.projectId).toBe(sessionObj.project_id); + })); + }); +})(); diff --git a/openstack_dashboard/static/app/core/server_groups/details/overview.html b/openstack_dashboard/static/app/core/server_groups/details/overview.html new file mode 100644 index 0000000000..5fe51c557c --- /dev/null +++ b/openstack_dashboard/static/app/core/server_groups/details/overview.html @@ -0,0 +1,17 @@ +
+ + +

{$ ::'Server Group Members' | translate $}

+
+
+ + +
+
diff --git a/openstack_dashboard/static/app/core/server_groups/server-groups.module.js b/openstack_dashboard/static/app/core/server_groups/server-groups.module.js index 39a4077c9e..f7d04c2bd8 100644 --- a/openstack_dashboard/static/app/core/server_groups/server-groups.module.js +++ b/openstack_dashboard/static/app/core/server_groups/server-groups.module.js @@ -27,7 +27,8 @@ .module('horizon.app.core.server_groups', [ 'horizon.framework.conf', 'horizon.app.core', - 'horizon.app.core.server_groups.actions' + 'horizon.app.core.server_groups.actions', + 'horizon.app.core.server_groups.details' ]) .constant('horizon.app.core.server_groups.resourceType', 'OS::Nova::ServerGroup') .run(run) @@ -50,7 +51,8 @@ .append({ id: 'name', priority: 1, - sortDefault: true + sortDefault: true, + urlFunction: serverGroupsService.getDetailsPath }) // The name is not unique, so we need to show the ID to // distinguish. @@ -89,28 +91,43 @@ return { name: gettext('Name'), id: gettext('ID'), - policy: gettext('Policy') + policy: gettext('Policy'), + project_id: gettext('Project ID'), + user_id: gettext('User ID') }; } config.$inject = [ + '$provide', '$windowProvider', - '$routeProvider' + '$routeProvider', + 'horizon.app.core.detailRoute' ]; /** * @name config + * @param {Object} $provide * @param {Object} $windowProvider * @param {Object} $routeProvider + * @param {String} detailRoute * @description Routes used by this module. * @returns {undefined} Returns nothing */ - function config($windowProvider, $routeProvider) { + function config($provide, $windowProvider, $routeProvider, detailRoute) { var path = $windowProvider.$get().STATIC_URL + 'app/core/server_groups/'; + $provide.constant('horizon.app.core.server_groups.basePath', path); $routeProvider.when('/project/server_groups', { templateUrl: path + 'panel.html' }); + + $routeProvider.when('/project/server_groups/:id', { + redirectTo: goToAngularDetails + }); + + function goToAngularDetails(params) { + return detailRoute + 'OS::Nova::ServerGroup/' + params.id; + } } })(); diff --git a/openstack_dashboard/static/app/core/server_groups/server-groups.service.js b/openstack_dashboard/static/app/core/server_groups/server-groups.service.js index c7f90ba5fd..445eb3970d 100644 --- a/openstack_dashboard/static/app/core/server_groups/server-groups.service.js +++ b/openstack_dashboard/static/app/core/server_groups/server-groups.service.js @@ -19,25 +19,56 @@ .factory('horizon.app.core.server_groups.service', serverGroupsService); serverGroupsService.$inject = [ + '$window', + 'horizon.app.core.detailRoute', 'horizon.app.core.openstack-service-api.nova' ]; /* * @ngdoc factory * @name horizon.app.core.server_groups.service - * * @description * This service provides functions that are used through the Server Groups * features. These are primarily used in the module registrations * but do not need to be restricted to such use. Each exposed function * is documented below. */ - function serverGroupsService(nova) { + function serverGroupsService($window, detailRoute, nova) { return { - getServerGroupsPromise: getServerGroupsPromise, - getServerGroupPolicies: getServerGroupPolicies + getDetailsPath: getDetailsPath, + getInstanceDetailsPath: getInstanceDetailsPath, + getServerGroupPolicies: getServerGroupPolicies, + getServerGroupPromise: getServerGroupPromise, + getServerGroupsPromise: getServerGroupsPromise }; + /* + * @ngdoc function + * @name getDetailsPath + * @param item {Object} - The server group object + * @description + * Given a server group object, returns the relative path to the details + * view. + */ + function getDetailsPath(item) { + return detailRoute + 'OS::Nova::ServerGroup/' + item.id; + } + + /* + * @ngdoc function + * @name getInstanceDetailsPath + * @param item {Object} - The instance object + * @description + * Given an instance object, returns the relative path to the details + * view. + */ + function getInstanceDetailsPath(item) { + // The current instances page only contains the django + // version, if we add angular instances pages in the future, + // the url here also needs to be modified accordingly. + return $window.WEBROOT + 'project/instances/' + item.id + '/'; + } + /* * @ngdoc function * @name getServerGroupPolicies @@ -61,6 +92,25 @@ } } + /* + * @ngdoc function + * @name getServerGroupPromise + * @description + * Given an id, returns a promise for the server group data. + */ + function getServerGroupPromise(identifier) { + return nova.getServerGroup(identifier).then(modifyResponse); + + function modifyResponse(response) { + return getServerGroupPolicies().then(modifyItem); + + function modifyItem(policies) { + response.data.policy = policies[response.data.policies[0]]; + return {data: response.data}; + } + } + } + /* * @ngdoc function * @name getServerGroupsPromise diff --git a/openstack_dashboard/static/app/core/server_groups/server-groups.service.spec.js b/openstack_dashboard/static/app/core/server_groups/server-groups.service.spec.js index 543fcd3e1f..3aa62400b3 100644 --- a/openstack_dashboard/static/app/core/server_groups/server-groups.service.spec.js +++ b/openstack_dashboard/static/app/core/server_groups/server-groups.service.spec.js @@ -16,16 +16,61 @@ "use strict"; describe('server groups service', function() { - var $q, $timeout, nova, service; + var $q, $timeout, $window, detailRoute, nova, service; beforeEach(module('horizon.app.core.server_groups')); beforeEach(inject(function($injector) { $q = $injector.get('$q'); $timeout = $injector.get('$timeout'); + $window = $injector.get('$window'); + detailRoute = $injector.get('horizon.app.core.detailRoute'); nova = $injector.get('horizon.app.core.openstack-service-api.nova'); service = $injector.get('horizon.app.core.server_groups.service'); })); + it("getDetailsPath creates urls using the item's ID", function() { + var item = {id: '11'}; + expect(service.getDetailsPath(item)).toBe(detailRoute + 'OS::Nova::ServerGroup/11'); + }); + + it("getInstanceDetailsPath creates urls using the item's ID", function() { + $window.WEBROOT = '/dashboard/'; + var item = {id: "12"}; + expect(service.getInstanceDetailsPath(item)).toBe('/dashboard/project/instances/12/'); + }); + + describe('getServerGroupPromise', function() { + it("provides a promise when soft policies are supported", inject(function() { + var deferred = $q.defer(); + var deferredPolicies = $q.defer(); + spyOn(nova, 'getServerGroup').and.returnValue(deferred.promise); + spyOn(nova, 'isFeatureSupported').and.returnValue(deferredPolicies.promise); + var result = service.getServerGroupPromise({}); + deferred.resolve({data: {id: '13', policies: ['affinity']}}); + deferredPolicies.resolve({data: true}); + $timeout.flush(); + + expect(nova.getServerGroup).toHaveBeenCalled(); + expect(nova.isFeatureSupported).toHaveBeenCalled(); + expect(result.$$state.value.data.id).toBe('13'); + })); + + it("provides a promise when soft policies are not supported", inject(function() { + var deferred = $q.defer(); + var deferredPolicies = $q.defer(); + spyOn(nova, 'getServerGroup').and.returnValue(deferred.promise); + spyOn(nova, 'isFeatureSupported').and.returnValue(deferredPolicies.promise); + var result = service.getServerGroupPromise({}); + deferred.resolve({data: {id: '15', policies: ['affinity']}}); + deferredPolicies.resolve({data: false}); + $timeout.flush(); + + expect(nova.getServerGroup).toHaveBeenCalled(); + expect(nova.isFeatureSupported).toHaveBeenCalled(); + expect(result.$$state.value.data.id).toBe('15'); + })); + }); + describe('getServerGroupsPromise', function() { it("provides a promise when soft policies are supported", inject(function() { var deferred = $q.defer(); diff --git a/openstack_dashboard/test/unit/api/rest/test_nova.py b/openstack_dashboard/test/unit/api/rest/test_nova.py index 1070c7bcc5..65edaecf05 100644 --- a/openstack_dashboard/test/unit/api/rest/test_nova.py +++ b/openstack_dashboard/test/unit/api/rest/test_nova.py @@ -397,6 +397,18 @@ class NovaRestTestCase(test.TestCase): nova.ServerGroup().delete(request, "1") self.mock_server_group_delete.assert_called_once_with(request, "1") + @test.create_mocks({api.nova: ['server_group_get']}) + def test_server_group_get_single(self): + request = self.mock_rest_request() + servergroup = self.server_groups.first() + self.mock_server_group_get.return_value = servergroup + + response = nova.ServerGroup().get(request, "1") + + self.assertStatusCode(response, 200) + self.assertEqual(servergroup.to_dict(), response.json) + self.mock_server_group_get.assert_called_once_with(request, "1") + # # Server Metadata # diff --git a/openstack_dashboard/test/unit/api/test_nova.py b/openstack_dashboard/test/unit/api/test_nova.py index 9a4d7120be..70fc88a326 100644 --- a/openstack_dashboard/test/unit/api/test_nova.py +++ b/openstack_dashboard/test/unit/api/test_nova.py @@ -667,3 +667,15 @@ class ComputeApiTests(test.APIMockTestCase): self.assertIsNone(api_val) novaclient.server_groups.delete.assert_called_once_with(servergroup_id) + + def test_server_group_get(self): + servergroup = self.server_groups.first() + novaclient = self.stub_novaclient() + self._mock_current_version(novaclient, '2.45') + novaclient.server_groups.get.return_value = servergroup + + ret_val = api.nova.server_group_get(self.request, servergroup.id) + + self.assertEqual(ret_val.id, servergroup.id) + novaclient.versions.get_current.assert_called_once_with() + novaclient.server_groups.get.assert_called_once_with(servergroup.id) diff --git a/releasenotes/notes/bp-ng-server-groups-c60849796a273138.yaml b/releasenotes/notes/bp-ng-server-groups-c60849796a273138.yaml index e9784d38ba..1aadfd870b 100644 --- a/releasenotes/notes/bp-ng-server-groups-c60849796a273138.yaml +++ b/releasenotes/notes/bp-ng-server-groups-c60849796a273138.yaml @@ -5,5 +5,6 @@ features: This blueprint add angular server groups panel below the Project->Compute panel group. The panel turns on if Nova API extension 'ServerGroups' is available. It displays information about - server groups. + server groups. The details page for each server group also shows + information about instances of that server group. Supported actions: create, delete.