Commit 3b0c2a81 authored by George Goldberg's avatar George Goldberg Committed by GitHub
Browse files

PLT-6595 (Client): Elasticsearch indexing system console UI (#6991)

* PLT-6595: System Console for Elasticsearch Job Management.

* Fixing UI issues

* Fixing colors

* ESLint Fixes.

* Update test snapshots.

* Fixing cancel button

* Fix review comments.

* Config capitalisation.

* Review fixes.
parent 5e8174af
......@@ -396,3 +396,16 @@ export function elasticsearchTest(config, success, error) {
}
);
}
export function elasticsearchPurgeIndexes(success, error) {
AdminActions.purgeElasticsearchIndexes()(dispatch, getState).then(
(data) => {
if (data && success) {
success(data);
} else if (data == null && error) {
const serverError = getState().requests.admin.purgeElasticsearchIndexes.error;
error({id: serverError.server_error_id, ...serverError});
}
}
);
}
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import * as JobsActions from 'mattermost-redux/actions/jobs';
import store from 'stores/redux_store.jsx';
const dispatch = store.dispatch;
const getState = store.getState;
export function createJob(job, success, error) {
JobsActions.createJob(job)(dispatch, getState).then(
(data) => {
if (data && success) {
success(data);
} else if (data == null && error) {
const serverError = getState().requests.jobs.createJob.error;
error({id: serverError.server_error_id, ...serverError});
}
}
);
}
export function cancelJob(jobId, success, error) {
JobsActions.cancelJob(jobId)(dispatch, getState).then(
(data) => {
if (data && success) {
success(data);
} else if (data == null && error) {
const serverError = getState().requests.jobs.cancelJob.error;
error({id: serverError.server_error_id, ...serverError});
}
}
);
}
......@@ -6,12 +6,13 @@ import React from 'react';
import * as Utils from 'utils/utils.jsx';
import AdminSettings from './admin_settings.jsx';
import {elasticsearchTest} from 'actions/admin_actions.jsx';
import {elasticsearchTest, elasticsearchPurgeIndexes} from 'actions/admin_actions.jsx';
import BooleanSetting from './boolean_setting.jsx';
import {FormattedMessage} from 'react-intl';
import SettingsGroup from './settings_group.jsx';
import TextSetting from './text_setting.jsx';
import RequestButton from './request_button/request_button.jsx';
import ElasticsearchStatus from './elasticsearch_status';
export default class ElasticsearchSettings extends AdminSettings {
constructor(props) {
......@@ -21,6 +22,7 @@ export default class ElasticsearchSettings extends AdminSettings {
this.doTestConfig = this.doTestConfig.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleSaved = this.handleSaved.bind(this);
this.renderSettings = this.renderSettings.bind(this);
}
......@@ -45,7 +47,8 @@ export default class ElasticsearchSettings extends AdminSettings {
enableIndexing: config.ElasticsearchSettings.EnableIndexing,
enableSearching: config.ElasticsearchSettings.EnableSearching,
configTested: true,
canSave: true
canSave: true,
canPurgeAndIndex: config.ElasticsearchSettings.EnableIndexing
};
}
......@@ -70,9 +73,21 @@ export default class ElasticsearchSettings extends AdminSettings {
});
}
if (id !== 'enableSearching') {
this.setState({
canPurgeAndIndex: false
});
}
super.handleChange(id, value);
}
handleSaved() {
this.setState({
canPurgeAndIndex: this.state.enableIndexing
});
}
canSave() {
return this.state.canSave;
}
......@@ -89,6 +104,7 @@ export default class ElasticsearchSettings extends AdminSettings {
canSave: true
});
success();
this.doSubmit();
},
(err) => {
this.setState({
......@@ -135,7 +151,7 @@ export default class ElasticsearchSettings extends AdminSettings {
values={{
documentationLink: (
<a
href='http://www.mattermost.com'
href='https://about.mattermost.com/default-elasticsearch-documentation/'
rel='noopener noreferrer'
target='_blank'
>
......@@ -167,7 +183,7 @@ export default class ElasticsearchSettings extends AdminSettings {
values={{
documentationLink: (
<a
href='http://www.mattermost.com'
href='https://about.mattermost.com/default-elasticsearch-server-setup/'
rel='noopener noreferrer'
target='_blank'
>
......@@ -245,7 +261,7 @@ export default class ElasticsearchSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.elasticsearch.testHelpText'
defaultMessage='Tests if the Mattermost server can connect to the Elasticsearch server specified. Testing the connection does not save the configuration. See log file for more detailed error messages.'
defaultMessage='Tests if the Mattermost server can connect to the Elasticsearch server specified. Testing the connection only saves the configuration if the test is successful. See log file for more detailed error messages.'
/>
}
buttonText={
......@@ -254,8 +270,45 @@ export default class ElasticsearchSettings extends AdminSettings {
defaultMessage='Test Connection'
/>
}
successMessage={{
id: 'admin.elasticsearch.testConfigSuccess',
defaultMessage: 'Test successful. Configuration saved.'
}}
disabled={!this.state.enableIndexing}
/>
<ElasticsearchStatus
isConfigured={this.state.canPurgeAndIndex}
/>
<RequestButton
requestAction={elasticsearchPurgeIndexes}
helpText={
<FormattedMessage
id='admin.elasticsearch.purgeIndexesHelpText'
defaultMessage='Purging will entirely remove the index on the Elasticsearch server. Search results may be incomplete until a bulk index of the existing post database is rebuilt.'
/>
}
buttonText={
<FormattedMessage
id='admin.elasticsearch.purgeIndexesButton'
defaultMessage='Purge Index'
/>
}
successMessage={{
id: 'admin.elasticsearch.purgeIndexesButton.success',
defaultMessage: 'Indexes purged successfully.'
}}
errorMessage={{
id: 'admin.elasticsearch.purgeIndexesButton.error',
defaultMessage: 'Failed to purge indexes: {error}'
}}
disabled={!this.state.canPurgeAndIndex}
label={(
<FormattedMessage
id='admin.elasticsearch.purgeIndexesButton.label'
defaultMessage='Purge Indexes:'
/>
)}
/>
<BooleanSetting
id='enableSearching'
label={
......
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getJobsByType} from 'mattermost-redux/actions/jobs';
import {JobTypes} from 'utils/constants.jsx';
import * as Selectors from 'mattermost-redux/selectors/entities/jobs';
import Status from './status.jsx';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
jobs: Selectors.makeGetJobsByType(JobTypes.ELASTICSEARCH_POST_INDEXING)(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getJobsByType
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Status);
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import {createJob, cancelJob} from 'actions/job_actions.jsx';
import {JobTypes, JobStatuses} from 'utils/constants.jsx';
import RequestButton from '../request_button/request_button.jsx';
export default class Status extends React.PureComponent {
static propTypes = {
/**
* Array of jobs
*/
jobs: PropTypes.arrayOf(PropTypes.object).isRequired,
/**
* Whether Elasticsearch is properly configured.
*/
isConfigured: PropTypes.bool.isRequired,
actions: PropTypes.shape({
/**
* Function to fetch jobs
*/
getJobsByType: PropTypes.func.isRequired
}).isRequired
};
constructor(props) {
super(props);
this.interval = null;
this.state = {
loading: true,
cancelInProgress: false
};
}
componentWillMount() {
// reload the cluster status every 15 seconds
this.interval = setInterval(this.reload, 15000);
}
componentDidMount() {
this.props.actions.getJobsByType(JobTypes.ELASTICSEARCH_POST_INDEXING).then(
() => this.setState({loading: false})
);
}
componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
reload = () => {
this.props.actions.getJobsByType(JobTypes.ELASTICSEARCH_POST_INDEXING).then(
() => {
this.setState({
loading: false,
cancelInProgress: false
});
}
);
};
createIndexJob = (success, error) => {
const job = {
type: JobTypes.ELASTICSEARCH_POST_INDEXING
};
createJob(
job,
() => {
this.reload();
success();
},
error
);
};
cancelIndexJob = (e) => {
e.preventDefault();
const chosenJob = this.getChosenJob();
if (!chosenJob) {
return;
}
this.setState({
cancelInProgress: true
});
cancelJob(
chosenJob.id,
() => {
this.reload();
},
() => {
this.reload();
}
);
};
getChosenJob = () => {
let chosenJob = null;
if (this.props.jobs.length > 0) {
for (let i = 0; i < this.props.jobs.length; i++) {
const job = this.props.jobs[i];
if (job.status === JobStatuses.CANCEL_REQUESTED || job.status === JobStatuses.IN_PROGRESS) {
chosenJob = job;
} else {
break;
}
}
if (!chosenJob) {
for (let i = 0; i < this.props.jobs.length; i++) {
const job = this.props.jobs[i];
if (job.status !== JobStatuses.PENDING && chosenJob) {
continue;
} else {
chosenJob = job;
break;
}
}
}
}
return chosenJob;
};
render() {
const chosenJob = this.getChosenJob();
let indexButtonDisabled = !this.props.isConfigured;
let buttonText = (
<FormattedMessage
id='admin.elasticsearch.indexButton.ready'
defaultMessage='Build Index'
/>
);
let cancelButton = null;
let indexButtonHelp = (
<FormattedMessage
id='admin.elasticsearch.indexHelpText.buildIndex'
defaultMessage='All posts in the database will be indexed from oldest to newest. Elasticsearch is available during indexing but search results may be incomplete until the indexing job is complete.'
/>
);
if (this.state.loading) {
indexButtonDisabled = true;
} else if (chosenJob) {
if (chosenJob.status === JobStatuses.PENDING || chosenJob.status === JobStatuses.IN_PROGRESS || chosenJob.status === JobStatuses.CANCEL_REQUESTED) {
indexButtonDisabled = true;
buttonText = (
<span>
<span className='fa fa-refresh icon--rotate'/>
<FormattedMessage
id='admin.elasticsearch.indexButton.inProgress'
defaultMessage='Indexing in progress'
/>
</span>
);
}
if (chosenJob.status === JobStatuses.PENDING || chosenJob.status === JobStatuses.IN_PROGRESS || chosenJob.status === JobStatuses.CANCEL_REQUESTED) {
indexButtonHelp = (
<FormattedMessage
id='admin.elasticsearch.indexHelpText.cancelIndexing'
defaultMessage='Cancelling stops the indexing job and removes it from the queue. Posts that have already been indexed will not be deleted.'
/>
);
}
if (!this.state.cancelInProgress && (chosenJob.status === JobStatuses.PENDING || chosenJob.status === JobStatuses.IN_PROGRESS)) {
cancelButton = (
<a
href='#'
className='btn btn-link'
onClick={this.cancelIndexJob}
>
<FormattedMessage
id='admin.elasticsearchStatus.cancelButton'
defaultMessage='Cancel'
/>
</a>
);
}
}
const indexButton = (
<RequestButton
requestAction={this.createIndexJob}
helpText={indexButtonHelp}
buttonText={buttonText}
disabled={indexButtonDisabled}
showSuccessMessage={false}
errorMessage={{
id: 'admin.elasticsearch.bulkIndexButton.error',
defaultMessage: 'Failed to schedule Bulk Index Job: {error}'
}}
alternativeActionElement={cancelButton}
label={(
<FormattedMessage
id='admin.elasticsearchStatus.bulkIndexLabel'
defaultMessage='Bulk Indexing:'
/>
)}
/>
);
let status = null;
let statusHelp = null;
let statusClass = null;
if (!this.props.isConfigured) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusIndexingDisabled'
defaultMessage='Indexing disabled.'
/>
);
} else if (this.state.loading) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusLoading'
defaultMessage='Loading...'
/>
);
statusClass = 'status-icon-unknown';
} else if (chosenJob) {
if (chosenJob.status === JobStatuses.PENDING) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusPending'
defaultMessage='Job pending.'
/>
);
statusHelp = (
<FormattedMessage
id='admin.elasticsearchStatus.statusPending.help'
defaultMessage='Elasticsearch index job is queued on the job server. If Elasticsearch is enabled, search results may be incomplete until the job is finished.'
/>
);
statusClass = 'status-icon-warning';
} else if (chosenJob.status === JobStatuses.IN_PROGRESS) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusInProgress'
defaultMessage='Job in progress. {percent}% complete.'
values={{
percent: chosenJob.progress
}}
/>
);
statusHelp = (
<FormattedMessage
id='admin.elasticsearchStatus.statusInProgress.help'
defaultMessage='Indexing is in progress on the job server. If Elasticsearch is enabled, search results may be incomplete until the job is finished.'
/>
);
statusClass = 'status-icon-warning';
} else if (chosenJob.status === JobStatuses.SUCCESS) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusSuccess'
defaultMessage='Indexing complete.'
/>
);
statusHelp = (
<FormattedMessage
id='admin.elasticsearchStatus.statusSuccess.help'
defaultMessage='Indexing is complete and new posts are being automatically indexed.'
/>
);
statusClass = 'status-icon-success';
} else if (chosenJob.status === JobStatuses.ERROR) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusError'
defaultMessage='Indexing error.'
/>
);
statusHelp = (
<FormattedMessage
id='admin.elasticsearchStatus.statusError.help'
defaultMessage='Mattermost encountered an error building the Elasticsearch index: {error}'
values={{
error: chosenJob.data ? (chosenJob.data.error || '') : ''
}}
/>
);
statusClass = 'status-icon-error';
} else if (chosenJob.status === JobStatuses.CANCEL_REQUESTED) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusRequestCancel'
defaultMessage='Canceling Job...'
/>
);
statusClass = 'status-icon-warning';
} else if (chosenJob.status === JobStatuses.CANCELED) {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusCancelled'
defaultMessage='Indexing job cancelled.'
/>
);
statusClass = 'status-icon-error';
}
} else {
status = (
<FormattedMessage
id='admin.elasticsearchStatus.statusNoJobs'
defaultMessage='No indexing jobs queued.'
/>
);
statusClass = 'status-icon-unknown';
}
if (statusHelp !== null) {
statusHelp = (
<div className='col-sm-offset-4 col-sm-8'>
<div className='help-text'>
{statusHelp}
</div>
</div>
);
}
statusClass = 'fa fa-circle margin--right ' + statusClass;
return (
<div>
{indexButton}
<div className='form-group'>
<div className='col-sm-offset-4 col-sm-8'>
<div className='help-text no-margin'>
<FormattedMessage
id='admin.elasticsearchStatus.status'
defaultMessage='Status: '
/>
<i
className={statusClass}
/>
{status}
</div>
</div>
{statusHelp}
</div>
</div>
);
}
}
......@@ -35,6 +35,13 @@ export default class RequestButton extends React.Component {
*/
buttonText: PropTypes.element.isRequired,
/**
* The element to display as the field label.
*
* Typically, this will be a <FormattedMessage/>
*/
label: PropTypes.element,
/**
* True if the button form control should be disabled, otherwise false.
*/
......@@ -100,7 +107,12 @@ export default class RequestButton extends React.Component {