539 lines
18 KiB
JavaScript
539 lines
18 KiB
JavaScript
/*
|
|
* Copyright 2015 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 'underscore';
|
|
import i18n from 'i18n';
|
|
import Backbone from 'backbone';
|
|
import React from 'react';
|
|
import utils from 'utils';
|
|
import models from 'models';
|
|
import dispatcher from 'dispatcher';
|
|
import {Input, Popover, Tooltip} from 'views/controls';
|
|
import {DeleteNodesDialog, RemoveOfflineNodeDialog, ShowNodeInfoDialog} from 'views/dialogs';
|
|
import {renamingMixin} from 'component_mixins';
|
|
|
|
var Node = React.createClass({
|
|
mixins: [renamingMixin('name')],
|
|
getInitialState() {
|
|
return {
|
|
actionInProgress: false,
|
|
extendedView: false,
|
|
labelsPopoverVisible: false
|
|
};
|
|
},
|
|
componentDidUpdate() {
|
|
if (!this.props.node.get('cluster') && !this.props.checked) {
|
|
this.props.node.set({pending_roles: []}, {assign: true});
|
|
}
|
|
},
|
|
getNodeLogsLink() {
|
|
var status = this.props.node.get('status');
|
|
var error = this.props.node.get('error_type');
|
|
var options = {type: 'remote', node: this.props.node.id};
|
|
if (status == 'discover') {
|
|
options.source = 'bootstrap/messages';
|
|
} else if (
|
|
status == 'provisioning' ||
|
|
status == 'provisioned' ||
|
|
(status == 'error' && error == 'provision')
|
|
) {
|
|
options.source = 'install/fuel-agent';
|
|
} else if (
|
|
status == 'deploying' ||
|
|
status == 'ready' ||
|
|
(status == 'error' && error == 'deploy')
|
|
) {
|
|
options.source = 'install/puppet';
|
|
}
|
|
return '#cluster/' + this.props.node.get('cluster') + '/logs/' +
|
|
utils.serializeTabOptions(options);
|
|
},
|
|
applyNewNodeName(newName) {
|
|
if (newName && newName != this.props.node.get('name')) {
|
|
this.setState({actionInProgress: true});
|
|
this.props.node.save({name: newName}, {patch: true, wait: true}).always(this.endRenaming);
|
|
} else {
|
|
this.endRenaming();
|
|
}
|
|
},
|
|
onNodeNameInputKeydown(e) {
|
|
if (e.key == 'Enter') {
|
|
this.applyNewNodeName(this.refs.name.getInputDOMNode().value);
|
|
} else if (e.key == 'Escape') {
|
|
this.endRenaming();
|
|
}
|
|
},
|
|
discardNodeDeletion(e) {
|
|
e.preventDefault();
|
|
if (this.state.actionInProgress) return;
|
|
this.setState({actionInProgress: true});
|
|
var node = new models.Node(this.props.node.attributes);
|
|
var data = {pending_deletion: false};
|
|
node.save(data, {patch: true})
|
|
.done(() => {
|
|
this.props.cluster.fetchRelated('nodes').done(() => {
|
|
this.setState({actionInProgress: false});
|
|
});
|
|
})
|
|
.fail((response) => {
|
|
utils.showErrorDialog({
|
|
title: i18n('cluster_page.nodes_tab.node.cant_discard'),
|
|
response: response
|
|
});
|
|
});
|
|
},
|
|
removeNode(e) {
|
|
e.preventDefault();
|
|
if (this.props.viewMode == 'compact') this.toggleExtendedNodePanel();
|
|
RemoveOfflineNodeDialog
|
|
.show()
|
|
.done(() => {
|
|
// sync('delete') is used instead of node.destroy() because we want
|
|
// to keep showing the 'Removing' status until the node is truly removed
|
|
// Otherwise this node would disappear and might reappear again upon
|
|
// cluster nodes refetch with status 'Removing' which would look ugly
|
|
// to the end user
|
|
return Backbone
|
|
.sync('delete', this.props.node)
|
|
.then(
|
|
(task) => {
|
|
dispatcher.trigger('networkConfigurationUpdated updateNodeStats ' +
|
|
'updateNotifications labelsConfigurationUpdated');
|
|
if (task.status == 'ready') {
|
|
// Do not send the 'DELETE' request again, just get rid
|
|
// of this node.
|
|
this.props.node.trigger('destroy', this.props.node);
|
|
return;
|
|
}
|
|
if (this.props.cluster) {
|
|
this.props.cluster.get('tasks').add(new models.Task(task), {parse: true});
|
|
}
|
|
this.props.node.set('status', 'removing');
|
|
},
|
|
(response) => {
|
|
utils.showErrorDialog({response: response});
|
|
}
|
|
);
|
|
});
|
|
},
|
|
showNodeDetails(e) {
|
|
e.preventDefault();
|
|
if (this.state.extendedView) this.toggleExtendedNodePanel();
|
|
ShowNodeInfoDialog.show({
|
|
node: this.props.node,
|
|
cluster: this.props.cluster,
|
|
nodeNetworkGroup: this.props.nodeNetworkGroups.get(this.props.node.get('group_id')),
|
|
renderActionButtons: this.props.renderActionButtons
|
|
});
|
|
},
|
|
toggleExtendedNodePanel() {
|
|
var states = this.state.extendedView ?
|
|
{extendedView: false, isRenaming: false} : {extendedView: true};
|
|
this.setState(states);
|
|
},
|
|
renderNameControl() {
|
|
if (this.state.isRenaming) return (
|
|
<Input
|
|
ref='name'
|
|
type='text'
|
|
name='node-name'
|
|
defaultValue={this.props.node.get('name')}
|
|
inputClassName='form-control node-name-input'
|
|
disabled={this.state.actionInProgress}
|
|
onKeyDown={this.onNodeNameInputKeydown}
|
|
maxLength='100'
|
|
selectOnFocus
|
|
autoFocus
|
|
/>
|
|
);
|
|
return (
|
|
<Tooltip text={i18n('cluster_page.nodes_tab.node.edit_name')}>
|
|
<p onClick={!this.state.actionInProgress && this.startRenaming}>
|
|
{this.props.node.get('name') || this.props.node.get('mac')}
|
|
</p>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
renderStatusLabel(status) {
|
|
return (
|
|
<span>
|
|
{i18n('cluster_page.nodes_tab.node.status.' + status, {
|
|
os: this.props.cluster && this.props.cluster.get('release').get('operating_system')
|
|
|| 'OS'
|
|
})}
|
|
</span>
|
|
);
|
|
},
|
|
renderNodeProgress(status) {
|
|
var nodeProgress = this.props.node.get('progress');
|
|
return (
|
|
<div className='progress'>
|
|
{status &&
|
|
<div className='progress-bar-title'>
|
|
{this.renderStatusLabel(status)}
|
|
{': ' + nodeProgress + '%'}
|
|
</div>
|
|
}
|
|
<div
|
|
className='progress-bar'
|
|
role='progressbar'
|
|
style={{width: _.max([nodeProgress, 3]) + '%'}}
|
|
>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
renderNodeHardwareSummary() {
|
|
var htCores = this.props.node.resource('ht_cores');
|
|
var hdd = this.props.node.resource('hdd');
|
|
var ram = this.props.node.resource('ram');
|
|
return (
|
|
<div className='node-hardware'>
|
|
<span>
|
|
{i18n('node_details.cpu')}
|
|
{': '}
|
|
{this.props.node.resource('cores') || '0'} ({_.isUndefined(htCores) ? '?' : htCores})
|
|
</span>
|
|
<span>
|
|
{i18n('node_details.hdd')}
|
|
{': '}
|
|
{_.isUndefined(hdd) ? '?' + i18n('common.size.gb') : utils.showDiskSize(hdd)}
|
|
</span>
|
|
<span>
|
|
{i18n('node_details.ram')}
|
|
{': '}
|
|
{_.isUndefined(ram) ? '?' + i18n('common.size.gb') : utils.showMemorySize(ram)}
|
|
</span>
|
|
</div>
|
|
);
|
|
},
|
|
renderLogsLink(iconRepresentation) {
|
|
return (
|
|
<Tooltip
|
|
key='logs'
|
|
text={iconRepresentation ? i18n('cluster_page.nodes_tab.node.view_logs') : null}
|
|
>
|
|
<a
|
|
className={'btn-view-logs ' + (iconRepresentation ? 'icon icon-logs' : 'btn')}
|
|
href={this.getNodeLogsLink()}
|
|
>
|
|
{!iconRepresentation && i18n('cluster_page.nodes_tab.node.view_logs')}
|
|
</a>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
renderNodeCheckbox() {
|
|
return (
|
|
<Input
|
|
type='checkbox'
|
|
name={this.props.node.id}
|
|
checked={this.props.checked}
|
|
disabled={this.props.locked || !this.props.node.isSelectable() || this.props.mode == 'edit'}
|
|
onChange={this.props.mode != 'edit' ? this.props.onNodeSelection : _.noop}
|
|
wrapperClassName='pull-left'
|
|
/>
|
|
);
|
|
},
|
|
renderRemoveButton() {
|
|
return (
|
|
<button onClick={this.removeNode} className='btn node-remove-button'>
|
|
{i18n('cluster_page.nodes_tab.node.remove')}
|
|
</button>
|
|
);
|
|
},
|
|
renderRoleList(roles) {
|
|
return (
|
|
<ul>
|
|
{_.map(roles, (role) => {
|
|
return (
|
|
<li
|
|
key={this.props.node.id + role}
|
|
className={utils.classNames({'text-success': !this.props.node.get('roles').length})}
|
|
>
|
|
{role}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
},
|
|
showDeleteNodesDialog(e) {
|
|
e.preventDefault();
|
|
if (this.props.viewMode == 'compact') this.toggleExtendedNodePanel();
|
|
DeleteNodesDialog
|
|
.show({
|
|
nodes: new models.Nodes(this.props.node),
|
|
cluster: this.props.cluster
|
|
})
|
|
.done(this.props.onNodeSelection);
|
|
},
|
|
renderLabels() {
|
|
var labels = this.props.node.get('labels');
|
|
if (_.isEmpty(labels)) return null;
|
|
return (
|
|
<ul>
|
|
{_.map(_.keys(labels).sort(_.partialRight(utils.natsort, {insensitive: true})), (key) => {
|
|
var value = labels[key];
|
|
return (
|
|
<li key={key + value} className='label'>
|
|
{key + (_.isNull(value) ? '' : ' "' + value + '"')}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
},
|
|
renderCompactNode(options) {
|
|
var node = this.props.node;
|
|
var {ns, status, roles, nodePanelClasses, logoClasses, statusClasses, isSelectable} = options;
|
|
return (
|
|
<div className='compact-node'>
|
|
<div className={utils.classNames(nodePanelClasses)}>
|
|
<label className='node-box'>
|
|
<div
|
|
className='node-box-inner clearfix'
|
|
onClick={isSelectable &&
|
|
_.partial(this.props.onNodeSelection, null, !this.props.checked)
|
|
}
|
|
>
|
|
<div className='node-checkbox'>
|
|
{this.props.checked && <i className='glyphicon glyphicon-ok' />}
|
|
</div>
|
|
<div className='node-name'>
|
|
<p>{node.get('name') || node.get('mac')}</p>
|
|
</div>
|
|
<div className={utils.classNames(statusClasses)}>
|
|
{_.contains(['provisioning', 'deploying'], status) ?
|
|
this.renderNodeProgress()
|
|
:
|
|
this.renderStatusLabel(status)
|
|
}
|
|
</div>
|
|
</div>
|
|
<div className='node-hardware'>
|
|
<p>
|
|
<span>
|
|
{node.resource('cores') || '0'} ({node.resource('ht_cores') || '?'})
|
|
</span> / <span>
|
|
{node.resource('hdd') ? utils.showDiskSize(node.resource('hdd')) : '?' +
|
|
i18n('common.size.gb')
|
|
}
|
|
</span> / <span>
|
|
{node.resource('ram') ? utils.showMemorySize(node.resource('ram')) : '?' +
|
|
i18n('common.size.gb')
|
|
}
|
|
</span>
|
|
</p>
|
|
<p className='btn btn-link' onClick={this.toggleExtendedNodePanel}>
|
|
{i18n(ns + 'more_info')}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
{this.state.extendedView &&
|
|
<Popover className='node-popover' toggle={this.toggleExtendedNodePanel}>
|
|
<div>
|
|
<div className='node-name clearfix'>
|
|
{this.renderNodeCheckbox()}
|
|
<div className='name pull-left'>
|
|
{this.renderNameControl()}
|
|
</div>
|
|
</div>
|
|
<div className='node-stats'>
|
|
{!!roles.length &&
|
|
<div className='role-list'>
|
|
<i className='glyphicon glyphicon-pushpin' />
|
|
{this.renderRoleList(roles)}
|
|
</div>
|
|
}
|
|
{!_.isEmpty(this.props.node.get('labels')) &&
|
|
<div className='node-labels'>
|
|
<i className='glyphicon glyphicon-tags pull-left' />
|
|
{this.renderLabels()}
|
|
</div>
|
|
}
|
|
<div className={utils.classNames(statusClasses)}>
|
|
<i className='glyphicon glyphicon-time' />
|
|
{_.contains(['provisioning', 'deploying'], status) ?
|
|
<div>
|
|
{this.renderStatusLabel(status)}
|
|
<div className='node-buttons'>
|
|
{this.renderLogsLink()}
|
|
</div>
|
|
{this.renderNodeProgress(status)}
|
|
</div>
|
|
:
|
|
<div>
|
|
{this.renderStatusLabel(status)}
|
|
<div className='node-buttons'>
|
|
{status == 'offline' && this.renderRemoveButton()}
|
|
{[
|
|
!!node.get('cluster') && this.renderLogsLink(),
|
|
this.props.renderActionButtons && node.hasChanges() &&
|
|
!this.props.locked &&
|
|
<button
|
|
className='btn btn-discard'
|
|
key='btn-discard'
|
|
onClick={
|
|
node.get('pending_deletion') ?
|
|
this.discardNodeDeletion : this.showDeleteNodesDialog
|
|
}
|
|
>
|
|
{i18n(
|
|
ns + (node.get('pending_deletion') ?
|
|
'discard_deletion' : 'delete_node'
|
|
)
|
|
)}
|
|
</button>
|
|
]}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div className='hardware-info clearfix'>
|
|
<div className={utils.classNames(logoClasses)} />
|
|
{this.renderNodeHardwareSummary()}
|
|
</div>
|
|
<div className='node-popover-buttons'>
|
|
<button className='btn btn-default node-settings' onClick={this.showNodeDetails}>
|
|
{i18n(ns + 'details')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Popover>
|
|
}
|
|
</div>
|
|
);
|
|
},
|
|
renderStandardNode(options) {
|
|
var node = this.props.node;
|
|
var {ns, status, roles, nodePanelClasses, logoClasses, statusClasses} = options;
|
|
return (
|
|
<div className={utils.classNames(nodePanelClasses)}>
|
|
<label className='node-box'>
|
|
{this.renderNodeCheckbox()}
|
|
<div className={utils.classNames(logoClasses)} />
|
|
<div className='node-name'>
|
|
<div className='name'>
|
|
{this.renderNameControl()}
|
|
</div>
|
|
<div className='role-list'>
|
|
{this.renderRoleList(roles)}
|
|
</div>
|
|
</div>
|
|
<div className='node-labels'>
|
|
{!_.isEmpty(node.get('labels')) &&
|
|
<button className='btn btn-link' onClick={this.toggleLabelsPopover}>
|
|
<i className='glyphicon glyphicon-tag-alt' />
|
|
{_.keys(node.get('labels')).length}
|
|
</button>
|
|
}
|
|
{this.state.labelsPopoverVisible &&
|
|
<Popover className='node-labels-popover' toggle={this.toggleLabelsPopover}>
|
|
{this.renderLabels()}
|
|
</Popover>
|
|
}
|
|
</div>
|
|
<div className='node-action'>
|
|
{[
|
|
!!node.get('cluster') && this.renderLogsLink(true),
|
|
this.props.renderActionButtons && node.hasChanges() && !this.props.locked &&
|
|
<Tooltip
|
|
key={'pending_addition_' + node.id}
|
|
text={
|
|
i18n(ns + (node.get('pending_deletion') ? 'discard_deletion' : 'delete_node'))
|
|
}
|
|
>
|
|
<div
|
|
className='icon btn-discard'
|
|
onClick={node.get('pending_deletion') ?
|
|
this.discardNodeDeletion : this.showDeleteNodesDialog
|
|
}
|
|
/>
|
|
</Tooltip>
|
|
]}
|
|
</div>
|
|
<div className={utils.classNames(statusClasses)}>
|
|
{_.contains(['provisioning', 'deploying'], status) ?
|
|
this.renderNodeProgress(status)
|
|
:
|
|
<div>
|
|
{this.renderStatusLabel(status)}
|
|
{status == 'offline' && this.renderRemoveButton()}
|
|
</div>
|
|
}
|
|
</div>
|
|
{this.renderNodeHardwareSummary()}
|
|
<div className='node-settings' onClick={this.showNodeDetails} />
|
|
</label>
|
|
</div>
|
|
);
|
|
},
|
|
toggleLabelsPopover(visible) {
|
|
this.setState({
|
|
labelsPopoverVisible: _.isBoolean(visible) ? visible : !this.state.labelsPopoverVisible
|
|
});
|
|
},
|
|
render() {
|
|
var ns = 'cluster_page.nodes_tab.node.';
|
|
var node = this.props.node;
|
|
var isSelectable = node.isSelectable() && !this.props.locked && this.props.mode != 'edit';
|
|
var status = node.getStatusSummary();
|
|
var roles = this.props.cluster ? node.sortedRoles(
|
|
this.props.cluster.get('roles').pluck('name')
|
|
) : [];
|
|
|
|
// compose classes
|
|
var nodePanelClasses = {
|
|
node: true,
|
|
selected: this.props.checked,
|
|
'col-xs-12': this.props.viewMode != 'compact',
|
|
unavailable: !isSelectable
|
|
};
|
|
nodePanelClasses[status] = status;
|
|
|
|
var manufacturer = node.get('manufacturer') || '';
|
|
var logoClasses = {
|
|
'manufacturer-logo': true
|
|
};
|
|
logoClasses[manufacturer.toLowerCase()] = manufacturer;
|
|
|
|
var statusClasses = {
|
|
'node-status': true
|
|
};
|
|
var statusClass = {
|
|
pending_addition: 'text-success',
|
|
pending_deletion: 'text-warning',
|
|
error: 'text-danger',
|
|
ready: 'text-info',
|
|
provisioning: 'text-info',
|
|
deploying: 'text-success',
|
|
provisioned: 'text-info'
|
|
}[status];
|
|
statusClasses[statusClass] = true;
|
|
|
|
var renderMethod = this.props.viewMode == 'compact' ? this.renderCompactNode :
|
|
this.renderStandardNode;
|
|
|
|
return renderMethod({
|
|
ns, status, roles, nodePanelClasses,
|
|
logoClasses, statusClasses, isSelectable
|
|
});
|
|
}
|
|
});
|
|
|
|
export default Node;
|