Introduce nodes assignment using NodePicker component

This change finishes the ongoing effor to separate Nodes tagging
and Nodes assignment into separate proceses

* Introduces NodePicker component to allow user to set number of
  Nodes to deploy as a specific Role
* Refactors Nodes assignment related selectors into separate module
* Adds selectors which take nodesAssignment form values into account
  to calculate available Nodes realtime
* Uses huge amount of redux-form functionality to achieve expected behavior
* Nodes Assignment form is submitted each time an input value changes
  without re-rendering all form inputs
* API call which updates the Node count parameters is debounced, so it
  is not issued too many times when user clicks fast to increase Nodes
  count
* Adds custom styling for NodePicker component
* Updates UpdateParameters action to support nodesAssignment form

Closes-Bug: #1640103
Change-Id: If7701190954d54b75421e5c1be6a8382b9f5c746
This commit is contained in:
Jiri Tomasek 2017-03-07 18:44:22 +01:00
parent ad6314526b
commit bd07eb24c0
19 changed files with 398 additions and 169 deletions

View File

@ -1,11 +1,11 @@
import { fromJS, Map } from 'immutable';
import * as selectors from '../../js/selectors/nodes';
import * as selectors from '../../js/selectors/nodesAssignment';
import { Port } from '../../js/immutableRecords/nodes';
import { Role, RolesState } from '../../js/immutableRecords/roles';
import { Parameter } from '../../js/immutableRecords/parameters';
import { Parameter, ParametersDefaultState } from '../../js/immutableRecords/parameters';
describe('Nodes selectors', () => {
describe('Nodes Assignment selectors', () => {
const state = {
nodes: Map({
isFetching: false,
@ -113,6 +113,18 @@ describe('Nodes selectors', () => {
identifier: 'block-storage'
})
})
}),
parameters: new ParametersDefaultState({
parameters: Map({
ControllerCount: new Parameter({
name: 'ControllerCount',
default: 2
}),
ComputeCount: new Parameter({
name: 'ComputeCount',
default: 1
})
})
})
};
@ -286,4 +298,15 @@ describe('Nodes selectors', () => {
expect(result.get('block-storage')).toEqual(0);
});
});
it('getRoleCountParameterByRole', () => {
const nodeCountParametersByRole = selectors.getNodeCountParametersByRole(state);
expect(nodeCountParametersByRole.get('control').default).toEqual(2);
expect(nodeCountParametersByRole.get('compute').default).toEqual(1);
});
it('getTotalAssignedNodesCount', () => {
const totalAssignedNodesCount = selectors.getTotalAssignedNodesCount(state);
expect(totalAssignedNodesCount).toEqual(3);
});
});

View File

