2048 lines
74 KiB
JavaScript
2048 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 utils from 'utils';
|
|
import models from 'models';
|
|
import dispatcher from 'dispatcher';
|
|
import {Input, Popover, Tooltip} 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';
|
|
|
|
var NodeListScreen, MultiSelectControl, NumberRangeControl, ManagementPanel,
|
|
NodeLabelsPanel, RolePanel, SelectAllMixin, NodeList, NodeGroup;
|
|
|
|
class Sorter {
|
|
constructor(name, order, isLabel) {
|
|
this.name = name;
|
|
this.order = order;
|
|
this.title = isLabel ? name : i18n(
|
|
'cluster_page.nodes_tab.sorters.' + name,
|
|
{defaultValue: name}
|
|
);
|
|
this.isLabel = isLabel;
|
|
return this;
|
|
}
|
|
|
|
static fromObject(sorterObject, isLabel) {
|
|
var sorterName = _.keys(sorterObject)[0];
|
|
return new Sorter(sorterName, sorterObject[sorterName], isLabel);
|
|
}
|
|
|
|
static toObject(sorter) {
|
|
return {[sorter.name]: sorter.order};
|
|
}
|
|
}
|
|
|
|
class Filter {
|
|
constructor(name, values, isLabel) {
|
|
this.name = name;
|
|
this.values = values;
|
|
this.title = isLabel ? name : i18n(
|
|
'cluster_page.nodes_tab.filters.' + name,
|
|
{defaultValue: name}
|
|
);
|
|
this.isLabel = isLabel;
|
|
this.isNumberRange = !isLabel &&
|
|
!_.contains(['roles', 'status', 'manufacturer', 'group_id', 'cluster'], name);
|
|
return this;
|
|
}
|
|
|
|
static fromObject(filters, isLabel) {
|
|
return _.map(filters, (values, name) => new Filter(name, values, isLabel));
|
|
}
|
|
|
|
static toObject(filters) {
|
|
return _.reduce(filters, (result, filter) => {
|
|
result[filter.name] = filter.values;
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
updateLimits(nodes, updateValues) {
|
|
if (this.isNumberRange) {
|
|
var limits = [0, 0];
|
|
if (nodes.length) {
|
|
var resources = nodes.invoke('resource', this.name);
|
|
limits = [_.min(resources), _.max(resources)];
|
|
if (this.name == 'hdd' || this.name == 'ram') {
|
|
limits = [
|
|
Math.floor(limits[0] / Math.pow(1024, 3)),
|
|
Math.ceil(limits[1] / Math.pow(1024, 3))
|
|
];
|
|
}
|
|
}
|
|
this.limits = limits;
|
|
if (updateValues) this.values = _.clone(limits);
|
|
}
|
|
}
|
|
}
|
|
|
|
NodeListScreen = 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 {
|
|
sorters: [],
|
|
filters: []
|
|
};
|
|
},
|
|
getInitialState() {
|
|
var {cluster, nodes} = this.props;
|
|
var uiSettings = (cluster || this.props.fuelSettings).get('ui_settings');
|
|
|
|
var availableFilters = this.props.filters.map((name) => {
|
|
var filter = new Filter(name, [], false);
|
|
filter.updateLimits(nodes, true);
|
|
return filter;
|
|
});
|
|
var activeFilters = cluster && this.props.mode == 'add' ?
|
|
Filter.fromObject(this.props.defaultFilters, false)
|
|
:
|
|
_.union(
|
|
Filter.fromObject(_.extend({}, this.props.defaultFilters, uiSettings.filter), false),
|
|
Filter.fromObject(uiSettings.filter_by_labels, true)
|
|
);
|
|
_.invoke(activeFilters, 'updateLimits', nodes, false);
|
|
|
|
var availableSorters = this.props.sorters.map((name) => new Sorter(name, 'asc', false));
|
|
var activeSorters = cluster && this.props.mode == 'add' ?
|
|
_.map(this.props.defaultSorting, _.partial(Sorter.fromObject, _, false))
|
|
:
|
|
_.union(
|
|
_.map(uiSettings.sort, _.partial(Sorter.fromObject, _, false)),
|
|
_.map(uiSettings.sort_by_labels, _.partial(Sorter.fromObject, _, true))
|
|
);
|
|
|
|
var search = cluster && this.props.mode == 'add' ? '' : uiSettings.search;
|
|
var viewMode = uiSettings.view_mode;
|
|
var isLabelsPanelOpen = false;
|
|
|
|
var states = {search, activeSorters, activeFilters, availableSorters, availableFilters,
|
|
viewMode, isLabelsPanelOpen};
|
|
|
|
// Equipment page
|
|
if (!cluster) return states;
|
|
|
|
// additonal Nodes tab states (Cluster page)
|
|
var roles = cluster.get('roles').pluck('name');
|
|
var selectedRoles = nodes.length ? _.filter(roles, (role) => !nodes.any((node) => {
|
|
return !node.hasRole(role);
|
|
})) : [];
|
|
var indeterminateRoles = nodes.length ? _.filter(roles, (role) => {
|
|
return !_.contains(selectedRoles, role) && nodes.any((node) => node.hasRole(role));
|
|
}) : [];
|
|
|
|
var configModels = {
|
|
cluster: cluster,
|
|
settings: cluster.get('settings'),
|
|
version: app.version,
|
|
default: cluster.get('settings')
|
|
};
|
|
|
|
return _.extend(states, {selectedRoles, indeterminateRoles, configModels});
|
|
},
|
|
selectNodes(ids, name, checked) {
|
|
this.props.selectNodes(ids, checked);
|
|
},
|
|
selectRoles(role, checked) {
|
|
var selectedRoles = this.state.selectedRoles;
|
|
if (checked) {
|
|
selectedRoles.push(role);
|
|
} else {
|
|
selectedRoles = _.without(selectedRoles, role);
|
|
}
|
|
this.setState({
|
|
selectedRoles: selectedRoles,
|
|
indeterminateRoles: _.without(this.state.indeterminateRoles, role)
|
|
});
|
|
},
|
|
fetchData() {
|
|
return this.props.nodes.fetch();
|
|
},
|
|
calculateFilterLimits() {
|
|
_.invoke(this.state.availableFilters, 'updateLimits', this.props.nodes, true);
|
|
_.invoke(this.state.activeFilters, 'updateLimits', this.props.nodes, false);
|
|
},
|
|
normalizeAppliedFilters(checkStandardNodeFilters = false) {
|
|
if (!this.props.cluster || this.props.mode != 'add') {
|
|
var normalizedFilters = _.map(this.state.activeFilters, (activeFilter) => {
|
|
var filter = _.clone(activeFilter);
|
|
if (filter.values.length) {
|
|
if (filter.isLabel) {
|
|
filter.values = _.intersection(
|
|
filter.values,
|
|
this.props.nodes.getLabelValues(filter.name)
|
|
);
|
|
} else if (checkStandardNodeFilters &&
|
|
_.contains(['manufacturer', 'group_id', 'cluster'], filter.name)) {
|
|
filter.values = _.filter(filter.values, (value) => {
|
|
return this.props.nodes.any((node) => node.get(filter.name) == value);
|
|
}, this);
|
|
}
|
|
}
|
|
return filter;
|
|
}, this);
|
|
if (
|
|
!_.isEqual(
|
|
_.pluck(normalizedFilters, 'values'),
|
|
_.pluck(this.state.activeFilters, 'values')
|
|
)
|
|
) {
|
|
this.updateFilters(normalizedFilters);
|
|
}
|
|
}
|
|
},
|
|
componentWillMount() {
|
|
this.updateInitialRoles();
|
|
this.props.nodes.on('update reset', this.updateInitialRoles, this);
|
|
this.props.nodes.on('update reset', this.calculateFilterLimits, this);
|
|
this.normalizeAppliedFilters(true);
|
|
|
|
this.changeSearch = _.debounce(this.changeSearch, 200, {leading: true});
|
|
|
|
if (this.props.mode != 'list') {
|
|
// hack to prevent node roles update after node polling
|
|
this.props.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 = this.props.cluster;
|
|
var maxNumberOfNodes = [];
|
|
var processedRoleLimits = {};
|
|
|
|
var selectedNodes = this.props.nodes.filter((node) => this.props.selectedNodeIds[node.id]);
|
|
var clusterNodes = this.props.cluster.get('nodes').filter((node) => {
|
|
return !_.contains(this.props.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.any((node) => node.hasRole(roleName));
|
|
processedRoleLimits[roleName] = role.checkLimits(
|
|
this.state.configModels,
|
|
nodesForLimitCheck,
|
|
!isRoleAlreadyAssigned,
|
|
['max']
|
|
);
|
|
}
|
|
});
|
|
|
|
_.each(processedRoleLimits, (roleLimit, roleName) => {
|
|
if (_.contains(this.state.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: processedRoleLimits,
|
|
// real number of nodes to add used by Select All controls
|
|
maxNumberOfNodes: maxNumberOfNodes.length ?
|
|
_.min(maxNumberOfNodes) - _.size(this.props.selectedNodeIds) : null
|
|
};
|
|
},
|
|
updateInitialRoles() {
|
|
this.initialRoles = _.zipObject(this.props.nodes.pluck('id'),
|
|
this.props.nodes.pluck('pending_roles'));
|
|
},
|
|
checkRoleAssignment(node, roles, options) {
|
|
if (!options.assign) node.set({pending_roles: node.previous('pending_roles')}, {assign: true});
|
|
},
|
|
hasChanges() {
|
|
return this.props.mode != 'list' && this.props.nodes.any((node) => {
|
|
return !_.isEqual(node.get('pending_roles'), this.initialRoles[node.id]);
|
|
});
|
|
},
|
|
changeSearch(value) {
|
|
this.updateSearch(_.trim(value));
|
|
},
|
|
clearSearchField() {
|
|
this.changeSearch.cancel();
|
|
this.updateSearch('');
|
|
},
|
|
updateSearch(value) {
|
|
this.setState({search: value});
|
|
if (!this.props.cluster || this.props.mode != 'add') {
|
|
this.changeUISettings({search: value});
|
|
}
|
|
},
|
|
addSorting(sorter) {
|
|
this.updateSorting(this.state.activeSorters.concat(sorter));
|
|
},
|
|
removeSorting(sorter) {
|
|
this.updateSorting(_.difference(this.state.activeSorters, [sorter]));
|
|
},
|
|
resetSorters() {
|
|
this.updateSorting(_.map(this.props.defaultSorting, _.partial(Sorter.fromObject, _, false)));
|
|
},
|
|
changeSortingOrder(sorterToChange) {
|
|
this.updateSorting(this.state.activeSorters.map((sorter) => {
|
|
if (sorter.name == sorterToChange.name && sorter.isLabel == sorterToChange.isLabel) {
|
|
return new Sorter(sorter.name, sorter.order == 'asc' ? 'desc' : 'asc', sorter.isLabel);
|
|
}
|
|
return sorter;
|
|
}));
|
|
},
|
|
updateSorting(sorters) {
|
|
this.setState({activeSorters: sorters});
|
|
if (!this.props.cluster || this.props.mode != 'add') {
|
|
var groupedSorters = _.groupBy(sorters, 'isLabel');
|
|
this.changeUISettings({
|
|
sort: _.map(groupedSorters.false, Sorter.toObject),
|
|
sort_by_labels: _.map(groupedSorters.true, Sorter.toObject)
|
|
});
|
|
}
|
|
},
|
|
updateFilters(filters) {
|
|
this.setState({activeFilters: filters});
|
|
if (!this.props.cluster || this.props.mode != 'add') {
|
|
var groupedFilters = _.groupBy(filters, 'isLabel');
|
|
this.changeUISettings({
|
|
filter: Filter.toObject(groupedFilters.false),
|
|
filter_by_labels: Filter.toObject(groupedFilters.true)
|
|
});
|
|
}
|
|
},
|
|
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,
|
|
label: _.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,
|
|
label: i18n('cluster_page.nodes_tab.node.status.' + status, {os: os})
|
|
};
|
|
});
|
|
break;
|
|
case 'manufacturer':
|
|
options = _.uniq(this.props.nodes.pluck('manufacturer')).map((manufacturer) => {
|
|
manufacturer = manufacturer || '';
|
|
return {
|
|
name: manufacturer.replace(/\s/g, '_'),
|
|
label: manufacturer
|
|
};
|
|
});
|
|
break;
|
|
case 'roles':
|
|
options = this.props.roles.invoke('pick', 'name', 'label');
|
|
break;
|
|
case 'group_id':
|
|
options = _.uniq(this.props.nodes.pluck('group_id')).map((groupId) => {
|
|
var nodeNetworkGroup = this.props.nodeNetworkGroups.get(groupId);
|
|
return {
|
|
name: groupId,
|
|
label: 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.pluck('cluster')).map((clusterId) => {
|
|
return {
|
|
name: clusterId,
|
|
label: 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.label, option2.label, {insensitive: true});
|
|
});
|
|
|
|
return options;
|
|
},
|
|
addFilter(filter) {
|
|
this.updateFilters(this.state.activeFilters.concat(filter));
|
|
},
|
|
changeFilter(filterToChange, values) {
|
|
this.updateFilters(this.state.activeFilters.map((filter) => {
|
|
if (filter.name == filterToChange.name && filter.isLabel == filterToChange.isLabel) {
|
|
var changedFilter = new Filter(filter.name, values, filter.isLabel);
|
|
changedFilter.limits = filter.limits;
|
|
return changedFilter;
|
|
}
|
|
return filter;
|
|
}));
|
|
},
|
|
removeFilter(filter) {
|
|
this.updateFilters(_.difference(this.state.activeFilters, [filter]));
|
|
},
|
|
resetFilters() {
|
|
this.updateFilters(Filter.fromObject(this.props.defaultFilters, false));
|
|
},
|
|
changeViewMode(name, value) {
|
|
this.setState({viewMode: value});
|
|
if (!this.props.cluster || this.props.mode != 'add') {
|
|
this.changeUISettings({view_mode: value});
|
|
}
|
|
},
|
|
changeUISettings(newSettings) {
|
|
var uiSettings = (this.props.cluster || this.props.fuelSettings).get('ui_settings');
|
|
var options = {patch: true, wait: true, validate: false};
|
|
_.extend(uiSettings, newSettings);
|
|
if (this.props.cluster) {
|
|
this.props.cluster.save({ui_settings: uiSettings}, options);
|
|
} else {
|
|
this.props.fuelSettings.save(null, options);
|
|
}
|
|
},
|
|
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.pluck('labels')).flatten().map(_.keys).flatten().uniq().value();
|
|
},
|
|
getFilterResults(filter, node) {
|
|
var result;
|
|
switch (filter.name) {
|
|
case 'roles':
|
|
result = _.any(filter.values, (role) => node.hasRole(role));
|
|
break;
|
|
case 'status':
|
|
result = _.contains(filter.values, node.getStatusSummary());
|
|
break;
|
|
case 'manufacturer':
|
|
case 'cluster':
|
|
case 'group_id':
|
|
result = _.contains(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 = this.props.cluster;
|
|
var locked = !!cluster && !!cluster.task({group: 'deployment', active: true});
|
|
var nodes = this.props.nodes;
|
|
var processedRoleData = cluster ? this.processRoleLimits() : {};
|
|
|
|
// labels to manage in labels panel
|
|
var selectedNodes = new models.Nodes(this.props.nodes.filter((node) => {
|
|
return this.props.selectedNodeIds[node.id];
|
|
}));
|
|
var selectedNodeLabels = _.chain(selectedNodes.pluck('labels'))
|
|
.flatten()
|
|
.map(_.keys)
|
|
.flatten()
|
|
.uniq()
|
|
.value();
|
|
|
|
// filter nodes
|
|
var filteredNodes = nodes.filter((node) => {
|
|
// search field
|
|
if (this.state.search) {
|
|
var search = this.state.search.toLowerCase();
|
|
if (!_.any(node.pick('name', 'mac', 'ip'), (attribute) => {
|
|
return _.contains((attribute || '').toLowerCase(), search);
|
|
})) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// filters
|
|
return _.all(this.state.activeFilters, (filter) => {
|
|
if (!filter.values.length) return true;
|
|
|
|
if (filter.isLabel) {
|
|
return _.contains(filter.values, node.getLabel(filter.name));
|
|
}
|
|
|
|
return this.getFilterResults(filter, node);
|
|
});
|
|
});
|
|
|
|
var screenNodesLabels = this.getNodeLabels();
|
|
return (
|
|
<div>
|
|
{this.props.mode == 'edit' &&
|
|
<div className='alert alert-warning'>
|
|
{i18n('cluster_page.nodes_tab.disk_configuration_reset_warning')}
|
|
</div>
|
|
}
|
|
<ManagementPanel
|
|
{... _.pick(
|
|
this.state,
|
|
'viewMode', 'search', 'activeSorters', 'activeFilters', 'availableSorters',
|
|
'availableFilters', 'isLabelsPanelOpen'
|
|
)}
|
|
{... _.pick(
|
|
this.props,
|
|
'cluster', 'mode', 'defaultSorting', 'statusesToFilter', 'defaultFilters'
|
|
)}
|
|
{... _.pick(
|
|
this,
|
|
'addSorting', 'removeSorting', 'resetSorters', 'changeSortingOrder',
|
|
'addFilter', 'changeFilter', 'removeFilter', 'resetFilters', 'getFilterOptions',
|
|
'toggleLabelsPanel', 'changeSearch', 'clearSearchField', 'changeViewMode'
|
|
)}
|
|
labelSorters={screenNodesLabels.map((name) => new Sorter(name, 'asc', true))}
|
|
labelFilters={screenNodesLabels.map((name) => new Filter(name, [], true))}
|
|
nodes={selectedNodes}
|
|
screenNodes={nodes}
|
|
filteredNodes={filteredNodes}
|
|
selectedNodeLabels={selectedNodeLabels}
|
|
hasChanges={this.hasChanges()}
|
|
locked={locked}
|
|
revertChanges={this.revertChanges}
|
|
selectNodes={this.selectNodes}
|
|
/>
|
|
{!!this.props.cluster && this.props.mode != 'list' &&
|
|
<RolePanel
|
|
{... _.pick(this.state, 'selectedRoles', 'indeterminateRoles', 'configModels')}
|
|
{... _.pick(this.props, 'cluster', 'mode', 'nodes', 'selectedNodeIds')}
|
|
{... _.pick(processedRoleData, 'processedRoleLimits')}
|
|
selectRoles={this.selectRoles}
|
|
/>
|
|
}
|
|
<NodeList
|
|
{... _.pick(this.state, 'viewMode', 'activeSorters', 'selectedRoles')}
|
|
{... _.pick(this.props, 'cluster', 'mode', 'statusesToFilter', 'selectedNodeIds',
|
|
'clusters', 'roles', 'nodeNetworkGroups')
|
|
}
|
|
{... _.pick(processedRoleData, 'maxNumberOfNodes', 'processedRoleLimits')}
|
|
nodes={filteredNodes}
|
|
totalNodesLength={nodes.length}
|
|
locked={this.state.isLabelsPanelOpen}
|
|
selectNodes={this.selectNodes}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
MultiSelectControl = React.createClass({
|
|
propTypes: {
|
|
name: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.bool]),
|
|
options: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
|
|
values: React.PropTypes.arrayOf(React.PropTypes.oneOfType([
|
|
React.PropTypes.string,
|
|
React.PropTypes.bool
|
|
])),
|
|
label: React.PropTypes.node.isRequired,
|
|
dynamicValues: React.PropTypes.bool,
|
|
onChange: React.PropTypes.func,
|
|
extraContent: React.PropTypes.node,
|
|
toggle: React.PropTypes.func.isRequired,
|
|
isOpen: React.PropTypes.bool.isRequired
|
|
},
|
|
getDefaultProps() {
|
|
return {
|
|
values: [],
|
|
isOpen: false
|
|
};
|
|
},
|
|
onChange(name, checked, isLabel) {
|
|
if (!this.props.dynamicValues) {
|
|
var values = name == 'all' ?
|
|
checked ? _.pluck(this.props.options, 'name') : []
|
|
:
|
|
checked ? _.union(this.props.values, [name]) : _.difference(this.props.values, [name]);
|
|
this.props.onChange(values);
|
|
} else {
|
|
this.props.onChange(_.find(this.props.options, {name: name, isLabel: isLabel}));
|
|
}
|
|
},
|
|
closeOnEscapeKey(e) {
|
|
if (e.key == 'Escape') this.props.toggle(false);
|
|
},
|
|
render() {
|
|
if (!this.props.options.length) return null;
|
|
|
|
var valuesAmount = this.props.values.length;
|
|
var label = this.props.label;
|
|
if (!this.props.dynamicValues && valuesAmount) {
|
|
label = this.props.label + ': ' + (valuesAmount > 3 ?
|
|
i18n(
|
|
'cluster_page.nodes_tab.node_management_panel.selected_options',
|
|
{label: this.props.label, count: valuesAmount}
|
|
)
|
|
:
|
|
_.map(this.props.values, (itemName) => {
|
|
return _.find(this.props.options, {name: itemName}).label;
|
|
}).join(', '));
|
|
}
|
|
|
|
var attributes, labels;
|
|
if (this.props.dynamicValues) {
|
|
var groupedOptions = _.groupBy(this.props.options, 'isLabel');
|
|
attributes = groupedOptions.false || [];
|
|
labels = groupedOptions.true || [];
|
|
}
|
|
|
|
var optionProps = (option) => {
|
|
return {
|
|
key: option.name,
|
|
type: 'checkbox',
|
|
name: option.name,
|
|
label: option.title
|
|
};
|
|
};
|
|
|
|
var classNames = {
|
|
'btn-group multiselect': true,
|
|
open: this.props.isOpen,
|
|
'more-control': this.props.dynamicValues
|
|
};
|
|
if (this.props.className) classNames[this.props.className] = true;
|
|
|
|
return (
|
|
<div className={utils.classNames(classNames)} tabIndex='-1' onKeyDown={this.closeOnEscapeKey}>
|
|
<button
|
|
className={'btn dropdown-toggle ' + ((this.props.dynamicValues && !this.props.isOpen) ?
|
|
'btn-link' : 'btn-default')
|
|
}
|
|
onClick={this.props.toggle}
|
|
>
|
|
{label} <span className='caret'></span>
|
|
</button>
|
|
{this.props.isOpen &&
|
|
<Popover toggle={this.props.toggle}>
|
|
{!this.props.dynamicValues ?
|
|
<div>
|
|
<div key='all'>
|
|
<Input
|
|
type='checkbox'
|
|
label={i18n('cluster_page.nodes_tab.node_management_panel.select_all')}
|
|
name='all'
|
|
checked={valuesAmount == this.props.options.length}
|
|
onChange={this.onChange}
|
|
/>
|
|
</div>
|
|
<div key='divider' className='divider' />
|
|
{_.map(this.props.options, (option) => {
|
|
return <Input {...optionProps(option)}
|
|
label={option.label}
|
|
checked={_.contains(this.props.values, option.name)}
|
|
onChange={this.onChange}
|
|
/>;
|
|
})}
|
|
</div>
|
|
:
|
|
<div>
|
|
{_.map(attributes, (option) => {
|
|
return <Input {...optionProps(option)}
|
|
checked={_.contains(this.props.values, option.name)}
|
|
onChange={_.partialRight(this.onChange, false)}
|
|
/>;
|
|
})}
|
|
{!!attributes.length && !!labels.length &&
|
|
<div key='divider' className='divider' />
|
|
}
|
|
{_.map(labels, (option) => {
|
|
return <Input {...optionProps(option)}
|
|
key={'label-' + option.name}
|
|
onChange={_.partialRight(this.onChange, true)}
|
|
/>;
|
|
})}
|
|
</div>
|
|
}
|
|
</Popover>
|
|
}
|
|
{this.props.extraContent}
|
|
</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'> — </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.pluck('id')});
|
|
app.navigate('#cluster/' + this.props.cluster.id + '/nodes' + url, {trigger: true});
|
|
},
|
|
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() {
|
|
DeleteNodesDialog.show({nodes: this.props.nodes, cluster: this.props.cluster})
|
|
.done(_.partial(this.props.selectNodes,
|
|
_.pluck(this.props.nodes.where({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 $.Deferred().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)
|
|
.done(() => {
|
|
$.when(this.props.cluster.fetch(), this.props.cluster.fetchRelated('nodes')).always(() => {
|
|
if (this.props.mode == 'add') {
|
|
dispatcher.trigger('updateNodeStats networkConfigurationUpdated ' +
|
|
'labelsConfigurationUpdated');
|
|
this.props.selectNodes();
|
|
}
|
|
});
|
|
})
|
|
.fail((response) => {
|
|
this.setState({actionInProgress: false});
|
|
utils.showErrorDialog({
|
|
message: i18n('cluster_page.nodes_tab.node_management_panel.' +
|
|
'node_management_error.saving_warning'),
|
|
response: response
|
|
});
|
|
});
|
|
},
|
|
applyAndRedirect() {
|
|
this.applyChanges().done(this.changeScreen);
|
|
},
|
|
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.clearSearchField();
|
|
},
|
|
activateSearch() {
|
|
this.setState({activeSearch: true});
|
|
$('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});
|
|
}
|
|
});
|
|
},
|
|
onSearchKeyDown(e) {
|
|
if (e.key == 'Escape') {
|
|
this.clearSearchField();
|
|
this.setState({activeSearch: false});
|
|
}
|
|
},
|
|
componentWillUnmount() {
|
|
$('html').off('click.search');
|
|
},
|
|
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 && _.contains(_.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 ns = 'cluster_page.nodes_tab.node_management_panel.';
|
|
|
|
var disksConflict, interfaceConflict, inactiveSorters, canResetSorters,
|
|
inactiveFilters, appliedFilters;
|
|
if (this.props.mode == 'list' && this.props.nodes.length) {
|
|
disksConflict = !this.props.nodes.areDisksConfigurable();
|
|
interfaceConflict = !this.props.nodes.areInterfacesConfigurable();
|
|
}
|
|
|
|
var managementButtonClasses = (isActive, className) => {
|
|
var classes = {
|
|
'btn btn-default pull-left': true,
|
|
active: isActive
|
|
};
|
|
classes[className] = true;
|
|
return classes;
|
|
};
|
|
|
|
if (this.props.mode != 'edit') {
|
|
var checkSorter = (sorter, isLabel) => {
|
|
return !_.any(this.props.activeSorters, {name: sorter.name, isLabel: isLabel});
|
|
};
|
|
inactiveSorters = _.union(
|
|
_.filter(this.props.availableSorters, _.partial(checkSorter, _, false)),
|
|
_.filter(this.props.labelSorters, _.partial(checkSorter, _, true))
|
|
)
|
|
.sort((sorter1, sorter2) => {
|
|
return utils.natsort(sorter1.title, sorter2.title, {insensitive: true});
|
|
});
|
|
canResetSorters = _.any(this.props.activeSorters, {isLabel: true}) ||
|
|
!_(this.props.activeSorters)
|
|
.where({isLabel: false})
|
|
.map(Sorter.toObject)
|
|
.isEqual(this.props.defaultSorting);
|
|
|
|
var checkFilter = (filter, isLabel) => {
|
|
return !_.any(this.props.activeFilters, {name: filter.name, isLabel: isLabel});
|
|
};
|
|
inactiveFilters = _.union(
|
|
_.filter(this.props.availableFilters, _.partial(checkFilter, _, false)),
|
|
_.filter(this.props.labelFilters, _.partial(checkFilter, _, true))
|
|
)
|
|
.sort((filter1, filter2) => {
|
|
return utils.natsort(filter1.title, filter2.title, {insensitive: true});
|
|
});
|
|
appliedFilters = _.reject(this.props.activeFilters, (filter) => !filter.values.length);
|
|
}
|
|
|
|
this.props.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'>
|
|
<div className='view-mode-switcher'>
|
|
<div className='btn-group' data-toggle='buttons'>
|
|
{_.map(models.Nodes.prototype.viewModes, (mode) => {
|
|
return (
|
|
<Tooltip key={mode + '-view'} text={i18n(ns + mode + '_mode_tooltip')}>
|
|
<label
|
|
className={utils.classNames(
|
|
managementButtonClasses(mode == this.props.viewMode, mode)
|
|
)}
|
|
onClick={mode != this.props.viewMode &&
|
|
_.partial(this.props.changeViewMode, 'view_mode', 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>
|
|
{this.props.mode != 'edit' && [
|
|
<Tooltip wrap key='labels-btn' text={i18n(ns + 'labels_tooltip')}>
|
|
<button
|
|
disabled={!this.props.nodes.length}
|
|
onClick={this.props.nodes.length && this.toggleLabelsPanel}
|
|
className={utils.classNames(
|
|
managementButtonClasses(this.props.isLabelsPanelOpen, 'btn-labels')
|
|
)}
|
|
>
|
|
<i className='glyphicon glyphicon-tag' />
|
|
</button>
|
|
</Tooltip>,
|
|
<Tooltip wrap key='sorters-btn' text={i18n(ns + 'sort_tooltip')}>
|
|
<button
|
|
disabled={!this.props.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={!this.props.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={!this.props.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 pull-left' key='search'>
|
|
<Input
|
|
type='text'
|
|
name='search'
|
|
ref='search'
|
|
defaultValue={this.props.search}
|
|
placeholder={i18n(ns + 'search_placeholder')}
|
|
disabled={!this.props.screenNodes.length}
|
|
onChange={this.searchNodes}
|
|
onKeyDown={this.onSearchKeyDown}
|
|
autoFocus
|
|
/>
|
|
{this.state.isSearchButtonVisible &&
|
|
<button
|
|
className='close btn-clear-search'
|
|
onClick={this.clearSearchField}
|
|
>
|
|
×
|
|
</button>
|
|
}
|
|
</div>
|
|
)
|
|
]}
|
|
</div>
|
|
<div className='control-buttons-box col-xs-7 text-right'>
|
|
{!!this.props.cluster && (
|
|
this.props.mode != 'list' ?
|
|
<div className='btn-group' role='group'>
|
|
<button
|
|
className='btn btn-default'
|
|
disabled={this.state.actionInProgress}
|
|
onClick={() => {
|
|
this.props.revertChanges();
|
|
this.changeScreen();
|
|
}}
|
|
>
|
|
{i18n('common.cancel_button')}
|
|
</button>
|
|
<button
|
|
className='btn btn-success btn-apply'
|
|
disabled={!this.isSavingPossible()}
|
|
onClick={this.applyAndRedirect}
|
|
>
|
|
{i18n('common.apply_changes_button')}
|
|
</button>
|
|
</div>
|
|
:
|
|
[
|
|
<div className='btn-group' role='group' key='configuration-buttons'>
|
|
<button
|
|
className='btn btn-default btn-configure-disks'
|
|
disabled={!this.props.nodes.length}
|
|
onClick={_.bind(this.goToConfigurationScreen, this, 'disks', disksConflict)}
|
|
>
|
|
{disksConflict && <i className='glyphicon glyphicon-danger-sign' />}
|
|
{i18n('dialog.show_node.disk_configuration' +
|
|
(_.all(this.props.nodes.invoke('areDisksConfigurable')) ? '_action' : ''))
|
|
}
|
|
</button>
|
|
<button
|
|
className='btn btn-default btn-configure-interfaces'
|
|
disabled={!this.props.nodes.length}
|
|
onClick={_.bind(this.goToConfigurationScreen, this, 'interfaces',
|
|
interfaceConflict)
|
|
}
|
|
>
|
|
{interfaceConflict && <i className='glyphicon glyphicon-danger-sign' />}
|
|
{i18n('dialog.show_node.network_configuration' +
|
|
(_.all(this.props.nodes.invoke('areInterfacesConfigurable')) ?
|
|
'_action' : ''))
|
|
}
|
|
</button>
|
|
</div>,
|
|
<div className='btn-group' role='group' key='role-management-buttons'>
|
|
{!this.props.locked && !!this.props.nodes.length &&
|
|
this.props.nodes.any({pending_deletion: false}) &&
|
|
<button
|
|
className='btn btn-danger btn-delete-nodes'
|
|
onClick={this.showDeleteNodesDialog}
|
|
>
|
|
<i className='glyphicon glyphicon-trash' />
|
|
{i18n('common.delete_button')}
|
|
</button>
|
|
}
|
|
{!!this.props.nodes.length &&
|
|
!this.props.nodes.any({pending_addition: false}) &&
|
|
<button
|
|
className='btn btn-success btn-edit-roles'
|
|
onClick={_.bind(this.changeScreen, this, 'edit', true)}
|
|
>
|
|
<i className='glyphicon glyphicon-edit' />
|
|
{i18n(ns + 'edit_roles_button')}
|
|
</button>
|
|
}
|
|
</div>,
|
|
!this.props.locked &&
|
|
<div className='btn-group' role='group' key='add-nodes-button'>
|
|
<button
|
|
className='btn btn-success btn-add-nodes'
|
|
onClick={_.bind(this.changeScreen, this, 'add', false)}
|
|
disabled={this.props.locked}
|
|
>
|
|
<i className='glyphicon glyphicon-plus' />
|
|
{i18n(ns + 'add_nodes_button')}
|
|
</button>
|
|
</div>
|
|
]
|
|
)}
|
|
</div>
|
|
{this.props.mode != 'edit' && !!this.props.screenNodes.length && [
|
|
this.props.isLabelsPanelOpen &&
|
|
<NodeLabelsPanel {... _.pick(this.props, 'nodes', 'screenNodes')}
|
|
key='labels'
|
|
labels={this.props.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 glyphicon-remove-sign' /> {i18n(ns + 'reset')}
|
|
</button>
|
|
}
|
|
</div>
|
|
{this.props.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(this.props.changeSortingOrder, sorter)}
|
|
>
|
|
{sorter.title}
|
|
<i
|
|
className={utils.classNames({
|
|
glyphicon: true,
|
|
'glyphicon-arrow-down': asc,
|
|
'glyphicon-arrow-up': !asc
|
|
})}
|
|
/>
|
|
</button>
|
|
{this.props.activeSorters.length > 1 &&
|
|
this.renderDeleteSorterButton(sorter)
|
|
}
|
|
</div>
|
|
);
|
|
})}
|
|
<MultiSelectControl
|
|
name='sorter-more'
|
|
label={i18n(ns + 'more')}
|
|
options={inactiveSorters}
|
|
onChange={this.props.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 glyphicon-remove-sign' /> {i18n(ns + 'reset')}
|
|
</button>
|
|
}
|
|
</div>
|
|
{_.map(this.props.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(this.props.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={this.props.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>
|
|
)
|
|
]}
|
|
{this.props.mode != 'edit' && !!this.props.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: this.props.filteredNodes.length,
|
|
total: this.props.screenNodes.length
|
|
})}
|
|
{_.map(appliedFilters, (filter) => {
|
|
var options = filter.isNumberRange ? null :
|
|
this.props.getFilterOptions(filter);
|
|
return (
|
|
<div key={filter.name}>
|
|
<strong>{filter.title}{!!filter.values.length && ':'} </strong>
|
|
<span>
|
|
{filter.isNumberRange ?
|
|
_.uniq(filter.values).join(' - ')
|
|
:
|
|
_.pluck(
|
|
_.filter(options, (option) => {
|
|
return _.contains(filter.values, option.name);
|
|
})
|
|
, 'label').join(', ')
|
|
}
|
|
</span>
|
|
</div>
|
|
);
|
|
}, this)}
|
|
</div>
|
|
<button
|
|
className='btn btn-link btn-reset-filters'
|
|
onClick={this.resetFilters}
|
|
>
|
|
<i className='glyphicon glyphicon-remove-sign' />
|
|
</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'>
|
|
{this.props.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
|
|
})}
|
|
/>
|
|
{!!this.props.activeSorters[index + 1] && ' + '}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
{canResetSorters &&
|
|
<button
|
|
className='btn btn-link btn-reset-sorting'
|
|
onClick={this.resetSorters}
|
|
>
|
|
<i className='glyphicon glyphicon-remove-sign' />
|
|
</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 = _.any(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() &&
|
|
_.all(_.pluck(this.state.labels, 'error'), _.isNull);
|
|
},
|
|
revertChanges() {
|
|
return this.props.toggleLabelsPanel();
|
|
},
|
|
applyChanges() {
|
|
if (!this.isSavingPossible()) return $.Deferred().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)
|
|
.done(() => {
|
|
this.props.screenNodes.fetch().always(() => {
|
|
dispatcher.trigger('labelsConfigurationUpdated');
|
|
this.props.screenNodes.trigger('change');
|
|
this.props.toggleLabelsPanel();
|
|
});
|
|
})
|
|
.fail((response) => {
|
|
utils.showErrorDialog({
|
|
message: i18n(
|
|
'cluster_page.nodes_tab.node_management_panel.' +
|
|
'node_management_error.labels_warning'
|
|
),
|
|
response: response
|
|
});
|
|
});
|
|
},
|
|
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>
|
|
);
|
|
}, this)}
|
|
<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>
|
|
<button
|
|
className='btn btn-success'
|
|
onClick={this.applyChanges}
|
|
disabled={!this.isSavingPossible()}
|
|
>
|
|
{i18n('common.apply_button')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
RolePanel = React.createClass({
|
|
componentDidMount() {
|
|
this.updateIndeterminateRolesState();
|
|
},
|
|
componentDidUpdate() {
|
|
this.updateIndeterminateRolesState();
|
|
this.assignRoles();
|
|
},
|
|
updateIndeterminateRolesState() {
|
|
_.each(this.refs, (roleView, role) => {
|
|
roleView.getInputDOMNode().indeterminate = _.contains(this.props.indeterminateRoles, role);
|
|
});
|
|
},
|
|
assignRoles() {
|
|
var roles = this.props.cluster.get('roles');
|
|
this.props.nodes.each((node) => {
|
|
if (this.props.selectedNodeIds[node.id]) roles.each((role) => {
|
|
var roleName = role.get('name');
|
|
if (!node.hasRole(roleName, true)) {
|
|
var nodeRoles = node.get('pending_roles');
|
|
if (_.contains(this.props.selectedRoles, roleName)) {
|
|
nodeRoles = _.union(nodeRoles, [roleName]);
|
|
} else if (!_.contains(this.props.indeterminateRoles, roleName)) {
|
|
nodeRoles = _.without(nodeRoles, roleName);
|
|
}
|
|
node.set({pending_roles: nodeRoles}, {assign: true});
|
|
}
|
|
});
|
|
});
|
|
},
|
|
processRestrictions(role, models) {
|
|
var name = role.get('name');
|
|
var restrictionsCheck = role.checkRestrictions(models, 'disable');
|
|
var roleLimitsCheckResults = this.props.processedRoleLimits[name];
|
|
var roles = this.props.cluster.get('roles');
|
|
var conflicts = _.chain(this.props.selectedRoles)
|
|
.union(this.props.indeterminateRoles)
|
|
.map((role) => roles.find({name: role}).conflicts)
|
|
.flatten()
|
|
.uniq()
|
|
.value();
|
|
var messages = [];
|
|
|
|
if (restrictionsCheck.result && restrictionsCheck.message) {
|
|
messages.push(restrictionsCheck.message);
|
|
}
|
|
if (roleLimitsCheckResults && !roleLimitsCheckResults.valid && roleLimitsCheckResults.message) {
|
|
messages.push(roleLimitsCheckResults.message);
|
|
}
|
|
if (_.contains(conflicts, name)) messages.push(i18n('cluster_page.nodes_tab.role_conflict'));
|
|
|
|
return {
|
|
result: restrictionsCheck.result || _.contains(conflicts, name) ||
|
|
(roleLimitsCheckResults && !roleLimitsCheckResults.valid &&
|
|
!_.contains(this.props.selectedRoles, name)
|
|
),
|
|
message: messages.join(' ')
|
|
};
|
|
},
|
|
render() {
|
|
return (
|
|
<div className='well role-panel'>
|
|
<h4>{i18n('cluster_page.nodes_tab.assign_roles')}</h4>
|
|
{this.props.cluster.get('roles').map((role) => {
|
|
if (!role.checkRestrictions(this.props.configModels, 'hide').result) {
|
|
var name = role.get('name');
|
|
var processedRestrictions = this.props.nodes.length ?
|
|
this.processRestrictions(role, this.props.configModels) : {};
|
|
return (
|
|
<Input
|
|
key={name}
|
|
ref={name}
|
|
type='checkbox'
|
|
name={name}
|
|
label={role.get('label')}
|
|
description={role.get('description')}
|
|
checked={_.contains(this.props.selectedRoles, name)}
|
|
disabled={!this.props.nodes.length || processedRestrictions.result}
|
|
tooltipText={!!this.props.nodes.length && processedRestrictions.message}
|
|
onChange={this.props.selectRoles}
|
|
wrapperClassName={name}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
SelectAllMixin = {
|
|
componentDidUpdate() {
|
|
if (this.refs['select-all']) {
|
|
var input = this.refs['select-all'].getInputDOMNode();
|
|
input.indeterminate = !input.checked && _.any(this.props.nodes, (node) => {
|
|
return this.props.selectedNodeIds[node.id];
|
|
});
|
|
}
|
|
},
|
|
renderSelectAllCheckbox() {
|
|
var checked = this.props.mode == 'edit' || (this.props.nodes.length &&
|
|
!_.any(this.props.nodes, (node) => !this.props.selectedNodeIds[node.id]));
|
|
return (
|
|
<Input
|
|
ref='select-all'
|
|
name='select-all'
|
|
type='checkbox'
|
|
checked={checked}
|
|
disabled={
|
|
this.props.mode == 'edit' || this.props.locked || !this.props.nodes.length ||
|
|
!checked && !_.isNull(this.props.maxNumberOfNodes) &&
|
|
this.props.maxNumberOfNodes < this.props.nodes.length
|
|
}
|
|
label={i18n('common.select_all')}
|
|
wrapperClassName='select-all pull-right'
|
|
onChange={_.bind(this.props.selectNodes, this.props, _.pluck(this.props.nodes, '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.showDiskSize(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 (_.contains(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: () => i18n(ns + 'status.' + node.getStatusSummary(), {
|
|
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.showDiskSize(node.resource('hdd'))}
|
|
),
|
|
disks: () => composeNodeDiskSizesLabel(node),
|
|
ram: () => i18n(
|
|
'node_details.total_ram',
|
|
{total: utils.showMemorySize(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 = _.pairs(_.groupBy(this.props.nodes, groupingMethod));
|
|
|
|
// sort grouped nodes by name, mac or ip
|
|
var formattedSorters = _.compact(_.map(this.props.activeSorters, (sorter) => {
|
|
if (_.contains(uniqValueSorters, sorter.name)) {
|
|
return {attr: sorter.name, desc: sorter.order == 'desc'};
|
|
}
|
|
}));
|
|
if (formattedSorters.length) {
|
|
_.each(groups, (group) => {
|
|
group[1].sort((node1, node2) =>
|
|
utils.multiSort(node1, node2, formattedSorters)
|
|
);
|
|
});
|
|
}
|
|
|
|
// sort grouped nodes by other applied sorters
|
|
var preferredRolesOrder = this.props.roles.pluck('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: () => {
|
|
result = _.indexOf(this.props.statusesToFilter, node1.getStatusSummary()) -
|
|
_.indexOf(this.props.statusesToFilter, node2.getStatusSummary());
|
|
},
|
|
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 groups = this.groupNodes();
|
|
var rolesWithLimitReached = _.keys(_.omit(this.props.processedRoleLimits,
|
|
(roleLimit, roleName) => {
|
|
return roleLimit.valid || !_.contains(this.props.selectedRoles, roleName);
|
|
}
|
|
));
|
|
return (
|
|
<div className={utils.classNames({
|
|
'node-list row': true, compact: this.props.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 {...this.props}
|
|
key={group[0]}
|
|
label={group[0]}
|
|
nodes={group[1]}
|
|
rolesWithLimitReached={rolesWithLimitReached}
|
|
/>;
|
|
})}
|
|
{this.props.totalNodesLength ?
|
|
(
|
|
!this.props.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.' + (this.props.mode == 'add' ?
|
|
'no_nodes_in_fuel' : 'no_nodes_in_environment'
|
|
)
|
|
)
|
|
)}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
NodeGroup = React.createClass({
|
|
mixins: [SelectAllMixin],
|
|
render() {
|
|
var availableNodes = this.props.nodes.filter((node) => node.isSelectable());
|
|
var nodesWithRestrictionsIds = _.pluck(_.filter(availableNodes, (node) => {
|
|
return _.any(this.props.rolesWithLimitReached, (role) => !node.hasRole(role));
|
|
}), 'id');
|
|
return (
|
|
<div className='nodes-group'>
|
|
<div className='row node-group-header'>
|
|
<div className='col-xs-10'>
|
|
<h4>{this.props.label} ({this.props.nodes.length})</h4>
|
|
</div>
|
|
<div className='col-xs-2'>
|
|
{this.renderSelectAllCheckbox()}
|
|
</div>
|
|
</div>
|
|
<div className='row'>
|
|
{this.props.nodes.map((node) => {
|
|
return <Node
|
|
{... _.pick(this.props, 'mode', 'viewMode', 'nodeNetworkGroups')}
|
|
key={node.id}
|
|
node={node}
|
|
renderActionButtons={!!this.props.cluster}
|
|
cluster={this.props.cluster || this.props.clusters.get(node.get('cluster'))}
|
|
checked={this.props.mode == 'edit' || this.props.selectedNodeIds[node.id]}
|
|
locked={this.props.locked || _.contains(nodesWithRestrictionsIds, node.id)}
|
|
onNodeSelection={_.bind(this.props.selectNodes, this.props, [node.id])}
|
|
/>;
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
export default NodeListScreen;
|