Add the capability to associate ports with portgroups

The create/edit port elements have been modified to enable
specification of an associated portgroup. The portgroup table in
the node-detail/configuration tab has been modeified to show the
number of ports for each portgroup.

Change-Id: I851b07110bcf85cce8ba1351509d4a8afcc9cd60
This commit is contained in:
Peter Piela 2017-07-05 09:24:10 -04:00
parent 8b1d09c454
commit 386b6783cf
15 changed files with 184 additions and 74 deletions

View File

@ -357,3 +357,15 @@ def portgroup_delete(request, portgroup_id):
http://docs.openstack.org/developer/python-ironicclient/api/ironicclient.v1.portgroup.html#ironicclient.v1.portgroup.PortgroupManager.delete http://docs.openstack.org/developer/python-ironicclient/api/ironicclient.v1.portgroup.html#ironicclient.v1.portgroup.PortgroupManager.delete
""" """
return ironicclient(request).portgroup.delete(portgroup_id) return ironicclient(request).portgroup.delete(portgroup_id)
def portgroup_get_ports(request, portgroup_id):
"""Get the ports associated with a specified portgroup.
:param request: HTTP request.
:param portgroup_id: The UUID or name of the portgroup.
:return: List of ports.
http://docs.openstack.org/developer/python-ironicclient/api/ironicclient.v1.portgroup.html#ironicclient.v1.portgroup.PortgroupManager.list_ports
"""
return ironicclient(request).portgroup.list_ports(portgroup_id)

View File

@ -379,3 +379,23 @@ class Portgroups(generic.View):
""" """
return ironic.portgroup_delete(request, return ironic.portgroup_delete(request,
request.DATA.get('portgroup_id')) request.DATA.get('portgroup_id'))
@urls.register
class PortgroupPorts(generic.View):
url_regex = r'ironic/portgroups/(?P<portgroup_id>{})/ports$'. \
format(LOGICAL_NAME_PATTERN)
@rest_utils.ajax()
def get(self, request, portgroup_id):
"""Get the ports for a specified portgroup.
:param request: HTTP request.
:param node_id: UUID or name of portgroup.
:return: List of port objects.
"""
ports = ironic.portgroup_get_ports(request, portgroup_id)
return {
'ports': [i.to_dict() for i in ports]
}

View File

