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:
Jiri Tomasek 2017-01-23 15:53:29 +01:00
parent 532e60e208
commit 95b70b100e
15 changed files with 467 additions and 66 deletions

View File

@ -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'
}
}
});
});
});

View File

@ -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('-');
});
});
});

View File

@ -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);
});
});

View File

@ -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));

View File

@ -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

View File

@ -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}

View File

@ -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))
};
}

View File

@ -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);

View File

@ -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
};

View File

@ -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
};

View File

@ -29,3 +29,8 @@ export const IronicNode = Record({
disk: undefined,
capabilities: 'boot_option:local'
});
export const Port = Record({
uuid: undefined,
address: undefined
});

View File

@ -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)
});

View File

@ -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));

View File

@ -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));

34
src/js/utils/nodes.js Normal file
View File

@ -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);
};