Add members tab to create load balancer workflow

This adds the members tab to the create load balancer workflow and
allows adding multiple members (nova servers) to the pool.

Partially-Implements: blueprint horizon-lbaas-v2-ui
Change-Id: I469dd9ff4d9aef9b91316699d1edec278957f091
This commit is contained in:
Justin Pomeroy 2015-12-08 13:35:43 -06:00
parent 32166ee5a8
commit 6c4828d8f6
22 changed files with 783 additions and 40 deletions

View File

@ -97,9 +97,45 @@ def create_pool(request, **kwargs):
poolSpec['name'] = data['pool']['name'] poolSpec['name'] = data['pool']['name']
if data['pool'].get('description'): if data['pool'].get('description'):
poolSpec['description'] = data['pool']['description'] poolSpec['description'] = data['pool']['description']
return neutronclient(request).create_lbaas_pool( pool = neutronclient(request).create_lbaas_pool(
{'pool': poolSpec}).get('pool') {'pool': poolSpec}).get('pool')
if data.get('members'):
args = (request, kwargs['loadbalancer_id'], add_member)
kwargs = {'callback_kwargs': {'pool_id': pool['id'],
'index': 0}}
thread.start_new_thread(poll_loadbalancer_status, args, kwargs)
return pool
def add_member(request, **kwargs):
"""Add a member to a pool.
"""
data = request.DATA
members = data['members']
index = kwargs['index']
member = members[index]
memberSpec = {
'address': member['address'],
'protocol_port': member['port'],
'subnet_id': member['subnet']
}
if member.get('weight'):
memberSpec['weight'] = member['weight']
member = neutronclient(request).create_lbaas_member(
kwargs['pool_id'], {'member': memberSpec}).get('member')
index += 1
if len(members) > index:
args = (request, kwargs['loadbalancer_id'], add_member)
kwargs = {'callback_kwargs': {'pool_id': kwargs['pool_id'],
'index': index}}
thread.start_new_thread(poll_loadbalancer_status, args, kwargs)
return member
@urls.register @urls.register
class LoadBalancers(generic.View): class LoadBalancers(generic.View):

View File

@ -47,7 +47,11 @@ ADD_JS_FILES = [
('dashboard/project/lbaasv2/loadbalancers/actions/create/details/' ('dashboard/project/lbaasv2/loadbalancers/actions/create/details/'
'details.controller.js'), 'details.controller.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/listener/' ('dashboard/project/lbaasv2/loadbalancers/actions/create/listener/'
'listener.controller.js') 'listener.controller.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/pool/'
'pool.controller.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/members/'
'members.controller.js')
] ]
ADD_JS_SPEC_FILES = [ ADD_JS_SPEC_FILES = [
@ -70,7 +74,11 @@ ADD_JS_SPEC_FILES = [
('dashboard/project/lbaasv2/loadbalancers/actions/create/details/' ('dashboard/project/lbaasv2/loadbalancers/actions/create/details/'
'details.controller.spec.js'), 'details.controller.spec.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/listener/' ('dashboard/project/lbaasv2/loadbalancers/actions/create/listener/'
'listener.controller.spec.js') 'listener.controller.spec.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/pool/'
'pool.controller.spec.js'),
('dashboard/project/lbaasv2/loadbalancers/actions/create/members/'
'members.controller.spec.js')
] ]
ADD_SCSS_FILES = [ ADD_SCSS_FILES = [

View File

@ -50,7 +50,7 @@ module.exports = function (config) {
// Sets up module to process templates. // Sets up module to process templates.
ngHtml2JsPreprocessor: { ngHtml2JsPreprocessor: {
prependPrefix: '/static/', prependPrefix: '/',
moduleName: 'templates' moduleName: 'templates'
}, },

View File

@ -33,8 +33,11 @@
.constant('horizon.dashboard.project.lbaasv2.patterns', { .constant('horizon.dashboard.project.lbaasv2.patterns', {
ipv4: '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$', ipv4: '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$',
ipv6: '^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?$' ipv6: '^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?$'
}); })
/* eslint-enable max-len */ /* eslint-enable max-len */
.constant('horizon.dashboard.project.lbaasv2.popovers', {
ipAddresses: '<ul><li ng-repeat="addr in member.addresses">{$ addr.ip $}</li></ul>'
});
config.$inject = [ config.$inject = [
'$provide', '$provide',

View File

@ -45,3 +45,25 @@
margin-top: 12px; margin-top: 12px;
} }
} }
transfer-table {
.member-weight,
.member-port {
display: inline-block;
width: 6em;
}
.member-address {
width: 20em;
}
span.invalid {
vertical-align: top;
margin: 8px 0px 0px 5px;
}
}
.addresses-popover + .popover {
ul {
list-style-type: disc;
padding-left: 10px;
}
}

View File

@ -0,0 +1,96 @@
/*
* Copyright 2016 IBM Corp.
*
* 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.dashboard.project.lbaasv2.loadbalancers')
.controller('AddMembersController', AddMembersController);
AddMembersController.$inject = [
'$scope',
'$compile',
'horizon.dashboard.project.lbaasv2.popovers',
'horizon.framework.util.i18n.gettext'
];
/**
* @ngdoc controller
* @name AddMembersController
* @description
* The `AddMembersController` controller provides functions for adding members to a pool.
* @param $scope The angular scope object.
* @param $compile The angular compile service.
* @param popovers LBaaS v2 popover templates constant.
* @param gettext The horizon gettext function for translation.
* @returns undefined
*/
function AddMembersController($scope, $compile, popoverTemplates, gettext) {
var ctrl = this;
// Error text for invalid fields
ctrl.portError = gettext('The port must be a number between 1 and 65535.');
ctrl.weightError = gettext('The weight must be a number between 1 and 256.');
// Table widget properties
ctrl.tableData = {
available: $scope.model.members,
allocated: $scope.model.spec.members,
displayedAvailable: [],
displayedAllocated: []
};
ctrl.tableLimits = {
maxAllocation: -1
};
ctrl.tableHelp = {
availHelpText: '',
noneAllocText: gettext('Select members from the available members below'),
noneAvailText: gettext('No available members')
};
// Functions to control the IP address popover
ctrl.showAddressPopover = showAddressPopover;
ctrl.hideAddressPopover = hideAddressPopover;
ctrl.addressPopoverTarget = addressPopoverTarget;
//////////
function showAddressPopover(event, member) {
var element = angular.element(event.target);
var scope = $scope.$new(true);
scope.member = member;
element.popover({
content: $compile(popoverTemplates.ipAddresses)(scope),
html: true,
placement: 'top',
title: interpolate(gettext('IP Addresses (%(count)s)'),
{ count: member.addresses.length }, true)
});
element.popover('show');
}
function hideAddressPopover(event) {
var element = angular.element(event.target);
element.popover('hide');
}
function addressPopoverTarget(member) {
return interpolate(gettext('%(ip)s...'), { ip: member.address.ip }, true);
}
}
})();