@ -28,7 +28,9 @@
'horizon.dashboard.admin.ironic.validMacAddressPattern', 'horizon.dashboard.admin.ironic.validMacAddressPattern',
'horizon.dashboard.admin.ironic.validDatapathIdPattern', 'horizon.dashboard.admin.ironic.validDatapathIdPattern',
'horizon.dashboard.admin.ironic.form-field.service', 'horizon.dashboard.admin.ironic.form-field.service',
'ctrl' 'horizon.app.core.openstack-service-api.ironic',
'ctrl',
'node'
]; ];
/** /**
@ -138,9 +140,12 @@
validMacAddressPattern, validMacAddressPattern,
validDatapathIdPattern, validDatapathIdPattern,
formFieldService, formFieldService,
ctrl) { ironic,
ctrl,
node) {
ctrl.port = { ctrl.port = {
extra: {} extra: {},
node_uuid: node.uuid
}; };
ctrl.address = new formFieldService.FormField({ ctrl.address = new formFieldService.FormField({
@ -162,12 +167,34 @@
options: ['True', 'False'], options: ['True', 'False'],
value: 'True'}); value: 'True'});
ctrl.portgroup_uuid = new formFieldService.FormField({
type: "select",
id: "portgroup-uuid",
title: gettext("Portgroup"),
desc: gettext("Portgroup that this port belongs to"),
portgroups: [],
options: "portgroup.uuid as portgroup.name ? portgroup.name : portgroup.uuid for portgroup in field.portgroups", // eslint-disable-line max-len
value: null});
// Object used to manage local-link-connection form fields // Object used to manage local-link-connection form fields
ctrl.localLinkConnection = ctrl.localLinkConnection =
new LocalLinkConnectionMgr(formFieldService, new LocalLinkConnectionMgr(formFieldService,
validMacAddressPattern, validMacAddressPattern,
validDatapathIdPattern); validDatapathIdPattern);
ironic.getPortgroups(node.uuid).then(function(portgroups) {
var field = ctrl.portgroup_uuid;
if (portgroups.length > 0) {
field.portgroups.push({uuid: null, name: gettext("Select a portgroup")});
}
field.portgroups = field.portgroups.concat(portgroups);
if (portgroups.length === 0) {
field.disable();
}
});
/** /**
* Cancel the modal * Cancel the modal
* *

View File

@ -12,6 +12,7 @@
<form id="CreatePortForm" name="CreatePortForm"> <form id="CreatePortForm" name="CreatePortForm">
<form-field field="ctrl.address" form="CreatePortForm"></form-field> <form-field field="ctrl.address" form="CreatePortForm"></form-field>
<form-field field="ctrl.pxeEnabled" form="CreatePortForm"></form-field> <form-field field="ctrl.pxeEnabled" form="CreatePortForm"></form-field>
<form-field field="ctrl.portgroup_uuid" form="CreatePortForm"></form-field>
</form> </form>
<form id="LocalLinkConnectionForm" <form id="LocalLinkConnectionForm"

View File

@ -42,6 +42,7 @@
$controller('BasePortController', $controller('BasePortController',
{ctrl: ctrl, {ctrl: ctrl,
node: node,
$uibModalInstance: $uibModalInstance}); $uibModalInstance: $uibModalInstance});
ctrl.modalTitle = gettext("Create Port"); ctrl.modalTitle = gettext("Create Port");
@ -54,7 +55,6 @@
*/ */
ctrl.createPort = function() { ctrl.createPort = function() {
var port = angular.copy(ctrl.port); var port = angular.copy(ctrl.port);
port.node_uuid = node.id;
port.address = ctrl.address.value; port.address = ctrl.address.value;
@ -67,6 +67,10 @@
port.pxe_enabled = ctrl.pxeEnabled.value; port.pxe_enabled = ctrl.pxeEnabled.value;
} }
if (ctrl.portgroup_uuid.value !== null) {
port.portgroup_uuid = ctrl.portgroup_uuid.value;
}
ironic.createPort(port).then( ironic.createPort(port).then(
function(createdPort) { function(createdPort) {
$rootScope.$emit(ironicEvents.CREATE_PORT_SUCCESS); $rootScope.$emit(ironicEvents.CREATE_PORT_SUCCESS);

View File

@ -51,6 +51,7 @@
var ctrl = this; var ctrl = this;
$controller('BasePortController', $controller('BasePortController',
{ctrl: ctrl, {ctrl: ctrl,
node: node,
$uibModalInstance: $uibModalInstance}); $uibModalInstance: $uibModalInstance});
ctrl.modalTitle = gettext("Edit Port"); ctrl.modalTitle = gettext("Edit Port");
@ -69,8 +70,15 @@
} }
ctrl.pxeEnabled.value = port.pxe_enabled ? 'True' : 'False'; ctrl.pxeEnabled.value = port.pxe_enabled ? 'True' : 'False';
ctrl.portgroup_uuid.value = port.portgroup_uuid;
if (cannotEditConnectivityAttr) { if (cannotEditConnectivityAttr) {
ctrl.pxeEnabled.disable(UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG); angular.forEach(
[ctrl.pxeEnabled, ctrl.portgroup_uuid],
function(field) {
field.disable(UNABLE_TO_UPDATE_CONNECTIVITY_ATTR_MSG);
});
} }
ctrl.localLinkConnection.setValues( ctrl.localLinkConnection.setValues(
@ -101,6 +109,9 @@
ctrl.localLinkConnection.toPortAttr(), ctrl.localLinkConnection.toPortAttr(),
"/local_link_connection"); "/local_link_connection");
patcher.buildPatch(port.extra, ctrl.port.extra, "/extra"); patcher.buildPatch(port.extra, ctrl.port.extra, "/extra");
patcher.buildPatch(port.portgroup_uuid,
ctrl.portgroup_uuid.value,
"/portgroup_uuid");
var patch = patcher.getPatch(); var patch = patcher.getPatch();
$log.info("patch = " + JSON.stringify(patch.patch)); $log.info("patch = " + JSON.stringify(patch.patch));

View File

@ -3,15 +3,24 @@
form[field.id].$dirty}"> form[field.id].$dirty}">
<label for="{$ field.id $}" <label for="{$ field.id $}"
class="control-label">{$ field.title $}</label> class="control-label">{$ field.title $}</label>
<span ng-if="field.getHelpText()" <span ng-if="field.desc"
class="help-icon" class="help-icon"
data-container="body" data-container="body"
data-html="true" data-html="true"
title="" title=""
data-toggle="tooltip" data-toggle="tooltip"
data-original-title="{$ field.getHelpText() $}"> data-original-title="{$ field.desc $}">
<span class="fa fa-question-circle"></span> <span class="fa fa-question-circle"></span>
</span> </span>
<span ng-if="field.info"
class="help-icon"
data-container="body"
data-html="true"
title=""
data-toggle="tooltip"
data-original-title="{$ field.info $}">
<span class="fa fa-info-circle"></span>
</span>
<span ng-if="field.required" <span ng-if="field.required"
class="hz-icon-required fa fa-asterisk"></span> class="hz-icon-required fa fa-asterisk"></span>
<div ng-switch="field.type"> <div ng-switch="field.type">
@ -24,8 +33,8 @@
ng-required="field.required" ng-required="field.required"
ng-disabled="field.disabled" ng-disabled="field.disabled"
ng-pattern="field.pattern" ng-pattern="field.pattern"
ng-change="field.change()" ng-change="{$ field.change $}"
placeholder="{$ field.getHelpText() $}"/> placeholder="{$ field.desc $}"/>
<div ng-switch-when="radio" <div ng-switch-when="radio"
class="btn-group" class="btn-group"
id="{$ field.id $}"> id="{$ field.id $}">

View File

@ -84,31 +84,6 @@
return angular.isDefined(this.value) && this.value !== ''; return angular.isDefined(this.value) && this.value !== '';
}; };
/**
* @description Test whether the field has help-text.
*
* @return {boolean} Return true if the field has help text.
*/
this.hasHelpText = function() {
return angular.isDefined(this.desc) || angular.isDefined(this.info);
};
/**
* @description Get the help-text associated with this field
*
* @return {string} Return true if the field has help text
*/
this.getHelpText = function() {
var text = angular.isDefined(this.desc) ? this.desc : '';
if (angular.isDefined(this.info)) {
if (text !== '') {
text += '<br><br>';
}
text += this.info;
}
return text;
};
/** /**
* @description Disable this field. * @description Disable this field.
* *

View File

@ -53,7 +53,8 @@
expect(field.info).toBeUndefined(); expect(field.info).toBeUndefined();
expect(field.autoFocus).toBe(false); expect(field.autoFocus).toBe(false);
expect(field.change).toBeUndefined(); expect(field.change).toBeUndefined();
expect(formFieldService).toBeDefined(); expect(field.hasValue).toBeDefined();
expect(field.disable).toBeDefined();
}); });
it('FormField - local parameters', function() { it('FormField - local parameters', function() {
@ -79,37 +80,6 @@
expect(field.hasValue()).toBe(true); expect(field.hasValue()).toBe(true);
}); });
it('hasHelpText', function() {
var field = new formFieldService.FormField({});
expect(field.hasHelpText()).toBe(false);
expect(field.getHelpText()).toBe('');
});
it('hasHelpText/getHelpText - desc', function() {
var field = new formFieldService.FormField({
desc: 'desc'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('desc');
});
it('hasHelpText/getHelpText - info', function() {
var field = new formFieldService.FormField({
info: 'info'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('info');
});
it('getHelpText - desc/info', function() {
var field = new formFieldService.FormField({
desc: 'desc',
info: 'info'
});
expect(field.hasHelpText()).toBe(true);
expect(field.getHelpText()).toBe('desc<br><br>info');
});
it('disable', function() { it('disable', function() {
var field = new formFieldService.FormField({}); var field = new formFieldService.FormField({});
expect(field.disabled).toBe(false); expect(field.disabled).toBe(false);

View File

@ -655,7 +655,27 @@
} }
return [status, ""]; return [status, ""];
}); });
}
// Get portgroup ports
$httpBackend.whenGET(/\/api\/ironic\/portgroups\/([^\/]+)\/ports$/,
undefined,
['portgroupId'])
.respond(function(method, url, data, headers, params) {
var ports = [];
var status = responseCode.RESOURCE_NOT_FOUND;
if (angular.isDefined(portgroups[params.portgroupId])) {
var portgroup = portgroups[params.portgroupId];
var node = nodes[portgroup.node_uuid];
angular.forEach(node.ports, function(port) {
if (port.portgroup_uuid === portgroup.uuid) {
ports.push(port);
}
});
status = responseCode.SUCCESS;
}
return [status, {ports: ports}];
});
} // init()
/** /**
* @description Get the list of supported drivers * @description Get the list of supported drivers
@ -694,6 +714,5 @@
$httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingExpectation();
$httpBackend.verifyNoOutstandingRequest(); $httpBackend.verifyNoOutstandingRequest();
} }
} } // ironicBackendMockService()
})();
}());

View File

@ -63,7 +63,8 @@
validateNode: validateNode, validateNode: validateNode,
createPortgroup: createPortgroup, createPortgroup: createPortgroup,
getPortgroups: getPortgroups, getPortgroups: getPortgroups,
deletePortgroup: deletePortgroup deletePortgroup: deletePortgroup,
getPortgroupPorts: getPortgroupPorts
}; };
return service; return service;
@ -644,5 +645,29 @@
return $q.reject(msg); return $q.reject(msg);
}); });
} }
/**
* @description Get the ports associated with a specified portgroup.
*
* http://developer.openstack.org/api-ref/baremetal/#list-ports-by-portgroup
*
* @param {string} portgroupId UUID or name of the portgroup.
* @return {promise} Promise containing a list of ports.
*/
function getPortgroupPorts(portgroupId) {
return apiService.get(
'/api/ironic/portgroups/' + portgroupId + '/ports')
.then(function(response) {
return response.data.ports; // List of ports
})
.catch(function(response) {
var msg = interpolate(
gettext('Unable to retrieve portgroup ports: %s'),
[response.data],
false);
toastService.add('error', msg);
return $q.reject(msg);
});
}
} }
}()); }());

