Support external members when creating load balancer

This allows adding external members when creating a load balancer
pool.

Partially-Implements: blueprint horizon-lbaas-v2-ui
Change-Id: If32838acfc9f51de4dd90ce6f293fe9b4a541638
This commit is contained in:
Justin Pomeroy 2016-02-04 15:38:49 -06:00
parent f544e0375a
commit c3d7b527d8
10 changed files with 302 additions and 117 deletions

View File

@ -46,24 +46,52 @@
}
}
transfer-table {
.member-weight,
.member-port {
display: inline-block;
width: 6em;
/* Load Balancer Wizard */
.lbaas-wizard {
table {
.member-weight,
.member-port {
width: 6em;
}
.member-address {
width: 20em;
}
div.form-field {
display: inline-block;
}
span.invalid {
vertical-align: top;
margin: 8px 0px 0px 5px;
}
}
.member-address {
width: 20em;
.addresses-popover + .popover {
ul {
list-style-type: disc;
padding-left: 10px;
}
}
span.invalid {
vertical-align: top;
margin: 8px 0px 0px 5px;
/* Pool Members tab */
[ng-form="memberDetailsForm"] {
.transfer-section:first-child {
/* Remove the borders around the last row in the top table that has the
"Add external member" action in it. */
.table-rsp.table-detail tbody tr:nth-last-child(2):not(.expanded) td,
.table-rsp.table-detail tbody tr:last-child:not(.spacer-row) td {
border-bottom: none;
}
/* Remove the striped background on the last row in the top table that has the
"Add external member" action in it. */
.table-rsp.table-detail.table-striped tbody tr:last-child > td {
background: none;
}
}
.transfer-section:last-child {
/* Hide the badge on the bottom table with the instance count. */
.transfer-heading .badge {
display: none;
}
}
}
}
.addresses-popover + .popover {
ul {
list-style-type: disc;
padding-left: 10px;
}
}

View File

@ -24,6 +24,7 @@
'$scope',
'$compile',
'horizon.dashboard.project.lbaasv2.popovers',
'horizon.dashboard.project.lbaasv2.patterns',
'horizon.framework.util.i18n.gettext'
];
@ -35,18 +36,21 @@
* @param $scope The angular scope object.
* @param $compile The angular compile service.
* @param popoverTemplates LBaaS v2 popover templates constant.
* @param patterns The LBaaS v2 patterns constant.
* @param gettext The horizon gettext function for translation.
* @returns undefined
*/
function MemberDetailsController($scope, $compile, popoverTemplates, gettext) {
function MemberDetailsController($scope, $compile, popoverTemplates, patterns, gettext) {
var ctrl = this;
var memberCounter = 0;
// 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.');
ctrl.ipError = gettext('The IP address is not valid.');
// Table widget properties
// Instances transer table widget properties
ctrl.tableData = {
available: $scope.model.members,
allocated: $scope.model.spec.members,
@ -58,15 +62,27 @@
};
ctrl.tableHelp = {
availHelpText: '',
noneAllocText: gettext('Select members from the available members below'),
noneAvailText: gettext('No available members')
noneAllocText: gettext('No members have been allocated'),
noneAvailText: gettext('No available instances'),
allocTitle: gettext('Allocated Members'),
availTitle: gettext('Available Instances')
};
// IP address validation pattern
ctrl.ipPattern = [patterns.ipv4, patterns.ipv6].join('|');
// Functions to control the IP address popover
ctrl.showAddressPopover = showAddressPopover;
ctrl.hideAddressPopover = hideAddressPopover;
ctrl.addressPopoverTarget = addressPopoverTarget;
// Member management
ctrl.allocateExternalMember = allocateExternalMember;
ctrl.allocateMember = allocateMember;
ctrl.deallocateMember = deallocateMember;
ctrl.getSubnetName = getSubnetName;
//////////
function showAddressPopover(event, member) {
@ -91,5 +107,32 @@
function addressPopoverTarget(member) {
return interpolate(gettext('%(ip)s...'), { ip: member.address.ip }, true);
}
function allocateExternalMember() {
var protocol = $scope.model.spec.pool.protocol;
$scope.model.spec.members.push({
id: memberCounter++,
address: null,
subnet: null,
port: { HTTP: 80, HTTPS: 443 }[protocol],
weight: 1
});
}
function allocateMember(member) {
var newMember = angular.extend(angular.copy(member), { id: memberCounter++ });
$scope.model.spec.members.push(newMember);
}
function deallocateMember(member) {
var index = $scope.model.spec.members.indexOf(member);
$scope.model.spec.members.splice(index, 1);
}
function getSubnetName(member) {
return $scope.model.subnets.filter(function filterSubnet(subnet) {
return subnet.id === member.address.subnet;
})[0].name;
}
}
})();

View File

@ -17,38 +17,51 @@
'use strict';
describe('Member Details 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' }]
}];
var model;
beforeEach(module('horizon.framework.util.i18n'));
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(function() {
model = {
spec: {
members: [],
pool: {
protocol: 'HTTP'
}
},
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' }]
}],
subnets: [{
id: '1',
name: 'subnet-1'
}]
};
});
describe('MemberDetailsController', function() {
var ctrl, scope;
var ctrl;
beforeEach(inject(function($controller) {
scope = {
model: {
spec: {
members: []
},
members: members
}
};
ctrl = $controller('MemberDetailsController', { $scope: scope });
ctrl = $controller('MemberDetailsController', { $scope: { model: model } });
}));
it('should define error messages for invalid fields', function() {
expect(ctrl.portError).toBeDefined();
expect(ctrl.weightError).toBeDefined();
expect(ctrl.ipError).toBeDefined();
});
it('should define patterns for validation', function() {
expect(ctrl.ipPattern).toBeDefined();
});
it('should define transfer table properties', function() {
@ -72,9 +85,36 @@
});
it('should properly format address popover target', function() {
var target = ctrl.addressPopoverTarget(members[0]);
var target = ctrl.addressPopoverTarget(model.members[0]);
expect(target).toBe('1.2.3.4...');
});
it('should allocate a new external member', function() {
ctrl.allocateExternalMember();
expect(model.spec.members.length).toBe(1);
expect(model.spec.members[0].id).toBe(0);
expect(model.spec.members[0].address).toBeNull();
expect(model.spec.members[0].subnet).toBeNull();
});
it('should allocate a given member', function() {
ctrl.allocateMember(model.members[0]);
expect(model.spec.members.length).toBe(1);
expect(model.spec.members[0].id).toBe(0);
expect(model.spec.members[0].address).toEqual(model.members[0].address);
expect(model.spec.members[0].subnet).toBeUndefined();
expect(model.spec.members[0].port).toEqual(model.members[0].port);
});
it('should deallocate a given member', function() {
ctrl.deallocateMember(model.spec.members[0]);
expect(model.spec.members.length).toBe(0);
});
it('should show subnet name for available instance', function() {
var name = ctrl.getSubnetName(model.members[0]);
expect(name).toBe('subnet-1');
});
});
describe('Member Details Step Template', function() {
@ -90,21 +130,16 @@
var popoverTemplates = $injector.get('horizon.dashboard.project.lbaasv2.popovers');
var markup = $templateCache.get(basePath + 'workflow/members/members.html');
$scope = $injector.get('$rootScope').$new();
$scope.model = {
spec: {
members: []
},
members: members
};
$scope.model = model;
$element = $compile(markup)($scope);
var popoverScope = $injector.get('$rootScope').$new();
popoverScope.member = members[0];
popoverScope.member = model.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;
ctrl.tableData.displayedAvailable = model.members;
$scope.$apply();
var popoverElement = $element.find('span.addresses-popover');
@ -116,7 +151,7 @@
popoverElement.trigger('mouseover');
expect(ctrl.showAddressPopover).toHaveBeenCalledWith(
jasmine.objectContaining({type: 'mouseover'}), members[0]);
jasmine.objectContaining({type: 'mouseover'}), model.members[0]);
expect($.fn.popover.calls.count()).toBe(2);
expect($.fn.popover.calls.argsFor(0)[0]).toEqual({
content: popoverContent,

View File

@ -1,3 +1,4 @@
<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>
<p translate>The Available Instances table contains existing instances that can be added as members of the pool. Use the "Add external member" button to add a member not found in the Available Instances table.</p>
<p translate>Each member must have a unique combination of IP address and port.</p>

View File

@ -4,7 +4,6 @@
<!--content-->
<div class="content">
<div translate class="subtitle">Add members to the load balancer pool.</div>
<transfer-table tr-model="ctrl.tableData"
limits="::ctrl.tableLimits"
help-text="::ctrl.tableHelp">
@ -16,18 +15,19 @@
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>Subnet</label>
</th>
<th class="rsp-p1"
ng-class="{ 'required': ctrl.tableData.displayedAllocated.length > 0 }">
<label translate>Port</label>
</th>
<th class="rsp-p1" translate>Weight</th>
<th></th>
</tr>
</thead>
@ -39,36 +39,40 @@
</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>
<tr ng-repeat="row in ctrl.tableData.displayedAllocated track by row.id">
<td class="rsp-p1">
<div ng-if="!row.addresses"
class="form-field member-address"
ng-class="{ 'has-error': memberDetailsForm['{$ ::row.id $}-address'].$invalid && memberDetailsForm['{$ ::row.id $}-address'].$dirty }">
<input name="{$ ::row.id $}-address" type="text" class="form-control input-sm"
ng-model="row.address" ng-pattern="::ctrl.ipPattern"
ng-required="true">
</div>
<span ng-if="!row.addresses"
class="fa fa-exclamation-triangle invalid"
ng-show="memberDetailsForm['{$ ::row.id $}-address'].$invalid && memberDetailsForm.$dirty"
popover="{$ ::ctrl.ipError $}"
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
<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">
ng-model="row.address" ng-required="true"
ng-if="row.addresses">
</select>
</div>
</td>
<td class="rsp-p1">
<div class="form-field member-weight"
ng-class="{ 'has-error': memberDetailsForm['{$ ::row.id $}-weight'].$invalid && memberDetailsForm['{$ ::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 ng-if="!row.addresses"
class="form-field">
<select name="{$ ::row.id $}-subnet" class="form-control input-sm"
ng-options="subnet.name for subnet in model.subnets"
ng-model="row.subnet">
</select>
</div>
<span class="fa fa-exclamation-triangle invalid"
ng-show="memberDetailsForm['{$ ::row.id $}-weight'].$invalid && memberDetailsForm.$dirty"
popover="{$ ::ctrl.weightError $}"
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
<span ng-if="row.addresses">{$ ctrl.getSubnetName(row) $}</span>
</td>
<td class="rsp-p1">
<div class="form-field member-port"
@ -83,27 +87,35 @@
popover-placement="top" popover-append-to-body="true"
popover-trigger="hover"></span>
</td>
<td class="rsp-p1">
<div class="form-field member-weight"
ng-class="{ 'has-error': memberDetailsForm['{$ ::row.id $}-weight'].$invalid && memberDetailsForm['{$ ::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="memberDetailsForm['{$ ::row.id $}-weight'].$invalid && memberDetailsForm.$dirty"
popover="{$ ::ctrl.weightError $}"
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>
callback="ctrl.deallocateMember" item="row">
<span translate>Remove</span>
</action>
</action-list>
</td>
</tr>
<tr ng-repeat-end class="detail-row">
<td class="detail" colspan="100">
<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>
<tr>
<td colspan="100">
<action-list class="pull-right">
<action action-classes="'btn btn-sm btn-default'"
callback="ctrl.allocateExternalMember">
<span translate>Add external member</span>
</action>
</action-list>
</td>
</tr>
</tbody>
@ -131,7 +143,7 @@
</tr>
</thead>
<tbody>
<tr ng-if="trCtrl.numAvailable() === 0">
<tr ng-if="ctrl.tableData.available.length === 0">
<td colspan="100">
<div class="no-rows-help">
{$ ::trCtrl.helpText.noneAvailText $}
@ -161,8 +173,8 @@
<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>
callback="ctrl.allocateMember" item="row">
<span translate>Add</span>
</action>
</action-list>
</td>
@ -184,8 +196,6 @@
</tbody>
</table>
</available>
</transfer-table> <!-- End Transfer Table -->
</div> <!-- end content -->
</div>

View File

@ -83,7 +83,8 @@
var spec = {
backdrop: 'static',
controller: 'ModalContainerController',
template: '<wizard ng-controller="' + args.controller + '"></wizard>',
template: '<wizard class="lbaas-wizard" ng-controller="' +
args.controller + '"></wizard>',
windowClass: 'modal-dialog-wizard',
resolve: {
launchContext: function() {

View File

@ -253,16 +253,33 @@
// subnet, and port so we can assume those exist here.
if (!finalSpec.pool || finalSpec.members.length === 0) {
delete finalSpec.members;
return;
}
var 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;
if (member.address && member.port) {
['id', 'name', 'description', 'addresses'].forEach(function deleteProperty(prop) {
if (angular.isDefined(member[prop])) {
delete member[prop];
}
});
if (angular.isObject(member.address)) {
member.subnet = member.address.subnet;
member.address = member.address.ip;
} else if (member.subnet) {
member.subnet = member.subnet.id;
} else {
delete member.subnet;
}
members.push(member);
}
});
if (members.length > 0) {
finalSpec.members = members;
} else {
delete finalSpec.members;
}
}
function cleanFinalSpecMonitor(finalSpec) {

View File

@ -379,6 +379,24 @@
description: 'bar',
port: 80,
weight: 1
}, {
id: 'external-member-0',
address: '2.3.4.5',
subnet: null,
port: 80,
weight: 1
}, {
id: 'external-member-1',
address: null,
subnet: null,
port: 80,
weight: 1
}, {
id: 'external-member-2',
address: '3.4.5.6',
subnet: { id: '1' },
port: 80,
weight: 1
}];
model.spec.monitor.type = 'PING';
model.spec.monitor.interval = 1;
@ -399,7 +417,7 @@
expect(finalSpec.pool.description).toBe('pool description');
expect(finalSpec.pool.protocol).toBe('HTTP');
expect(finalSpec.pool.method).toBe('LEAST_CONNECTIONS');
expect(finalSpec.members.length).toBe(1);
expect(finalSpec.members.length).toBe(3);
expect(finalSpec.members[0].address).toBe('1.2.3.4');
expect(finalSpec.members[0].subnet).toBe('1');
expect(finalSpec.members[0].port).toBe(80);
@ -407,7 +425,16 @@
expect(finalSpec.members[0].addresses).toBeUndefined();
expect(finalSpec.members[0].id).toBeUndefined();
expect(finalSpec.members[0].name).toBeUndefined();
expect(finalSpec.members[0].description).toBeUndefined();
expect(finalSpec.members[1].id).toBeUndefined();
expect(finalSpec.members[1].address).toBe('2.3.4.5');
expect(finalSpec.members[1].subnet).toBeUndefined();
expect(finalSpec.members[1].port).toBe(80);
expect(finalSpec.members[1].weight).toBe(1);
expect(finalSpec.members[2].id).toBeUndefined();
expect(finalSpec.members[2].address).toBe('3.4.5.6');
expect(finalSpec.members[2].subnet).toBe('1');
expect(finalSpec.members[2].port).toBe(80);
expect(finalSpec.members[2].weight).toBe(1);
expect(finalSpec.monitor.type).toBe('PING');
expect(finalSpec.monitor.interval).toBe(1);
expect(finalSpec.monitor.retry).toBe(1);
@ -463,6 +490,27 @@
expect(finalSpec.members).toBeUndefined();
});
it('should delete members if no members are valid', 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';
model.spec.members = [{
id: 'foo',
address: '2.3.4.5',
weight: 1
}];
var finalSpec = model.submit();
expect(finalSpec.loadbalancer).toBeDefined();
expect(finalSpec.listener).toBeDefined();
expect(finalSpec.pool).toBeDefined();
expect(finalSpec.members).toBeUndefined();
});
it('should delete monitor if any required property not set', function() {
model.spec.loadbalancer.ip = '1.2.3.4';
model.spec.loadbalancer.subnet = model.subnets[0];

View File

@ -43,13 +43,11 @@
//////////
function protocolChange(protocol) {
var port = '';
if (protocol === 'HTTP') {
port = 80;
} else if (protocol === 'HTTPS') {
port = 443;
}
$scope.model.members.forEach(function setPort(member) {
var port = { HTTP: 80, HTTPS: 443 }[protocol];
$scope.model.members.forEach(function setAvailableInstancePort(member) {
member.port = port;
});
$scope.model.spec.members.forEach(function setAllocatedMemberPort(member) {
member.port = port;
});
}

View File

@ -18,14 +18,18 @@
describe('Create Pool Details Step', function() {
var ctrl;
var members = [{port: ''}, {port: ''}];
var availableMembers = [{port: ''}, {port: ''}];
var allocatedMembers = [{port: ''}, {port: ''}];
beforeEach(module('horizon.dashboard.project.lbaasv2'));
beforeEach(inject(function($controller) {
var scope = {
model: {
members: members
members: availableMembers,
spec: {
members: allocatedMembers
}
}
};
ctrl = $controller('CreatePoolDetailsController', { $scope: scope });
@ -38,7 +42,7 @@
it('should update member ports on protocol change to HTTP', function() {
ctrl.protocolChange('HTTP');
members.forEach(function(member) {
availableMembers.concat(allocatedMembers).forEach(function(member) {
expect(member.port).toBe(80);
});
});
@ -46,7 +50,7 @@
it('should update member ports on protocol change to HTTPS', function() {
ctrl.protocolChange('HTTPS');
members.forEach(function(member) {
availableMembers.concat(allocatedMembers).forEach(function(member) {
expect(member.port).toBe(443);
});
});
@ -54,8 +58,8 @@
it('should update member ports on protocol change to TCP', function() {
ctrl.protocolChange('TCP');
members.forEach(function(member) {
expect(member.port).toBe('');
availableMembers.concat(allocatedMembers).forEach(function(member) {
expect(member.port).toBeUndefined();
});
});
});