@ -89,14 +89,6 @@ describe(' validations selectors', () => {
}),
Service1NestedResourceParameter1: new Parameter({
name: 'Service1NestedResourceParameter1'
}),
ControllerCount: new Parameter({
name: 'ControllerCount',
default: 2
}),
ComputeCount: new Parameter({
name: 'ComputeCount',
default: 1
})
}),
mistralParameters: Map()
@ -104,7 +96,7 @@ describe(' validations selectors', () => {
};
it('getParametersExclInternal', () => {
expect(selectors.getParametersExclInternal(state).size).toEqual(7);
expect(selectors.getParametersExclInternal(state).size).toEqual(5);
});
it('getRootParameters', () => {
@ -130,15 +122,4 @@ describe(' validations selectors', () => {
it('getRoleNetworkConfig', () => {
expect(selectors.getRoleNetworkConfig(state, 'control').name).toEqual('NetworkConfigResource');
});
it('getRoleCountParameterByRole', () => {
const nodeCountParametersByRole = selectors.getNodeCountParametersByRole(state);
expect(nodeCountParametersByRole.get('control').default).toEqual(2);
expect(nodeCountParametersByRole.get('compute').default).toEqual(1);
});
it('getTotalAssignedNodesCount', () => {
const totalAssignedNodesCount = selectors.getTotalAssignedNodesCount(state);
expect(totalAssignedNodesCount).toEqual(3);
});
});

View File

@ -3,6 +3,7 @@ import { normalize } from 'normalizr';
import { browserHistory } from 'react-router';
import uuid from 'node-uuid';
import * as _ from 'lodash';
import { startSubmit, stopSubmit } from 'redux-form';
import NotificationActions from '../actions/NotificationActions';
import ParametersConstants from '../constants/ParametersConstants';
@ -90,11 +91,13 @@ export default {
updateParameters(planName, data, inputFieldNames, url) {
return (dispatch, getState, { getIntl }) => {
const { formatMessage } = getIntl(getState());
dispatch(startSubmit('nodesAssignment'));
dispatch(this.updateParametersPending());
MistralApiService.runAction(MistralConstants.PARAMETERS_UPDATE,
{ container: planName, parameters: data })
.then(response => {
dispatch(this.updateParametersSuccess(data));
dispatch(stopSubmit('nodesAssignment'));
dispatch(NotificationActions.notify({
title: formatMessage(messages.parametersUpdatedNotficationTitle),
message: formatMessage(messages.parametersUpdatedNotficationMessage),
@ -104,6 +107,7 @@ export default {
}).catch(error => {
logger.error('Error in ParametersActions.updateParameters', error);
let errorHandler = new MistralApiErrorHandler(error, inputFieldNames);
dispatch(stopSubmit('nodesAssignment', { _error: error }));
dispatch(this.updateParametersFailed(errorHandler.errors, errorHandler.formFieldErrors));
});
};

View File

@ -8,9 +8,9 @@ import { getCurrentStack,
getCurrentStackDeploymentProgress,
getCurrentStackDeploymentInProgress } from '../../selectors/stacks';
import { getAvailableNodes,
getAvailableNodesCountsByRole } from '../../selectors/nodes';
import { getNodeCountParametersByRole,
getTotalAssignedNodesCount } from '../../selectors/parameters';
getAvailableNodesCountsByRole,
getNodeCountParametersByRole,
getTotalAssignedNodesCount } from '../../selectors/nodesAssignment';
import { getEnvironmentConfigurationSummary } from '../../selectors/environmentConfiguration';
import { getCurrentPlan } from '../../selectors/plans';
import { getRoles } from '../../selectors/roles';

View File

@ -8,8 +8,8 @@ import React from 'react';
import { List, Map } from 'immutable';
import { getAvailableNodesByRole,
getUntaggedAvailableNodes,
getNodesOperationInProgress } from '../../selectors/nodes';
getUntaggedAvailableNodes } from '../../selectors/nodesAssignment';
import { getNodesOperationInProgress } from '../../selectors/nodes';
import { getRoles } from '../../selectors/roles';
import { getCurrentPlan } from '../../selectors/plans';
import FormErrorList from '../ui/forms/FormErrorList';

View File

@ -0,0 +1,74 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Form, reduxForm, submit } from 'redux-form';
import React from 'react';
import { getAssignedNodesCountsByRole } from '../../selectors/nodesAssignment';
import { getCurrentPlan } from '../../selectors/plans';
import ParametersActions from '../../actions/ParametersActions';
import FormErrorList from '../ui/forms/FormErrorList';
class NodesAssignmentForm extends React.Component {
constructor(props) {
super(props);
this.debouncedUpdate = debounce(this.update.bind(this), 1000);
}
componentWillReceiveProps(nextProps) {
// TODO(jtomasek): update this to happen as onChange when this is released https://github.com/erikras/redux-form/pull/2576
// and combine it with calling to this.props.submit() rather than calling nextProps.handleSubmit(....)
// make sure debouncing works properly
if (nextProps.dirty && nextProps.valid && !nextProps.submitting) {
// nextProps.submit();
nextProps.handleSubmit(this.debouncedUpdate.bind(this))();
} else if (nextProps.invalid || nextProps.submitting) {
this.debouncedUpdate.cancel();
}
}
update(data) {
this.props.updateParameters(this.props.currentPlan.name, data);
}
render() {
const { error, handleSubmit, children } = this.props;
return (
<Form onSubmit={handleSubmit(this.debouncedUpdate.bind(this))}>
<FormErrorList errors={error ? [error] : []}/>
{children}
</Form>
);
}
}
NodesAssignmentForm.propTypes = {
children: React.PropTypes.node,
currentPlan: ImmutablePropTypes.record.isRequired,
error: React.PropTypes.object,
handleSubmit: React.PropTypes.func.isRequired,
pristine: React.PropTypes.bool.isRequired,
updateParameters: React.PropTypes.func.isRequired
};
const mapStateToProps = (state, ownProps) => ({
currentPlan: getCurrentPlan(state),
initialValues: getAssignedNodesCountsByRole(state).toJS()
});
const mapDispatchToProps = (dispatch) => {
return {
updateParameters: (currentPlanName, data, inputFields, redirectPath) => {
dispatch(ParametersActions.updateParameters(
currentPlanName, data, inputFields, redirectPath));
},
submit: () => dispatch(submit('nodesAssignment'))
};
};
const form = reduxForm({
enableReinitialize: true,
keepDirtyOnReinitialize: true,
form: 'nodesAssignment'
});
export default connect(mapStateToProps, mapDispatchToProps)(form(NodesAssignmentForm));

View File

@ -1,8 +1,14 @@
import { defineMessages, FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { Field } from 'redux-form';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import Link from '../ui/Link';
import NodePickerInput from '../ui/reduxForm/NodePickerInput';
import { maxValue,
minValue,
number,
messages as validationMessages } from '../ui/reduxForm/validations';
const messages = defineMessages({
nodesAssigned: {
@ -23,10 +29,18 @@ const messages = defineMessages({
const RoleCard = ({ assignedNodesCountParameter,
availableNodesCount,
identifier,
intl,
name,
title }) => {
const disabled = !assignedNodesCountParameter
|| !availableNodesCount && !assignedNodesCountParameter.default;
const validations = [
maxValue(availableNodesCount, intl.formatMessage(validationMessages.maxValue,
{ max: availableNodesCount.toString() })),
minValue(0, intl.formatMessage(validationMessages.minValue, { min: '0' })),
number(intl.formatMessage(validationMessages.number))
];
return (
<div className={`card-pf card-pf-accented role-card ${identifier}`}>
<h2 className="card-pf-title">
@ -38,10 +52,22 @@ const RoleCard = ({ assignedNodesCountParameter,
</Link>
</h2>
<div className="card-pf-body">
<p className="card-pf-utilization-details">
<span className="card-pf-utilization-card-details-count">
{assignedNodesCountParameter ? assignedNodesCountParameter.default : '-'}
</span>
<div className="card-pf-utilization-details">
<div className="node-picker-cell">
{assignedNodesCountParameter
? <Field
component={NodePickerInput}
increment={1}
validate={validations}
name={assignedNodesCountParameter.name}
max={availableNodesCount}/>
: <NodePickerInput
increment={1}
input={{ value: '-' }}
meta={{ submitting: true }}
max={availableNodesCount}
min={0}/>}
</div>
<span className="card-pf-utilization-card-details-description">
<span className="card-pf-utilization-card-details-line-1">
<FormattedMessage
@ -52,7 +78,7 @@ const RoleCard = ({ assignedNodesCountParameter,
<FormattedMessage {...messages.nodesAssigned}/>
</span>
</span>
</p>
</div>
</div>
<div className="card-pf-footer">
<p>
@ -72,8 +98,9 @@ RoleCard.propTypes = {
assignedNodesCountParameter: ImmutablePropTypes.record,
availableNodesCount: React.PropTypes.number.isRequired,
identifier: React.PropTypes.string.isRequired,
intl: React.PropTypes.object,
name: React.PropTypes.string.isRequired,
title: React.PropTypes.string.isRequired
};
export default RoleCard;
export default injectIntl(RoleCard);

View File

@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import Loader from '../ui/Loader';
import NodesAssignmentForm from './NodesAssignmentForm';
import RoleCard from './RoleCard';
const messages = defineMessages({
@ -49,7 +50,9 @@ class Roles extends React.Component {
content={this.props.intl.formatMessage(messages.loadingDeploymentRoles)}
height={40}>
<div className="row-cards-pf">
{this.renderRoleCards()}
<NodesAssignmentForm>
{this.renderRoleCards()}
</NodesAssignmentForm>
</div>
</Loader>
</div>

View File

@ -0,0 +1,77 @@
import ClassNames from 'classnames';
import React from 'react';
const NodePickerInput = props => {
const { increment, input: { value, onChange }, max, meta: { error, submitting }, min } = props;
const incrementValue = increment => {
const incrementedValue = value + increment;
if (incrementedValue <= max && incrementedValue >= min) {
return incrementedValue;
} else if (incrementedValue > max) {
return max;
} else if (incrementedValue < min) {
return min;
}
};
return (
<div className="node-picker">
<PickerArrow
direction="up"
onClick={() => onChange(incrementValue(increment))}
disabled={submitting || value + increment > max}/>
<NodeCount error={error}>
<span className="value">{value}</span>
</NodeCount>
<PickerArrow
direction="down"
onClick={() => onChange(incrementValue(-increment))}
disabled={submitting || value - increment < min}/>
</div>
);
};
NodePickerInput.propTypes = {
increment: React.PropTypes.number.isRequired,
input: React.PropTypes.object.isRequired,
max: React.PropTypes.number.isRequired,
meta: React.PropTypes.object.isRequired,
min: React.PropTypes.number.isRequired
};
NodePickerInput.defaultProps = {
min: 0
};
export default NodePickerInput;
const PickerArrow = ({ direction, disabled, onClick }) => {
return (
<button
type="button"
className={`btn btn-default btn-node-picker btn-node-picker-${direction}`}
onClick={onClick}
disabled={disabled}>
<span className={`fa fa-angle-${direction}`} aria-hidden="true"/>
</button>
);
};
PickerArrow.propTypes = {
direction: React.PropTypes.oneOf(['up', 'down']).isRequired,
disabled: React.PropTypes.bool.isRequired,
onClick: React.PropTypes.func.isRequired
};
const NodeCount = ({ children, error }) => {
const classes = ClassNames({
'node-count': true,
'text-danger': error
});
return (
<div className={classes} title={error}>
{children}
</div>
);
};
NodeCount.propTypes = {
children: React.PropTypes.node.isRequired,
error: React.PropTypes.string
};

View File

@ -0,0 +1,25 @@
import { defineMessages } from 'react-intl';
export const messages = defineMessages({
maxValue: {
id: 'reduxForm.validations.maxValue',
defaultMessage: 'Maximum value is {max}.'
},
minValue: {
id: 'reduxForm.validations.minValue',
defaultMessage: 'Minimum value is {min}.'
},
number: {
id: 'reduxForm.validations.number',
defaultMessage: 'Value needs to be a number.'
}
});
export const minValue = (min, message) => value =>
value && value < min ? message : undefined;
export const maxValue = (max, message) => value =>
value && value > max ? message : undefined;
export const number = message => value =>
value && isNaN(Number(value)) ? message : undefined;

View File

@ -1,4 +1,6 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import currentPlanReducer from './currentPlanReducer';
import environmentConfigurationReducer from './environmentConfigurationReducer';
import i18nReducer from './i18nReducer';
@ -26,7 +28,8 @@ const appReducer = combineReducers({
registerNodes: registerNodesReducer,
roles: rolesReducer,
stacks: stacksReducer,
validations: validationsReducer
validations: validationsReducer,
form: formReducer
});
export default appReducer;

View File

@ -3,7 +3,6 @@ import { List, Set } from 'immutable';
import { parseNodeCapabilities } from '../utils/nodes';
import { getRoles } from './roles';
import { getNodeCountParametersByRole } from './parameters';
export const getNodes = state => state.nodes.get('all').sortBy(n => n.get('uuid'));
export const getNodesByIds = (state, nodeIds) =>
@ -34,7 +33,7 @@ export const getRegisteredNodes = createSelector(
*/
export const getProfilesList = createSelector(
getNodes, nodes => nodes.reduce((profiles, v, k) => {
const profile = _getNodeCapabilities(v).profile;
const profile = getNodeCapabilities(v).profile;
return profile ? profiles.push(profile) : profiles;
}, List()).sort()
);
@ -47,58 +46,6 @@ export const getAvailableNodeProfiles = createSelector(
Set.fromKeys(roles).union(profiles).toList().sort()
);
export const getAvailableNodes = createSelector(
getRegisteredNodes, (nodes) => nodes.filter(node => node.get('provision_state') === 'available')
);
export const getUntaggedAvailableNodes = createSelector(
getAvailableNodes, (availableNodes) =>
availableNodes.filterNot(node => _getNodeCapabilities(node).profile)
);
/**
* Returns Nodes available for assignment for each Role (tagged to specific role
* + untagged nodes)
* TODO(jtomasek): remove this when NodesAssignment component is no longer used
*/
export const getAvailableNodesByRole = createSelector(
[getAvailableNodes, getUntaggedAvailableNodes, getRoles], (nodes, untaggedNodes, roles) =>
roles.map(role => nodes.filter(node => _getNodeCapabilities(node).profile === role.identifier)
.merge(untaggedNodes))
);
/**
* Returns sum of untagged assigned Nodes counts across all Roles
*/
export const getTotalUntaggedAssignedNodesCount = createSelector(
[getAvailableNodes, getRoles, getNodeCountParametersByRole],
(nodes, roles, parametersByRole) =>
roles.reduce((total, role) => {
const taggedCount =
nodes.filter(node => _getNodeCapabilities(node).profile === role.identifier).size;
const assignedCount = parametersByRole.getIn([role.identifier, 'default'], 0);
const remainder = Math.max(0, assignedCount - taggedCount);
return total + remainder;
}, 0)
);
/**
* Returns maximum Nodes count available to assign by each Role
*/
export const getAvailableNodesCountsByRole = createSelector(
[getAvailableNodes, getUntaggedAvailableNodes, getRoles, getNodeCountParametersByRole,
getTotalUntaggedAssignedNodesCount],
(nodes, untaggedNodes, roles, parametersByRole, untaggedAssignedCount) =>
roles.map(role => {
const taggedCount
= nodes.filter(node => _getNodeCapabilities(node).profile === role.identifier).size;
const assignedCount = parametersByRole.getIn([role.identifier, 'default'], 0);
const untaggedCount = untaggedNodes.size;
return taggedCount
+ Math.max(0, untaggedCount - (untaggedAssignedCount - (assignedCount - taggedCount)));
})
);
export const getDeployedNodes = createSelector(
getNodesWithMacs, (nodes) =>
nodes.filter( node => node.get('provision_state') === 'active' )
@ -129,5 +76,5 @@ const filterPorts = (ports) =>
* @param node
* @returns capabilities object
*/
const _getNodeCapabilities = (node) =>
export const getNodeCapabilities = (node) =>
parseNodeCapabilities(node.getIn(['properties', 'capabilities'], ''));

View File

@ -0,0 +1,97 @@
import { createSelector } from 'reselect';
import { getFormValues } from 'redux-form';
import { getParameters } from './parameters';
import { getRegisteredNodes, getNodeCapabilities } from './nodes';
import { getRoles } from './roles';
export const getAvailableNodes = createSelector(
getRegisteredNodes, (nodes) => nodes.filter(node => node.get('provision_state') === 'available')
);
export const getUntaggedAvailableNodes = createSelector(
getAvailableNodes, (availableNodes) =>
availableNodes.filterNot(node => getNodeCapabilities(node).profile)
);
/**
* Returns Nodes available for assignment for each Role (tagged to specific role
* + untagged nodes)
* TODO(jtomasek): remove this when NodesAssignment component is no longer used
*/
export const getAvailableNodesByRole = createSelector(
[getAvailableNodes, getUntaggedAvailableNodes, getRoles], (nodes, untaggedNodes, roles) =>
roles.map(role => nodes.filter(node => getNodeCapabilities(node).profile === role.identifier)
.merge(untaggedNodes))
);
/**
* Returns <RoleName>Count parameters for each Role
*/
export const getNodeCountParametersByRole = createSelector(
[getRoles, getParameters], (roles, parameters) =>
roles.map(role => parameters.get(`${role.name}Count`))
);
/**
* Returns <RoleName>Count parameters for each Role combined with values from
* nodesAssignment form
*/
export const getNodeCountParametersByRoleFromFormValues = createSelector(
[getNodeCountParametersByRole, getFormValues('nodesAssignment')],
(parametersByRole, formValues) =>
parametersByRole.map(parameter =>
parameter &&
parameter.set('default', formValues && formValues[parameter.name]
? formValues[parameter.name]
: parameter.default))
);
/**
* Returns sum of untagged assigned Nodes counts across all Roles
*/
export const getTotalUntaggedAssignedNodesCount = createSelector(
[getAvailableNodes, getRoles, getNodeCountParametersByRoleFromFormValues],
(nodes, roles, parametersByRole) =>
roles.reduce((total, role) => {
const taggedCount =
nodes.filter(node => getNodeCapabilities(node).profile === role.identifier).size;
const assignedCount = parametersByRole.getIn([role.identifier, 'default'], 0);
const remainder = Math.max(0, assignedCount - taggedCount);
return total + remainder;
}, 0)
);
/**
* Returns maximum Nodes count available to assign by each Role
*/
export const getAvailableNodesCountsByRole = createSelector(
[getAvailableNodes, getUntaggedAvailableNodes, getRoles,
getNodeCountParametersByRoleFromFormValues, getTotalUntaggedAssignedNodesCount],
(nodes, untaggedNodes, roles, parametersByRole, untaggedAssignedCount) =>
roles.map(role => {
const taggedCount
= nodes.filter(node => getNodeCapabilities(node).profile === role.identifier).size;
const assignedCount = parametersByRole.getIn([role.identifier, 'default'], 0);
const untaggedCount = untaggedNodes.size;
return taggedCount
+ Math.max(0, untaggedCount - untaggedAssignedCount
+ Math.max(0, assignedCount - taggedCount));
})
);
/**
* Returns 'default' value of <RoleName>Count parameter for each Role
* { ControllerCount: 1, ComputeCount: 0, ... }
*/
export const getAssignedNodesCountsByRole = createSelector(
getNodeCountParametersByRole, nodeCountParametersByRole =>
nodeCountParametersByRole.mapEntries(([role, parameter]) =>
parameter ? [parameter.name, parameter.default] : undefined)
);
export const getTotalAssignedNodesCount = createSelector(
getNodeCountParametersByRole, countParamsByRole =>
countParamsByRole.reduce((total, parameter) =>
parameter ? total + parseInt(parameter.default) : total, 0)
);

View File

@ -2,7 +2,7 @@ import { createSelector } from 'reselect';
import { List, Set } from 'immutable';
import { internalParameters } from '../constants/ParametersConstants';
import { getRole, getRoles } from './roles';
import { getRole } from './roles';
import { getEnvironment } from './environmentConfiguration';
import { Resource } from '../immutableRecords/parameters';
@ -111,20 +111,6 @@ export const getResourceParameters = createSelector(
resource.parameters.update(filterParameters(parameters))
);
/**
* Returns <RoleName>Count parameters for each Role
*/
export const getNodeCountParametersByRole = createSelector(
[getRoles, getParameters], (roles, parameters) =>
roles.map(role => parameters.get(`${role.name}Count`))
);
export const getTotalAssignedNodesCount = createSelector(
getNodeCountParametersByRole, countParamsByRole =>
countParamsByRole.reduce((total, parameter) =>
parameter ? total + parseInt(parameter.default) : total, 0)
);
/**
* Helper function to convert list of resource uuids into map of actual resources
* @param resources - Map of resources to filter on

View File

@ -95,9 +95,9 @@
@import "components/EnvironmentConfiguration";
@import "components/DeploymentDetail";
@import "components/DeploymentPlan";
@import "components/NodeStack";
@import "components/NodePicker";
@import "components/NodePickerInput";
@import "components/Plans";
@import "components/RoleCard";
@import "components/Validations";
.wrapper-fixed-body {

View File

@ -1,17 +0,0 @@
// NodePicker component
.node-picker {
display: inline-block;
button.picker-arrow {
border: none;
background: transparent;
outline: none;
text-shadow: 1px 1px 2px gray;
&:hover {
cursor: pointer;
}
&:active {
text-shadow: none;
color: lighten(gray, 1%);
}
}
}

View File

@ -0,0 +1,30 @@
// NodePicker component
.node-picker {
.btn-node-picker {
width: 100%;
padding: 0;
display: block;
line-height: 1;
&.btn-node-picker-up {
border-radius: 3px 3px 0 0;
border-bottom: none;
}
&.btn-node-picker-down {
border-radius: 0 0 3px 3px;
border-top: none;
}
}
}
.node-count {
min-width: 45px;
text-align: center;
border: 1px solid @btn-default-border;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2) inset;
background: @color-pf-black-200;
padding: 0 12px;
span.value {
font-size: 26px;
font-weight: 300;
}
}

View File

@ -1,43 +0,0 @@
// NodeStack component
.node-stack {
// this element is required to achieve correct stack effect
// http://stackoverflow.com/questions/3032856/z-index-of-before-or-after-to-be-below-the-element-is-that-possible
display: inline-block;
position: relative;
z-index: 1;
.stack {
@node-size: 40px;
position: relative;
display: inline-block;
width: @node-size;
height: @node-size;
line-height: @node-size;
text-align: center;
border: 1px solid gray;
background: white;
margin: 0 18px;
&:before, &:after {
display: none;
content: "";
position: absolute;
width: 100%;
height: 100%;
border: inherit;
background: inherit;
}
&:before {
left: -4px;
top: -4px;
z-index: -1;
}
&:after {
left: -7px;
top: -7px;
z-index: -2;
}
&.single-stack:before { display: block; }
&.double-stack {
&:before, &:after { display: block; }
}
}
}

View File

@ -0,0 +1,12 @@
.role-card {
.node-picker-cell {
display: table-cell;
vertical-align: middle;
width: 1px;
padding: 0 12px 0 0;
}
.card-pf-utilization-card-details-description {
float: none;
}
}