New readonly panel for trunks

Enable display of trunks on the project dashboard.

To test it devstack needs to have neutron with trunk support, eg:

 local.conf:
 enable_plugin neutron https://git.openstack.org/openstack/neutron
 enable_service q-trunk

No special horizon config is needed. As long as the 'trunk'
API extension is available (openstack extension show trunk)
the panel should automatically appear under Project/Network/Trunks.

Co-Authored-By: Akihiro Motoki <amotoki@gmail.com>
Co-Authored-By: Bence Romsics <bence.romsics@ericsson.com>
Change-Id: Iacb83f22f81e09457953622e61065f0bb2c27407
Partially-Implements: blueprint neutron-trunk-ui
This commit is contained in:
Lajos Katona 2017-03-14 08:31:28 +01:00 committed by Akihiro Motoki
parent 071ba2cb08
commit 3524b3d4f7
20 changed files with 549 additions and 2 deletions

View File

@ -107,6 +107,20 @@ class Subnet(NeutronAPIDictWrapper):
super(Subnet, self).__init__(apidict)
class Trunk(NeutronAPIDictWrapper):
"""Wrapper for neutron trunks."""
@property
def subport_count(self):
return len(self._apidict.get('sub_ports', []))
def to_dict(self):
trunk_dict = super(Trunk, self).to_dict()
trunk_dict['name_or_id'] = self.name_or_id
trunk_dict['subport_count'] = self.subport_count
return trunk_dict
class SubnetPool(NeutronAPIDictWrapper):
"""Wrapper for neutron subnetpools."""
@ -623,6 +637,13 @@ def list_resources_with_long_filters(list_method,
return resources
@profiler.trace
def trunk_list(request, **params):
LOG.debug("trunk_list(): params=%s", params)
trunks = neutronclient(request).list_trunks(**params).get('trunks')
return [Trunk(t) for t in trunks]
@profiler.trace
def network_list(request, **params):
LOG.debug("network_list(): params=%s", params)

View File

@ -136,6 +136,23 @@ class Ports(generic.View):
return{'items': [n.to_dict() for n in result]}
@urls.register
class Trunks(generic.View):
"""API for neutron Trunks
"""
url_regex = r'neutron/trunks/$'
@rest_utils.ajax()
def get(self, request):
"""Get a list of trunks
The listing result is an object with property "items".
Each item is a trunk.
"""
result = api.neutron.trunk_list(request, **request.GET)
return {'items': [n.to_dict() for n in result]}
@urls.register
class Services(generic.View):
"""API for Neutron agents

View File

@ -0,0 +1,44 @@
# Copyright 2017 Ericsson
#
# 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.
import logging
from django.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard.api import neutron
LOG = logging.getLogger(__name__)
class Trunks(horizon.Panel):
name = _("Trunks")
slug = "trunks"
permissions = ('openstack.services.network',)
def allowed(self, context):
request = context['request']
try:
return (
super(Trunks, self).allowed(context)
and request.user.has_perms(self.permissions)
and neutron.is_extension_supported(request,
extension_alias='trunk')
)
except Exception:
LOG.error("Call to list enabled services failed. This is likely "
"due to a problem communicating with the Neutron "
"endpoint. Trunks panel will not be displayed.")
return False

View File

@ -0,0 +1,24 @@
# Copyright 2017 Ericsson
#
# 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.
from django.conf.urls import url
from django.utils.translation import ugettext_lazy as _
from horizon.browsers.views import AngularIndexView
title = _("Trunks")
urlpatterns = [
url(r'^$', AngularIndexView.as_view(title=title), name='index'),
]

View File

@ -0,0 +1,10 @@
# The slug of the panel to be added to HORIZON_CONFIG. Required.
PANEL = 'trunks'
# The slug of the dashboard the PANEL associated with. Required.
PANEL_DASHBOARD = 'project'
# The slug of the panel group the PANEL is associated with.
PANEL_GROUP = 'network'
# Python panel class of the PANEL to be added.
ADD_PANEL = \
'openstack_dashboard.dashboards.project.trunks.panel.Trunks'

View File

@ -38,6 +38,7 @@
'horizon.app.core.images',
'horizon.app.core.metadata',
'horizon.app.core.openstack-service-api',
'horizon.app.core.trunks',
'horizon.app.core.workflow',
'horizon.framework.conf',
'horizon.framework.util',

View File

@ -43,7 +43,8 @@
getAgents: getAgents,
getExtensions: getExtensions,
getDefaultQuotaSets: getDefaultQuotaSets,
updateProjectQuota: updateProjectQuota
updateProjectQuota: updateProjectQuota,
getTrunks: getTrunks
};
return service;
@ -332,6 +333,22 @@
toastService.add('error', gettext('Unable to update project quota data.'));
});
}
}
// Trunks
/**
* @name getTrunks
* @description
* Get a list of trunks for a tenant.
*
* @returns {Object} An object with property "items". Each item is a trunk.
*/
function getTrunks(params) {
var config = params ? {'params' : params} : {};
return apiService.get('/api/neutron/trunks/', config)
.error(function () {
toastService.add('error', gettext('Unable to retrieve the trunks.'));
});
}
}
}());

View File

@ -125,6 +125,13 @@
},
42
]
},
{
"func": "getTrunks",
"method": "get",
"path": "/api/neutron/trunks/",
"data": {},
"error": "Unable to retrieve the trunks."
}
];

View File

@ -0,0 +1,3 @@
<hz-resource-panel resource-type-name="OS::Neutron::Trunk">
<hz-resource-table resource-type-name="OS::Neutron::Trunk"></hz-resource-table>
</hz-resource-panel>

View File

@ -0,0 +1,17 @@
<hz-resource-property-list
resource-type-name="OS::Neutron::Trunk"
item="item"
property-groups="[
['name', 'id', 'project_id'],
['created_at', 'updated_at', 'description']]">
</hz-resource-property-list>
<div class="row">
<div class="col-sm-4">
<dl>
<div ng-repeat="(key, value) in item.extras">
<dt>{$ key $}</dt>
<dd>{$ value $}</dd>
</div>
</dl>
</div>
</div>

View File

@ -0,0 +1,160 @@
/**
* Copyright 2017 Ericsson
*
* 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.trunks
*
* @description
* Provides all of the services and widgets required
* to support and display trunks related content.
*/
angular
.module('horizon.app.core.trunks', [
'ngRoute',
'horizon.framework.conf',
'horizon.app.core'
])
.constant('horizon.app.core.trunks.resourceType', 'OS::Neutron::Trunk')
.run(run)
.config(config);
run.$inject = [
'horizon.framework.conf.resource-type-registry.service',
'horizon.framework.util.i18n.gettext',
'horizon.app.core.trunks.basePath',
'horizon.app.core.trunks.service',
'horizon.app.core.trunks.resourceType'
];
function run(registry,
gettext,
basePath,
trunksService,
trunkResourceType) {
registry.getResourceType(trunkResourceType)
.setNames(gettext('Trunk'), gettext('Trunks'))
.setSummaryTemplateUrl(basePath + 'summary.html')
.setProperties(trunkProperties())
.setListFunction(trunksService.getTrunksPromise)
.tableColumns
.append({
id: 'name_or_id',
priority: 1,
sortDefault: true
})
.append({
id: 'port_id',
priority: 1
})
.append({
id: 'subport_count',
priority: 1
})
.append({
id: 'admin_state',
priority: 1
})
.append({
id: 'status',
priority: 1
});
/**
* Filtering - client-side MagicSearch
* all facets for trunks table
*/
registry.getResourceType(trunkResourceType).filterFacets
.append({
label: gettext('Name'),
name: 'name',
singleton: true
})
.append({
label: gettext('Parent Port'),
name: 'port_id',
singleton: true
})
.append({
label: gettext('Status'),
name: 'status',
singleton: true,
options: [
{label: gettext('Active'), key: 'ACTIVE'},
{label: gettext('Down'), key: 'DOWN'},
{label: gettext('Build'), key: 'BUILD'},
{label: gettext('Degraded'), key: 'DEGRADED'},
{label: gettext('Error'), key: 'ERROR'}
]
})
.append({
label: gettext('Admin State'),
name: 'admin_state_up',
singleton: true,
options: [
{label: gettext('Up'), key: 'true'},
{label: gettext('Down'), key: 'false'}
]
});
}
/**
* @name trunkProperties
* @description resource properties for trunk module
*/
function trunkProperties() {
return {
admin_state: gettext('Admin State'),
created_at: gettext('Created at'),
description: gettext('Description'),
id: gettext('ID'),
name: gettext('Name'),
name_or_id: gettext('Name'),
port_id: gettext('Parent Port'),
project_id: gettext('Project ID'),
status: gettext('Status'),
subport_count: gettext('Subport Count'),
updated_at: gettext('Updated at')
};
}
config.$inject = [
'$provide',
'$windowProvider',
'$routeProvider'
];
/**
* @name config
* @param {Object} $provide
* @param {Object} $windowProvider
* @param {Object} $routeProvider
* @description Routes used by this module.
* @returns {undefined} Returns nothing
*/
function config($provide, $windowProvider, $routeProvider) {
var path = $windowProvider.$get().STATIC_URL + 'app/core/trunks/';
$provide.constant('horizon.app.core.trunks.basePath', path);
$routeProvider.when('/project/trunks', {
templateUrl: path + 'panel.html'
});
}
})();

View File

@ -0,0 +1,52 @@
/**
* Copyright 2017 Ericsson
*
* 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('horizon.app.core.trunks', function () {
it('should exist', function () {
expect(angular.module('horizon.app.core.trunks')).toBeDefined();
});
});
describe('loading the trunk module', function () {
var registry;
beforeEach(module('horizon.app.core.trunks'));
beforeEach(inject(function($injector) {
registry = $injector.get('horizon.framework.conf.resource-type-registry.service');
}));
it('registers names', function() {
expect(registry.getResourceType('OS::Neutron::Trunk').getName()).toBe("Trunks");
});
it('should set facets for search', function () {
var names = registry.getResourceType('OS::Neutron::Trunk').filterFacets
.map(getName);
expect(names).toContain('name');
expect(names).toContain('port_id');
expect(names).toContain('status');
expect(names).toContain('admin_state_up');
function getName(x) {
return x.name;
}
});
});
})();

View File

@ -0,0 +1,61 @@
/**
* Copyright 2017 Ericsson
*
* 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.trunks')
.factory('horizon.app.core.trunks.service', trunksService);
trunksService.$inject = [
'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.userSession'
];
/*
* @ngdoc factory
* @name horizon.app.core.trunks.service
*
* @description
* This service provides functions that are used through the Trunks
* 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 trunksService(neutron, userSession) {
return {
getTrunksPromise: getTrunksPromise
};
/*
* @ngdoc function
* @name getTrunksPromise
* @description
* Given filter/query parameters, returns a promise for the matching
* trunks. This is used in displaying lists of Trunks.
*/
function getTrunksPromise(params) {
return userSession.get().then(getTrunksForProject);
function getTrunksForProject(userSession) {
params.project_id = userSession.project_id;
return neutron.getTrunks(params);
}
}
}
})();

View File

@ -0,0 +1,48 @@
/**
* Copyright 2017 Ericsson
*
* 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('trunks service', function() {
var service;
beforeEach(module('horizon.framework.util'));
beforeEach(module('horizon.framework.conf'));
beforeEach(module('horizon.app.core.trunks'));
beforeEach(inject(function($injector) {
service = $injector.get('horizon.app.core.trunks.service');
}));
describe('getTrunksPromise', function() {
it("provides a promise that gets translated", inject(function($q, $injector, $timeout) {
var neutron = $injector.get('horizon.app.core.openstack-service-api.neutron');
var session = $injector.get('horizon.app.core.openstack-service-api.userSession');
var deferred = $q.defer();
var deferredSession = $q.defer();
spyOn(neutron, 'getTrunks').and.returnValue(deferred.promise);
spyOn(session, 'get').and.returnValue(deferredSession.promise);
var result = service.getTrunksPromise({});
deferred.resolve({data: {items: [{id: 1, updated_at: 'Apr10'}]}});
deferredSession.resolve({project_id: '42'});
$timeout.flush();
expect(neutron.getTrunks).toHaveBeenCalled();
expect(result.$$state.value.data.items[0].updated_at).toBe('Apr10');
expect(result.$$state.value.data.items[0].id).toBe(1);
}));
});
});
})();

View File

@ -78,6 +78,8 @@
.setNames(gettext('Floating IP'), gettext('Floating IPs'));
registry.getResourceType('OS::Neutron::SecurityGroup')
.setNames(gettext('Security Group'), gettext('Security Groups'));
registry.getResourceType('OS::Neutron::Trunk')
.setNames(gettext('Trunk'), gettext('Trunks'));
registry.getResourceType('OS::Keystone::User')
.setNames(gettext('User'), gettext('Users'));
registry.getResourceType('OS::Keystone::Group')

View File

@ -159,6 +159,19 @@ class NeutronPortsTestCase(test.TestCase):
request, network_id=TEST.api_networks.first().get("id"))
class NeutronTrunksTestCase(test.TestCase):
@mock.patch.object(neutron.api, 'neutron')
def test_get(self, client):
request = self.mock_rest_request(GET={})
client.trunk_list.return_value = self.trunks.list()
response = neutron.Trunks().get(request)
self.assertStatusCode(response, 200)
self.assertItemsCollectionEqual(
response,
[t.to_dict() for t in self.trunks.list()])
class NeutronExtensionsTestCase(test.TestCase):
def setUp(self):
super(NeutronExtensionsTestCase, self).setUp()

View File

@ -415,6 +415,33 @@ class NeutronApiTests(test.APITestCase):
api.neutron.port_delete(self.request, port_id)
def test_trunk_list(self):
trunks = {'trunks': self.api_trunks.list()}
neutron_client = self.stub_neutronclient()
neutron_client.list_trunks().AndReturn(trunks)
self.mox.ReplayAll()
ret_val = api.neutron.trunk_list(self.request)
for t in ret_val:
self.assertIsInstance(t, api.neutron.Trunk)
def test_trunk_object(self):
trunk = self.api_trunks.first().copy()
obj = api.neutron.Trunk(trunk)
self.assertEqual(0, obj.subport_count)
trunk_dict = obj.to_dict()
self.assertIsInstance(trunk_dict, dict)
self.assertEqual(trunk['name'], trunk_dict['name_or_id'])
self.assertEqual(0, trunk_dict['subport_count'])
trunk['name'] = '' # to test name_or_id
trunk['sub_ports'] = [uuidutils.generate_uuid() for i in range(2)]
obj = api.neutron.Trunk(trunk)
self.assertEqual(2, obj.subport_count)
trunk_dict = obj.to_dict()
self.assertEqual(obj.name_or_id, trunk_dict['name_or_id'])
self.assertEqual(2, trunk_dict['subport_count'])
def test_router_list(self):
routers = {'routers': self.api_routers.list()}

View File

@ -278,6 +278,11 @@ TEST_GLOBAL_MOCKS_ON_PANELS = {
'.firewalls.panel.Firewall.can_access'),
'return_value': True,
},
'trunk': {
'method': ('openstack_dashboard.dashboards.project'
'.trunks.panel.Trunks.can_access'),
'return_value': True,
},
'vpn': {
'method': ('openstack_dashboard.dashboards.project'
'.vpn.panel.VPN.can_access'),

View File

@ -31,6 +31,7 @@ def data(TEST):
TEST.subnets = utils.TestDataContainer()
TEST.subnetpools = utils.TestDataContainer()
TEST.ports = utils.TestDataContainer()
TEST.trunks = utils.TestDataContainer()
TEST.routers = utils.TestDataContainer()
TEST.routers_with_rules = utils.TestDataContainer()
TEST.routers_with_routes = utils.TestDataContainer()
@ -59,6 +60,7 @@ def data(TEST):
TEST.api_subnets = utils.TestDataContainer()
TEST.api_subnetpools = utils.TestDataContainer()
TEST.api_ports = utils.TestDataContainer()
TEST.api_trunks = utils.TestDataContainer()
TEST.api_routers = utils.TestDataContainer()
TEST.api_routers_with_routes = utils.TestDataContainer()
TEST.api_floating_ips = utils.TestDataContainer()
@ -327,6 +329,18 @@ def data(TEST):
TEST.api_ports.add(port_dict)
TEST.ports.add(neutron.Port(port_dict))
trunk_dict = {'status': 'UP',
'sub_ports': [],
'name': 'trunk1',
'admin_state_up': True,
'tenant_id': '1',
'project_id': '1',
'port_id': '895d375c-1447-11e7-a52f-f7f280bbc809',
'id': '94fcb9e8-1447-11e7-bed6-8b8c4ac74491'}
TEST.api_trunks.add(trunk_dict)
TEST.trunks.add(neutron.Trunk(trunk_dict))
router_dict = {'id': '279989f7-54bb-41d9-ba42-0d61f12fda61',
'name': 'router1',
'status': 'ACTIVE',
@ -574,11 +588,15 @@ def data(TEST):
extension_5 = {"name": "HA Router extension",
"alias": "l3-ha",
"description": "Add HA capability to routers."}
extension_6 = {"name": "Trunks",
"alias": "trunk",
"description": "Provides support for trunk ports."}
TEST.api_extensions.add(extension_1)
TEST.api_extensions.add(extension_2)
TEST.api_extensions.add(extension_3)
TEST.api_extensions.add(extension_4)
TEST.api_extensions.add(extension_5)
TEST.api_extensions.add(extension_6)
# 1st agent.
agent_dict = {"binary": "neutron-openvswitch-agent",