Lock interfaces properties with different availability batch editing

When interfaces are edited in bulk for multiple nodes they might support
different set of properties/functions (e.g. dpdk or sriov). Current
behavior presumes these sets are similar for all nodes/interfaces and
no additional validation is performed. Due to this it is possible now
to modify properties that interface does not support.

This fix compares interfaces properties across the nodes and determines
intersected properties which can be modified in bulk. Other properties
get locked for editing and rendered with the lock icon.

Methods for changes detection and application are affected also not
to respect properties with different availability.

Change-Id: I38b0c8e73351dc85757f418ae413c9356b1c4936
Closes-Bug: #1561458
This commit is contained in:
Nikolay Bogdanov 2016-04-01 18:05:02 +03:00
parent ac86398ca1
commit 49f2d9b41d
4 changed files with 216 additions and 38 deletions

View File

@ -3760,6 +3760,16 @@ input[type=range] {
padding-left: 0;
margin-left: @property-offset;
}
&.forbidden {
opacity: 0.8;
.glyphicon {
margin-right: 4px;
}
button {
text-decoration: none;
color: @gray;
}
}
}
}
.mtu-control {

View File

@ -450,7 +450,9 @@
"physical_network": "Physical Network Name",
"dpdk": "DPDK",
"dpdk_description": "The Data Plane Development Kit (DPDK) provides high-performance packet processing libraries and user space drivers.",
"dpdk_in_ovs_bond": "DPDK can not be disabled in OVS bond"
"dpdk_in_ovs_bond": "DPDK can not be disabled in OVS bond",
"different_availability": "<Different Availability>",
"availability_tooltip": "Some network interfaces do not support this feature, therefore, these properties will not change after saving."
},
"configure_disks": {
"no_disks": "No unassigned disks available.",

View File

@ -324,6 +324,15 @@ var utils = {
},
makePath(...args) {
return args.join('.');
},
deepOmit(object, keys) {
if (!_.isObject(object) && !_.isArray(object)) {
return object;
}
return _.reduce(_.difference(_.keys(object), keys), (result, key) => {
result[key] = this.deepOmit(object[key], keys);
return result;
}, _.isArray(object) ? [] : {});
}
};

View File