View File

@ -28,6 +28,7 @@
'getDriverProperties', 'getDriverProperties',
'getNode', 'getNode',
'getNodes', 'getNodes',
'getPortgroupPorts',
'getPortgroups', 'getPortgroups',
'getPortsWithNode', 'getPortsWithNode',
'getBootDevice', 'getBootDevice',
@ -576,6 +577,24 @@
ironicBackendMockService.flush(); ironicBackendMockService.flush();
}); });
it('getPortgroupPorts', function() {
createNode({driver: defaultDriver})
.then(function(node) {
return ironicAPI.createPortgroup({node_uuid: node.uuid});
})
.then(function(portgroup) {
expect(portgroup).toBeDefined();
expect(portgroup)
.toEqual(ironicBackendMockService.getPortgroup(portgroup.uuid));
ironicAPI.getPortgroupPorts(portgroup.uuid).then(function(ports) {
expect(ports).toEqual([]);
});
})
.catch(failTest);
ironicBackendMockService.flush();
});
}); });
}); });
})(); })();

View File

@ -182,6 +182,12 @@
function retrievePortgroups() { function retrievePortgroups() {
ironic.getPortgroups(ctrl.node.uuid).then(function(portgroups) { ironic.getPortgroups(ctrl.node.uuid).then(function(portgroups) {
ctrl.portgroupsSrc = portgroups; ctrl.portgroupsSrc = portgroups;
angular.forEach(portgroups, function(portgroup) {
portgroup.ports = [];
ironic.getPortgroupPorts(portgroup.uuid).then(function(ports) {
portgroup.ports = ports;
});
});
}); });
} }