View File

@ -0,0 +1,141 @@
/*
* Copyright 2016 IBM Corp.
*
* 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('Create Load Balancer Add Members Step', function() {
var members = [{
id: '1',
name: 'foo',
description: 'bar',
weight: 1,
port: 80,
address: { ip: '1.2.3.4', subnet: '1' },
addresses: [{ ip: '1.2.3.4', subnet: '1' },
{ ip: '2.3.4.5', subnet: '2' }]
}];
beforeEach(module('horizon.framework.util.i18n'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
describe('AddMembersController', function() {
var ctrl, scope;
beforeEach(inject(function($controller) {
scope = {
model: {
spec: {
members: []
},
members: members
}
};
ctrl = $controller('AddMembersController', { $scope: scope });
}));
it('should define error messages for invalid fields', function() {
expect(ctrl.portError).toBeDefined();
expect(ctrl.weightError).toBeDefined();
});
it('should define transfer table properties', function() {
expect(ctrl.tableData).toBeDefined();
expect(ctrl.tableLimits).toBeDefined();
expect(ctrl.tableHelp).toBeDefined();
});
it('should have available members', function() {
expect(ctrl.tableData.available).toBeDefined();
expect(ctrl.tableData.available.length).toBe(1);
expect(ctrl.tableData.available[0].id).toBe('1');
});
it('should not have allocated members', function() {
expect(ctrl.tableData.allocated).toEqual([]);
});
it('should allow adding multiple members', function() {
expect(ctrl.tableLimits.maxAllocation).toBe(-1);
});
it('should properly format address popover target', function() {
var target = ctrl.addressPopoverTarget(members[0]);
expect(target).toBe('1.2.3.4...');
});
});
describe('Add Members Template', function() {
var $scope, $element, popoverContent;
beforeEach(module('templates'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(inject(function($injector) {
var $compile = $injector.get('$compile');
var $templateCache = $injector.get('$templateCache');
var basePath = $injector.get('horizon.dashboard.project.lbaasv2.basePath');
var popoverTemplates = $injector.get('horizon.dashboard.project.lbaasv2.popovers');
var markup = $templateCache.get(
basePath + 'loadbalancers/actions/create/members/members.html');
$scope = $injector.get('$rootScope').$new();
$scope.model = {
spec: {
members: []
},
members: members
};
$element = $compile(markup)($scope);
var popoverScope = $injector.get('$rootScope').$new();
popoverScope.member = members[0];
popoverContent = $compile(popoverTemplates.ipAddresses)(popoverScope);
}));
it('should show IP addresses popover on hover', function() {
var ctrl = $element.scope().ctrl;
ctrl.tableData.displayedAvailable = members;
$scope.$apply();
var popoverElement = $element.find('span.addresses-popover');
expect(popoverElement.length).toBe(1);
$.fn.popover = angular.noop;
spyOn($.fn, 'popover');
spyOn(ctrl, 'showAddressPopover').and.callThrough();
popoverElement.trigger('mouseover');
expect(ctrl.showAddressPopover).toHaveBeenCalledWith(
jasmine.objectContaining({type: 'mouseover'}), members[0]);
expect($.fn.popover.calls.count()).toBe(2);
expect($.fn.popover.calls.argsFor(0)[0]).toEqual({
content: popoverContent,
html: true,
placement: 'top',
title: 'IP Addresses (2)'
});
expect($.fn.popover.calls.argsFor(1)[0]).toBe('show');
spyOn(ctrl, 'hideAddressPopover').and.callThrough();
popoverElement.trigger('mouseleave');
expect(ctrl.hideAddressPopover)
.toHaveBeenCalledWith(jasmine.objectContaining({type: 'mouseleave'}));
expect($.fn.popover.calls.count()).toBe(3);
expect($.fn.popover.calls.argsFor(2)[0]).toBe('hide');
});
});
});
})();

View File

@ -0,0 +1,3 @@
<h1 translate>Pool Members Help</h1>
<p translate>Add members to the load balancer pool. The Available Members table contains existing instances that can be added to the pool.</p>

View File

@ -0,0 +1,191 @@
<div ng-controller="AddMembersController as ctrl">
<h1 translate>Pool Members</h1>
<!--content-->
<div class="content">
<div translate class="subtitle">Add members to the load balancer pool. All required listener and pool details must also be provided.</div>
<transfer-table tr-model="ctrl.tableData"
limits="::ctrl.tableLimits"
help-text="::ctrl.tableHelp">
<!-- Allocated-->
<allocated>
<table st-table="ctrl.tableData.displayedAllocated"
st-safe-src="ctrl.tableData.allocated" hz-table
class="table-striped table-rsp table-detail modern form-group">
<thead>
<tr>
<th class="expander"></th>
<th class="rsp-p2" translate>Name</th>
<th class="rsp-p3" translate>Description</th>
<th class="rsp-p1"
ng-class="{ 'required': ctrl.tableData.displayedAllocated.length > 0 }">
<label translate>IP Address</label>
</th>
<th class="rsp-p1" translate>Weight</th>
<th class="rsp-p1"
ng-class="{ 'required': ctrl.tableData.displayedAllocated.length > 0 }">
<label translate>Port</label>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="ctrl.tableData.allocated.length === 0">
<td colspan="0">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAllocText $}
</div>
</td>
</tr>
<tr ng-repeat-start="row in ctrl.tableData.displayedAllocated track by row.id">
<td class="expander">
<i class="fa fa-chevron-right"
hz-expand-detail
duration="200">
</i>
</td>
<td class="rsp-p2">{$ ::row.name $}</td>
<td class="rsp-p3">{$ ::row.description | noValue $}</td>
<td class="rsp-p1">
<span ng-if="row.addresses.length === 1">{$ row.address.ip $}</span>
<div ng-if="row.addresses.length > 1"
class="form-field member-address">
<select class="form-control input-sm"
ng-options="addr.ip for addr in row.addresses"
ng-model="row.address" ng-required="true">
</select>
</div>
</td>
<td class="rsp-p1">
<div class="form-field member-weight"
ng-class="{ 'has-error': createLoadBalancerMembersForm['{$ ::row.id $}-weight'].$invalid && createLoadBalancerMembersForm['{$ ::row.id $}-weight'].$dirty }">
<input name="{$ ::row.id $}-weight" type="number" class="form-control input-sm"
ng-model="row.weight" ng-pattern="/^\d+$/" min="1" max="256">
</div>
<span class="fa fa-exclamation-triangle invalid"
ng-show="createLoadBalancerMembersForm['{$ ::row.id $}-weight'].$invalid && createLoadBalancerMembersForm.$dirty"
popover="{$ ::ctrl.weightError $}"
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
</td>
<td class="rsp-p1">
<div class="form-field member-port"
ng-class="{ 'has-error': createLoadBalancerMembersForm['{$ ::row.id $}-port'].$invalid && createLoadBalancerMembersForm['{$ ::row.id $}-port'].$dirty }">
<input name="{$ ::row.id $}-port" type="number" class="form-control input-sm"
ng-model="row.port" ng-pattern="/^\d+$/" min="1" max="65535"
ng-required="true">
</div>
<span class="fa fa-exclamation-triangle invalid"
ng-show="createLoadBalancerMembersForm['{$ ::row.id $}-port'].$invalid && createLoadBalancerMembersForm.$dirty"
popover="{$ ::ctrl.portError $}"
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
</td>
<td class="action-col">
<action-list>
<action action-classes="'btn btn-sm btn-default'"
callback="trCtrl.deallocate" item="row">
<span class="fa fa-minus"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td class="detail" colspan="0">
<div class="row">
<dl class="rsp-alt-p2 col-sm-2">
<dt translate>Name</dt>
<dd>{$ ::row.name $}</dd>
</dl>
<dl class="rsp-alt-p3 col-sm-2">
<dt translate>Description</dt>
<dd>{$ ::row.description | noValue $}</dd>
</dl>
</div>
</td>
</tr>
</tbody>
</table>
</allocated>
<!-- Available -->
<available>
<table st-table="ctrl.tableData.displayedAvailable"
st-safe-src="ctrl.tableData.available"
hz-table class="table-striped table-rsp table-detail modern">
<thead>
<tr>
<th class="search-header" colspan="0">
<hz-search-bar group-classes="input-group-sm" icon-classes="fa-search">
</hz-search-bar>
</th>
</tr>
<tr>
<th class="expander"></th>
<th st-sort="name" st-sort-default class="rsp-p2" translate>Name</th>
<th class="rsp-p3" translate>Description</th>
<th class="rsp-p1" translate>IP Address</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<td colspan="0">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAvailText $}
</div>
</td>
</tr>
<tr ng-repeat-start="row in ctrl.tableData.displayedAvailable track by row.id"
ng-if="!trCtrl.allocatedIds[row.id]">
<td class="expander">
<i class="fa fa-chevron-right"
hz-expand-detail
duration="200">
</i>
</td>
<td class="rsp-p2">{$ ::row.name $}</td>
<td class="rsp-p3">{$ ::row.description | noValue $}</td>
<td class="rsp-p1">
<span ng-if="row.addresses.length === 1">{$ row.address.ip $}</span>
<!-- The current version of the popover directive doesn't seem to allow HTML content (0.11.0) -->
<span ng-if="row.addresses.length > 1"
class="addresses-popover"
ng-mouseover="ctrl.showAddressPopover($event, row)"
ng-mouseleave="ctrl.hideAddressPopover($event)">
{$ ctrl.addressPopoverTarget(row) $}
</span>
</td>
<td class="action-col">
<action-list>
<action action-classes="'btn btn-sm btn-default'"
callback="trCtrl.allocate" item="row">
<span class="fa fa-plus"></span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td class="detail" colspan="0">
<div class="row">
<dl class="rsp-alt-p2 col-sm-2">
<dt translate>Name</dt>
<dd>{$ ::row.name $}</dd>
</dl>
<dl class="rsp-alt-p3 col-sm-2">
<dt translate>Description</dt>
<dd>{$ ::row.description | noValue $}</dd>
</dl>
</div>
</td>
</tr>
</tbody>
</table>
</available>
</transfer-table> <!-- End Transfer Table -->
</div> <!-- end content -->
</div>

View File

@ -24,7 +24,6 @@
modalService.$inject = [ modalService.$inject = [
'$modal', '$modal',
'$location', '$location',
'$window',
'horizon.framework.widgets.toast.service', 'horizon.framework.widgets.toast.service',
'horizon.framework.util.i18n.gettext', 'horizon.framework.util.i18n.gettext',
'horizon.app.core.openstack-service-api.policy' 'horizon.app.core.openstack-service-api.policy'
@ -39,14 +38,13 @@
* *
* @param $modal The angular bootstrap $modal service. * @param $modal The angular bootstrap $modal service.
* @param $location The angular $location service. * @param $location The angular $location service.
* @param $window The angular reference to the browser window object.
* @param toastService The horizon toast service. * @param toastService The horizon toast service.
* @param gettext The horizon gettext function for translation. * @param gettext The horizon gettext function for translation.
* @param policy The horizon policy service. * @param policy The horizon policy service.
* @returns The modal service for the create load balancer workflow. * @returns The modal service for the create load balancer workflow.
*/ */
function modalService($modal, $location, $window, toastService, gettext, policy) { function modalService($modal, $location, toastService, gettext, policy) {
var service = { var service = {
allowed: allowed, allowed: allowed,
@ -77,7 +75,7 @@
var modal = $modal.open(spec); var modal = $modal.open(spec);
modal.result.then(function(response) { modal.result.then(function(response) {
toastService.add('success', gettext('A new load balancer is being created.')); toastService.add('success', gettext('A new load balancer is being created.'));
$location.path($window.WEBROOT + 'project/ngloadbalancersv2/detail/' + response.data.id); $location.path('project/ngloadbalancersv2/detail/' + response.data.id);
}); });
} }

View File

@ -98,7 +98,7 @@
modalService.perform(); modalService.perform();
expect(toastService.add).toHaveBeenCalledWith('success', jasmine.any(String)); expect(toastService.add).toHaveBeenCalledWith('success', jasmine.any(String));
expect($location.path).toHaveBeenCalledWith('/project/ngloadbalancersv2/detail/1'); expect($location.path).toHaveBeenCalledWith('project/ngloadbalancersv2/detail/1');
}); });
}); });

