Nodes Table tagging
This change adds Nodes table action to tag Nodes to a Profile By selecting nodes in table and clicking 'Tag Nodes' table action, A form is displayed to let user select from existing profiles or specify custom one. Available profiles are gathered from the list of existing profiles on all nodes and roles available in current plan The way Nodes are stored in app state is slightly changed. Node ports are normalized into separate map in nodes state. References to them are stored in 'portsDetail' Node attribute rather than 'ports' so in case when node is updated (response does not include nested ports), the portsDetail won't get overwritten Partial-Bug: #1640103 Change-Id: Icc61fbeda133845b5aadef8b749db20c2c411d3a
This commit is contained in:
parent
532e60e208
commit
95b70b100e
|
@ -108,13 +108,13 @@ describe('Asynchronous Nodes Actions', () => {
|
|||
spyOn(NodesActions, 'receiveNodes');
|
||||
// Mock the service call.
|
||||
spyOn(IronicApiService, 'getNodes').and.callFake(
|
||||
createResolvingPromise({ nodes: [{ uuid: 'uuid' }] })
|
||||
createResolvingPromise({ nodes: [{ uuid: '123' }] })
|
||||
);
|
||||
// Note that `getNode` is called multilpe times but always returns the same response
|
||||
// Note that `getNode` is called multiple times but always returns the same response
|
||||
// to keep the test simple.
|
||||
spyOn(IronicApiService, 'getNode').and.callFake(createResolvingPromise({ uuid: 'uuid' }));
|
||||
spyOn(IronicApiService, 'getNodePorts').and.callFake(createResolvingPromise({
|
||||
ports: [{ address: 'mac' }]}));
|
||||
ports: [{ uuid: 'port1', address: 'mac' }]}));
|
||||
|
||||
// Call the action creator and the resulting action.
|
||||
// In this case, dispatch and getState are just empty placeHolders.
|
||||
|
@ -129,10 +129,19 @@ describe('Asynchronous Nodes Actions', () => {
|
|||
|
||||
it('dispatches receiveNodes', () => {
|
||||
expect(NodesActions.receiveNodes).toHaveBeenCalledWith({
|
||||
uuid: {
|
||||
uuid: 'uuid',
|
||||
macs: 'mac'
|
||||
}});
|
||||
nodes: {
|
||||
123: {
|
||||
uuid: '123',
|
||||
portsDetail: ['port1']
|
||||
}
|
||||
},
|
||||
ports: {
|
||||
port1: {
|
||||
uuid: 'port1',
|
||||
address: 'mac'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import TestUtils from 'react-addons-test-utils';
|
|||
import { Map, Set } from 'immutable';
|
||||
|
||||
import NodesTable from '../../../js/components/nodes/NodesTable';
|
||||
import { NodesTableRoleCell } from '../../../js/components/nodes/NodesTable';
|
||||
import { NodesTableProfileCell } from '../../../js/components/nodes/NodesTable';
|
||||
import { Role } from '../../../js/immutableRecords/roles';
|
||||
|
||||
const initialState = {
|
||||
|
@ -86,7 +86,7 @@ describe('NodesTableRoleCell', () => {
|
|||
describe('getAssignedRoleTitle', () => {
|
||||
it('should return Not Assigned when profile is not set in node.properties.capabilities', () => {
|
||||
let shallowRenderer = TestUtils.createRenderer();
|
||||
shallowRenderer.render(<NodesTableRoleCell data={nodes.toList().toJS()}
|
||||
shallowRenderer.render(<NodesTableProfileCell data={nodes.toList().toJS()}
|
||||
roles={roles}
|
||||
rowIndex={0}/>);
|
||||
roleCellInstance = shallowRenderer._instance._instance;
|
||||
|
@ -95,11 +95,11 @@ describe('NodesTableRoleCell', () => {
|
|||
|
||||
it('should return Not Assigned when profile is not set in node.properties.capabilities', () => {
|
||||
let shallowRenderer = TestUtils.createRenderer();
|
||||
shallowRenderer.render(<NodesTableRoleCell data={nodes.toList().toJS()}
|
||||
shallowRenderer.render(<NodesTableProfileCell data={nodes.toList().toJS()}
|
||||
roles={roles}
|
||||
rowIndex={1}/>);
|
||||
roleCellInstance = shallowRenderer._instance._instance;
|
||||
expect(roleCellInstance.getAssignedRoleTitle()).toEqual('Not assigned');
|
||||
expect(roleCellInstance.getAssignedRoleTitle()).toEqual('-');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import { parseNodeCapabilities,
|
||||
stringifyNodeCapabilities,
|
||||
setNodeCapability } from '../../js/utils/nodes';
|
||||
|
||||
describe('parseNodeCapabilities', () => {
|
||||
beforeEach(function() {
|
||||
this.capabilitiesString = 'capability1:cap1,capability2:cap2';
|
||||
});
|
||||
|
||||
it('returns an object from capabilities string', function() {
|
||||
const expectedObject = {
|
||||
capability1: 'cap1',
|
||||
capability2: 'cap2'
|
||||
};
|
||||
expect(parseNodeCapabilities(this.capabilitiesString)).toEqual(expectedObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringifyNodeCapabilities', () => {
|
||||
beforeEach(function() {
|
||||
this.capabilitiesObject = {
|
||||
capability1: 'cap1',
|
||||
capability2: 'cap2'
|
||||
};
|
||||
});
|
||||
|
||||
it('returns an string from capabilities object', function() {
|
||||
const expectedString = 'capability1:cap1,capability2:cap2';
|
||||
expect(stringifyNodeCapabilities(this.capabilitiesObject)).toEqual(expectedString);
|
||||
});
|
||||
|
||||
it('removes capabilities with empty value', function() {
|
||||
const capabilitiesObject = {
|
||||
capability1: 'cap1',
|
||||
capability2: 'cap2',
|
||||
capability3: ''
|
||||
};
|
||||
const expectedString = 'capability1:cap1,capability2:cap2';
|
||||
expect(stringifyNodeCapabilities(capabilitiesObject)).toEqual(expectedString);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNodeCapability', () => {
|
||||
it('updates node capabilities with new capability', function() {
|
||||
const inputString = 'capability1:cap1,capability2:cap2';
|
||||
const expectedString = 'capability1:cap1,capability2:cap2,capability3:cap3';
|
||||
expect(setNodeCapability(inputString, 'capability3', 'cap3')).toEqual(expectedString);
|
||||
});
|
||||
|
||||
it('updates existing node capability', function() {
|
||||
const inputString = 'capability1:cap1,capability2:cap2';
|
||||
const expectedString = 'capability1:cap1,capability2:newValue';
|
||||
expect(setNodeCapability(inputString, 'capability2', 'newValue')).toEqual(expectedString);
|
||||
});
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { normalize, arrayOf } from 'normalizr';
|
||||
import when from 'when';
|
||||
import { reduce } from 'lodash';
|
||||
|
||||
import { getNodesByIds } from '../selectors/nodes';
|
||||
import IronicApiErrorHandler from '../services/IronicApiErrorHandler';
|
||||
import IronicApiService from '../services/IronicApiService';
|
||||
import MistralApiService from '../services/MistralApiService';
|
||||
|
@ -11,6 +11,7 @@ import NotificationActions from './NotificationActions';
|
|||
import { nodeSchema } from '../normalizrSchemas/nodes';
|
||||
import MistralConstants from '../constants/MistralConstants';
|
||||
import logger from '../services/logger';
|
||||
import { setNodeCapability } from '../utils/nodes';
|
||||
|
||||
export default {
|
||||
startOperation(nodeIds) {
|
||||
|
@ -40,10 +41,10 @@ export default {
|
|||
};
|
||||
},
|
||||
|
||||
receiveNodes(nodes) {
|
||||
receiveNodes(entities) {
|
||||
return {
|
||||
type: NodesConstants.RECEIVE_NODES,
|
||||
payload: nodes
|
||||
payload: entities
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -51,20 +52,13 @@ export default {
|
|||
return (dispatch, getState) => {
|
||||
dispatch(this.requestNodes());
|
||||
IronicApiService.getNodes().then(response => {
|
||||
const normalizedNodes = normalize(response.nodes, arrayOf(nodeSchema)).entities.nodes || {};
|
||||
return when.map(Object.keys(normalizedNodes), nodeUUID => {
|
||||
return IronicApiService.getNodePorts(nodeUUID).then(response => {
|
||||
const macs = reduce(response.ports, (result, value) => {
|
||||
result.push(value.address);
|
||||
return result;
|
||||
}, []).join(', ');
|
||||
return {
|
||||
nodeUUID,
|
||||
macs
|
||||
};
|
||||
return when.map(response.nodes, node => {
|
||||
return IronicApiService.getNodePorts(node.uuid).then(response => {
|
||||
node.portsDetail = response.ports;
|
||||
return node;
|
||||
});
|
||||
}).then(values => {
|
||||
values.forEach(v => normalizedNodes[v.nodeUUID].macs = v.macs);
|
||||
}).then(nodes => {
|
||||
const normalizedNodes = normalize(nodes, arrayOf(nodeSchema)).entities;
|
||||
dispatch(this.receiveNodes(normalizedNodes));
|
||||
});
|
||||
}).catch((error) => {
|
||||
|
@ -147,6 +141,22 @@ export default {
|
|||
};
|
||||
},
|
||||
|
||||
tagNodes(nodeIds, tag) {
|
||||
return (dispatch, getState) => {
|
||||
const nodes = getNodesByIds(getState(), nodeIds);
|
||||
nodes.map(node => {
|
||||
dispatch(this.updateNode({
|
||||
uuid: node.get('uuid'),
|
||||
patches: [{
|
||||
op: 'replace',
|
||||
path: '/properties/capabilities',
|
||||
value: setNodeCapability(node.getIn(['properties', 'capabilities']), 'profile', tag)
|
||||
}]
|
||||
}));
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
startProvideNodes(nodeIds) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(this.startOperation(nodeIds));
|
||||
|
|
|
@ -9,6 +9,7 @@ import { DataTableCell,
|
|||
import { DataTableCheckBoxCell } from '../ui/tables/DataTableCells';
|
||||
import DataTableColumn from '../ui/tables/DataTableColumn';
|
||||
import Loader from '../ui/Loader';
|
||||
import { parseNodeCapabilities } from '../../utils/nodes';
|
||||
|
||||
|
||||
export default class NodesTable extends React.Component {
|
||||
|
@ -77,8 +78,8 @@ export default class NodesTable extends React.Component {
|
|||
cell={<DataTableDataFieldCell data={filteredData} field="name"/>}/>
|
||||
<DataTableColumn
|
||||
key="role"
|
||||
header={<DataTableHeaderCell key="role">Role</DataTableHeaderCell>}
|
||||
cell={<NodesTableRoleCell data={filteredData} roles={this.props.roles}/>}/>
|
||||
header={<DataTableHeaderCell key="role">Profile</DataTableHeaderCell>}
|
||||
cell={<NodesTableProfileCell data={filteredData} roles={this.props.roles}/>}/>
|
||||
<DataTableColumn
|
||||
key="properties.cpu_arch"
|
||||
header={<DataTableHeaderCell key="properties.cpu_arch">CPU Arch.</DataTableHeaderCell>}
|
||||
|
@ -131,17 +132,17 @@ NodesTableCheckBoxCell.propTypes = {
|
|||
rowIndex: React.PropTypes.number
|
||||
};
|
||||
|
||||
export class NodesTableRoleCell extends React.Component {
|
||||
export class NodesTableProfileCell extends React.Component {
|
||||
getAssignedRoleTitle() {
|
||||
const fieldValue = _.result(this.props.data[this.props.rowIndex],
|
||||
const capabilities = _.result(this.props.data[this.props.rowIndex],
|
||||
'properties.capabilities',
|
||||
'');
|
||||
const capabilitiesMatch = fieldValue.match(/.*profile:([\w\-]+)/);
|
||||
if(capabilitiesMatch && Array.isArray(capabilitiesMatch) && capabilitiesMatch.length > 1) {
|
||||
const role = this.props.roles.get(capabilitiesMatch[1]);
|
||||
return role ? role.title : capabilitiesMatch[1];
|
||||
const profile = parseNodeCapabilities(capabilities).profile;
|
||||
if(profile) {
|
||||
const role = this.props.roles.get(profile);
|
||||
return role ? role.title : profile;
|
||||
} else {
|
||||
return 'Not assigned';
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -153,7 +154,7 @@ export class NodesTableRoleCell extends React.Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
NodesTableRoleCell.propTypes = {
|
||||
NodesTableProfileCell.propTypes = {
|
||||
data: React.PropTypes.array.isRequired,
|
||||
roles: ImmutablePropTypes.map.isRequired,
|
||||
rowIndex: React.PropTypes.number
|
||||
|
|
|
@ -11,8 +11,6 @@ import PXEAndDRACDriverFields from './driver_fields/PXEAndDRACDriverFields';
|
|||
export default class RegisterNodeForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.driverOptions = ['pxe_ipmitool', 'pxe_ssh', 'pxe_drac'];
|
||||
|
||||
this.macAddressValidator = {
|
||||
matchRegexp:
|
||||
/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}(,([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})*$/
|
||||
|
@ -62,7 +60,20 @@ export default class RegisterNodeForm extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
renderDriverOptions() {
|
||||
return ['pxe_ipmitool', 'pxe_ssh', 'pxe_drac'].map((value, index) =>
|
||||
<option key={index}>{value}</option>
|
||||
);
|
||||
}
|
||||
|
||||
renderArchitectureOptions() {
|
||||
return [undefined, 'x86_64', 'i386'].map((value, index) =>
|
||||
<option key={index} value={value}>{value}</option>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>Node Detail</h4>
|
||||
|
@ -89,8 +100,9 @@ export default class RegisterNodeForm extends React.Component {
|
|||
inputColumnClasses="col-sm-7"
|
||||
labelColumnClasses="col-sm-5"
|
||||
value={this.props.selectedNode.pm_type}
|
||||
options={this.driverOptions}
|
||||
required/>
|
||||
required>
|
||||
{this.renderDriverOptions()}
|
||||
</HorizontalSelect>
|
||||
{this.renderDriverFields()}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
@ -99,8 +111,9 @@ export default class RegisterNodeForm extends React.Component {
|
|||
title="Architecture"
|
||||
inputColumnClasses="col-sm-7"
|
||||
labelColumnClasses="col-sm-5"
|
||||
value={this.props.selectedNode.arch}
|
||||
options={[undefined, 'x86_64', 'i386']} />
|
||||
value={this.props.selectedNode.arch}>
|
||||
{this.renderArchitectureOptions()}
|
||||
</HorizontalSelect>
|
||||
<HorizontalInput name="cpu"
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
|
@ -5,12 +5,15 @@ import React from 'react';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Formsy from 'formsy-react';
|
||||
|
||||
import { getRegisteredNodes, getNodesOperationInProgress } from '../../selectors/nodes';
|
||||
import { getRoles } from '../../selectors/roles';
|
||||
import { getAvailableNodeProfiles,
|
||||
getRegisteredNodes,
|
||||
getNodesOperationInProgress } from '../../selectors/nodes';
|
||||
import ConfirmationModal from '../ui/ConfirmationModal';
|
||||
import FormErrorList from '../ui/forms/FormErrorList';
|
||||
import NodesActions from '../../actions/NodesActions';
|
||||
import NodesTable from './NodesTable';
|
||||
import TagNodesModal from './tag_nodes/TagNodesModal';
|
||||
|
||||
class RegisteredNodesTabPane extends React.Component {
|
||||
constructor() {
|
||||
|
@ -18,6 +21,8 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
this.state = {
|
||||
canSubmit: false,
|
||||
showDeleteModal: false,
|
||||
showTagNodesModal: false,
|
||||
submitParameters: {},
|
||||
submitType: 'introspect'
|
||||
};
|
||||
}
|
||||
|
@ -56,6 +61,13 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
disabled={!this.state.canSubmit || this.props.nodesOperationInProgress}>
|
||||
Introspect Nodes
|
||||
</button>
|
||||
<button className="btn btn-default"
|
||||
type="button"
|
||||
name="tag"
|
||||
onClick={() => this.setState({ showTagNodesModal: true })}
|
||||
disabled={!this.state.canSubmit || this.props.nodesOperationInProgress}>
|
||||
Tag Nodes
|
||||
</button>
|
||||
<button className="btn btn-default"
|
||||
type="button"
|
||||
name="provide"
|
||||
|
@ -74,6 +86,14 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
onTagNodesSubmit(tag) {
|
||||
this.setState({
|
||||
submitType: 'tag',
|
||||
showTagNodesModal: false,
|
||||
submitParameters: { tag: tag }
|
||||
}, this.refs.registeredNodesTableForm.submit);
|
||||
}
|
||||
|
||||
multipleSubmit(e) {
|
||||
this.setState({
|
||||
submitType: e.target.name
|
||||
|
@ -88,6 +108,10 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
case ('introspect'):
|
||||
this.props.introspectNodes(nodeIds);
|
||||
break;
|
||||
case ('tag'):
|
||||
this.props.tagNodes(nodeIds, this.state.submitParameters.tag);
|
||||
this.setState({ submitParameters: {} });
|
||||
break;
|
||||
case ('provide'):
|
||||
this.props.provideNodes(nodeIds);
|
||||
break;
|
||||
|
@ -125,6 +149,11 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
confirmActionName="delete"
|
||||
onConfirm={this.multipleSubmit.bind(this)}
|
||||
onCancel={() => this.setState({ showDeleteModal: false })}/>
|
||||
<TagNodesModal
|
||||
availableProfiles={this.props.availableProfiles.toArray()}
|
||||
onProfileSelected={this.onTagNodesSubmit.bind(this)}
|
||||
onCancel={() => this.setState({ showTagNodesModal: false, submitParameters: {} })}
|
||||
show={this.state.showTagNodesModal} />
|
||||
</Formsy.Form>
|
||||
{this.props.children}
|
||||
</div>
|
||||
|
@ -132,6 +161,7 @@ class RegisteredNodesTabPane extends React.Component {
|
|||
}
|
||||
}
|
||||
RegisteredNodesTabPane.propTypes = {
|
||||
availableProfiles: ImmutablePropTypes.list.isRequired,
|
||||
children: React.PropTypes.node,
|
||||
deleteNodes: React.PropTypes.func.isRequired,
|
||||
formErrors: ImmutablePropTypes.list,
|
||||
|
@ -142,7 +172,8 @@ RegisteredNodesTabPane.propTypes = {
|
|||
nodesOperationInProgress: React.PropTypes.bool.isRequired,
|
||||
provideNodes: React.PropTypes.func.isRequired,
|
||||
registeredNodes: ImmutablePropTypes.map,
|
||||
roles: ImmutablePropTypes.map
|
||||
roles: ImmutablePropTypes.map,
|
||||
tagNodes: React.PropTypes.func.isRequired
|
||||
};
|
||||
RegisteredNodesTabPane.defaultProps = {
|
||||
formErrors: List(),
|
||||
|
@ -151,6 +182,7 @@ RegisteredNodesTabPane.defaultProps = {
|
|||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
availableProfiles: getAvailableNodeProfiles(state),
|
||||
roles: getRoles(state),
|
||||
registeredNodes: getRegisteredNodes(state),
|
||||
nodesInProgress: state.nodes.get('nodesInProgress'),
|
||||
|
@ -163,7 +195,8 @@ function mapDispatchToProps(dispatch) {
|
|||
return {
|
||||
deleteNodes: nodeIds => dispatch(NodesActions.deleteNodes(nodeIds)),
|
||||
introspectNodes: nodeIds => dispatch(NodesActions.startNodesIntrospection(nodeIds)),
|
||||
provideNodes: nodeIds => dispatch(NodesActions.startProvideNodes(nodeIds))
|
||||
provideNodes: nodeIds => dispatch(NodesActions.startProvideNodes(nodeIds)),
|
||||
tagNodes: (nodeIds, tag) => dispatch(NodesActions.tagNodes(nodeIds, tag))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import Formsy from 'formsy-react';
|
||||
import React from 'react';
|
||||
|
||||
import HorizontalSelect from '../../ui/forms/HorizontalSelect';
|
||||
import HorizontalInput from '../../ui/forms/HorizontalInput';
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: {
|
||||
id: 'TagNodesForm.confirm',
|
||||
defaultMessage: 'Confirm'
|
||||
},
|
||||
cancel: {
|
||||
id: 'TagNodesForm.cancel',
|
||||
defaultMessage: 'Cancel'
|
||||
},
|
||||
customProfileLabel: {
|
||||
id: 'TagNodesForm.customProfileLabel',
|
||||
defaultMessage: 'Custom Profile'
|
||||
},
|
||||
selectProfileLabel: {
|
||||
id: 'TagNodesForm.selectProfileLabel',
|
||||
defaultMessage: 'Select Profile'
|
||||
},
|
||||
noProfileOption: {
|
||||
id: 'TagNodesForm.noProfileOption',
|
||||
defaultMessage: 'No Profile (Untag)'
|
||||
},
|
||||
customProfileOption: {
|
||||
id: 'TagNodesForm.customProfileOption',
|
||||
defaultMessage: 'Specify Custom Profile'
|
||||
},
|
||||
customProfileDescription: {
|
||||
id: 'TagNodesForm.customProfileDescription',
|
||||
defaultMessage: 'Lowercase with dashes as a separator. E.g. "block-storage"'
|
||||
},
|
||||
customProfileErrorMessage: {
|
||||
id: 'TagNodesForm.customProfileErrorMessage',
|
||||
defaultMessage: 'Must be lowercase with dashes as a separator'
|
||||
}
|
||||
});
|
||||
|
||||
class TagNodesForm extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
canSubmit: false,
|
||||
showCustomInput: false
|
||||
};
|
||||
}
|
||||
|
||||
enableButton() {
|
||||
this.setState({ canSubmit: true });
|
||||
}
|
||||
|
||||
disableButton() {
|
||||
this.setState({ canSubmit: false });
|
||||
}
|
||||
|
||||
checkSelectedProfile(currentValues, isChanged) {
|
||||
if (currentValues.profile === 'custom') {
|
||||
this.setState({ showCustomInput: true });
|
||||
} else {
|
||||
this.setState({ showCustomInput: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(formData, resetForm, invalidateForm) {
|
||||
const { customProfile, profile } = formData;
|
||||
this.props.onSubmit(profile === 'custom' ? customProfile : profile);
|
||||
}
|
||||
|
||||
renderOptions() {
|
||||
return this.props.profiles
|
||||
.map((profile, index) =>
|
||||
<option key={index}>{profile}</option>
|
||||
).concat([
|
||||
<option key="spacer1" value="spacer" disabled>──────────</option>,
|
||||
<option key="noProfile"
|
||||
value="">
|
||||
{this.props.intl.formatMessage(messages.noProfileOption)}
|
||||
</option>,
|
||||
<option key="custom"
|
||||
value="custom">
|
||||
{this.props.intl.formatMessage(messages.customProfileOption)}
|
||||
</option>
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl;
|
||||
return (
|
||||
<Formsy.Form ref="tagNodesForm"
|
||||
className="form form-horizontal"
|
||||
onChange={this.checkSelectedProfile.bind(this)}
|
||||
onSubmit={this.handleSubmit.bind(this)}
|
||||
onValid={this.enableButton.bind(this)}
|
||||
onInvalid={this.disableButton.bind(this)}>
|
||||
<div className="modal-body">
|
||||
<fieldset>
|
||||
<HorizontalSelect name="profile"
|
||||
title={formatMessage(messages.selectProfileLabel)}
|
||||
inputColumnClasses="col-sm-7"
|
||||
labelColumnClasses="col-sm-3"
|
||||
value="">
|
||||
{this.renderOptions()}
|
||||
</HorizontalSelect>
|
||||
{this.state.showCustomInput
|
||||
? <HorizontalInput name="customProfile"
|
||||
title={formatMessage(messages.customProfileLabel)}
|
||||
type="text"
|
||||
inputColumnClasses="col-sm-7"
|
||||
labelColumnClasses="col-sm-3"
|
||||
value=""
|
||||
validations={{ matchRegexp: /^[0-9a-z]+(-[0-9a-z]+)*$/ }}
|
||||
validationError={formatMessage(messages.customProfileErrorMessage)}
|
||||
description={formatMessage(messages.customProfileDescription)}
|
||||
required/>
|
||||
: null}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-primary"
|
||||
disabled={!this.state.canSubmit}
|
||||
type="submit">
|
||||
<FormattedMessage {...messages.confirm}/>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="btn btn-default"
|
||||
aria-label="Close"
|
||||
onClick={this.props.onCancel}>
|
||||
<FormattedMessage {...messages.cancel}/>
|
||||
</button>
|
||||
</div>
|
||||
</Formsy.Form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
TagNodesForm.propTypes = {
|
||||
intl: React.PropTypes.object.isRequired,
|
||||
onCancel: React.PropTypes.func.isRequired,
|
||||
onSubmit: React.PropTypes.func.isRequired,
|
||||
profiles: React.PropTypes.array.isRequired
|
||||
};
|
||||
export default injectIntl(TagNodesForm);
|
|
@ -0,0 +1,42 @@
|
|||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import React from 'react';
|
||||
|
||||
import Modal from '../../ui/Modal';
|
||||
import TagNodesForm from './TagNodesForm';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'TagNodesModal.title',
|
||||
defaultMessage: 'Tag Nodes into Profiles'
|
||||
}
|
||||
});
|
||||
|
||||
export default class TagNodesModal extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Modal dialogClasses="modal-md" show={this.props.show}>
|
||||
<div className="modal-header">
|
||||
<button type="button"
|
||||
className="close"
|
||||
aria-label="Close"
|
||||
onClick={this.props.onCancel}>
|
||||
<span aria-hidden="true" className="pficon pficon-close"/>
|
||||
</button>
|
||||
<h4 className="modal-title">
|
||||
<span className="fa fa-tag"/> <FormattedMessage {...messages.title}/>
|
||||
</h4>
|
||||
</div>
|
||||
<TagNodesForm
|
||||
onCancel={this.props.onCancel}
|
||||
onSubmit={this.props.onProfileSelected}
|
||||
profiles={this.props.availableProfiles}/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
TagNodesModal.propTypes = {
|
||||
availableProfiles: React.PropTypes.array.isRequired,
|
||||
onCancel: React.PropTypes.func.isRequired,
|
||||
onProfileSelected: React.PropTypes.func.isRequired,
|
||||
show: React.PropTypes.bool.isRequired
|
||||
};
|
|
@ -11,12 +11,6 @@ class HorizontalSelect extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
let options = this.props.options.map((option, index) => {
|
||||
return (
|
||||
<option key={index}>{option}</option>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label htmlFor={this.props.name}
|
||||
|
@ -30,7 +24,7 @@ class HorizontalSelect extends React.Component {
|
|||
className="form-control"
|
||||
onChange={this.changeValue.bind(this)}
|
||||
value={this.props.getValue()}>
|
||||
{options}
|
||||
{this.props.children}
|
||||
</select>
|
||||
<InputErrorMessage getErrorMessage={this.props.getErrorMessage} />
|
||||
<InputDescription description={this.props.description} />
|
||||
|
@ -40,13 +34,16 @@ class HorizontalSelect extends React.Component {
|
|||
}
|
||||
}
|
||||
HorizontalSelect.propTypes = {
|
||||
children: React.PropTypes.oneOfType([
|
||||
React.PropTypes.arrayOf(React.PropTypes.element),
|
||||
React.PropTypes.element
|
||||
]),
|
||||
description: React.PropTypes.string,
|
||||
getErrorMessage: React.PropTypes.func,
|
||||
getValue: React.PropTypes.func,
|
||||
inputColumnClasses: React.PropTypes.string.isRequired,
|
||||
labelColumnClasses: React.PropTypes.string.isRequired,
|
||||
name: React.PropTypes.string.isRequired,
|
||||
options: React.PropTypes.array.isRequired,
|
||||
setValue: React.PropTypes.func,
|
||||
title: React.PropTypes.string.isRequired
|
||||
};
|
||||
|
|
|
@ -29,3 +29,8 @@ export const IronicNode = Record({
|
|||
disk: undefined,
|
||||
capabilities: 'boot_option:local'
|
||||
});
|
||||
|
||||
export const Port = Record({
|
||||
uuid: undefined,
|
||||
address: undefined
|
||||
});
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import { Schema } from 'normalizr';
|
||||
import { arrayOf, Schema } from 'normalizr';
|
||||
|
||||
export const nodeSchema = new Schema('nodes', { idAttribute: 'uuid' });
|
||||
export const portSchema = new Schema('ports', { idAttribute: 'uuid' });
|
||||
|
||||
nodeSchema.define({
|
||||
portsDetail: arrayOf(portSchema)
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { fromJS, Map, Set } from 'immutable';
|
||||
|
||||
import NodesConstants from '../constants/NodesConstants';
|
||||
import { Port } from '../immutableRecords/nodes';
|
||||
|
||||
const initialState = Map({
|
||||
isFetching: false,
|
||||
|
@ -10,7 +11,8 @@ const initialState = Map({
|
|||
introspectedFilter: '',
|
||||
deployedFilter: '',
|
||||
maintenanceFilter: '',
|
||||
all: Map()
|
||||
all: Map(),
|
||||
ports: Map()
|
||||
});
|
||||
|
||||
export default function nodesReducer(state = initialState, action) {
|
||||
|
@ -19,10 +21,13 @@ export default function nodesReducer(state = initialState, action) {
|
|||
case NodesConstants.REQUEST_NODES:
|
||||
return state.set('isFetching', true);
|
||||
|
||||
case NodesConstants.RECEIVE_NODES:
|
||||
case NodesConstants.RECEIVE_NODES: {
|
||||
const { nodes, ports } = action.payload;
|
||||
return state
|
||||
.set('all', fromJS(action.payload))
|
||||
.set('isFetching', false);
|
||||
.set('all', fromJS(nodes || {}))
|
||||
.set('ports', Map(ports).map(port => new Port(port)))
|
||||
.set('isFetching', false);
|
||||
}
|
||||
|
||||
case NodesConstants.START_NODES_OPERATION:
|
||||
return state.update('nodesInProgress',
|
||||
|
@ -44,7 +49,7 @@ export default function nodesReducer(state = initialState, action) {
|
|||
nodesInProgress => nodesInProgress.remove(action.payload));
|
||||
|
||||
case NodesConstants.UPDATE_NODE_SUCCESS:
|
||||
return state.setIn(['all', action.payload.uuid], fromJS(action.payload))
|
||||
return state.updateIn(['all', action.payload.uuid], node => node.merge(fromJS(action.payload)))
|
||||
.update('nodesInProgress',
|
||||
nodesInProgress => nodesInProgress.remove(action.payload.uuid));
|
||||
|
||||
|
|
|
@ -1,27 +1,65 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import { List, Set } from 'immutable';
|
||||
|
||||
const nodes = state => state.nodes.get('all');
|
||||
const nodesInProgress = state => state.nodes.get('nodesInProgress');
|
||||
import { parseNodeCapabilities } from '../utils/nodes';
|
||||
import { getRoles } from './roles';
|
||||
|
||||
export const getNodes = state => state.nodes.get('all').sortBy(n => n.get('uuid'));
|
||||
export const getNodesByIds = (state, nodeIds) =>
|
||||
state.nodes.get('all').filter((v, k) => nodeIds.includes(k))
|
||||
.sortBy(n => n.get('uuid'));
|
||||
export const getPorts = state => state.nodes.get('ports');
|
||||
export const nodesInProgress = state => state.nodes.get('nodesInProgress');
|
||||
|
||||
/**
|
||||
* Return Nodes including mac addresses as string at macs attribute
|
||||
*/
|
||||
export const getNodesWithMacs = createSelector(
|
||||
[getNodes, getPorts], (nodes, ports) =>
|
||||
nodes
|
||||
.map(node => node
|
||||
.update('portsDetail', filterPorts(ports))
|
||||
.update(node => node
|
||||
.set('macs', node.get('portsDetail').reduce((str, v) => str + v.address, ''))))
|
||||
);
|
||||
|
||||
export const getRegisteredNodes = createSelector(
|
||||
nodes, (nodes) => {
|
||||
getNodesWithMacs, (nodes) => {
|
||||
return nodes.filterNot( node => node.get('provision_state') === 'active' ||
|
||||
node.get('maintenance') );
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Return a list of profiles collected across all nodes
|
||||
*/
|
||||
export const getProfilesList = createSelector(
|
||||
getNodes, nodes => nodes.reduce((profiles, v, k) => {
|
||||
const profile = parseNodeCapabilities(v.getIn(['properties', 'capabilities'])).profile;
|
||||
return profile ? profiles.push(profile) : profiles;
|
||||
}, List()).sort()
|
||||
);
|
||||
|
||||
/**
|
||||
* Return a list of profiles merged with role identifiers
|
||||
*/
|
||||
export const getAvailableNodeProfiles = createSelector(
|
||||
[getProfilesList, getRoles], (profiles, roles) =>
|
||||
Set.fromKeys(roles).union(profiles).toList().sort()
|
||||
);
|
||||
|
||||
export const getAvailableNodes = createSelector(
|
||||
nodes, (nodes) => nodes.filter(node => node.get('provision_state') === 'available')
|
||||
getNodes, (nodes) => nodes.filter(node => node.get('provision_state') === 'available')
|
||||
);
|
||||
|
||||
export const getDeployedNodes = createSelector(
|
||||
nodes, (nodes) => {
|
||||
getNodesWithMacs, (nodes) => {
|
||||
return nodes.filter( node => node.get('provision_state') === 'active' );
|
||||
}
|
||||
);
|
||||
|
||||
export const getMaintenanceNodes = createSelector(
|
||||
nodes, (nodes) => {
|
||||
getNodesWithMacs, (nodes) => {
|
||||
return nodes.filter( node => node.get('maintenance') );
|
||||
}
|
||||
);
|
||||
|
@ -48,3 +86,11 @@ export const getAssignedNodes = (availableNodes, roleIdentifier) => {
|
|||
node => node.getIn(['properties', 'capabilities'], '').includes(`profile:${roleIdentifier}`)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to convert list of port uuids into map of actual ports
|
||||
* @param ports - Map of ports to filter on
|
||||
* @returns function
|
||||
*/
|
||||
const filterPorts = (ports) =>
|
||||
portUUIDs => ports.filter((p, k) => portUUIDs.includes(k));
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Convert Node's capabilities string to object
|
||||
*/
|
||||
export const parseNodeCapabilities = (capabilities) => {
|
||||
let capsObject = {};
|
||||
capabilities.split(',').forEach(cap => {
|
||||
let tup = cap.split(/:(.*)/);
|
||||
capsObject[tup[0]] = tup[1];
|
||||
});
|
||||
return capsObject;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Node's capabilities object to string
|
||||
*/
|
||||
export const stringifyNodeCapabilities = (capabilities) => {
|
||||
return Object.keys(capabilities).reduce((caps, key) => {
|
||||
if (!capabilities[key]) {
|
||||
return caps;
|
||||
} else {
|
||||
caps.push(`${key}:${capabilities[key]}`);
|
||||
return caps;
|
||||
}
|
||||
}, []).join(',');
|
||||
};
|
||||
|
||||
/**
|
||||
* Set or update Node capability
|
||||
*/
|
||||
export const setNodeCapability = (capabilitiesString, key, newValue) => {
|
||||
let capabilitiesObj = parseNodeCapabilities(capabilitiesString);
|
||||
capabilitiesObj[key] = newValue;
|
||||
return stringifyNodeCapabilities(capabilitiesObj);
|
||||
};
|
Loading…
Reference in New Issue