fuel-ui/static/views/cluster_page_tabs/nodes_tab_screens/node_list_screen.js

2073 lines
74 KiB
JavaScript

/*
* Copyright 2014 Mirantis, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
**/
import $ from 'jquery';
import _ from 'underscore';
import i18n from 'i18n';
import Backbone from 'backbone';
import React from 'react';
import ReactDOM from 'react-dom';
import {NODE_VIEW_MODES, NODE_STATUSES, NODE_LIST_SORTERS, NODE_LIST_FILTERS} from 'consts';
import utils from 'utils';
import models from 'models';
import dispatcher from 'dispatcher';
import {Input, Popover, Tooltip, ProgressButton, MultiSelectControl} from 'views/controls';
import {DeleteNodesDialog} from 'views/dialogs';
import {backboneMixin, pollingMixin, dispatcherMixin, unsavedChangesMixin} from 'component_mixins';
import Node from 'views/cluster_page_tabs/nodes_tab_screens/node';
import {Sorter, Filter} from 'views/cluster_page_tabs/nodes_tab_screens/sorter_and_filter';
var NodeListScreen, NodeListScreenContent, NumberRangeControl, ManagementPanel,
NodeLabelsPanel, RolePanel, Role, SelectAllMixin, NodeList, NodeGroup;
var sorterNs = 'cluster_page.nodes_tab.sorters.';
NodeListScreen = React.createClass({
propTypes: {
uiSettings: React.PropTypes.object,
defaultFilters: React.PropTypes.object,
statusesToFilter: React.PropTypes.arrayOf(React.PropTypes.string),
availableFilters: React.PropTypes.arrayOf(React.PropTypes.string),
defaultSorting: React.PropTypes.arrayOf(React.PropTypes.object),
availableSorters: React.PropTypes.arrayOf(React.PropTypes.string),
showRolePanel: React.PropTypes.bool,
selectedNodeIds: React.PropTypes.object
},
getDefaultProps() {
return {
defaultFilters: {status: []},
statusesToFilter: NODE_STATUSES,
availableFilters: NODE_LIST_FILTERS,
defaultSorting: [{status: 'asc'}],
availableSorters: NODE_LIST_SORTERS,
showRolePanel: false,
selectedNodeIds: {}
};
},
getInitialState() {
var {cluster, nodes, uiSettings, defaultFilters, defaultSorting, showRolePanel} = this.props;
var activeFilters, activeSorters, search, viewMode; // node list state list
if (uiSettings) {
var {
filter, filter_by_labels: filterByLabels, sort, sort_by_labels: sortByLabels
} = uiSettings;
activeFilters = _.union(
Filter.fromObject(_.extend({}, defaultFilters, filter), false),
Filter.fromObject(filterByLabels, true)
);
activeSorters = _.union(
_.map(sort, _.partial(Sorter.fromObject, _, sorterNs, false)),
_.map(sortByLabels, _.partial(Sorter.fromObject, _, sorterNs, true))
);
search = uiSettings.search;
viewMode = uiSettings.view_mode;
} else {
activeFilters = Filter.fromObject(defaultFilters, false);
activeSorters = _.map(defaultSorting, _.partial(Sorter.fromObject, _, sorterNs, false));
search = '';
viewMode = this.props.viewMode || _.first(NODE_VIEW_MODES);
}
_.invokeMap(activeFilters, 'updateLimits', nodes, false);
var availableFilters = _.map(this.props.availableFilters, (name) => {
var filter = new Filter(name, [], false);
filter.updateLimits(nodes, true);
return filter;
});
var availableSorters = _.map(this.props.availableSorters,
(name) => new Sorter(name, 'asc', sorterNs, false)
);
var states = {
availableFilters, activeFilters, availableSorters, activeSorters, search, viewMode
};
// add role panel functionality
if (showRolePanel) {
var roles = cluster.get('roles').pluck('name');
var selectedRoles = nodes.length ?
_.filter(roles, (role) => nodes.every((node) => node.hasRole(role))) : [];
var indeterminateRoles = nodes.length ?
_.filter(roles,
(role) => !_.includes(selectedRoles, role) && nodes.some((node) => node.hasRole(role))
) : [];
var configModels = {
cluster,
settings: cluster.get('settings'),
version: app.version,
default: cluster.get('settings')
};
_.extend(states, {selectedRoles, indeterminateRoles, configModels});
}
return states;
},
updateSearch(search) {
this.setState({search});
if (this.props.updateUISettings) {
this.props.updateUISettings('search', _.trim(search));
}
},
changeViewMode(viewMode) {
this.setState({viewMode});
if (this.props.updateUISettings) {
this.props.updateUISettings('view_mode', viewMode);
}
},
updateSorting(activeSorters, updateLabelsOnly = false) {
this.setState({activeSorters});
if (this.props.updateUISettings) {
var groupedSorters = _.groupBy(activeSorters, 'isLabel');
if (!updateLabelsOnly) {
this.props.updateUISettings('sort', _.map(groupedSorters.false, Sorter.toObject));
}
this.props.updateUISettings('sort_by_labels', _.map(groupedSorters.true, Sorter.toObject));
}
},
updateFilters(filters, updateLabelsOnly = false) {
this.setState({activeFilters: filters});
if (this.props.updateUISettings) {
var groupedFilters = _.groupBy(filters, 'isLabel');
if (!updateLabelsOnly) {
this.props.updateUISettings('filter', Filter.toObject(groupedFilters.false));
}
this.props.updateUISettings('filter_by_labels', Filter.toObject(groupedFilters.true));
}
},
selectRoles(role, checked) {
var {selectedRoles, indeterminateRoles} = this.state;
if (checked) {
selectedRoles.push(role);
} else {
selectedRoles = _.without(selectedRoles, role);
}
indeterminateRoles = _.without(indeterminateRoles, role);
this.setState({selectedRoles, indeterminateRoles});
},
selectNodes(ids = [], checked = false) {
var nodeSelection = {};
if (ids.length) {
nodeSelection = this.props.selectedNodeIds;
_.each(ids, (id) => {
if (checked) {
nodeSelection[id] = true;
} else {
delete nodeSelection[id];
}
});
}
this.props.selectNodes(nodeSelection);
},
render() {
var roleProps = this.props.showRolePanel ? {
roles: this.props.cluster.get('roles'),
selectRoles: this.selectRoles
} : {};
return <NodeListScreenContent
{...this.props}
{...roleProps}
{...this.state}
{... _.pick(this,
'selectNodes', 'updateSearch', 'changeViewMode', 'updateSorting', 'updateFilters'
)}
/>;
}
});
NodeListScreenContent = React.createClass({
mixins: [
pollingMixin(20, true),
backboneMixin('cluster', 'change:status'),
backboneMixin('nodes', 'update change'),
backboneMixin({
modelOrCollection: (props) => props.cluster && props.cluster.get('tasks'),
renderOn: 'update change:status'
}),
dispatcherMixin('labelsConfigurationUpdated', 'normalizeAppliedFilters')
],
getDefaultProps() {
return {
showBatchActionButtons: true,
showLabelManagementButton: true,
showViewModeButtons: true,
nodeActionsAvailable: true
};
},
getInitialState() {
return {isLabelsPanelOpen: false};
},
selectNodes(ids, name, checked) {
this.props.selectNodes(ids, checked);
},
fetchData() {
return this.props.nodes.fetch();
},
calculateFilterLimits() {
_.invokeMap(this.props.availableFilters, 'updateLimits', this.props.nodes, true);
_.invokeMap(this.props.activeFilters, 'updateLimits', this.props.nodes, false);
},
normalizeAppliedFilters(checkStandardNodeFilters = false) {
var {nodes, activeFilters, updateFilters} = this.props;
var normalizedFilters = _.map(activeFilters, (activeFilter) => {
var filter = _.clone(activeFilter);
if (filter.values.length) {
if (filter.isLabel) {
filter.values = _.intersection(filter.values, nodes.getLabelValues(filter.name));
} else if (
checkStandardNodeFilters &&
_.includes(['manufacturer', 'group_id', 'cluster'], filter.name)
) {
filter.values = _.filter(filter.values, (value) => nodes.some({[filter.name]: value}));
}
}
return filter;
});
if (!_.isEqual(_.map(normalizedFilters, 'values'), _.map(activeFilters, 'values'))) {
updateFilters(normalizedFilters);
}
},
componentWillMount() {
var {mode, nodes, updateSearch, showRolePanel} = this.props;
this.updateInitialRoles();
nodes.on('update reset', this.updateInitialRoles, this);
if (mode !== 'edit') {
nodes.on('update reset', this.calculateFilterLimits, this);
this.normalizeAppliedFilters(true);
this.changeSearch = _.debounce(updateSearch, 200, {leading: true});
}
if (showRolePanel) {
// hack to prevent node roles update after node polling
nodes.on('change:pending_roles', this.checkRoleAssignment, this);
}
},
componentWillUnmount() {
this.props.nodes.off('update reset', this.updateInitialRoles, this);
this.props.nodes.off('update reset', this.calculateFilterLimits, this);
this.props.nodes.off('change:pending_roles', this.checkRoleAssignment, this);
},
processRoleLimits() {
var {cluster, nodes, selectedNodeIds, selectedRoles, configModels} = this.props;
var maxNumberOfNodes = [];
var processedRoleLimits = {};
var selectedNodes = nodes.filter((node) => selectedNodeIds[node.id]);
var clusterNodes = cluster.get('nodes').filter(
(node) => !_.includes(selectedNodeIds, node.id)
);
var nodesForLimitCheck = new models.Nodes(_.union(selectedNodes, clusterNodes));
cluster.get('roles').each((role) => {
if ((role.get('limits') || {}).max) {
var roleName = role.get('name');
var isRoleAlreadyAssigned = nodesForLimitCheck.some((node) => node.hasRole(roleName));
processedRoleLimits[roleName] = role.checkLimits(
configModels,
nodesForLimitCheck,
!isRoleAlreadyAssigned,
['max']
);
}
});
_.each(processedRoleLimits, (roleLimit, roleName) => {
if (_.includes(selectedRoles, roleName)) maxNumberOfNodes.push(roleLimit.limits.max);
});
return {
// need to cache roles with limits in order to avoid calculating this twice on the RolePanel
processedRoleLimits,
// real number of nodes to add used by Select All controls
maxNumberOfNodes: maxNumberOfNodes.length ?
_.min(maxNumberOfNodes) - _.size(selectedNodeIds) : null
};
},
updateInitialRoles() {
this.initialRoles = _.zipObject(this.props.nodes.map('id'),
this.props.nodes.map('pending_roles'));
},
checkRoleAssignment(node, roles, options) {
if (!options.assign) node.set({pending_roles: node.previous('pending_roles')}, {assign: true});
},
hasChanges() {
return this.props.showRolePanel && this.props.nodes.some((node) => {
return !_.isEqual(node.get('pending_roles'), this.initialRoles[node.id]);
});
},
addSorting(sorter) {
this.props.updateSorting(this.props.activeSorters.concat(sorter));
},
removeSorting(sorter) {
this.props.updateSorting(_.difference(this.props.activeSorters, [sorter]));
},
resetSorters() {
this.props.updateSorting(
_.map(this.props.defaultSorting, _.partial(Sorter.fromObject, _, sorterNs, false))
);
},
changeSortingOrder(sorterToChange) {
this.props.updateSorting(
this.props.activeSorters.map((sorter) => {
var {name, order, isLabel} = sorter;
if (name === sorterToChange.name && isLabel === sorterToChange.isLabel) {
return new Sorter(name, order === 'asc' ? 'desc' : 'asc', sorterNs, isLabel);
}
return sorter;
})
);
},
getFilterOptions(filter) {
if (filter.isLabel) {
var values = _.uniq(this.props.nodes.getLabelValues(filter.name));
var ns = 'cluster_page.nodes_tab.node_management_panel.';
return values.map((value) => {
return {
name: value,
title: _.isNull(value) ? i18n(ns + 'label_value_not_specified') : value === false ?
i18n(ns + 'label_not_assigned') : value
};
});
}
var options;
switch (filter.name) {
case 'status':
var os = this.props.cluster && this.props.cluster.get('release').get('operating_system') ||
'OS';
options = this.props.statusesToFilter.map((status) => {
return {
name: status,
title: i18n('cluster_page.nodes_tab.node.status.' + status, {os: os})
};
});
break;
case 'manufacturer':
options = _.uniq(this.props.nodes.map('manufacturer')).map((manufacturer) => {
manufacturer = manufacturer || '';
return {
name: manufacturer.replace(/\s/g, '_'),
title: manufacturer
};
});
break;
case 'roles':
options = this.props.roles.map(
(role) => ({name: role.get('name'), title: role.get('label')})
);
break;
case 'group_id':
options = _.uniq(this.props.nodes.map('group_id')).map((groupId) => {
var nodeNetworkGroup = this.props.nodeNetworkGroups.get(groupId);
return {
name: groupId,
title: nodeNetworkGroup ?
nodeNetworkGroup.get('name') +
(
this.props.cluster ?
'' :
' (' + this.props.clusters.get(nodeNetworkGroup.get('cluster_id')).get('name') + ')'
)
:
i18n('common.not_specified')
};
});
break;
case 'cluster':
options = _.uniq(this.props.nodes.map('cluster')).map((clusterId) => {
return {
name: clusterId,
title: clusterId ? this.props.clusters.get(clusterId).get('name') :
i18n('cluster_page.nodes_tab.node.unallocated')
};
});
break;
}
// sort option list
options.sort((option1, option2) => {
// sort Node Network Group filter options by node network group id
if (this.props.name === 'group_id') return option1.name - option2.name;
return utils.natsort(option1.title, option2.title, {insensitive: true});
});
return options;
},
addFilter(filter) {
this.props.updateFilters(this.props.activeFilters.concat(filter));
},
changeFilter(filterToChange, values) {
this.props.updateFilters(
this.props.activeFilters.map((filter) => {
var {name, limits, isLabel} = filter;
if (name === filterToChange.name && isLabel === filterToChange.isLabel) {
var changedFilter = new Filter(name, values, isLabel);
changedFilter.limits = limits;
return changedFilter;
}
return filter;
})
);
},
removeFilter(filter) {
this.props.updateFilters(_.difference(this.props.activeFilters, [filter]));
},
resetFilters() {
this.props.updateFilters(Filter.fromObject(this.props.defaultFilters, false));
},
revertChanges() {
this.props.nodes.each((node) => {
node.set({pending_roles: this.initialRoles[node.id]}, {silent: true});
});
},
toggleLabelsPanel(value) {
this.setState({
isLabelsPanelOpen: _.isUndefined(value) ? !this.state.isLabelsPanelOpen : value
});
},
getNodeLabels() {
return _.chain(this.props.nodes.map('labels')).flatten().map(_.keys).flatten().uniq().value();
},
getFilterResults(filter, node) {
var result;
switch (filter.name) {
case 'roles':
result = _.some(filter.values, (role) => node.hasRole(role));
break;
case 'status':
result = _.includes(filter.values, node.getStatus()) ||
_.includes(filter.values, 'pending_addition') && node.get('pending_addition') ||
_.includes(filter.values, 'pending_deletion') && node.get('pending_deletion');
break;
case 'manufacturer':
case 'cluster':
case 'group_id':
result = _.includes(filter.values, node.get(filter.name));
break;
default:
// handle number ranges
var currentValue = node.resource(filter.name);
if (filter.name === 'hdd' || filter.name === 'ram') {
currentValue = currentValue / Math.pow(1024, 3);
}
result = currentValue >= filter.values[0] &&
(_.isUndefined(filter.values[1]) || currentValue <= filter.values[1]);
break;
}
return result;
},
render() {
var {cluster, nodes, selectedNodeIds, search, activeFilters, showRolePanel, mode} = this.props;
var locked = !!cluster && !!cluster.task({group: 'deployment', active: true});
var processedRoleData = cluster ? this.processRoleLimits() : {};
// labels to manage in labels panel
var selectedNodes = new models.Nodes(nodes.filter((node) => selectedNodeIds[node.id]));
var selectedNodeLabels = _.chain(selectedNodes.map('labels'))
.flatten()
.map(_.keys)
.flatten()
.uniq()
.value();
// filter nodes
var filteredNodes = nodes.filter((node) => {
// search field
if (
search &&
_.every(node.pick('name', 'mac', 'ip'), (attribute) => {
return !_.includes((attribute || '').toLowerCase(), search.toLowerCase());
})
) {
return false;
}
// filters
return _.every(activeFilters, (filter) => {
if (!filter.values.length) return true;
if (filter.isLabel) {
return _.includes(filter.values, node.getLabel(filter.name));
}
return this.getFilterResults(filter, node);
});
});
var screenNodesLabels = this.getNodeLabels();
return (
<div>
{mode === 'edit' &&
<div className='alert alert-warning'>
{i18n('cluster_page.nodes_tab.disk_configuration_reset_warning')}
</div>
}
<ManagementPanel
{... _.pick(this.props,
'cluster', 'mode', 'showBatchActionButtons',
'viewMode', 'changeViewMode', 'showViewModeButtons',
'search', 'updateSearch',
'activeSorters', 'availableSorters', 'defaultSorting',
'activeFilters', 'availableFilters', 'defaultFilters',
'showLabelManagementButton'
)}
{... _.pick(this,
'addSorting', 'removeSorting', 'resetSorters', 'changeSortingOrder',
'addFilter', 'changeFilter', 'removeFilter', 'resetFilters', 'getFilterOptions',
'changeSearch',
'toggleLabelsPanel',
'revertChanges',
'selectNodes'
)}
{... _.pick(this.state, 'isLabelsPanelOpen')}
labelSorters={screenNodesLabels.map((name) => new Sorter(name, 'asc', sorterNs, true))}
labelFilters={screenNodesLabels.map((name) => new Filter(name, [], true))}
nodes={selectedNodes}
screenNodes={nodes}
filteredNodes={filteredNodes}
selectedNodeLabels={selectedNodeLabels}
hasChanges={this.hasChanges()}
locked={locked}
/>
{showRolePanel &&
<RolePanel
{... _.pick(this.props,
'cluster',
'mode',
'nodes',
'selectedNodeIds',
'selectedRoles',
'indeterminateRoles',
'selectRoles',
'configModels'
)}
{... _.pick(processedRoleData, 'processedRoleLimits')}
/>
}
<NodeList
{... _.pick(this.props,
'cluster', 'mode', 'statusesToFilter', 'selectedNodeIds',
'clusters', 'roles', 'nodeNetworkGroups', 'nodeActionsAvailable',
'viewMode', 'activeSorters', 'selectedRoles'
)}
{... _.pick(processedRoleData, 'maxNumberOfNodes', 'processedRoleLimits')}
nodes={filteredNodes}
totalNodesLength={nodes.length}
locked={this.state.isLabelsPanelOpen}
selectNodes={this.selectNodes}
/>
</div>
);
}
});
NumberRangeControl = React.createClass({
propTypes: {
name: React.PropTypes.string,
label: React.PropTypes.node.isRequired,
values: React.PropTypes.array,
onChange: React.PropTypes.func,
extraContent: React.PropTypes.node,
prefix: React.PropTypes.string,
min: React.PropTypes.number,
max: React.PropTypes.number,
toggle: React.PropTypes.func.isRequired,
isOpen: React.PropTypes.bool.isRequired
},
getDefaultProps() {
return {
values: [],
isOpen: false,
min: 0,
max: 0
};
},
changeValue(name, value, index) {
var values = this.props.values;
values[index] = _.max([Number(value), 0]);
this.props.onChange(values);
},
closeOnEscapeKey(e) {
if (e.key === 'Escape') this.props.toggle(this.props.name, false);
},
render() {
var classNames = {'btn-group number-range': true, open: this.props.isOpen};
if (this.props.className) classNames[this.props.className] = true;
var props = {
type: 'number',
inputClassName: 'pull-left',
min: this.props.min,
max: this.props.max,
error: this.props.values[0] > this.props.values[1] || null
};
return (
<div className={utils.classNames(classNames)} tabIndex='-1' onKeyDown={this.closeOnEscapeKey}>
<button className='btn btn-default dropdown-toggle' onClick={this.props.toggle}>
{this.props.label + ': ' + _.uniq(this.props.values).join(' - ')}
{' '}
<span className='caret' />
</button>
{this.props.isOpen &&
<Popover toggle={this.props.toggle}>
<div className='clearfix'>
<Input {...props}
name='start'
value={this.props.values[0]}
onChange={_.partialRight(this.changeValue, 0)}
autoFocus
/>
<span className='pull-left'> &mdash; </span>
<Input {...props}
name='end'
value={this.props.values[1]}
onChange={_.partialRight(this.changeValue, 1)}
/>
</div>
</Popover>
}
{this.props.extraContent}
</div>
);
}
});
ManagementPanel = React.createClass({
mixins: [unsavedChangesMixin],
getInitialState() {
return {
actionInProgress: false,
isSearchButtonVisible: !!this.props.search,
activeSearch: !!this.props.search,
openFilter: null,
isMoreFilterControlVisible: false,
isMoreSorterControlVisible: false
};
},
changeScreen(url, passNodeIds) {
url = url ? '/' + url : '';
if (passNodeIds) url += '/' + utils.serializeTabOptions({nodes: this.props.nodes.map('id')});
app.navigate('/cluster/' + this.props.cluster.id + '/nodes' + url);
},
goToConfigurationScreen(action, conflict) {
if (conflict) {
var ns = 'cluster_page.nodes_tab.node_management_panel.node_management_error.';
utils.showErrorDialog({
title: i18n(ns + 'title'),
message: <div>
<i className='glyphicon glyphicon-danger-sign' />
{i18n(ns + action + '_configuration_warning')}
</div>
});
return;
}
this.changeScreen(action, true);
},
showDeleteNodesDialog() {
var {cluster, nodes, selectNodes} = this.props;
DeleteNodesDialog
.show({nodes, cluster})
.then(_.partial(selectNodes, _.map(nodes.filter({status: 'ready'}), 'id'), null, true));
},
hasChanges() {
return this.props.hasChanges;
},
isSavingPossible() {
return !this.state.actionInProgress && this.hasChanges();
},
revertChanges() {
return this.props.revertChanges();
},
applyChanges() {
if (!this.isSavingPossible()) return Promise.reject();
this.setState({actionInProgress: true});
var nodes = new models.Nodes(this.props.nodes.map((node) => {
var data = {id: node.id, pending_roles: node.get('pending_roles')};
if (node.get('pending_roles').length) {
if (this.props.mode === 'add') {
return _.extend(data, {cluster_id: this.props.cluster.id, pending_addition: true});
}
} else if (node.get('pending_addition')) {
return _.extend(data, {cluster_id: null, pending_addition: false});
}
return data;
}));
return Backbone.sync('update', nodes)
.then(
() => Promise.all([
this.props.cluster.fetch(),
this.props.cluster.get('nodes').fetch()
]),
(response) => {
this.setState({actionInProgress: false});
utils.showErrorDialog({
message: i18n('cluster_page.nodes_tab.node_management_panel.' +
'node_management_error.saving_warning'),
response
});
}
)
.catch(() => true)
.then(() => {
if (this.props.mode === 'add') {
dispatcher.trigger('updateNodeStats networkConfigurationUpdated ' +
'labelsConfigurationUpdated');
this.props.selectNodes();
}
});
},
applyAndRedirect() {
this.applyChanges().then(_.partial(this.changeScreen, '', false));
},
searchNodes(name, value) {
this.setState({isSearchButtonVisible: !!value});
this.props.changeSearch(value);
},
clearSearchField() {
this.setState({isSearchButtonVisible: false});
this.refs.search.getInputDOMNode().value = '';
this.refs.search.getInputDOMNode().focus();
this.props.changeSearch.cancel();
this.props.updateSearch('');
},
attachSearchEvent() {
$('html').on('click.search', (e) => {
if (!this.props.search && this.refs.search &&
!$(e.target).closest(ReactDOM.findDOMNode(this.refs.search)).length) {
this.setState({activeSearch: false});
}
});
},
activateSearch() {
this.setState({activeSearch: true});
this.attachSearchEvent();
},
onSearchKeyDown(e) {
if (e.key === 'Escape') {
this.clearSearchField();
this.setState({activeSearch: false});
}
},
componentWillUnmount() {
$('html').off('click.search');
},
componentDidMount() {
if (this.state.activeSearch) this.attachSearchEvent();
},
removeSorting(sorter) {
this.props.removeSorting(sorter);
this.setState({
sortersKey: _.now(),
isMoreSorterControlVisible: false
});
},
resetSorters(e) {
e.stopPropagation();
this.props.resetSorters();
this.setState({
sortersKey: _.now(),
isMoreSorterControlVisible: false
});
},
toggleFilter(filter, visible) {
var isFilterOpen = this.isFilterOpen(filter);
visible = _.isBoolean(visible) ? visible : !isFilterOpen;
this.setState({
openFilter: visible ? filter : isFilterOpen ? null : this.state.openFilter
});
},
toggleMoreFilterControl(visible) {
this.setState({
isMoreFilterControlVisible: _.isBoolean(visible) ? visible :
!this.state.isMoreFilterControlVisible,
openFilter: null
});
},
toggleMoreSorterControl(visible) {
this.setState({
isMoreSorterControlVisible: _.isBoolean(visible) ? visible :
!this.state.isMoreSorterControlVisible
});
},
isFilterOpen(filter) {
return !_.isNull(this.state.openFilter) &&
this.state.openFilter.name === filter.name &&
this.state.openFilter.isLabel === filter.isLabel;
},
addFilter(filter) {
this.props.addFilter(filter);
this.toggleMoreFilterControl();
this.toggleFilter(filter, true);
},
removeFilter(filter) {
this.props.removeFilter(filter);
this.setState({filtersKey: _.now()});
this.toggleFilter(filter, false);
},
resetFilters(e) {
e.stopPropagation();
this.props.resetFilters();
this.setState({
filtersKey: _.now(),
openFilter: null
});
},
toggleSorters() {
this.setState({
newLabels: [],
areSortersVisible: !this.state.areSortersVisible,
isMoreSorterControlVisible: false,
areFiltersVisible: false
});
this.props.toggleLabelsPanel(false);
},
toggleFilters() {
this.setState({
newLabels: [],
areFiltersVisible: !this.state.areFiltersVisible,
openFilter: null,
areSortersVisible: false
});
this.props.toggleLabelsPanel(false);
},
renderDeleteFilterButton(filter) {
if (!filter.isLabel && _.includes(_.keys(this.props.defaultFilters), filter.name)) return null;
return (
<i
className='btn btn-link glyphicon glyphicon-minus-sign btn-remove-filter'
onClick={_.partial(this.removeFilter, filter)}
/>
);
},
toggleLabelsPanel() {
this.setState({
newLabels: [],
areFiltersVisible: false,
areSortersVisible: false
});
this.props.toggleLabelsPanel();
},
renderDeleteSorterButton(sorter) {
return (
<i
className='btn btn-link glyphicon glyphicon-minus-sign btn-remove-sorting'
onClick={_.partial(this.removeSorting, sorter)}
/>
);
},
render() {
var {
nodes, screenNodes, filteredNodes, mode, locked, showBatchActionButtons,
viewMode, changeViewMode, showViewModeButtons,
search,
activeSorters, availableSorters, labelSorters, defaultSorting, changeSortingOrder, addSorting,
activeFilters, availableFilters, labelFilters, changeFilter, getFilterOptions,
isLabelsPanelOpen, selectedNodeLabels, showLabelManagementButton,
revertChanges
} = this.props;
var ns = 'cluster_page.nodes_tab.node_management_panel.';
var disksConflict, interfaceConflict, inactiveSorters, canResetSorters,
inactiveFilters, appliedFilters;
if (mode === 'list' && nodes.length) {
disksConflict = !nodes.areDisksConfigurable();
interfaceConflict = !nodes.areInterfacesConfigurable();
}
var managementButtonClasses = (isActive, className) => {
var classes = {
'btn btn-default pull-left': true,
active: isActive
};
classes[className] = true;
return classes;
};
if (mode !== 'edit') {
var checkSorter = ({name}, isLabel) => !_.some(activeSorters, {name, isLabel});
inactiveSorters = _.union(
_.filter(availableSorters, _.partial(checkSorter, _, false)),
_.filter(labelSorters, _.partial(checkSorter, _, true))
)
.sort((sorter1, sorter2) => {
return utils.natsort(sorter1.title, sorter2.title, {insensitive: true});
});
canResetSorters = _.some(activeSorters, {isLabel: true}) ||
!_(activeSorters)
.filter({isLabel: false})
.map(Sorter.toObject)
.isEqual(defaultSorting);
var checkFilter = ({name}, isLabel) => !_.some(activeFilters, {name, isLabel});
inactiveFilters = _.union(
_.filter(availableFilters, _.partial(checkFilter, _, false)),
_.filter(labelFilters, _.partial(checkFilter, _, true))
)
.sort((filter1, filter2) => {
return utils.natsort(filter1.title, filter2.title, {insensitive: true});
});
appliedFilters = _.reject(activeFilters, ({values}) => !values.length);
}
selectedNodeLabels.sort(_.partialRight(utils.natsort, {insensitive: true}));
return (
<div className='row'>
<div className='sticker node-management-panel'>
<div className='node-list-management-buttons col-xs-5'>
{showViewModeButtons &&
<div className='view-mode-switcher'>
<div className='btn-group' data-toggle='buttons'>
{_.map(NODE_VIEW_MODES, (mode) => {
return (
<Tooltip key={mode + '-view'} text={i18n(ns + mode + '_mode_tooltip')}>
<label
className={utils.classNames(
managementButtonClasses(mode === viewMode, mode)
)}
onClick={
mode !== viewMode && _.partial(changeViewMode, mode)
}
>
<input type='radio' name='view_mode' value={mode} />
<i
className={utils.classNames({
glyphicon: true,
'glyphicon-th-list': mode === 'standard',
'glyphicon-th': mode === 'compact'
})}
/>
</label>
</Tooltip>
);
})}
</div>
</div>
}
{mode !== 'edit' && [
showLabelManagementButton &&
<Tooltip wrap key='labels-btn' text={i18n(ns + 'labels_tooltip')}>
<button
disabled={!nodes.length}
onClick={nodes.length && this.toggleLabelsPanel}
className={utils.classNames(
managementButtonClasses(isLabelsPanelOpen, 'btn-labels')
)}
>
<i className='glyphicon glyphicon-tag' />
</button>
</Tooltip>,
<Tooltip wrap key='sorters-btn' text={i18n(ns + 'sort_tooltip')}>
<button
disabled={!screenNodes.length}
onClick={this.toggleSorters}
className={utils.classNames(
managementButtonClasses(this.state.areSortersVisible, 'btn-sorters')
)}
>
<i className='glyphicon glyphicon-sort' />
</button>
</Tooltip>,
<Tooltip wrap key='filters-btn' text={i18n(ns + 'filter_tooltip')}>
<button
disabled={!screenNodes.length}
onClick={this.toggleFilters}
className={utils.classNames(
managementButtonClasses(this.state.areFiltersVisible, 'btn-filters')
)}
>
<i className='glyphicon glyphicon-filter' />
</button>
</Tooltip>,
!this.state.activeSearch && (
<Tooltip wrap key='search-btn' text={i18n(ns + 'search_tooltip')}>
<button
disabled={!screenNodes.length}
onClick={this.activateSearch}
className={utils.classNames(managementButtonClasses(false, 'btn-search'))}
>
<i className='glyphicon glyphicon-search' />
</button>
</Tooltip>
),
this.state.activeSearch && (
<div className='search' key='search'>
<Input
type='text'
name='search'
ref='search'
defaultValue={search}
placeholder={i18n(ns + 'search_placeholder')}
disabled={!screenNodes.length}
onChange={this.searchNodes}
onKeyDown={this.onSearchKeyDown}
autoFocus
/>
{this.state.isSearchButtonVisible &&
<button
className='close btn-clear-search'
onClick={this.clearSearchField}
>
&times;
</button>
}
</div>
)
]}
</div>
<div className='control-buttons-box col-xs-7 text-right'>
{showBatchActionButtons && (
mode !== 'list' ?
<div className='btn-group' role='group'>
<button
className='btn btn-default'
disabled={this.state.actionInProgress}
onClick={() => {
revertChanges();
this.changeScreen();
}}
>
{i18n('common.cancel_button')}
</button>
<ProgressButton
className='btn btn-success btn-apply'
disabled={!this.isSavingPossible()}
onClick={this.applyAndRedirect}
progress={this.state.actionInProgress}
>
{i18n('common.apply_changes_button')}
</ProgressButton>
</div>
:
[
!!nodes.length &&
<div className='btn-group' role='group' key='configuration-buttons'>
<button
className='btn btn-default btn-configure-disks'
onClick={() => this.goToConfigurationScreen('disks', disksConflict)}
>
{disksConflict && <i className='glyphicon glyphicon-danger-sign' />}
{i18n('dialog.show_node.disk_configuration' +
(_.every(nodes.invokeMap('areDisksConfigurable')) ? '_action' : ''))
}
</button>
<button
className='btn btn-default btn-configure-interfaces'
onClick={
() => this.goToConfigurationScreen('interfaces', interfaceConflict)
}
>
{interfaceConflict && <i className='glyphicon glyphicon-danger-sign' />}
{i18n('dialog.show_node.network_configuration' +
(_.every(nodes.invokeMap('areInterfacesConfigurable')) ? '_action' : ''))
}
</button>
</div>,
<div className='btn-group' role='group' key='role-management-buttons'>
{!locked && !!nodes.length && nodes.some({pending_deletion: false}) &&
<button
className='btn btn-danger btn-delete-nodes'
onClick={this.showDeleteNodesDialog}
>
<i className='glyphicon glyphicon-trash' />
{i18n('common.delete_button')}
</button>
}
{!locked && !!nodes.length &&
<button
className='btn btn-success btn-edit-roles'
onClick={_.partial(this.changeScreen, 'edit', true)}
>
<i className='glyphicon glyphicon-edit' />
{i18n(ns + 'edit_roles_button')}
</button>
}
</div>,
!locked &&
<div className='btn-group' role='group' key='add-nodes-button'>
<button
className='btn btn-success btn-add-nodes'
onClick={_.partial(this.changeScreen, 'add', false)}
disabled={locked}
>
<i className='glyphicon glyphicon-plus-white' />
{i18n(ns + 'add_nodes_button')}
</button>
</div>
]
)}
</div>
{mode !== 'edit' && !!screenNodes.length && [
isLabelsPanelOpen &&
<NodeLabelsPanel {... _.pick(this.props, 'nodes', 'screenNodes')}
key='labels'
labels={selectedNodeLabels}
toggleLabelsPanel={this.toggleLabelsPanel}
/>,
this.state.areSortersVisible && (
<div className='col-xs-12 sorters' key='sorters'>
<div className='well clearfix' key={this.state.sortersKey}>
<div className='well-heading'>
<i className='glyphicon glyphicon-sort' /> {i18n(ns + 'sort_by')}
{canResetSorters &&
<button
className='btn btn-link pull-right btn-reset-sorting'
onClick={this.resetSorters}
>
<i className='glyphicon discard-changes-icon' /> {i18n(ns + 'reset')}
</button>
}
</div>
{activeSorters.map((sorter) => {
var asc = sorter.order === 'asc';
return (
<div
key={'sort_by-' + sorter.name + (sorter.isLabel && '-label')}
className={utils.classNames({
'sorter-control pull-left': true,
['sort-by-' + sorter.name + '-' + sorter.order]: !sorter.isLabel
})}
>
<button
className='btn btn-default'
onClick={_.partial(changeSortingOrder, sorter)}
>
{sorter.title}
<i
className={utils.classNames({
glyphicon: true,
'glyphicon-arrow-down': asc,
'glyphicon-arrow-up': !asc
})}
/>
</button>
{activeSorters.length > 1 && this.renderDeleteSorterButton(sorter)}
</div>
);
})}
<MultiSelectControl
name='sorter-more'
label={i18n(ns + 'more')}
options={inactiveSorters}
onChange={addSorting}
dynamicValues
isOpen={this.state.isMoreSorterControlVisible}
toggle={this.toggleMoreSorterControl}
/>
</div>
</div>
),
this.state.areFiltersVisible && (
<div className='col-xs-12 filters' key='filters'>
<div className='well clearfix' key={this.state.filtersKey}>
<div className='well-heading'>
<i className='glyphicon glyphicon-filter' /> {i18n(ns + 'filter_by')}
{!!appliedFilters.length &&
<button
className='btn btn-link pull-right btn-reset-filters'
onClick={this.resetFilters}
>
<i className='glyphicon discard-changes-icon' /> {i18n(ns + 'reset')}
</button>
}
</div>
{_.map(activeFilters, (filter) => {
var props = {
key: (filter.isLabel ? 'label-' : '') + filter.name,
ref: filter.name,
name: filter.name,
values: filter.values,
className: utils.classNames({
'filter-control': true,
['filter-by-' + filter.name]: !filter.isLabel
}),
label: filter.title,
extraContent: this.renderDeleteFilterButton(filter),
onChange: _.partial(changeFilter, filter),
prefix: i18n(
'cluster_page.nodes_tab.filters.prefixes.' + filter.name,
{defaultValue: ''}
),
isOpen: this.isFilterOpen(filter),
toggle: _.partial(this.toggleFilter, filter)
};
if (filter.isNumberRange) {
return <NumberRangeControl
{...props}
min={filter.limits[0]}
max={filter.limits[1]}
/>;
}
return <MultiSelectControl
{...props}
options={getFilterOptions(filter)}
/>;
})}
<MultiSelectControl
name='filter-more'
label={i18n(ns + 'more')}
options={inactiveFilters}
onChange={this.addFilter}
dynamicValues
isOpen={this.state.isMoreFilterControlVisible}
toggle={this.toggleMoreFilterControl}
/>
</div>
</div>
)
]}
{mode !== 'edit' && !!screenNodes.length &&
<div className='col-xs-12'>
{(!this.state.areSortersVisible || !this.state.areFiltersVisible &&
!!appliedFilters.length) &&
<div className='active-sorters-filters'>
{!this.state.areFiltersVisible && !!appliedFilters.length &&
<div className='active-filters row' onClick={this.toggleFilters}>
<strong className='col-xs-1'>{i18n(ns + 'filter_by')}</strong>
<div className='col-xs-11'>
{i18n('cluster_page.nodes_tab.filter_results_amount', {
count: filteredNodes.length,
total: screenNodes.length
})}
{_.map(appliedFilters, (filter) => {
var options = filter.isNumberRange ? null : getFilterOptions(filter);
return (
<div key={filter.name}>
<strong>{filter.title}{!!filter.values.length && ':'} </strong>
<span>
{filter.isNumberRange ?
_.uniq(filter.values).join(' - ')
:
_.map(
_.filter(options, ({name}) => _.includes(filter.values, name))
, 'title').join(', ')
}
</span>
</div>
);
})}
</div>
<button
className='btn btn-link btn-reset-filters'
onClick={this.resetFilters}
>
<i className='glyphicon discard-changes-icon' />
</button>
</div>
}
{!this.state.areSortersVisible &&
<div className='active-sorters row' onClick={this.toggleSorters}>
<strong className='col-xs-1'>{i18n(ns + 'sort_by')}</strong>
<div className='col-xs-11'>
{activeSorters.map((sorter, index) => {
var asc = sorter.order === 'asc';
return (
<span key={sorter.name + (sorter.isLabel && '-label')}>
{sorter.title}
<i
className={utils.classNames({
glyphicon: true,
'glyphicon-arrow-down': asc,
'glyphicon-arrow-up': !asc
})}
/>
{!!activeSorters[index + 1] && ' + '}
</span>
);
})}
</div>
{canResetSorters &&
<button
className='btn btn-link btn-reset-sorting'
onClick={this.resetSorters}
>
<i className='glyphicon discard-changes-icon' />
</button>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
);
}
});
NodeLabelsPanel = React.createClass({
mixins: [unsavedChangesMixin],
getInitialState() {
var labels = _.map(this.props.labels, (label) => {
var labelValues = this.props.nodes.getLabelValues(label);
var definedLabelValues = _.reject(labelValues, _.isUndefined);
return {
key: label,
values: _.uniq(definedLabelValues),
checked: labelValues.length === definedLabelValues.length,
indeterminate: labelValues.length !== definedLabelValues.length,
error: null
};
});
return {
labels: _.cloneDeep(labels),
initialLabels: _.cloneDeep(labels),
actionInProgress: false
};
},
hasChanges() {
return !_.isEqual(this.state.labels, this.state.initialLabels);
},
componentDidMount() {
_.each(this.state.labels, (labelData) => {
this.refs[labelData.key].getInputDOMNode().indeterminate = labelData.indeterminate;
});
},
addLabel() {
var labels = this.state.labels;
labels.push({
key: '',
values: [null],
checked: false,
error: null
});
this.setState({labels: labels});
},
changeLabelKey(index, oldKey, newKey) {
var labels = this.state.labels;
var labelData = labels[index];
labelData.key = newKey;
if (!labelData.indeterminate) labelData.checked = true;
this.validateLabels(labels);
this.setState({labels: labels});
},
changeLabelState(index, key, checked) {
var labels = this.state.labels;
var labelData = labels[index];
labelData.checked = checked;
labelData.indeterminate = false;
this.validateLabels(labels);
this.setState({labels: labels});
},
changeLabelValue(index, key, value) {
var labels = this.state.labels;
var labelData = labels[index];
labelData.values = [value || null];
if (!labelData.indeterminate) labelData.checked = true;
this.validateLabels(labels);
this.setState({labels: labels});
},
validateLabels(labels) {
_.each(labels, (currentLabel, currentIndex) => {
currentLabel.error = null;
if (currentLabel.checked || currentLabel.indeterminate) {
var ns = 'cluster_page.nodes_tab.node_management_panel.labels.';
if (!_.trim(currentLabel.key)) {
currentLabel.error = i18n(ns + 'empty_label_key');
} else {
var doesLabelExist = _.some(labels, (label, index) => {
return index !== currentIndex &&
_.trim(label.key) === _.trim(currentLabel.key) &&
(label.checked || label.indeterminate);
});
if (doesLabelExist) currentLabel.error = i18n(ns + 'existing_label');
}
}
});
},
isSavingPossible() {
return !this.state.actionInProgress && this.hasChanges() &&
_.every(_.map(this.state.labels, 'error'), _.isNull);
},
revertChanges() {
return this.props.toggleLabelsPanel();
},
applyChanges() {
if (!this.isSavingPossible()) return Promise.reject();
this.setState({actionInProgress: true});
var nodes = new models.Nodes(
this.props.nodes.map((node) => {
var nodeLabels = node.get('labels');
_.each(this.state.labels, (labelData, index) => {
var oldLabel = this.props.labels[index];
// delete label
if (!labelData.checked && !labelData.indeterminate) {
delete nodeLabels[oldLabel];
}
var nodeHasLabel = !_.isUndefined(nodeLabels[oldLabel]);
var label = labelData.key;
// rename label
if ((labelData.checked || labelData.indeterminate) && nodeHasLabel) {
var labelValue = nodeLabels[oldLabel];
delete nodeLabels[oldLabel];
nodeLabels[label] = labelValue;
}
// add label
if (labelData.checked && !nodeHasLabel) {
nodeLabels[label] = labelData.values[0];
}
// change label value
if (!_.isUndefined(nodeLabels[label]) && labelData.values.length === 1) {
nodeLabels[label] = labelData.values[0];
}
});
return {id: node.id, labels: nodeLabels};
})
);
return Backbone.sync('update', nodes)
.then(
() => this.props.screenNodes.fetch(),
(response) => {
utils.showErrorDialog({
message: i18n(
'cluster_page.nodes_tab.node_management_panel.' +
'node_management_error.labels_warning'
),
response
});
}
)
.catch(() => true)
.then(() => {
dispatcher.trigger('labelsConfigurationUpdated');
this.props.screenNodes.trigger('change');
this.props.toggleLabelsPanel();
});
},
render() {
var ns = 'cluster_page.nodes_tab.node_management_panel.labels.';
return (
<div className='col-xs-12 labels'>
<div className='well clearfix'>
<div className='well-heading'>
<i className='glyphicon glyphicon-tag' /> {i18n(ns + 'manage_labels')}
</div>
<div className='forms-box form-inline'>
<p>
{i18n(ns + 'bulk_label_action_start')}
<strong>
{i18n(ns + 'selected_nodes_amount', {count: this.props.nodes.length})}
</strong>
{i18n(ns + 'bulk_label_action_end')}
</p>
{_.map(this.state.labels, (labelData, index) => {
var labelValueProps = labelData.values.length > 1 ? {
value: '',
wrapperClassName: 'has-warning',
tooltipText: i18n(ns + 'label_value_warning')
} : {
value: labelData.values[0]
};
var showControlLabels = index === 0;
return (
<div
className={utils.classNames({clearfix: true, 'has-label': showControlLabels})}
key={index}
>
<Input
type='checkbox'
ref={labelData.key}
checked={labelData.checked}
onChange={_.partial(this.changeLabelState, index)}
wrapperClassName='pull-left'
/>
<Input
type='text'
maxLength='100'
label={showControlLabels && i18n(ns + 'label_key')}
value={labelData.key}
onChange={_.partial(this.changeLabelKey, index)}
error={labelData.error}
wrapperClassName='label-key-control'
autoFocus={index === this.state.labels.length - 1}
/>
<Input {...labelValueProps}
type='text'
maxLength='100'
label={showControlLabels && i18n(ns + 'label_value')}
onChange={_.partial(this.changeLabelValue, index)}
/>
</div>
);
})}
<button
className='btn btn-default btn-add-label'
onClick={this.addLabel}
disabled={this.state.actionInProgress}
>
{i18n(ns + 'add_label')}
</button>
</div>
{!!this.state.labels.length &&
<div className='control-buttons text-right'>
<div className='btn-group' role='group'>
<button
className='btn btn-default'
onClick={this.revertChanges}
disabled={this.state.actionInProgress}
>
{i18n('common.cancel_button')}
</button>
<ProgressButton
className='btn btn-success'
onClick={this.applyChanges}
disabled={!this.isSavingPossible()}
progress={this.state.actionInProgress}
>
{i18n('common.apply_button')}
</ProgressButton>
</div>
</div>
}
</div>
</div>
);
}
});
RolePanel = React.createClass({
componentDidUpdate() {
this.assignRoles();
},
assignRoles() {
var {cluster, nodes, selectedNodeIds, selectedRoles, indeterminateRoles} = this.props;
var roles = cluster.get('roles');
nodes.each((node) => {
if (selectedNodeIds[node.id]) {
roles.each((role) => {
var roleName = role.get('name');
if (!node.hasRole(roleName, true)) {
var nodeRoles = node.get('pending_roles');
if (_.includes(selectedRoles, roleName)) {
nodeRoles = _.union(nodeRoles, [roleName]);
} else if (!_.includes(indeterminateRoles, roleName)) {
nodeRoles = _.without(nodeRoles, roleName);
}
node.set({pending_roles: nodeRoles}, {assign: true});
}
});
}
});
},
processRestrictions(role) {
var {
cluster, nodes, selectedRoles, indeterminateRoles, processedRoleLimits, configModels
} = this.props;
var name = role.get('name');
var restrictionsCheck = role.checkRestrictions(configModels, 'disable');
var roleLimitsCheckResults = processedRoleLimits[name];
var roles = cluster.get('roles');
var conflicts = _.chain(selectedRoles)
.union(indeterminateRoles)
.map((role) => roles.find({name: role}).conflicts)
.flatten()
.uniq()
.value();
var warnings = [];
if (restrictionsCheck.result && restrictionsCheck.message) {
warnings.push(restrictionsCheck.message);
}
if (roleLimitsCheckResults && !roleLimitsCheckResults.valid && roleLimitsCheckResults.message) {
warnings.push(roleLimitsCheckResults.message);
}
if (_.includes(conflicts, name)) {
warnings.push(i18n('cluster_page.nodes_tab.role_conflict'));
}
var isDeployed = nodes.some((node) => node.hasRole(name, true));
if (isDeployed) {
warnings.push(i18n('cluster_page.nodes_tab.deployed_role'));
}
return {
result: restrictionsCheck.result ||
_.includes(conflicts, name) ||
isDeployed ||
(
roleLimitsCheckResults &&
!roleLimitsCheckResults.valid &&
!_.includes(selectedRoles, name)
),
warnings
};
},
render() {
var {cluster, nodes, selectedRoles, indeterminateRoles, selectRoles, configModels} = this.props;
var groups = models.Roles.prototype.groups;
var groupedRoles = cluster.get('roles').groupBy(
(role) => _.includes(groups, role.get('group')) ? role.get('group') : 'other'
);
return (
<div className='well role-panel'>
<h4>{i18n('cluster_page.nodes_tab.assign_roles')}
<div className='help-block'>{i18n('cluster_page.nodes_tab.assign_roles_help')}</div>
</h4>
{_.map(groups, (group) =>
<div key={group} className={group + ' row'}>
<div className='col-xs-1'>
<h6>{group}</h6>
</div>
<div className='col-xs-11'>
{_.map(groupedRoles[group], (role) => {
if (role.checkRestrictions(configModels, 'hide').result) return null;
var roleName = role.get('name');
var selected = _.includes(selectedRoles, roleName);
return (
<Role
key={roleName}
ref={roleName}
{...{role, selected}}
indeterminated={_.includes(indeterminateRoles, roleName)}
restrictions={this.processRestrictions(role)}
isRolePanelDisabled={!nodes.length}
onClick={() => selectRoles(roleName, !selected)}
/>
);
})}
</div>
</div>
)}
</div>
);
}
});
Role = React.createClass({
getDefaultProps() {
return {showPopoverTimeout: 800};
},
getInitialState() {
return {
isPopoverVisible: false,
isPopoverForceHidden: false
};
},
startCountdown() {
this.popoverTimeout = _.delay(() => this.togglePopover(true), this.props.showPopoverTimeout);
},
stopCountdown() {
if (this.popoverTimeout) clearTimeout(this.popoverTimeout);
delete this.popoverTimeout;
},
resetCountdown() {
if (!this.state.isPopoverForceHidden) {
this.stopCountdown();
this.startCountdown();
}
},
forceHidePopover() {
this.stopCountdown();
this.setState({
isPopoverVisible: false,
isPopoverForceHidden: true
});
},
togglePopover(isVisible) {
this.stopCountdown();
this.setState({
isPopoverVisible: isVisible,
isPopoverForceHidden: false
});
},
onKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.forceHidePopover();
this.props.onClick();
}
},
onClick() {
ReactDOM.findDOMNode(this).blur();
this.props.onClick();
},
render() {
var {role, selected, indeterminated, restrictions, isRolePanelDisabled} = this.props;
var isRoleAvailable = !restrictions.result;
var disabled = !isRoleAvailable || isRolePanelDisabled;
var {warnings} = restrictions;
return (
<div
tabIndex={isRoleAvailable ? 0 : -1}
className={utils.classNames({
'role-block': true,
[role.get('name')]: true,
selected,
indeterminated,
unavailable: !isRoleAvailable
})}
onFocus={this.resetCountdown}
onBlur={() => this.togglePopover(false)}
onMouseEnter={this.startCountdown}
onMouseMove={this.resetCountdown}
onMouseLeave={() => this.togglePopover(false)}
onKeyDown={!disabled && this.onKeyDown}
>
<div onClick={this.forceHidePopover}>
<div className='role' onClick={!disabled && this.onClick}>
<i
className={utils.classNames({
glyphicon: true,
'glyphicon-selected-role': selected && !warnings.length,
'glyphicon-indeterminated-role': indeterminated && !warnings.length,
'glyphicon-warning-sign': !!warnings.length
})}
/>
<span>{role.get('label')}</span>
</div>
</div>
{this.state.isPopoverVisible &&
<Popover placement='top'>
<div>
{_.map(warnings, (text, index) => <p key={index} className='text-warning'>{text}</p>)}
{!!warnings.length && <hr />}
<div>{role.get('description')}</div>
</div>
</Popover>
}
</div>
);
}
});
SelectAllMixin = {
componentDidMount() {
this.setSelectAllCheckboxIndeterminateState();
},
componentDidUpdate() {
this.setSelectAllCheckboxIndeterminateState();
},
setSelectAllCheckboxIndeterminateState() {
if (this.refs['select-all']) {
var input = this.refs['select-all'].getInputDOMNode();
input.indeterminate = !input.checked &&
_.some(this.props.nodes, (node) => this.props.selectedNodeIds[node.id]);
}
},
renderSelectAllCheckbox() {
var {
nodes, selectedNodeIds, maxNumberOfNodes, selectNodes, mode, locked, nodeActionsAvailable
} = this.props;
var nodesToSelect = _.filter(nodes, (node) => node.isSelectable());
if (!nodeActionsAvailable) {
// exclude offline nodes from autoselection
nodesToSelect = _.filter(nodesToSelect, (node) => node.get('online'));
}
var checked = mode === 'edit' ||
nodesToSelect.length && !_.some(nodesToSelect, (node) => !selectedNodeIds[node.id]);
return (
<Input
ref='select-all'
name='select-all'
type='checkbox'
checked={checked}
disabled={
mode === 'edit' ||
locked ||
!nodesToSelect.length ||
!checked && !_.isNull(maxNumberOfNodes) && maxNumberOfNodes < nodesToSelect.length
}
label={i18n('common.select_all')}
wrapperClassName='select-all pull-right'
onChange={_.partial(selectNodes, _.map(nodesToSelect, 'id'))}
/>
);
}
};
NodeList = React.createClass({
mixins: [SelectAllMixin],
groupNodes() {
var uniqValueSorters = ['name', 'mac', 'ip'];
var composeNodeDiskSizesLabel = function(node) {
var diskSizes = node.resource('disks');
return i18n('node_details.disks_amount', {
count: diskSizes.length,
size: diskSizes.map((size) => utils.showSize(size) + ' ' +
i18n('node_details.hdd')).join(', ')
});
};
var labelNs = 'cluster_page.nodes_tab.node_management_panel.labels.';
var getLabelValue = (node, label) => {
var labelValue = node.getLabel(label);
return labelValue === false ?
i18n(labelNs + 'not_assigned_label', {label: label})
:
_.isNull(labelValue) ?
i18n(labelNs + 'not_specified_label', {label: label})
:
label + ' "' + labelValue + '"';
};
var groupingMethod = (node) => {
return _.compact(_.map(this.props.activeSorters, (sorter) => {
if (_.includes(uniqValueSorters, sorter.name)) return null;
if (sorter.isLabel) return getLabelValue(node, sorter.name);
var ns = 'cluster_page.nodes_tab.node.';
var cluster = this.props.cluster || this.props.clusters.get(node.get('cluster'));
var sorterNameFormatters = {
roles: () => node.getRolesSummary(this.props.roles) || i18n(ns + 'no_roles'),
status: () => {
if (!node.get('online')) return i18n(ns + 'status.offline');
return i18n(ns + 'status.' + node.get('status'), {
os: cluster && cluster.get('release').get('operating_system') || 'OS'
});
},
manufacturer: () => node.get('manufacturer') || i18n('common.not_specified'),
group_id: () => {
var nodeNetworkGroup = this.props.nodeNetworkGroups.get(node.get('group_id'));
return nodeNetworkGroup && i18n(ns + 'node_network_group', {
group: nodeNetworkGroup.get('name') +
(this.props.cluster ? '' : ' (' + cluster.get('name') + ')')
}) || i18n(ns + 'no_node_network_group');
},
cluster: () => cluster && i18n(
ns + 'cluster',
{cluster: cluster.get('name')}
) || i18n(ns + 'unallocated'),
hdd: () => i18n(
'node_details.total_hdd',
{total: utils.showSize(node.resource('hdd'))}
),
disks: () => composeNodeDiskSizesLabel(node),
ram: () => i18n(
'node_details.total_ram',
{total: utils.showSize(node.resource('ram'))}
),
interfaces: () => i18n(
'node_details.interfaces_amount',
{count: node.resource('interfaces')}
),
default: () => i18n('node_details.' + sorter.name, {count: node.resource(sorter.name)})
};
return (sorterNameFormatters[sorter.name] || sorterNameFormatters.default)();
})).join('; ');
};
var groups = _.toPairs(_.groupBy(this.props.nodes, groupingMethod));
// sort nodes in a group by name, mac or ip or by id (default)
var formattedSorters = _.compact(_.map(this.props.activeSorters, ({name, order}) =>
_.includes(uniqValueSorters, name) && {attr: name, desc: order === 'desc'}
));
_.each(groups, (group) => {
group[1].sort((node1, node2) => utils.multiSort(
node1, node2,
formattedSorters.length ? formattedSorters : [{attr: 'id'}]
));
});
// sort grouped nodes by other applied sorters
var preferredRolesOrder = this.props.roles.map('name');
return groups.sort((group1, group2) => {
var result;
_.each(this.props.activeSorters, (sorter) => {
var node1 = group1[1][0];
var node2 = group2[1][0];
if (sorter.isLabel) {
var node1Label = node1.getLabel(sorter.name);
var node2Label = node2.getLabel(sorter.name);
if (node1Label && node2Label) {
result = utils.natsort(node1Label, node2Label, {insensitive: true});
} else {
result = node1Label === node2Label ? 0 : _.isString(node1Label) ? -1 :
_.isNull(node1Label) ? -1 : 1;
}
} else {
var comparators = {
roles: () => {
var roles1 = node1.sortedRoles(preferredRolesOrder);
var roles2 = node2.sortedRoles(preferredRolesOrder);
var order;
if (!roles1.length && !roles2.length) {
result = 0;
} else if (!roles1.length) {
result = 1;
} else if (!roles2.length) {
result = -1;
} else {
while (!order && roles1.length && roles2.length) {
order = _.indexOf(preferredRolesOrder, roles1.shift()) -
_.indexOf(preferredRolesOrder, roles2.shift());
}
result = order || roles1.length - roles2.length;
}
},
status: () => {
var status1 = !node1.get('online') ? 'offline' : node1.get('status');
var status2 = !node2.get('online') ? 'offline' : node2.get('status');
result = _.indexOf(this.props.statusesToFilter, status1) -
_.indexOf(this.props.statusesToFilter, status2);
},
manufacturer: () => {
result = utils.compare(node1, node2, {attr: sorter.name});
},
disks: () => {
result = utils.natsort(composeNodeDiskSizesLabel(node1),
composeNodeDiskSizesLabel(node2));
},
group_id: () => {
var nodeGroup1 = node1.get('group_id');
var nodeGroup2 = node2.get('group_id');
result = nodeGroup1 === nodeGroup2 ? 0 :
!nodeGroup1 ? 1 : !nodeGroup2 ? -1 : nodeGroup1 - nodeGroup2;
},
cluster: () => {
var cluster1 = node1.get('cluster');
var cluster2 = node2.get('cluster');
result = cluster1 === cluster2 ? 0 :
!cluster1 ? 1 : !cluster2 ? -1 :
utils.natsort(this.props.clusters.get(cluster1).get('name'),
this.props.clusters.get(cluster2).get('name'));
},
default: () => {
result = node1.resource(sorter.name) - node2.resource(sorter.name);
}
};
(comparators[sorter.name] || comparators.default)();
}
if (sorter.order === 'desc') {
result = result * -1;
}
return !_.isUndefined(result) && !result;
});
return result;
});
},
render() {
var {mode, nodes, viewMode, processedRoleLimits, selectedRoles, totalNodesLength} = this.props;
var groups = this.groupNodes();
var rolesWithLimitReached = _.keys(_.omitBy(processedRoleLimits,
(roleLimit, roleName) => roleLimit.valid || !_.includes(selectedRoles, roleName)
));
return (
<div className={utils.classNames({
'node-list row': true,
compact: viewMode === 'compact'
})}>
{groups.length > 1 &&
<div className='col-xs-12 node-list-header'>
{this.renderSelectAllCheckbox()}
</div>
}
<div className='col-xs-12 content-elements'>
{groups.map((group) => {
return <NodeGroup
{... _.pick(this.props,
'cluster', 'clusters', 'nodeNetworkGroups', 'locked',
'selectNodes', 'selectedNodeIds', 'nodeActionsAvailable',
'mode', 'viewMode'
)}
key={group[0]}
label={group[0]}
nodes={group[1]}
rolesWithLimitReached={rolesWithLimitReached}
/>;
})}
{totalNodesLength ?
(
!nodes.length &&
<div className='alert alert-warning'>
{i18n('cluster_page.nodes_tab.no_filtered_nodes_warning')}
</div>
)
:
<div className='alert alert-warning'>
{utils.renderMultilineText(
i18n(
'cluster_page.nodes_tab.' + (
mode === 'add' ? 'no_nodes_in_fuel' : 'no_nodes_in_environment'
)
)
)}
</div>
}
</div>
</div>
);
}
});
NodeGroup = React.createClass({
mixins: [SelectAllMixin],
render() {
var {
label, nodes, cluster, locked, clusters,
selectNodes, selectedNodeIds, rolesWithLimitReached, nodeActionsAvailable
} = this.props;
var availableNodes = nodes.filter((node) => node.isSelectable());
var nodesWithRestrictionsIds = _.map(
_.filter(availableNodes,
(node) => _.some(rolesWithLimitReached, (role) => !node.hasRole(role))
),
'id'
);
return (
<div className='nodes-group'>
<div className='row node-group-header'>
<div className='col-xs-10'>
<h4>{label} ({nodes.length})</h4>
</div>
<div className='col-xs-2'>
{this.renderSelectAllCheckbox()}
</div>
</div>
<div className='row'>
{nodes.map((node) => {
return <Node
{... _.pick(this.props,
'mode', 'viewMode', 'nodeNetworkGroups', 'nodeActionsAvailable'
)}
key={node.id}
node={node}
renderActionButtons={!!cluster && node.isSelectable()}
cluster={cluster || clusters.get(node.get('cluster'))}
checked={selectedNodeIds[node.id]}
locked={
locked ||
_.includes(nodesWithRestrictionsIds, node.id) ||
!nodeActionsAvailable && !node.get('online')
}
onNodeSelection={_.partial(selectNodes, [node.id])}
/>;
})}
</div>
</div>
);
}
});
export default NodeListScreen;