fuel-web/nailgun/static/views/dialogs.js

1576 lines
73 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.
**/
define(
[
'jquery',
'underscore',
'i18n',
'backbone',
'react',
'utils',
'models',
'dispatcher',
'views/controls',
'component_mixins'
],
function($, _, i18n, Backbone, React, utils, models, dispatcher, controls, componentMixins) {
'use strict';
var dialogs = {};
function getActiveDialog() {
return app.dialog;
}
function setActiveDialog(dialog) {
if (dialog) {
app.dialog = dialog;
} else {
delete app.dialog;
}
}
var dialogMixin = dialogs.dialogMixin = {
propTypes: {
title: React.PropTypes.node,
message: React.PropTypes.node,
modalClass: React.PropTypes.node,
error: React.PropTypes.bool,
closeable: React.PropTypes.bool,
keyboard: React.PropTypes.bool,
background: React.PropTypes.bool,
backdrop: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.bool
])
},
statics: {
show: function(dialogOptions = {}, showOptions = {}) {
var activeDialog = getActiveDialog();
if (activeDialog) {
var result = $.Deferred();
if (showOptions.preventDuplicate && activeDialog.constructor === this) {
result.reject();
} else {
$(React.findDOMNode(activeDialog)).on('hidden.bs.modal', () => {
this.show(dialogOptions).then(result.resolve, result.reject);
});
}
return result;
} else {
return React.render(React.createElement(this, dialogOptions), $('#modal-container')[0]).getResult();
}
}
},
getInitialState: function() {
return {
actionInProgress: false,
result: $.Deferred()
};
},
getResult: function() {
return this.state.result;
},
componentDidMount: function() {
setActiveDialog(this);
Backbone.history.on('route', this.close, this);
var $el = $(React.findDOMNode(this));
$el.on('hidden.bs.modal', this.handleHidden);
$el.on('shown.bs.modal', () => $el.find('input:enabled:first').focus());
$el.modal(_.defaults(
{keyboard: false},
_.pick(this.props, ['background', 'backdrop']),
{background: true, backdrop: true}
));
},
rejectResult: function() {
if (this.state.result.state() == 'pending') this.state.result.reject();
},
componentWillUnmount: function() {
Backbone.history.off(null, null, this);
$(React.findDOMNode(this)).off('shown.bs.modal hidden.bs.modal');
this.rejectResult();
setActiveDialog(null);
},
handleHidden: function() {
React.unmountComponentAtNode(React.findDOMNode(this).parentNode);
},
close: function() {
$(React.findDOMNode(this)).modal('hide');
this.rejectResult();
},
closeOnLinkClick: function(e) {
// close dialogs on click of any internal link inside it
if (e.target.tagName == 'A' && !e.target.target && e.target.href) this.close();
},
closeOnEscapeKey: function(e) {
if (this.props.keyboard !== false && this.props.closeable !== false && e.key == 'Escape') this.close();
if (_.isFunction(this.onKeyDown)) this.onKeyDown(e);
},
showError: function(response, message) {
var props = {error: true};
props.message = utils.getResponseText(response) || message;
this.setProps(props);
},
renderImportantLabel: function() {
return <span className='label label-danger'>{i18n('common.important')}</span>;
},
submitAction: function() {
this.state.result.resolve();
this.close();
},
render: function() {
var classes = {'modal fade': true};
classes[this.props.modalClass] = this.props.modalClass;
return (
<div className={utils.classNames(classes)} tabIndex='-1' onClick={this.closeOnLinkClick} onKeyDown={this.closeOnEscapeKey}>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
{this.props.closeable !== false &&
<button type='button' className='close' aria-label='Close' onClick={this.close}>
<span aria-hidden='true'>&times;</span>
</button>
}
<h4 className='modal-title'>{this.props.title || this.state.title || (this.props.error ? i18n('dialog.error_dialog.title') : '')}</h4>
</div>
<div className='modal-body'>
{this.props.error ?
<div className='text-error'>
{this.props.message || i18n('dialog.error_dialog.server_error')}
</div>
: this.renderBody()}
</div>
<div className='modal-footer'>
{this.renderFooter && !this.props.error ?
this.renderFooter()
:
<button className='btn btn-default' onClick={this.close}>{i18n('common.close_button')}</button>
}
</div>
</div>
</div>
</div>
);
}
};
var registrationResponseErrorMixin = {
showResponseErrors: function(response, form) {
var jsonObj,
error = '';
try {
jsonObj = JSON.parse(response.responseText);
error = jsonObj.message;
if (_.isObject(form)) {
form.validationError = {};
_.each(jsonObj.errors, function(value, name) {
form.validationError['credentials.' + name] = value;
});
}
} catch (e) {
error = i18n('welcome_page.register.connection_error');
}
this.setState({error: error});
}
};
dialogs.ErrorDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {
return {error: true};
}
});
dialogs.NailgunUnavailabilityDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps() {
return {
title: i18n('dialog.nailgun_unavailability.title'),
modalClass: 'nailgun-unavailability-dialog',
closeable: false,
keyboard: false,
backdrop: false,
retryDelayIntervals: [5, 10, 15, 20, 30, 60]
};
},
getInitialState() {
var initialDelay = this.props.retryDelayIntervals[0];
return {
currentDelay: initialDelay,
currentDelayInterval: initialDelay
};
},
componentWillMount() {
this.startCountdown();
},
componentDidMount() {
$(React.findDOMNode(this)).on('shown.bs.modal', () => $(React.findDOMNode(this.refs['retry-button'])).focus());
},
startCountdown() {
this.activeTimeout = _.delay(this.countdown, 1000);
},
stopCountdown() {
if (this.activeTimeout) clearTimeout(this.activeTimeout);
delete this.activeTimeout;
},
countdown: function() {
var {currentDelay} = this.state;
currentDelay--;
if (!currentDelay) {
this.setState({currentDelay, actionInProgress: true});
this.reinitializeUI();
} else {
this.setState({currentDelay});
this.startCountdown();
}
},
reinitializeUI() {
app.initialize().then(this.close, () => {
var {retryDelayIntervals} = this.props;
var nextDelay = retryDelayIntervals[retryDelayIntervals.indexOf(this.state.currentDelayInterval) + 1] || _.last(retryDelayIntervals);
_.defer(() => this.setState({
actionInProgress: false,
currentDelay: nextDelay,
currentDelayInterval: nextDelay
}, this.startCountdown));
});
},
retryNow() {
this.stopCountdown();
this.setState({
currentDelay: 0,
currentDelayInterval: 0,
actionInProgress: true
});
this.reinitializeUI();
},
renderBody() {
return (
<div>
<p>
{i18n('dialog.nailgun_unavailability.unavailability_message')}
{' '}
{this.state.currentDelay ?
i18n('dialog.nailgun_unavailability.retry_delay_message', {count: this.state.currentDelay})
:
i18n('dialog.nailgun_unavailability.retrying')
}
</p>
<p>
{i18n('dialog.nailgun_unavailability.unavailability_reasons')}
</p>
</div>
);
},
renderFooter() {
return (
<button
ref='retry-button'
className='btn btn-success'
onClick={this.retryNow}
disabled={this.state.actionInProgress}
>
{i18n('dialog.nailgun_unavailability.retry_now')}
</button>
);
}
});
dialogs.DiscardNodeChangesDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps() {
return {
title: i18n('dialog.discard_changes.title')
};
},
discardNodeChanges() {
this.setState({actionInProgress: true});
var nodes = new models.Nodes(this.props.nodes.map(function(node) {
if (node.get('pending_deletion')) return {
id: node.id,
pending_deletion: false
};
return {
id: node.id,
cluster_id: null,
pending_addition: false,
pending_roles: []
};
}));
Backbone.sync('update', nodes)
.then(() => this.props.cluster.fetchRelated('nodes'))
.done(() => {
dispatcher.trigger('updateNodeStats networkConfigurationUpdated labelsConfigurationUpdated');
this.state.result.resolve();
this.close();
})
.fail((response) => this.showError(response, i18n('dialog.discard_changes.cant_discard')));
},
renderBody() {
return (
<div className='text-danger'>
{this.renderImportantLabel()}
{i18n('dialog.discard_changes.' + (
this.props.nodes[0].get('pending_deletion') ? 'discard_deletion' : 'discard_addition'
))}
</div>
);
},
renderFooter() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>{i18n('common.cancel_button')}</button>,
<button key='discard' className='btn btn-danger' disabled={this.state.actionInProgress} onClick={this.discardNodeChanges}>{i18n('dialog.discard_changes.discard_button')}</button>
]);
}
});
dialogs.DeployChangesDialog = React.createClass({
mixins: [
dialogMixin,
// this is needed to somehow handle the case when verification is in progress and user pressed Deploy
componentMixins.backboneMixin({
modelOrCollection: function(props) {
return props.cluster.get('tasks');
},
renderOn: 'update change:status'
})
],
getDefaultProps: function() {return {title: i18n('dialog.display_changes.title')};},
ns: 'dialog.display_changes.',
deployCluster: function() {
this.setState({actionInProgress: true});
dispatcher.trigger('deploymentTasksUpdated');
var task = new models.Task();
task.save({}, {url: _.result(this.props.cluster, 'url') + '/changes', type: 'PUT'})
.done(function() {
this.close();
dispatcher.trigger('deploymentTaskStarted');
}.bind(this))
.fail(this.showError);
},
renderBody: function() {
var cluster = this.props.cluster;
return (
<div className='display-changes-dialog'>
{!cluster.needsRedeployment() && _.contains(['new', 'stopped'], cluster.get('status')) &&
<div>
<div className='text-warning'>
<i className='glyphicon glyphicon-warning-sign' />
<div className='instruction'>
{i18n('cluster_page.dashboard_tab.locked_settings_alert') + ' '}
</div>
</div>
<div className='text-warning'>
<i className='glyphicon glyphicon-warning-sign' />
<div className='instruction'>
{i18n('cluster_page.dashboard_tab.package_information') + ' '}
<a
target='_blank'
href={utils.composeDocumentationLink('operations.html#troubleshooting')}
>
{i18n('cluster_page.dashboard_tab.operations_guide')}
</a>
{i18n('cluster_page.dashboard_tab.for_more_information_configuration')}
</div>
</div>
</div>
}
<div className='confirmation-question'>
{i18n(this.ns + 'are_you_sure_deploy')}
</div>
</div>
);
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>{i18n('common.cancel_button')}</button>,
<button key='deploy'
className='btn start-deployment-btn btn-success'
disabled={this.state.actionInProgress || this.state.isInvalid}
onClick={this.deployCluster}
>{i18n(this.ns + 'deploy')}</button>
]);
}
});
dialogs.ProvisionVMsDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {return {title: i18n('dialog.provision_vms.title')};},
startProvisioning: function() {
this.setState({actionInProgress: true});
var task = new models.Task();
task.save({}, {url: _.result(this.props.cluster, 'url') + '/spawn_vms', type: 'PUT'})
.done(function() {
this.close();
dispatcher.trigger('deploymentTaskStarted');
}.bind(this))
.fail(_.bind(function(response) {
this.showError(response, i18n('dialog.provision_vms.provision_vms_error'));
}, this));
},
renderBody: function() {
var vmsCount = this.props.cluster.get('nodes').where(function(node) {
return node.get('pending_addition') && node.hasRole('virt');
}).length;
return i18n('dialog.provision_vms.text', {count: vmsCount});
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>{i18n('common.cancel_button')}</button>,
<button key='provision' className='btn btn-success' disabled={this.state.actionInProgress} onClick={this.startProvisioning}>{i18n('common.start_button')}</button>
]);
}
});
dialogs.StopDeploymentDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {return {title: i18n('dialog.stop_deployment.title')};},
stopDeployment: function() {
this.setState({actionInProgress: true});
var task = new models.Task();
task.save({}, {url: _.result(this.props.cluster, 'url') + '/stop_deployment', type: 'PUT'})
.done(function() {
this.close();
dispatcher.trigger('deploymentTaskStarted');
}.bind(this))
.fail(_.bind(function(response) {
this.showError(response, i18n('dialog.stop_deployment.stop_deployment_error.stop_deployment_warning'));
}, this));
},
renderBody: function() {
var ns = 'dialog.stop_deployment.';
return (
<div className='text-danger'>
{this.renderImportantLabel()}
{this.props.cluster.get('nodes').any({status: 'provisioning'}) ?
<span>
{i18n(ns + 'provisioning_warning')}
<br/><br/>
{i18n(ns + 'redeployment_warning')}
</span>
:
i18n(ns + 'text')
}
</div>
);
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>{i18n('common.cancel_button')}</button>,
<button key='deploy' className='btn stop-deployment-btn btn-danger' disabled={this.state.actionInProgress} onClick={this.stopDeployment}>{i18n('common.stop_button')}</button>
]);
}
});
dialogs.RemoveClusterDialog = React.createClass({
mixins: [dialogMixin],
getInitialState: function() {
return {confirmation: false};
},
getDefaultProps: function() {
return {title: i18n('dialog.remove_cluster.title')};
},
removeCluster: function() {
this.setState({actionInProgress: true});
this.props.cluster
.destroy({wait: true})
.then(
() => {
this.close();
dispatcher.trigger('updateNodeStats updateNotifications');
app.navigate('#clusters', {trigger: true});
},
this.showError
);
},
showConfirmationForm: function() {
this.setState({confirmation: true});
},
getText: function() {
var ns = 'dialog.remove_cluster.',
runningTask = this.props.cluster.task({active: true});
if (runningTask) {
if (runningTask.match({name: 'stop_deployment'})) {
return i18n(ns + 'stop_deployment_is_running');
}
return i18n(ns + 'incomplete_actions_text');
}
if (this.props.cluster.get('nodes').length) {
return i18n(ns + 'node_returned_text');
}
return i18n(ns + 'default_text');
},
renderBody: function() {
var clusterName = this.props.cluster.get('name');
return (
<div>
<div className='text-danger'>
{this.renderImportantLabel()}
{this.getText()}
</div>
{this.state.confirmation &&
<div className='confirm-deletion-form'>
{i18n('dialog.remove_cluster.enter_environment_name', {name: clusterName})}
<controls.Input
type='text'
disabled={this.state.actionInProgress}
onChange={(name, value) => this.setState({confirmationError: value != clusterName})}
onPaste={(e) => e.preventDefault()}
autoFocus
/>
</div>
}
</div>
);
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>{i18n('common.cancel_button')}</button>,
<button
key='remove'
className='btn btn-danger remove-cluster-btn'
disabled={this.state.actionInProgress || this.state.confirmation && _.isUndefined(this.state.confirmationError) || this.state.confirmationError}
onClick={this.props.cluster.get('status') == 'new' || this.state.confirmation ? this.removeCluster : this.showConfirmationForm}
>
{i18n('common.delete_button')}
</button>
]);
}
});
// FIXME: the code below neeeds deduplication
// extra confirmation logic should be moved out to dialog mixin
dialogs.ResetEnvironmentDialog = React.createClass({
mixins: [dialogMixin],
getInitialState: function() {
return {confirmation: false};
},
getDefaultProps: function() {
return {title: i18n('dialog.reset_environment.title')};
},
resetEnvironment: function() {
this.setState({actionInProgress: true});
dispatcher.trigger('deploymentTasksUpdated');
var task = new models.Task();
task.save({}, {url: _.result(this.props.cluster, 'url') + '/reset', type: 'PUT'})
.done(function() {
this.close();
dispatcher.trigger('deploymentTaskStarted');
}.bind(this))
.fail(this.showError);
},
renderBody: function() {
var clusterName = this.props.cluster.get('name');
return (
<div>
<div className='text-danger'>
{this.renderImportantLabel()}
{i18n('dialog.reset_environment.text')}
</div>
{this.state.confirmation &&
<div className='confirm-reset-form'>
{i18n('dialog.reset_environment.enter_environment_name', {name: clusterName})}
<controls.Input
type='text'
name='name'
disabled={this.state.actionInProgress}
onChange={_.bind(function(name, value) {
this.setState({confirmationError: value != clusterName});
}, this)}
onPaste={function(e) {e.preventDefault();}}
autoFocus
/>
</div>
}
</div>
);
},
showConfirmationForm: function() {
this.setState({confirmation: true});
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' disabled={this.state.actionInProgress} onClick={this.close}>{i18n('common.cancel_button')}</button>,
<button
key='reset'
className='btn btn-danger reset-environment-btn'
disabled={this.state.actionInProgress || this.state.confirmation && _.isUndefined(this.state.confirmationError) || this.state.confirmationError}
onClick={this.state.confirmation ? this.resetEnvironment : this.showConfirmationForm}
>
{i18n('common.reset_button')}
</button>
]);
}
});
dialogs.ShowNodeInfoDialog = React.createClass({
mixins: [
dialogMixin,
componentMixins.backboneMixin('node'),
componentMixins.renamingMixin('hostname')
],
getDefaultProps: function() {
return {modalClass: 'always-show-scrollbar'};
},
getInitialState: function() {
return {
title: i18n('dialog.show_node.default_dialog_title'),
VMsConf: null,
VMsConfValidationError: null,
hostnameChangingError: null
};
},
goToConfigurationScreen: function(url) {
this.close();
app.navigate('#cluster/' + this.props.node.get('cluster') + '/nodes/' + url + '/' + utils.serializeTabOptions({nodes: this.props.node.id}), {trigger: true});
},
showSummary: function(meta, group) {
var summary = '';
try {
switch (group) {
case 'system':
summary = (meta.system.manufacturer || '') + ' ' + (meta.system.product || '');
break;
case 'memory':
if (_.isArray(meta.memory.devices) && meta.memory.devices.length) {
var sizes = _.countBy(_.pluck(meta.memory.devices, 'size'), utils.showMemorySize);
summary = _.map(_.keys(sizes).sort(), function(size) {return sizes[size] + ' x ' + size;}).join(', ');
summary += ', ' + utils.showMemorySize(meta.memory.total) + ' ' + i18n('dialog.show_node.total');
} else summary = utils.showMemorySize(meta.memory.total) + ' ' + i18n('dialog.show_node.total');
break;
case 'disks':
summary = meta.disks.length + ' ';
summary += i18n('dialog.show_node.drive', {count: meta.disks.length});
summary += ', ' + utils.showDiskSize(_.reduce(_.pluck(meta.disks, 'size'), function(sum, n) {return sum + n;}, 0)) + ' ' + i18n('dialog.show_node.total');
break;
case 'cpu':
var frequencies = _.countBy(_.pluck(meta.cpu.spec, 'frequency'), utils.showFrequency);
summary = _.map(_.keys(frequencies).sort(), function(frequency) {return frequencies[frequency] + ' x ' + frequency;}).join(', ');
break;
case 'interfaces':
var bandwidths = _.countBy(_.pluck(meta.interfaces, 'current_speed'), utils.showBandwidth);
summary = _.map(_.keys(bandwidths).sort(), function(bandwidth) {return bandwidths[bandwidth] + ' x ' + bandwidth;}).join(', ');
break;
}
} catch (ignore) {}
return summary;
},
showPropertyName: function(propertyName) {
return String(propertyName).replace(/_/g, ' ');
},
showPropertyValue: function(group, name, value) {
try {
if (group == 'memory' && (name == 'total' || name == 'maximum_capacity' || name == 'size')) {
value = utils.showMemorySize(value);
} else if (group == 'disks' && name == 'size') {
value = utils.showDiskSize(value);
} else if (name == 'size') {
value = utils.showSize(value);
} else if (name == 'frequency') {
value = utils.showFrequency(value);
} else if (name == 'max_speed' || name == 'current_speed') {
value = utils.showBandwidth(value);
} else if (_.isBoolean(value)) {
value = value ? i18n('common.true') : i18n('common.false');
}
} catch (ignore) {}
return !_.isNumber(value) && _.isEmpty(value) ? '\u00A0' : value;
},
componentDidUpdate: function() {
this.assignAccordionEvents();
},
componentDidMount: function() {
this.assignAccordionEvents();
this.setDialogTitle();
if (this.props.node.get('pending_addition') && this.props.node.hasRole('virt')) {
var VMsConfModel = new models.BaseModel();
VMsConfModel.url = _.result(this.props.node, 'url') + '/vms_conf';
this.setProps({VMsConfModel: VMsConfModel});
this.setState({actionInProgress: true});
VMsConfModel.fetch().always(_.bind(function() {
this.setState({
actionInProgress: false,
VMsConf: JSON.stringify(VMsConfModel.get('vms_conf'))
});
}, this));
}
},
setDialogTitle: function() {
var name = this.props.node && this.props.node.get('name');
if (name && name != this.state.title) this.setState({title: name});
},
assignAccordionEvents: function() {
$('.panel-collapse', React.findDOMNode(this))
.on('show.bs.collapse', function(e) {$(e.currentTarget).siblings('.panel-heading').find('i').removeClass('glyphicon-plus').addClass('glyphicon-minus');})
.on('hide.bs.collapse', function(e) {$(e.currentTarget).siblings('.panel-heading').find('i').removeClass('glyphicon-minus').addClass('glyphicon-plus');})
.on('hidden.bs.collapse', function(e) {e.stopPropagation();});
},
toggle: function(groupIndex) {
$(React.findDOMNode(this.refs['togglable_' + groupIndex])).collapse('toggle');
},
onVMsConfChange: function() {
this.setState({VMsConfValidationError: null});
},
saveVMsConf: function() {
var parsedVMsConf;
try {
parsedVMsConf = JSON.parse(this.refs['vms-config'].getInputDOMNode().value);
} catch (e) {
this.setState({VMsConfValidationError: i18n('node_details.invalid_vms_conf_msg')});
}
if (parsedVMsConf) {
this.setState({actionInProgress: true});
this.props.VMsConfModel.save({vms_conf: parsedVMsConf}, {method: 'PUT'})
.fail(_.bind(function(response) {
this.setState({VMsConfValidationError: utils.getResponseText(response)});
}, this))
.always(_.bind(function() {
this.setState({actionInProgress: false});
}, this));
}
},
startHostnameRenaming: function(e) {
this.setState({hostnameChangingError: null});
this.startRenaming(e);
},
onHostnameInputKeydown: function(e) {
this.setState({hostnameChangingError: null});
if (e.key == 'Enter') {
this.setState({actionInProgress: true});
var hostname = _.trim(this.refs.hostname.getInputDOMNode().value);
(hostname != this.props.node.get('hostname') ?
this.props.node.save({hostname: hostname}, {patch: true, wait: true}) :
$.Deferred().resolve()
)
.fail((response) => {
this.setState({
hostnameChangingError: utils.getResponseText(response),
actionInProgress: false
});
this.refs.hostname.getInputDOMNode().focus();
})
.done(this.endRenaming);
} else if (e.key == 'Escape') {
this.endRenaming();
e.stopPropagation();
React.findDOMNode(this).focus();
}
},
renderBody: function() {
var node = this.props.node,
meta = node.get('meta');
if (!meta) return <controls.ProgressBar />;
var groupOrder = ['system', 'cpu', 'memory', 'disks', 'interfaces'],
groups = _.sortBy(_.keys(meta), (group) => _.indexOf(groupOrder, group)),
sortOrder = {
disks: ['name', 'model', 'size'],
interfaces: ['name', 'mac', 'state', 'ip', 'netmask', 'current_speed', 'max_speed', 'driver', 'bus_info']
};
if (this.state.VMsConf) groups.push('config');
return (
<div className='node-details-popup'>
<div className='row'>
<div className='col-xs-5'><div className='node-image-outline' /></div>
<div className='col-xs-7 node-summary'>
{this.props.cluster &&
<div><strong>{i18n('dialog.show_node.cluster')}: </strong>{this.props.cluster.get('name')}</div>
}
<div><strong>{i18n('dialog.show_node.manufacturer_label')}: </strong>{node.get('manufacturer') || i18n('common.not_available')}</div>
{this.props.nodeNetworkGroup &&
<div>
<strong>{i18n('dialog.show_node.node_network_group')}: </strong>
{this.props.nodeNetworkGroup.get('name')}
</div>
}
<div><strong>{i18n('dialog.show_node.mac_address_label')}: </strong>{node.get('mac') || i18n('common.not_available')}</div>
<div><strong>{i18n('dialog.show_node.fqdn_label')}: </strong>{(node.get('meta').system || {}).fqdn || node.get('fqdn') || i18n('common.not_available')}</div>
<div className='change-hostname'>
<strong>{i18n('dialog.show_node.hostname_label')}: </strong>
{this.state.isRenaming ?
<controls.Input
ref='hostname'
type='text'
defaultValue={node.get('hostname')}
inputClassName={'input-sm'}
error={this.state.hostnameChangingError}
disabled={this.state.actionInProgress}
onKeyDown={this.onHostnameInputKeydown}
selectOnFocus
autoFocus
/>
:
<span>
<span className='node-hostname'>
{node.get('hostname') || i18n('common.not_available')}
</span>
{(node.get('pending_addition') || !node.get('cluster')) &&
<button
className='btn-link glyphicon glyphicon-pencil'
onClick={this.startHostnameRenaming}
/>
}
</span>
}
</div>
</div>
</div>
<div className='panel-group' id='accordion' role='tablist' aria-multiselectable='true'>
{_.map(groups, function(group, groupIndex) {
var groupEntries = meta[group],
subEntries = [];
if (group == 'interfaces' || group == 'disks') groupEntries = _.sortBy(groupEntries, 'name');
if (_.isPlainObject(groupEntries)) subEntries = _.find(_.values(groupEntries), _.isArray);
return (
<div className='panel panel-default' key={group}>
<div className='panel-heading' role='tab' id={'heading' + group} onClick={this.toggle.bind(this, groupIndex)}>
<div className='panel-title'>
<div data-parent='#accordion' aria-expanded='true' aria-controls={'body' + group}>
<strong>{i18n('node_details.' + group, {defaultValue: group})}</strong> {this.showSummary(meta, group)}
<i className='glyphicon glyphicon-plus pull-right' />
</div>
</div>
</div>
<div className='panel-collapse collapse' role='tabpanel' aria-labelledby={'heading' + group} ref={'togglable_' + groupIndex}>
<div className='panel-body enable-selection'>
{_.isArray(groupEntries) &&
<div>
{_.map(groupEntries, function(entry, entryIndex) {
return (
<div className='nested-object' key={'entry_' + groupIndex + entryIndex}>
{_.map(utils.sortEntryProperties(entry, sortOrder[group]), function(propertyName) {
if (!_.isPlainObject(entry[propertyName]) && !_.isArray(entry[propertyName])) return this.renderNodeInfo(propertyName, this.showPropertyValue(group, propertyName, entry[propertyName]));
}, this)}
</div>
);
}, this)}
</div>
}
{_.isPlainObject(groupEntries) &&
<div>
{_.map(groupEntries, function(propertyValue, propertyName) {
if (!_.isPlainObject(propertyValue) && !_.isArray(propertyValue) && !_.isNumber(propertyName)) return this.renderNodeInfo(propertyName, this.showPropertyValue(group, propertyName, propertyValue));
}, this)}
{!_.isEmpty(subEntries) &&
<div>
{_.map(subEntries, function(subentry, subentrysIndex) {
return (
<div className='nested-object' key={'subentries_' + groupIndex + subentrysIndex}>
{_.map(utils.sortEntryProperties(subentry), function(propertyName) {
if (!_.isPlainObject(subentry[propertyName]) && !_.isArray(subentry[propertyName])) return this.renderNodeInfo(propertyName, this.showPropertyValue(group, propertyName, subentry[propertyName]));
}, this)}
</div>
);
}, this)}
</div>
}
</div>
}
{(!_.isPlainObject(groupEntries) && !_.isArray(groupEntries) && !_.isUndefined(groupEntries)) &&
<div>{groupEntries}</div>
}
{group == 'config' &&
<div className='vms-config'>
<controls.Input
ref='vms-config'
type='textarea'
label={i18n('node_details.vms_config_msg')}
error={this.state.VMsConfValidationError}
onChange={this.onVMsConfChange}
defaultValue={this.state.VMsConf}
/>
<button
className='btn btn-success'
onClick={this.saveVMsConf}
disabled={this.state.VMsConfValidationError || this.state.actionInProgress}
>
{i18n('common.save_settings_button')}
</button>
</div>
}
</div>
</div>
</div>
);
}, this)}
</div>
</div>
);
},
renderFooter: function() {
return (
<div>
{this.props.renderActionButtons && this.props.node.get('cluster') &&
<div className='btn-group' role='group'>
<button className='btn btn-default btn-edit-disks' onClick={_.partial(this.goToConfigurationScreen, 'disks')}>
{i18n('dialog.show_node.disk_configuration' + (this.props.node.areDisksConfigurable() ? '_action' : ''))}
</button>
<button className='btn btn-default btn-edit-networks' onClick={_.partial(this.goToConfigurationScreen, 'interfaces')}>
{i18n('dialog.show_node.network_configuration' + (this.props.node.areInterfacesConfigurable() ? '_action' : ''))}
</button>
</div>
}
<div className='btn-group' role='group'>
<button className='btn btn-default' onClick={this.close}>{i18n('common.close_button')}</button>
</div>
</div>
);
},
renderNodeInfo: function(name, value) {
return (
<div key={name + value} className='node-details-row'>
<label>{i18n('dialog.show_node.' + name, {defaultValue: this.showPropertyName(name)})}</label>
{value}
</div>
);
}
});
dialogs.DiscardSettingsChangesDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {return {title: i18n('dialog.dismiss_settings.title')};},
proceedWith: function(method) {
this.setState({actionInProgress: true});
$.when(method ? method() : $.Deferred().resolve())
.done(this.state.result.resolve)
.done(this.close)
.fail(_.partial(this.showError, null, i18n('dialog.dismiss_settings.saving_failed_message')));
},
discard: function() {
this.proceedWith(this.props.revertChanges);
},
save: function() {
this.proceedWith(this.props.applyChanges);
},
getMessage: function() {
if (this.props.isDiscardingPossible === false) return 'no_discard_message';
if (this.props.isSavingPossible === false) return 'no_saving_message';
return 'default_message';
},
renderBody: function() {
return (
<div className='text-danger dismiss-settings-dialog'>
{this.renderImportantLabel()}
{i18n('dialog.dismiss_settings.' + this.getMessage())}
</div>
);
},
renderFooter: function() {
var buttons = [
<button
key='stay'
className='btn btn-default'
onClick={this.close}
>
{i18n('dialog.dismiss_settings.stay_button')}
</button>,
<button
key='leave'
className='btn btn-danger proceed-btn'
onClick={this.discard}
disabled={this.state.actionInProgress || this.props.isDiscardingPossible === false}
>
{i18n('dialog.dismiss_settings.leave_button')}
</button>,
<button
key='save'
className='btn btn-success'
onClick={this.save}
disabled={this.state.actionInProgress || this.props.isSavingPossible === false}
>
{i18n('dialog.dismiss_settings.apply_and_proceed_button')}
</button>
];
return buttons;
}
});
dialogs.RemoveOfflineNodeDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps() {
return {
title: i18n('dialog.remove_node.title'),
defaultMessage: i18n('dialog.remove_node.default_message')
};
},
renderBody() {
return (
<div className='text-danger'>
{this.renderImportantLabel()}
{this.props.defaultMessage}
</div>
);
},
renderFooter() {
return [
<button key='close' className='btn btn-default' onClick={this.close}>
{i18n('common.cancel_button')}
</button>,
<button key='remove' className='btn btn-danger btn-delete' onClick={this.submitAction}>
{i18n('cluster_page.nodes_tab.node.remove')}
</button>
];
}
});
dialogs.DeleteNodesDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {return {title: i18n('dialog.delete_nodes.title')};},
renderBody: function() {
var ns = 'dialog.delete_nodes.',
notDeployedNodesAmount = this.props.nodes.reject({status: 'ready'}).length,
deployedNodesAmount = this.props.nodes.length - notDeployedNodesAmount;
return (
<div className='text-danger'>
{this.renderImportantLabel()}
{i18n(ns + 'common_message', {count: this.props.nodes.length})}
<br/>
{!!notDeployedNodesAmount && i18n(ns + 'not_deployed_nodes_message', {count: notDeployedNodesAmount})}
{' '}
{!!deployedNodesAmount && i18n(ns + 'deployed_nodes_message', {count: deployedNodesAmount})}
</div>
);
},
renderFooter: function() {
return [
<button key='cancel' className='btn btn-default' onClick={this.close}>{i18n('common.cancel_button')}</button>,
<button key='delete' className='btn btn-danger btn-delete' onClick={this.deleteNodes} disabled={this.state.actionInProgress}>{i18n('common.delete_button')}</button>
];
},
deleteNodes: function() {
this.setState({actionInProgress: true});
var nodes = new models.Nodes(this.props.nodes.map(function(node) {
// mark deployed node as pending deletion
if (node.get('status') == 'ready') return {
id: node.id,
pending_deletion: true
};
// remove not deployed node from cluster
return {
id: node.id,
cluster_id: null,
pending_addition: false,
pending_roles: []
};
}));
Backbone.sync('update', nodes)
.then(_.bind(function() {
return this.props.cluster.fetchRelated('nodes');
}, this))
.done(_.bind(function() {
dispatcher.trigger('updateNodeStats networkConfigurationUpdated labelsConfigurationUpdated');
this.state.result.resolve();
this.close();
}, this))
.fail(_.bind(function(response) {
this.showError(response, i18n('cluster_page.nodes_tab.node_deletion_error.node_deletion_warning'));
}, this));
}
});
dialogs.ChangePasswordDialog = React.createClass({
mixins: [
dialogMixin,
React.addons.LinkedStateMixin
],
getDefaultProps: function() {
return {
title: i18n('dialog.change_password.title'),
modalClass: 'change-password'
};
},
getInitialState: function() {
return {
currentPassword: '',
confirmationPassword: '',
newPassword: '',
validationError: false
};
},
getError: function(name) {
var ns = 'dialog.change_password.';
if (name == 'currentPassword' && this.state.validationError) return i18n(ns + 'wrong_current_password');
if (this.state.newPassword != this.state.confirmationPassword) {
if (name == 'confirmationPassword') return i18n(ns + 'new_password_mismatch');
if (name == 'newPassword') return '';
}
return null;
},
renderBody: function() {
var ns = 'dialog.change_password.',
fields = ['currentPassword', 'newPassword', 'confirmationPassword'],
translationKeys = ['current_password', 'new_password', 'confirm_new_password'];
return (
<div className='forms-box'>
{_.map(fields, function(name, index) {
return <controls.Input
key={name}
name={name}
ref={name}
type='password'
label={i18n(ns + translationKeys[index])}
maxLength='50'
onChange={this.handleChange.bind(this, (name == 'currentPassword'))}
onKeyDown={this.handleKeyDown}
disabled={this.state.actionInProgress}
toggleable={name == 'currentPassword'}
defaultValue={this.state[name]}
error={this.getError(name)}
/>;
}, this)}
</div>
);
},
renderFooter: function() {
return [
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>
{i18n('common.cancel_button')}
</button>,
<button key='apply' className='btn btn-success' onClick={this.changePassword}
disabled={this.state.actionInProgress || !this.isPasswordChangeAvailable()}>
{i18n('common.apply_button')}
</button>
];
},
isPasswordChangeAvailable: function() {
return this.state.newPassword.length && !this.state.validationError &&
(this.state.newPassword == this.state.confirmationPassword);
},
handleKeyDown: function(e) {
if (e.key == 'Enter') {
e.preventDefault();
this.changePassword();
}
if (e.key == ' ') {
e.preventDefault();
return false;
}
},
handleChange: function(clearError, name, value) {
var newState = {};
newState[name] = value.trim();
if (clearError) {
newState.validationError = false;
}
this.setState(newState);
},
changePassword: function() {
if (this.isPasswordChangeAvailable()) {
var keystoneClient = app.keystoneClient;
this.setState({actionInProgress: true});
keystoneClient.changePassword(this.state.currentPassword, this.state.newPassword)
.done(_.bind(function() {
dispatcher.trigger(this.state.newPassword == keystoneClient.DEFAULT_PASSWORD ? 'showDefaultPasswordWarning' : 'hideDefaultPasswordWarning');
app.user.set({token: keystoneClient.token});
this.close();
}, this))
.fail(_.bind(function() {
this.setState({validationError: true, actionInProgress: false});
$(this.refs.currentPassword.getInputDOMNode()).focus();
}, this));
}
}
});
dialogs.RegistrationDialog = React.createClass({
mixins: [
dialogMixin,
registrationResponseErrorMixin,
componentMixins.backboneMixin('registrationForm', 'change invalid')
],
getInitialState: function() {
return {
loading: true
};
},
getDefaultProps: function() {
return {
title: i18n('dialog.registration.title'),
modalClass: 'registration',
backdrop: 'static'
};
},
componentDidMount: function() {
var registrationForm = this.props.registrationForm;
registrationForm.fetch()
.then(null, function() {
registrationForm.url = registrationForm.nailgunUrl;
return registrationForm.fetch();
})
.fail(_.bind(function(response) {
this.showResponseErrors(response);
this.setState({connectionError: true});
}, this))
.always(_.bind(function() {this.setState({loading: false});}, this));
},
onChange: function(inputName, value) {
var registrationForm = this.props.registrationForm,
name = registrationForm.makePath('credentials', inputName, 'value');
if (registrationForm.validationError) delete registrationForm.validationError['credentials.' + inputName];
registrationForm.set(name, value);
},
composeOptions: function(values) {
return _.map(values, function(value, index) {
return (
<option key={index} value={value.data}>
{value.label}
</option>
);
});
},
getAgreementLink: function(link) {
return (<span>{i18n('dialog.registration.i_agree')} <a href={link} target='_blank'>{i18n('dialog.registration.terms_and_conditions')}</a></span>);
},
validateRegistrationForm: function() {
var registrationForm = this.props.registrationForm,
isValid = registrationForm.isValid();
if (!registrationForm.attributes.credentials.agree.value) {
if (!registrationForm.validationError) registrationForm.validationError = {};
registrationForm.validationError['credentials.agree'] = i18n('dialog.registration.agree_error');
isValid = false;
}
this.setState({
error: null,
hideRequiredFieldsNotice: isValid
});
if (isValid) this.createAccount();
},
createAccount: function() {
var registrationForm = this.props.registrationForm;
this.setState({actionInProgress: true});
registrationForm.save(registrationForm.attributes, {type: 'POST'})
.done(_.bind(function(response) {
var currentAttributes = _.cloneDeep(this.props.settings.attributes);
var collector = function(path) {
return function(name) {
this.props.settings.set(this.props.settings.makePath(path, name, 'value'), response[name]);
};
};
_.each(['company', 'name', 'email'], collector('statistics'), this);
_.each(['email', 'password'], collector('tracking'), this);
this.props.saveSettings(currentAttributes)
.done(_.bind(function() {
this.props.tracking.set(this.props.settings.attributes);
this.props.setConnected();
this.close();
}, this));
}, this))
.fail(_.bind(function(response) {
this.setState({actionInProgress: false});
this.showResponseErrors(response, registrationForm);
}, this));
},
checkCountry: function() {
var country = this.props.registrationForm.attributes.credentials.country.value;
return !(country == 'Canada' || country == 'United States' || country == 'us');
},
renderBody: function() {
var registrationForm = this.props.registrationForm;
if (this.state.loading) return <controls.ProgressBar />;
var fieldsList = registrationForm.attributes.credentials,
actionInProgress = this.state.actionInProgress,
error = this.state.error,
sortedFields = _.chain(_.keys(fieldsList))
.without('metadata')
.sortBy(function(inputName) {return fieldsList[inputName].weight;})
.value(),
halfWidthField = ['first_name', 'last_name', 'company', 'phone', 'country', 'region'];
return (
<div className='registration-form tracking'>
{actionInProgress && <controls.ProgressBar />}
{error &&
<div className='text-danger'>
<i className='glyphicon glyphicon-danger-sign' />
{error}
</div>
}
{!this.state.hideRequiredFieldsNotice && !this.state.connectionError &&
<div className='alert alert-warning'>
{i18n('welcome_page.register.required_fields')}
</div>
}
<form className='form-inline row'>
{_.map(sortedFields, function(inputName) {
var input = fieldsList[inputName],
path = 'credentials.' + inputName,
inputError = (registrationForm.validationError || {})[path],
classes = {
'col-md-12': !_.contains(halfWidthField, inputName),
'col-md-6': _.contains(halfWidthField, inputName),
'text-center': inputName == 'agree'
};
return <controls.Input
ref={inputName}
key={inputName}
name={inputName}
label={inputName != 'agree' ? input.label : this.getAgreementLink(input.description)}
{... _.pick(input, 'type', 'value')}
children={input.type == 'select' && this.composeOptions(input.values)}
wrapperClassName={utils.classNames(classes)}
onChange={this.onChange}
error={inputError}
disabled={actionInProgress || (inputName == 'region' && this.checkCountry())}
description={inputName != 'agree' && input.description}
maxLength='50'
/>;
}, this)}
</form>
</div>
);
},
renderFooter: function() {
var buttons = [
<button key='cancel' className='btn btn-default' onClick={this.close}>
{i18n('common.cancel_button')}
</button>
];
if (!this.state.loading) buttons.push(
<button key='apply' className='btn btn-success' disabled={this.state.actionInProgress || this.state.connectionError} onClick={this.validateRegistrationForm}>
{i18n('welcome_page.register.create_account')}
</button>
);
return buttons;
}
});
dialogs.RetrievePasswordDialog = React.createClass({
mixins: [
dialogMixin,
registrationResponseErrorMixin,
componentMixins.backboneMixin('remoteRetrievePasswordForm', 'change invalid')
],
getInitialState: function() {
return {loading: true};
},
getDefaultProps: function() {
return {
title: i18n('dialog.retrieve_password.title'),
modalClass: 'retrieve-password-form'
};
},
componentDidMount: function() {
var remoteRetrievePasswordForm = this.props.remoteRetrievePasswordForm;
remoteRetrievePasswordForm.fetch()
.then(null, function() {
remoteRetrievePasswordForm.url = remoteRetrievePasswordForm.nailgunUrl;
return remoteRetrievePasswordForm.fetch();
})
.fail(_.bind(function(response) {
this.showResponseErrors(response);
this.setState({connectionError: true});
}, this))
.always(_.bind(function() {this.setState({loading: false});}, this));
},
onChange: function(inputName, value) {
var remoteRetrievePasswordForm = this.props.remoteRetrievePasswordForm;
if (remoteRetrievePasswordForm.validationError) delete remoteRetrievePasswordForm.validationError['credentials.email'];
remoteRetrievePasswordForm.set('credentials.email.value', value);
},
retrievePassword: function() {
var remoteRetrievePasswordForm = this.props.remoteRetrievePasswordForm;
if (remoteRetrievePasswordForm.isValid()) {
this.setState({actionInProgress: true});
remoteRetrievePasswordForm.save()
.done(this.passwordSent)
.fail(this.showResponseErrors)
.always(_.bind(function() {
this.setState({actionInProgress: false});
}, this));
}
},
passwordSent: function() {
this.setState({passwordSent: true});
},
renderBody: function() {
var ns = 'dialog.retrieve_password.',
remoteRetrievePasswordForm = this.props.remoteRetrievePasswordForm;
if (this.state.loading) return <controls.ProgressBar />;
var error = this.state.error,
actionInProgress = this.state.actionInProgress,
input = (remoteRetrievePasswordForm.get('credentials') || {}).email,
inputError = remoteRetrievePasswordForm ? (remoteRetrievePasswordForm.validationError || {})['credentials.email'] : null;
return (
<div className='retrieve-password-content'>
{!this.state.passwordSent ?
<div>
{actionInProgress && <controls.ProgressBar />}
{error &&
<div className='text-danger'>
<i className='glyphicon glyphicon-danger-sign' />
{error}
</div>
}
{input &&
<div>
<p>{i18n(ns + 'submit_email')}</p>
<controls.Input
{... _.pick(input, 'type', 'value', 'description')}
onChange={this.onChange}
error={inputError}
disabled={actionInProgress}
placeholder={input.label}
/>
</div>
}
</div>
:
<div>
<div>{i18n(ns + 'done')}</div>
<div>{i18n(ns + 'check_email')}</div>
</div>
}
</div>
);
},
renderFooter: function() {
if (this.state.passwordSent) return [
<button key='close' className='btn btn-default' onClick={this.close}>
{i18n('common.close_button')}
</button>
];
var buttons = [
<button key='cancel' className='btn btn-default' onClick={this.close}>
{i18n('common.cancel_button')}
</button>
];
if (!this.state.loading) buttons.push(
<button key='apply' className='btn btn-success' disabled={this.state.actionInProgress || this.state.connectionError} onClick={this.retrievePassword}>
{i18n('dialog.retrieve_password.send_new_password')}
</button>
);
return buttons;
}
});
dialogs.CreateNodeNetworkGroupDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {
return {
title: i18n('cluster_page.network_tab.add_node_network_group'),
ns: 'cluster_page.network_tab.'
};
},
getInitialState: function() {
return {
error: null
};
},
renderBody: function() {
return (
<div className='node-network-group-creation'>
<controls.Input
name='node-network-group-name'
type='text'
label={i18n(this.props.ns + 'node_network_group_name')}
onChange={this.onChange}
error={this.state.error}
wrapperClassName='node-group-name'
inputClassName='node-group-input-name'
maxLength='50'
disabled={this.state.actionInProgress}
autoFocus
/>
</div>
);
},
renderFooter: function() {
return [
<button key='cancel' className='btn btn-default' onClick={this.close} disabled={this.state.actionInProgress}>
{i18n('common.cancel_button')}
</button>,
<button key='apply' className='btn btn-success' onClick={this.createNodeNetworkGroup} disabled={this.state.actionInProgress || this.state.error}>
{i18n(this.props.ns + 'add')}
</button>
];
},
onKeyDown: function(e) {
if (e.key == 'Enter') {
e.preventDefault();
this.createNodeNetworkGroup();
}
},
onChange: function(name, value) {
this.setState({
error: null,
name: value
});
},
createNodeNetworkGroup: function() {
var error = (new models.NodeNetworkGroup()).validate({
name: this.state.name,
nodeNetworkGroups: this.props.nodeNetworkGroups
});
if (error) {
this.setState({error: error});
} else {
this.setState({actionInProgress: true});
(new models.NodeNetworkGroup({
cluster_id: this.props.clusterId,
name: this.state.name
}))
.save(null, {validate: false})
.then(
this.submitAction,
(response) => {
this.close();
utils.showErrorDialog({
title: i18n(this.props.ns + 'node_network_group_creation_error'),
response: response
});
}
);
}
}
});
dialogs.RemoveNodeNetworkGroupDialog = React.createClass({
mixins: [dialogMixin],
getDefaultProps: function() {
return {title: i18n('dialog.remove_node_network_group.title')};
},
renderBody: function() {
return (
<div>
<div className='text-danger'>
{this.renderImportantLabel()}
{this.props.showUnsavedChangesWarning && (i18n('dialog.remove_node_network_group.unsaved_changes_alert') + ' ')}
{i18n('dialog.remove_node_network_group.confirmation')}
</div>
</div>
);
},
renderFooter: function() {
return ([
<button key='cancel' className='btn btn-default' onClick={this.close}>
{i18n('common.cancel_button')}
</button>,
<button
key='remove'
className='btn btn-danger remove-cluster-btn'
onClick={this.submitAction}
>
{i18n('common.delete_button')}
</button>
]);
}
});
return dialogs;
});