View File

@ -16,6 +16,8 @@
(function () { (function () {
'use strict'; 'use strict';
var push = Array.prototype.push;
angular angular
.module('horizon.dashboard.project.lbaasv2.loadbalancers') .module('horizon.dashboard.project.lbaasv2.loadbalancers')
.factory('horizon.dashboard.project.lbaasv2.loadbalancers.actions.create.model', .factory('horizon.dashboard.project.lbaasv2.loadbalancers.actions.create.model',
@ -24,6 +26,7 @@
createLoadBalancerModel.$inject = [ createLoadBalancerModel.$inject = [
'$q', '$q',
'horizon.app.core.openstack-service-api.neutron', 'horizon.app.core.openstack-service-api.neutron',
'horizon.app.core.openstack-service-api.nova',
'horizon.app.core.openstack-service-api.lbaasv2', 'horizon.app.core.openstack-service-api.lbaasv2',
'horizon.framework.util.i18n.gettext' 'horizon.framework.util.i18n.gettext'
]; ];
@ -40,13 +43,14 @@
* *
* @param $q The angular service for promises. * @param $q The angular service for promises.
* @param neutronAPI The neutron service API. * @param neutronAPI The neutron service API.
* @param novaAPI The nova service API.
* @param lbaasv2API The LBaaS V2 service API. * @param lbaasv2API The LBaaS V2 service API.
* @param gettext The horizon gettext function for translation. * @param gettext The horizon gettext function for translation.
* @returns The model service for the create load balancer workflow. * @returns The model service for the create load balancer workflow.
*/ */
function createLoadBalancerModel($q, neutronAPI, lbaasv2API, gettext) { function createLoadBalancerModel($q, neutronAPI, novaAPI, lbaasv2API, gettext) {
var initPromise; var initPromise, ports;
/** /**
* @ngdoc model api object * @ngdoc model api object
@ -71,6 +75,7 @@
spec: null, spec: null,
subnets: [], subnets: [],
members: [],
listenerProtocols: ['TCP', 'HTTP', 'HTTPS'], listenerProtocols: ['TCP', 'HTTP', 'HTTPS'],
poolProtocols: ['TCP', 'HTTP', 'HTTPS'], poolProtocols: ['TCP', 'HTTP', 'HTTPS'],
methods: ['ROUND_ROBIN', 'LEAST_CONNECTIONS', 'SOURCE_IP'], methods: ['ROUND_ROBIN', 'LEAST_CONNECTIONS', 'SOURCE_IP'],
@ -113,7 +118,8 @@
description: null, description: null,
protocol: null, protocol: null,
method: null method: null
} },
members: []
}; };
if (model.initializing) { if (model.initializing) {
@ -123,7 +129,9 @@
promise = $q.all([ promise = $q.all([
lbaasv2API.getLoadBalancers().then(onGetLoadBalancers), lbaasv2API.getLoadBalancers().then(onGetLoadBalancers),
neutronAPI.getSubnets().then(onGetSubnets) neutronAPI.getSubnets().then(onGetSubnets),
neutronAPI.getPorts().then(onGetPorts),
novaAPI.getServers().then(onGetServers)
]); ]);
promise.then(onInitSuccess, onInitFail); promise.then(onInitSuccess, onInitFail);
@ -133,6 +141,7 @@
} }
function onInitSuccess() { function onInitSuccess() {
initMemberAddresses();
model.initializing = false; model.initializing = false;
model.initialized = true; model.initialized = true;
} }
@ -166,9 +175,24 @@
delete finalSpec.pool; delete finalSpec.pool;
} }
// Members require a pool, address, subnet, and port but the wizard requires the address,
// subnet, and port so we can assume those exist here.
if (!finalSpec.pool || finalSpec.members.length === 0) {
delete finalSpec.members;
}
angular.forEach(finalSpec.members, function cleanMember(member) {
delete member.id;
delete member.addresses;
delete member.name;
delete member.description;
member.subnet = member.address.subnet;
member.address = member.address.ip;
});
// Delete null properties // Delete null properties
angular.forEach(finalSpec, function(group, groupName) { angular.forEach(finalSpec, function deleteNullsForGroup(group, groupName) {
angular.forEach(group, function(value, key) { angular.forEach(group, function deleteNullValue(value, key) {
if (value === null) { if (value === null) {
delete finalSpec[groupName][key]; delete finalSpec[groupName][key];
} }
@ -195,9 +219,43 @@
} }
function onGetSubnets(response) { function onGetSubnets(response) {
model.subnets = []; model.subnets.length = 0;
angular.forEach(response.data.items, function(subnet) { push.apply(model.subnets, response.data.items);
model.subnets.push(subnet); }
function onGetServers(response) {
model.members.length = 0;
var members = [];
angular.forEach(response.data.items, function(server) {
members.push({
id: server.id,
name: server.name,
description: server.description,
weight: 1
});
});
push.apply(model.members, members);
}
function onGetPorts(response) {
ports = response.data.items;
}
function initMemberAddresses() {
angular.forEach(model.members, function(member) {
var memberPorts = ports.filter(function(port) {
return port.device_id === member.id;
});
member.addresses = [];
angular.forEach(memberPorts, function(port) {
angular.forEach(port.fixed_ips, function(ip) {
member.addresses.push({
ip: ip.ip_address,
subnet: ip.subnet_id
});
});
});
member.address = member.addresses[0];
}); });
} }

View File

@ -19,6 +19,7 @@
describe('LBaaS v2 Create Load Balancer Workflow Model Service', function() { describe('LBaaS v2 Create Load Balancer Workflow Model Service', function() {
var model, $q, scope; var model, $q, scope;
beforeEach(module('horizon.framework.util.i18n'));
beforeEach(module('horizon.dashboard.project.lbaasv2')); beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(module(function($provide) { beforeEach(module(function($provide) {
@ -44,6 +45,31 @@
var deferred = $q.defer(); var deferred = $q.defer();
deferred.resolve({ data: { items: subnets } }); deferred.resolve({ data: { items: subnets } });
return deferred.promise;
},
getPorts: function() {
var ports = [ { device_id: '1',
fixed_ips: [{ ip_address: '1.2.3.4', subnet_id: '1' },
{ ip_address: '2.3.4.5', subnet_id: '2' }] },
{ device_id: '2',
fixed_ips: [{ ip_address: '3.4.5.6', subnet_id: '1' },
{ ip_address: '4.5.6.7', subnet_id: '2' }] } ];
var deferred = $q.defer();
deferred.resolve({ data: { items: ports } });
return deferred.promise;
}
});
$provide.value('horizon.app.core.openstack-service-api.nova', {
getServers: function() {
var servers = [ { id: '1', name: 'server-1' },
{ id: '2', name: 'server-2' } ];
var deferred = $q.defer();
deferred.resolve({ data: { items: servers } });
return deferred.promise; return deferred.promise;
} }
}); });
@ -76,6 +102,10 @@
expect(model.subnets).toEqual([]); expect(model.subnets).toEqual([]);
}); });
it('has empty members array', function() {
expect(model.members).toEqual([]);
});
it('has array of pool protocols', function() { it('has array of pool protocols', function() {
expect(model.poolProtocols).toEqual(['TCP', 'HTTP', 'HTTPS']); expect(model.poolProtocols).toEqual(['TCP', 'HTTP', 'HTTPS']);
}); });
@ -108,10 +138,12 @@
expect(model.initializing).toBe(false); expect(model.initializing).toBe(false);
expect(model.initialized).toBe(true); expect(model.initialized).toBe(true);
expect(model.subnets.length).toBe(2); expect(model.subnets.length).toBe(2);
expect(model.members.length).toBe(2);
expect(model.spec).toBeDefined(); expect(model.spec).toBeDefined();
expect(model.spec.loadbalancer).toBeDefined(); expect(model.spec.loadbalancer).toBeDefined();
expect(model.spec.listener).toBeDefined(); expect(model.spec.listener).toBeDefined();
expect(model.spec.pool).toBeDefined(); expect(model.spec.pool).toBeDefined();
expect(model.spec.members).toEqual([]);
}); });
it('should initialize names', function() { it('should initialize names', function() {
@ -156,7 +188,7 @@
// This is here to ensure that as people add/change spec properties, they don't forget // This is here to ensure that as people add/change spec properties, they don't forget
// to implement tests for them. // to implement tests for them.
it('has the right number of properties', function() { it('has the right number of properties', function() {
expect(Object.keys(model.spec).length).toBe(3); expect(Object.keys(model.spec).length).toBe(4);
expect(Object.keys(model.spec.loadbalancer).length).toBe(4); expect(Object.keys(model.spec.loadbalancer).length).toBe(4);
expect(Object.keys(model.spec.listener).length).toBe(4); expect(Object.keys(model.spec.listener).length).toBe(4);
expect(Object.keys(model.spec.pool).length).toBe(4); expect(Object.keys(model.spec.pool).length).toBe(4);
@ -227,6 +259,16 @@
model.spec.pool.description = 'pool description'; model.spec.pool.description = 'pool description';
model.spec.pool.protocol = 'HTTP'; model.spec.pool.protocol = 'HTTP';
model.spec.pool.method = 'LEAST_CONNECTIONS'; model.spec.pool.method = 'LEAST_CONNECTIONS';
model.spec.members = [{
address: { ip: '1.2.3.4', subnet: '1' },
addresses: [{ ip: '1.2.3.4', subnet: '1' },
{ ip: '2.3.4.5', subnet: '2' }],
id: '1',
name: 'foo',
description: 'bar',
port: 80,
weight: 1
}];
var finalSpec = model.createLoadBalancer(); var finalSpec = model.createLoadBalancer();
@ -242,6 +284,15 @@
expect(finalSpec.pool.description).toBe('pool description'); expect(finalSpec.pool.description).toBe('pool description');
expect(finalSpec.pool.protocol).toBe('HTTP'); expect(finalSpec.pool.protocol).toBe('HTTP');
expect(finalSpec.pool.method).toBe('LEAST_CONNECTIONS'); expect(finalSpec.pool.method).toBe('LEAST_CONNECTIONS');
expect(finalSpec.members.length).toBe(1);
expect(finalSpec.members[0].address).toBe('1.2.3.4');
expect(finalSpec.members[0].subnet).toBe('1');
expect(finalSpec.members[0].port).toBe(80);
expect(finalSpec.members[0].weight).toBe(1);
expect(finalSpec.members[0].addresses).toBeUndefined();
expect(finalSpec.members[0].id).toBeUndefined();
expect(finalSpec.members[0].name).toBeUndefined();
expect(finalSpec.members[0].description).toBeUndefined();
}); });
it('should delete listener if any required property is not set', function() { it('should delete listener if any required property is not set', function() {
@ -268,6 +319,22 @@
expect(finalSpec.listener).toBeDefined(); expect(finalSpec.listener).toBeDefined();
expect(finalSpec.pool).toBeUndefined(); expect(finalSpec.pool).toBeUndefined();
}); });
it('should delete members if none selected', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];
model.spec.listener.protocol = 'HTTPS';
model.spec.listener.port = 80;
model.spec.pool.protocol = 'HTTP';
model.spec.pool.method = 'LEAST_CONNECTIONS';
var finalSpec = model.createLoadBalancer();
expect(finalSpec.loadbalancer).toBeDefined();
expect(finalSpec.listener).toBeDefined();
expect(finalSpec.pool).toBeDefined();
expect(finalSpec.members).toBeUndefined();
});
}); });
}); });

View File

@ -0,0 +1,57 @@
/*
* Copyright 2016 IBM Corp.
*
* 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.dashboard.project.lbaasv2.loadbalancers')
.controller('CreatePoolDetailsController', CreatePoolDetailsController);
CreatePoolDetailsController.$inject = [
'$scope'
];
/**
* @ngdoc controller
* @name CreatePoolDetailsController
* @description
* The `CreatePoolDetailsController` controller provides functions for configuring
* pool details.
* @param $scope The angular scope object.
* @returns undefined
*/
function CreatePoolDetailsController($scope) {
var ctrl = this;
ctrl.protocolChange = protocolChange;
//////////
function protocolChange(protocol) {
var port = '';
if (protocol === 'HTTP') {
port = 80;
} else if (protocol === 'HTTPS') {
port = 443;
}
$scope.model.members.forEach(function setPort(member) {
member.port = port;
});
}
}
})();

View File

@ -0,0 +1,62 @@
/*
* Copyright 2016 IBM Corp.
*
* 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('Create Pool Details Step', function() {
var ctrl;
var members = [{port: ''}, {port: ''}];
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(inject(function($controller) {
var scope = {
model: {
members: members
}
};
ctrl = $controller('CreatePoolDetailsController', { $scope: scope });
}));
it('should define protocolChange function', function() {
expect(ctrl.protocolChange).toBeDefined();
});
it('should update member ports on protocol change to HTTP', function() {
ctrl.protocolChange('HTTP');
members.forEach(function(member) {
expect(member.port).toBe(80);
});
});
it('should update member ports on protocol change to HTTPS', function() {
ctrl.protocolChange('HTTPS');
members.forEach(function(member) {
expect(member.port).toBe(443);
});
});
it('should update member ports on protocol change to TCP', function() {
ctrl.protocolChange('TCP');
members.forEach(function(member) {
expect(member.port).toBe('');
});
});
});
})();

View File

@ -1,4 +1,4 @@
<div> <div ng-controller="CreatePoolDetailsController as ctrl">
<h1 translate>Pool Details</h1> <h1 translate>Pool Details</h1>
<!--content--> <!--content-->
@ -35,7 +35,8 @@
<select class="form-control input-sm" name="pool-protocol" <select class="form-control input-sm" name="pool-protocol"
id="pool-protocol" id="pool-protocol"
ng-options="protocol for protocol in model.poolProtocols" ng-options="protocol for protocol in model.poolProtocols"
ng-model="model.spec.pool.protocol"> ng-model="model.spec.pool.protocol"
ng-change="ctrl.protocolChange(model.spec.pool.protocol)">
</select> </select>
</div> </div>
</div> </div>

View File

@ -52,6 +52,13 @@
templateUrl: basePath + 'loadbalancers/actions/create/pool/pool.html', templateUrl: basePath + 'loadbalancers/actions/create/pool/pool.html',
helpUrl: basePath + 'loadbalancers/actions/create/pool/pool.help.html', helpUrl: basePath + 'loadbalancers/actions/create/pool/pool.help.html',
formName: 'createLoadBalancerPoolForm' formName: 'createLoadBalancerPoolForm'
},
{
id: 'members',
title: gettext('Pool Members'),
templateUrl: basePath + 'loadbalancers/actions/create/members/members.html',
helpUrl: basePath + 'loadbalancers/actions/create/members/members.help.html',
formName: 'createLoadBalancerMembersForm'
} }
], ],

View File

@ -41,12 +41,13 @@
it('should have steps defined', function () { it('should have steps defined', function () {
expect(createLoadBalancerWorkflow.steps).toBeDefined(); expect(createLoadBalancerWorkflow.steps).toBeDefined();
expect(createLoadBalancerWorkflow.steps.length).toBe(3); expect(createLoadBalancerWorkflow.steps.length).toBe(4);
var forms = [ var forms = [
'createLoadBalancerDetailsForm', 'createLoadBalancerDetailsForm',
'createLoadBalancerListenerForm', 'createLoadBalancerListenerForm',
'createLoadBalancerPoolForm' 'createLoadBalancerPoolForm',
'createLoadBalancerMembersForm'
]; ];
forms.forEach(function(expectedForm, idx) { forms.forEach(function(expectedForm, idx) {

View File

@ -22,8 +22,7 @@
LoadBalancerDetailController.$inject = [ LoadBalancerDetailController.$inject = [
'horizon.app.core.openstack-service-api.lbaasv2', 'horizon.app.core.openstack-service-api.lbaasv2',
'$routeParams', '$routeParams'
'$window'
]; ];
/** /**
@ -35,15 +34,13 @@
* *
* @param api The LBaaS v2 API service. * @param api The LBaaS v2 API service.
* @param $routeParams The angular $routeParams service. * @param $routeParams The angular $routeParams service.
* @param $window The angular reference to the browser window object.
* @returns undefined * @returns undefined
*/ */
function LoadBalancerDetailController(api, $routeParams, $window) { function LoadBalancerDetailController(api, $routeParams) {
var ctrl = this; var ctrl = this;
ctrl.loadbalancer = {}; ctrl.loadbalancer = {};
ctrl.webroot = $window.webroot;
var loadbalancerId = $routeParams.loadbalancerId; var loadbalancerId = $routeParams.loadbalancerId;

View File

@ -39,13 +39,13 @@
<div> <div>
<dt translate>Subnet ID</dt> <dt translate>Subnet ID</dt>
<dd> <dd>
<a target="_self" ng-href="{$ ::ctrl.webroot $}project/networks/subnets/{$ ::ctrl.loadbalancer.vip_subnet_id $}/detail">{$ ::ctrl.loadbalancer.vip_subnet_id $}</a> <a target="_self" ng-href="project/networks/subnets/{$ ::ctrl.loadbalancer.vip_subnet_id $}/detail">{$ ::ctrl.loadbalancer.vip_subnet_id $}</a>
</dd> </dd>
</div> </div>
<div> <div>
<dt translate>Port ID</dt> <dt translate>Port ID</dt>
<dd> <dd>
<a target="_self" ng-href="{$ ::ctrl.webroot $}project/networks/ports/{$ ::ctrl.loadbalancer.vip_port_id $}/detail">{$ ::ctrl.loadbalancer.vip_port_id $}</a> <a target="_self" ng-href="project/networks/ports/{$ ::ctrl.loadbalancer.vip_port_id $}/detail">{$ ::ctrl.loadbalancer.vip_port_id $}</a>
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@ -21,8 +21,6 @@
.controller('LoadBalancersTableController', LoadBalancersTableController); .controller('LoadBalancersTableController', LoadBalancersTableController);
LoadBalancersTableController.$inject = [ LoadBalancersTableController.$inject = [
'$scope',
'$window',
'horizon.app.core.openstack-service-api.lbaasv2', 'horizon.app.core.openstack-service-api.lbaasv2',
'horizon.dashboard.project.lbaasv2.loadbalancers.actions.batchActions' 'horizon.dashboard.project.lbaasv2.loadbalancers.actions.batchActions'
]; ];
@ -34,20 +32,17 @@
* @description * @description
* Controller for the LBaaS v2 load balancers table. Serves as the focal point for table actions. * Controller for the LBaaS v2 load balancers table. Serves as the focal point for table actions.
* *
* @param $scope The angular $scope object.
* @param $window The angular reference to the browser window object.
* @param api The LBaaS V2 service API. * @param api The LBaaS V2 service API.
* @param batchActions The load balancer batch actions service. * @param batchActions The load balancer batch actions service.
* @returns undefined * @returns undefined
*/ */
function LoadBalancersTableController($scope, $window, api, batchActions) { function LoadBalancersTableController(api, batchActions) {
var ctrl = this; var ctrl = this;
ctrl.items = []; ctrl.items = [];
ctrl.src = []; ctrl.src = [];
ctrl.checked = {}; ctrl.checked = {};
ctrl.webroot = $window.webroot;
ctrl.batchActions = batchActions; ctrl.batchActions = batchActions;
init(); init();

View File

@ -18,7 +18,7 @@
Table-batch-actions: Table-batch-actions:
This is where batch actions like searching, creating, and deleting. This is where batch actions like searching, creating, and deleting.
--> -->
<th colspan="100" class="search-header"> <th colspan="0" class="search-header">
<hz-search-bar group-classes="input-group-sm" icon-classes="fa-search"> <hz-search-bar group-classes="input-group-sm" icon-classes="fa-search">
<actions allowed="table.batchActions.actions" type="batch"></actions> <actions allowed="table.batchActions.actions" type="batch"></actions>
</hz-search-bar> </hz-search-bar>
@ -71,7 +71,7 @@
duration="200"> duration="200">
</span> </span>
</td> </td>
<td class="rsp-p1"><a ng-href="{$ ::ctrl.webroot $}project/ngloadbalancersv2/detail/{$ ::item.id $}">{$ item.name $}</a></td> <td class="rsp-p1"><a ng-href="project/ngloadbalancersv2/detail/{$ ::item.id $}">{$ item.name $}</a></td>
<td class="rsp-p1">{$ item.description | noValue $}</td> <td class="rsp-p1">{$ item.description | noValue $}</td>
<td class="rsp-p1">{$ item.operating_status | operatingStatus $}</td> <td class="rsp-p1">{$ item.operating_status | operatingStatus $}</td>
<td class="rsp-p1">{$ item.provisioning_status | provisioningStatus $}</td> <td class="rsp-p1">{$ item.provisioning_status | provisioningStatus $}</td>
@ -86,7 +86,7 @@
Can be toggled using the chevron button. Can be toggled using the chevron button.
Ensure colspan is greater or equal to number of column-headers. Ensure colspan is greater or equal to number of column-headers.
--> -->
<td class="detail" colspan="100"> <td class="detail" colspan="0">
<!-- <!--
The responsive columns that disappear typically should reappear here The responsive columns that disappear typically should reappear here
with the same responsive priority that they disappear. with the same responsive priority that they disappear.