diff --git a/static/styles/main.less b/static/styles/main.less index 2544ebe31..0f8b4e573 100644 --- a/static/styles/main.less +++ b/static/styles/main.less @@ -3770,6 +3770,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 { diff --git a/static/translations/core.json b/static/translations/core.json index 0ed6ae08c..12206f536 100644 --- a/static/translations/core.json +++ b/static/translations/core.json @@ -453,7 +453,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": "", + "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.", diff --git a/static/utils.js b/static/utils.js index f0547d8fd..072e36083 100644 --- a/static/utils.js +++ b/static/utils.js @@ -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) ? [] : {}); } }; diff --git a/static/views/cluster_page_tabs/nodes_tab_screens/edit_node_interfaces_screen.js b/static/views/cluster_page_tabs/nodes_tab_screens/edit_node_interfaces_screen.js index 4b994ef59..9f8e89713 100644 --- a/static/views/cluster_page_tabs/nodes_tab_screens/edit_node_interfaces_screen.js +++ b/static/views/cluster_page_tabs/nodes_tab_screens/edit_node_interfaces_screen.js @@ -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}); }, @@ -407,7 +530,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) => { @@ -459,8 +582,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 (
@@ -514,12 +637,15 @@ var EditNodeInterfacesScreen = React.createClass({
{interfaces.map((ifc, index) => { var ifcName = ifc.get('name'); + var limitations = this.state.limitations[ifc.isBond() ? ifcName : ifc.id]; + if (!_.contains(slaveInterfaceNames, ifcName)) { return ( + + ; + }, 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 (
+ {offloadingRestricted && this.renderLockTooltip('offloading')} {i18n(ns + 'offloading_modes') + ':'} {_.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', @@ -792,12 +939,17 @@ var NodeInterface = React.createClass({ case 'dpdk': return ( + {!equal && this.renderLockTooltip(propertyName)} {i18n(ns + propertyName) + ':'} - @@ -805,8 +957,9 @@ var NodeInterface = React.createClass({ default: return ( + {!equal && this.renderLockTooltip(propertyName)} {i18n(ns + propertyName) + ':'} - @@ -965,6 +1118,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 (
@@ -977,7 +1134,7 @@ var NodeInterface = React.createClass({ onClick={() => this.switchActiveSubtab( isConfigurationModeOn ? this.state.activeInterfaceSectionName : - this.renderedIfcProperties[0] + defaultSubtab )} />