@ -23,7 +23,7 @@ import models from 'models';
import dispatcher from 'dispatcher';
import Expression from 'expression';
import OffloadingModes from 'views/cluster_page_tabs/nodes_tab_screens/offloading_modes_control';
import {Input} from 'views/controls';
import {Input, Tooltip} from 'views/controls';
import {backboneMixin, unsavedChangesMixin} from 'component_mixins';
import {DragSource, DropTarget} from 'react-dnd';
import ReactDOM from 'react-dom';
@ -83,11 +83,48 @@ var EditNodeInterfacesScreen = React.createClass({
};
},
componentWillMount() {
this.setState({initialInterfaces: _.cloneDeep(this.interfacesToJSON(this.props.interfaces))});
this.setState({
initialInterfaces: _.cloneDeep(this.interfacesToJSON(this.props.interfaces)),
limitations: this.getEditLimitations()
});
},
componentDidMount() {
this.validate();
},
compareInterfacesProperties(interfaces, path, iteratee = _.identity,
source = 'interface_properties') {
// Checks if all the sub parameters are equal for all interfaces property
var ifcProperties = _.map(interfaces.map((ifc) => {
var interfaceProperty = ifc.get(source);
return _.get(interfaceProperty, path, interfaceProperty);
}), iteratee);
var shown = _.first(ifcProperties);
var equal = !_.any(ifcProperties, (ifcProperty) => !_.isEqual(ifcProperty, shown));
return {equal, shown};
},
getEditLimitations() {
// Gets limitations for interfaces parameters editing.
// Parameter should not be editable if its differently available
// across the nodes interfaces
return this.props.interfaces.reduce((result, ifc, index) => {
var interfaces = this.props.nodes.map((node) => {
return node.interfaces.at(index);
});
var key = ifc.isBond() ? ifc.get('name') : ifc.id;
result[key] = {
offloading_modes: this.compareInterfacesProperties(
interfaces, '',
(value) => utils.deepOmit(value, ['state']),
'offloading_modes'
),
dpdk: this.compareInterfacesProperties(interfaces, 'dpdk.available'),
sriov: this.compareInterfacesProperties(interfaces, 'sriov.available'),
mtu: {equal: true, shown: true}
};
return result;
}, {});
},
isLocked() {
return !!this.props.cluster.task({group: 'deployment', active: true}) ||
!_.all(this.props.nodes.invoke('areInterfacesConfigurable'));
@ -107,15 +144,43 @@ var EditNodeInterfacesScreen = React.createClass({
},
hasChangesInRemainingNodes() {
var initialInterfacesData = _.map(this.state.initialInterfaces, this.interfacesPickFromJSON);
var limitationsKeys = this.props.nodes.at(0).interfaces.map(
(ifc) => ifc.get(ifc.isBond ? 'name' : 'id')
);
return _.any(this.props.nodes.slice(1), (node) => {
var interfacesData = this.interfacesToJSON(node.interfaces, true);
return _.any(initialInterfacesData, (ifcData, index) => {
var limitations = this.state.limitations[limitationsKeys[index]];
var omittedProperties = _.filter(
_.keys(limitations),
(key) => !_.get(limitations[key], 'equal', true)
);
return _.any(ifcData, (data, attribute) => {
if (attribute === 'slaves') {
// bond 'slaves' attribute contains information about slave name only
// but interface names can be different between nodes
// and can not be used for the comparison
return data.length !== (interfacesData[index].slaves || {}).length;
// Restricted parameters should not participate in changes detection
switch (attribute) {
case 'offloading_modes': {
// Do not compare offloading modes if they differ
if (!_.get(limitations, 'offloading_modes.equal', false)) return false;
// otherwise remove set states before it
return !_.isEqual(..._.invoke(
[data, interfacesData[index][attribute]],
(value) => utils.deepOmit(value, ['state']))
);
}
case 'interface_properties': {
// Omit restricted parameters from the comparison
return !_.isEqual(..._.invoke(
[data, interfacesData[index][attribute]],
_.omit, omittedProperties)
);
}
case 'slaves': {
// bond 'slaves' attribute contains information about slave name only
// but interface names can be different between nodes
// and can not be used for the comparison
return data.length !== (interfacesData[index].slaves || {}).length;
}
}
return !_.isEqual(data, interfacesData[index][attribute]);
});
@ -145,6 +210,37 @@ var EditNodeInterfacesScreen = React.createClass({
revertChanges() {
this.props.interfaces.reset(_.cloneDeep(this.state.initialInterfaces), {parse: true});
},
updateWithLimitations(sourceInterface, targetInterface) {
// Interface parameters should be updated with respect to limitations:
// restricted parameters should not be changed
var limitations = this.state.limitations[targetInterface.id];
var targetInterfaceProperties = targetInterface.get('interface_properties');
var sourceInterfaceProperties = sourceInterface.get('interface_properties');
if (targetInterface.get('offloading_modes')
&& _.get(limitations, 'offloading_modes.equal', false)) {
targetInterface.set({
offloading_modes: sourceInterface.get('offloading_modes')
});
// If set of offloading modes supported is the same, disable_offloading
// parameters updated as well (it is probably obsolete)
var disableOffloading = _.get(sourceInterfaceProperties, 'disable_offloading');
if (!_.isUndefined(disableOffloading)) {
_.set(targetInterfaceProperties, 'disable_offloading', disableOffloading);
}
}
_.each(sourceInterfaceProperties, (propertyValue, propertyName) => {
// Set all unrestricted parameters values
if (!_.isPlainObject(propertyValue)
&& _.get(limitations, propertyName + '.equal', false)) {
_.set(targetInterfaceProperties, propertyName, propertyValue);
}
});
targetInterface.set({
interface_properties: sourceInterfaceProperties
});
},
applyChanges() {
if (!this.isSavingPossible()) return $.Deferred().reject();
@ -187,14 +283,9 @@ var EditNodeInterfacesScreen = React.createClass({
ifc.set({
assigned_networks: new models.InterfaceNetworks(
updatedIfc.get('assigned_networks').toJSON()
),
interface_properties: updatedIfc.get('interface_properties')
)
});
if (ifc.get('offloading_modes')) {
ifc.set({
offloading_modes: updatedIfc.get('offloading_modes')
});
}
this.updateWithLimitations(updatedIfc, ifc);
});
return Backbone.sync('update', node.interfaces, {url: _.result(node, 'url') + '/interfaces'});
@ -257,16 +348,21 @@ var EditNodeInterfacesScreen = React.createClass({
this.setState({actionInProgress: true});
var interfaces = this.props.interfaces.filter((ifc) => ifc.get('checked') && !ifc.isBond());
var bond = this.props.interfaces.find((ifc) => ifc.get('checked') && ifc.isBond());
var limitations = this.state.limitations;
var bondName;
if (!bond) {
// if no bond selected - create new one
var bondMode = _.flatten(
_.pluck(this.props.bondingConfig.properties[bondType].mode, 'values')
)[0];
bondName = this.props.interfaces.generateBondName(
bondType === 'linux' ? 'bond' : 'ovs-bond'
);
bond = new models.Interface({
type: 'bond',
name: this.props.interfaces.generateBondName(bondType === 'linux' ? 'bond' : 'ovs-bond'),
name: bondName,
mode: bondMode,
assigned_networks: new models.InterfaceNetworks(),
slaves: _.invoke(interfaces, 'pick', 'name'),
@ -288,9 +384,12 @@ var EditNodeInterfacesScreen = React.createClass({
},
offloading_modes: this.getIntersectedOffloadingModes(interfaces)
});
limitations[bondName] = {};
} else {
// adding interfaces to existing bond
var bondProperties = _.cloneDeep(bond.get('interface_properties'));
bondName = bond.get('name');
if (bondProperties.dpdk.enabled) {
bondProperties.dpdk.enabled = !_.any(interfaces,
(ifc) => !ifc.get('interface_properties').dpdk.enabled
@ -304,18 +403,42 @@ var EditNodeInterfacesScreen = React.createClass({
// remove the bond to add it later and trigger re-rendering
this.props.interfaces.remove(bond, {silent: true});
}
_.each(interfaces, (ifc) => {
limitations[bondName] = _.reduce(interfaces, (result, ifc) => {
bond.get('assigned_networks').add(ifc.get('assigned_networks').models);
ifc.get('assigned_networks').reset();
ifc.set({checked: false});
});
return this.mergeLimitations(result, limitations[ifc.id]);
}, limitations[bondName]);
this.props.interfaces.add(bond);
this.setState({actionInProgress: false});
this.setState({
actionInProgress: false,
limitations: limitations
});
},
mergeLimitations(limitation1, limitation2) {
return _.merge(limitation1, limitation2, (value1, value2, interfaceProperty) => {
switch (interfaceProperty) {
case 'mtu':
case 'offloading_modes':
// Offloading modes are presumed to be calculated intersection
return {equal: true, shown: true};
case 'dpdk':
if (_.isUndefined(value1) || _.isUndefined(value2)) break;
// Both interfaces should support DPDK in order bond to support it either
var equal = true;
var shown = value1.shown && value2.shown;
return {equal: equal, shown: shown};
case 'sriov':
return {equal: true, shown: false};
}
});
},
unbondInterfaces() {
this.setState({actionInProgress: true});
_.each(this.props.interfaces.where({checked: true}), (bond) => {
return this.removeInterfaceFromBond(bond.get('name'));
this.removeInterfaceFromBond(bond.get('name'));
});
this.setState({actionInProgress: false});
},
@ -408,7 +531,7 @@ var EditNodeInterfacesScreen = React.createClass({
return !_.chain(this.state.interfacesErrors).values().some().value() &&
!this.state.actionInProgress && this.hasChanges();
},
getIfcProperty(property) {
getInterfaceProperty(property) {
var {interfaces, nodes} = this.props;
var bondsCount = interfaces.filter((ifc) => ifc.isBond()).length;
var getPropertyValues = (ifcIndex) => {
@ -460,8 +583,8 @@ var EditNodeInterfacesScreen = React.createClass({
this.validateSpeedsForBonding(checkedBonds.concat(checkedInterfaces)) ||
interfaces.any((ifc) => ifc.isBond() && this.validateSpeedsForBonding([ifc]));
var interfaceSpeeds = this.getIfcProperty('current_speed');
var interfaceNames = this.getIfcProperty('name');
var interfaceSpeeds = this.getInterfaceProperty('current_speed');
var interfaceNames = this.getInterfaceProperty('name');
return (
<div className='row'>
@ -515,12 +638,15 @@ var EditNodeInterfacesScreen = React.createClass({
<div className='ifc-list col-xs-12'>
{interfaces.map((ifc, index) => {
var ifcName = ifc.get('name');
var limitations = this.state.limitations[ifc.isBond() ? ifcName : ifc.id];
if (!_.contains(slaveInterfaceNames, ifcName)) {
return (
<NodeInterfaceDropTarget
{...this.props}
key={'interface-' + ifcName}
interface={ifc}
limitations={limitations}
hasChanges={
!_.isEqual(
_.findWhere(this.state.initialInterfaces, {name: ifcName}),
@ -746,42 +872,63 @@ var NodeInterface = React.createClass({
_.set(interfaceProperties, name, value);
this.props.interface.set('interface_properties', interfaceProperties);
},
renderLockTooltip(property) {
return <Tooltip key={property + '-unavailable'} text={i18n(ns + 'availability_tooltip')}>
<span className='glyphicon glyphicon-lock' aria-hidden='true'></span>
</Tooltip>;
},
renderConfigurableAttributes() {
var ifc = this.props.interface;
var limitations = this.props.limitations;
var ifcProperties = ifc.get('interface_properties');
var errors = (this.props.errors || {}).interface_properties;
var offloadingModes = ifc.get('offloading_modes') || [];
var {collapsed, activeInterfaceSectionName} = this.state;
var offloadingRestricted = !limitations.offloading_modes.equal;
var offloadingTabClasses = {
forbidden: offloadingRestricted,
'property-item-container': true,
active: !collapsed && activeInterfaceSectionName === this.renderedIfcProperties[0]
};
return (
<div className='properties-list'>
<span className={utils.classNames(offloadingTabClasses)}>
{offloadingRestricted && this.renderLockTooltip('offloading')}
{i18n(ns + 'offloading_modes') + ':'}
<button
className='btn btn-link property-item'
onClick={() => this.switchActiveSubtab(this.renderedIfcProperties[0])}
disabled={offloadingRestricted}
>
{ifcProperties.disable_offloading ?
i18n(ns + 'disable_offloading')
:
offloadingModes.length ?
this.makeOffloadingModesExcerpt()
{offloadingRestricted ?
i18n(ns + 'different_availability')
:
i18n(ns + 'default_offloading')
offloadingModes.length ?
this.makeOffloadingModesExcerpt()
:
ifcProperties.disable_offloading ?
i18n(ns + 'disable_offloading')
:
i18n(ns + 'default_offloading')
}
</button>
</span>
{_.map(ifcProperties, (propertyValue, propertyName) => {
if (_.isPlainObject(propertyValue) && !propertyValue.available) return null;
var {equal, shown} = _.get(
limitations, propertyName,
{equal: true, shown: true}
);
var propertyShown = !equal || shown;
if (_.isPlainObject(propertyValue) && !propertyShown) return null;
if (_.contains(this.renderedIfcProperties, propertyName)) {
var classes = {
'text-danger': _.has(errors, propertyName),
'property-item-container': true,
[propertyName]: true,
active: !collapsed && activeInterfaceSectionName === propertyName
active: !collapsed && activeInterfaceSectionName === propertyName,
forbidden: !equal
};
var commonButtonProps = {
className: 'btn btn-link property-item',
@ -793,12 +940,17 @@ var NodeInterface = React.createClass({
case 'dpdk':
return (
<span key={propertyName} className={utils.classNames(classes)}>
{!equal && this.renderLockTooltip(propertyName)}
{i18n(ns + propertyName) + ':'}
<button {...commonButtonProps}>
{propertyValue.enabled ?
i18n('common.enabled')
:
i18n('common.disabled')
<button {...commonButtonProps} disabled={!equal}>
{equal ?
propertyValue.enabled ?
i18n('common.enabled')
:
i18n('common.disabled')
:
i18n(ns + 'different_availability')
}
</button>
</span>
@ -806,8 +958,9 @@ var NodeInterface = React.createClass({
default:
return (
<span key={propertyName} className={utils.classNames(classes)}>
{!equal && this.renderLockTooltip(propertyName)}
{i18n(ns + propertyName) + ':'}
<button {...commonButtonProps}>
<button {...commonButtonProps} disabled={!equal}>
{propertyValue || i18n(ns + propertyName + '_placeholder')}
</button>
</span>
@ -966,6 +1119,10 @@ var NodeInterface = React.createClass({
'glyphicon glyphicon-menu-down': true,
rotate: !this.state.collapsed
});
var defaultSubtab = _.find(this.renderedIfcProperties, (ifcProperty) => {
var limitation = _.get(this.props.limitations, ifcProperty);
return limitation && limitation.equal && !!limitation.shown;
});
return (
<div className='ifc-properties clearfix forms-box'>
<div className='row'>
@ -978,7 +1135,7 @@ var NodeInterface = React.createClass({
onClick={() => this.switchActiveSubtab(
isConfigurationModeOn ?
this.state.activeInterfaceSectionName :
this.renderedIfcProperties[0]
defaultSubtab
)}
/>
</div>