View File

@ -168,6 +168,9 @@
<th translate class="rsp-p2" style="width:white-space:nowrap;"> <th translate class="rsp-p2" style="width:white-space:nowrap;">
Name Name
</th> </th>
<th translate class="rsp-p2" style="width:white-space:nowrap;">
Ports
</th>
<th translate class="actions_column"> <th translate class="actions_column">
Actions Actions
</th> </th>
@ -194,6 +197,9 @@
<td class="rsp-p1"> <td class="rsp-p1">
{$ portgroup.name | noValue $} {$ portgroup.name | noValue $}
</td> </td>
<td class="rsp-p1">
{$ portgroup.ports.length | noValue $}
</td>
<td class="actions_column"> <td class="actions_column">
<action-list uib-dropdown class="pull-right"> <action-list uib-dropdown class="pull-right">
<action button-type="split-button" <action button-type="split-button"
@ -203,9 +209,11 @@
{$ ::'Edit portgroup' | translate $} {$ ::'Edit portgroup' | translate $}
</action> </action>
<menu> <menu>
<li role="presentation"> <li role="presentation"
ng-class="{disabled: portgroup.ports.length > 0}">
<a role="menuitem" <a role="menuitem"
ng-click="ctrl.deletePortgroups([portgroup]); ng-click="portgroup.ports.length > 0 ||
ctrl.deletePortgroups([portgroup]);
$event.stopPropagation(); $event.stopPropagation();
$event.preventDefault()"> $event.preventDefault()">
<span class="fa fa-trash"></span> <span class="fa fa-trash"></span>

View File

@ -140,7 +140,11 @@
if (isProperty(source) && isProperty(target)) { if (isProperty(source) && isProperty(target)) {
if (source !== target) { if (source !== target) {
patcher.patch.push({op: "replace", path: path, value: target}); if (target === null) {
patcher.patch.push({op: "remove", path: path});
} else {
patcher.patch.push({op: "replace", path: path, value: target});
}
} }
} else if (isCollection(source) && isCollection(target)) { } else if (isCollection(source) && isCollection(target)) {
angular.forEach(source, function(sourceItem, sourceItemName) { angular.forEach(source, function(sourceItem, sourceItemName) {