[ML] Data Frames: Improve start/stop behavior (#36757)

- Adds a confirm modal to the Start button of the data frame jobs list. image
- Adds a snippet with a warning about possibly cluster load to wizard. image
- Uses _stop?force=true to stop jobs.
- Adds a check to disable the start button in the jobs list if the job is a completed batch job.
This commit is contained in:
Walter Rafelsberger 2019-05-21 20:55:36 +02:00 committed by GitHub
parent 6ab8d133b7
commit 1e25d1d5e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 382 additions and 176 deletions

View file

@ -211,16 +211,19 @@ export const JobCreateForm: SFC<Props> = React.memo(
return `PUT _data_frame/transforms/${jobId}\n${JSON.stringify(jobConfig, null, 2)}\n\n`;
}
const ITEM_STYLE = { width: '300px' };
// TODO move this to SASS
const FLEX_GROUP_STYLE = { height: '90px', maxWidth: '800px' };
const FLEX_ITEM_STYLE = { width: '200px' };
const PANEL_ITEM_STYLE = { width: '300px' };
return (
<EuiForm>
{!created && (
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton fill isDisabled={created && started} onClick={createAndStartDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createAndStartDataFrameButton', {
defaultMessage: 'Create & start',
defaultMessage: 'Create and start',
})}
</EuiButton>
</EuiFlexItem>
@ -230,7 +233,7 @@ export const JobCreateForm: SFC<Props> = React.memo(
'xpack.ml.dataframe.jobCreateForm.createAndStartDataFrameDescription',
{
defaultMessage:
'Create and start the data frame job. After the job is started, you will be offered options to continue exploring the data frame job.',
'Creates and starts the data frame job. A data frame job will increase search and indexing load in your cluster. Please stop the job if excessive load is experienced. After the job is started, you will be offered options to continue exploring the data frame job.',
}
)}
</EuiText>
@ -238,9 +241,9 @@ export const JobCreateForm: SFC<Props> = React.memo(
</EuiFlexGroup>
)}
{created && (
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiButton isDisabled={created && started} onClick={startDataFrame}>
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton fill isDisabled={created && started} onClick={startDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.startDataFrameButton', {
defaultMessage: 'Start',
})}
@ -250,14 +253,14 @@ export const JobCreateForm: SFC<Props> = React.memo(
<EuiText color="subdued" size="s">
{i18n.translate('xpack.ml.dataframe.jobCreateForm.startDataFrameDescription', {
defaultMessage:
'Starts the data frame job. After the job is started, you will be offered options to continue exploring the data frame job.',
'Starts the data frame job. A data frame job will increase search and indexing load in your cluster. Please stop the job if excessive load is experienced. After the job is started, you will be offered options to continue exploring the data frame job.',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton isDisabled={created} onClick={createDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createDataFrameButton', {
defaultMessage: 'Create',
@ -273,8 +276,8 @@ export const JobCreateForm: SFC<Props> = React.memo(
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiCopy textToCopy={getJobConfigDevConsoleStatement()}>
{(copy: () => void) => (
<EuiButton onClick={copy} style={{ width: '100%' }}>
@ -324,7 +327,7 @@ export const JobCreateForm: SFC<Props> = React.memo(
<Fragment>
<EuiHorizontalRule />
<EuiFlexGrid gutterSize="l">
<EuiFlexItem style={ITEM_STYLE}>
<EuiFlexItem style={PANEL_ITEM_STYLE}>
<EuiCard
icon={<EuiIcon size="xxl" type="list" />}
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.jobsListCardTitle', {
@ -340,7 +343,7 @@ export const JobCreateForm: SFC<Props> = React.memo(
/>
</EuiFlexItem>
{started === true && createIndexPattern === true && indexPatternId === undefined && (
<EuiFlexItem style={ITEM_STYLE}>
<EuiFlexItem style={PANEL_ITEM_STYLE}>
<EuiPanel style={{ position: 'relative' }}>
<EuiProgress size="xs" color="primary" position="absolute" />
<EuiText color="subdued" size="s">
@ -357,7 +360,7 @@ export const JobCreateForm: SFC<Props> = React.memo(
</EuiFlexItem>
)}
{started === true && indexPatternId !== undefined && (
<EuiFlexItem style={ITEM_STYLE}>
<EuiFlexItem style={PANEL_ITEM_STYLE}>
<EuiCard
icon={<EuiIcon size="xxl" type="discoverApp" />}
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.discoverCardTitle', {

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: Job List Actions <StartAction /> Minimal initialization 1`] = `
<Fragment>
<EuiToolTip
content="Your license has expired. Please contact your administrator."
delay="regular"
position="top"
>
<EuiButtonEmpty
aria-label="Start"
color="text"
disabled={true}
iconSide="left"
iconType="play"
onClick={[Function]}
size="xs"
type="button"
>
Start
</EuiButtonEmpty>
</EuiToolTip>
</Fragment>
`;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { DataFrameJobListRow } from './common';
import { DeleteAction } from './action_delete';
import dataFrameJobListRow from './__mocks__/data_frame_job_list_row.json';
describe('Data Frame: Job List Actions <DeleteAction />', () => {
test('Minimal initialization', () => {
const item: DataFrameJobListRow = dataFrameJobListRow;
const props = {
disabled: false,
item,
deleteJob(d: DataFrameJobListRow) {},
};
const wrapper = shallow(<DeleteAction {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, SFC, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { DataFrameJobListRow, DATA_FRAME_RUNNING_STATE } from './common';
interface DeleteActionProps {
item: DataFrameJobListRow;
deleteJob(d: DataFrameJobListRow): void;
}
export const DeleteAction: SFC<DeleteActionProps> = ({ deleteJob, item }) => {
const disabled = item.state.task_state === DATA_FRAME_RUNNING_STATE.STARTED;
const canDeleteDataFrameJob: boolean = checkPermission('canDeleteDataFrameJob');
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setModalVisible(false);
deleteJob(item);
};
const openModal = () => setModalVisible(true);
const buttonDeleteText = i18n.translate('xpack.ml.dataframe.jobsList.deleteActionName', {
defaultMessage: 'Delete',
});
let deleteButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={disabled || !canDeleteDataFrameJob}
iconType="trash"
onClick={openModal}
aria-label={buttonDeleteText}
>
{buttonDeleteText}
</EuiButtonEmpty>
);
if (disabled || !canDeleteDataFrameJob) {
deleteButton = (
<EuiToolTip
position="top"
content={
disabled
? i18n.translate('xpack.ml.dataframe.jobsList.deleteActionDisabledToolTipContent', {
defaultMessage: 'Stop the data frame job in order to delete it.',
})
: createPermissionFailureMessage('canStartStopDataFrameJob')
}
>
{deleteButton}
</EuiToolTip>
);
}
return (
<Fragment>
{deleteButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.jobsList.deleteModalTitle', {
defaultMessage: 'Delete {jobId}',
values: { jobId: item.config.id },
})}
onCancel={closeModal}
onConfirm={deleteAndCloseModal}
cancelButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalCancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalDeleteButton',
{
defaultMessage: 'Delete',
}
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="danger"
>
<p>
{i18n.translate('xpack.ml.dataframe.jobsList.deleteModalBody', {
defaultMessage: 'Are you sure you want to delete this job?',
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { DataFrameJobListRow } from './common';
import { StartAction } from './action_start';
import dataFrameJobListRow from './__mocks__/data_frame_job_list_row.json';
describe('Data Frame: Job List Actions <StartAction />', () => {
test('Minimal initialization', () => {
const item: DataFrameJobListRow = dataFrameJobListRow;
const props = {
disabled: false,
item,
startJob(d: DataFrameJobListRow) {},
};
const wrapper = shallow(<StartAction {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, SFC, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { DataFrameJobListRow, isCompletedBatchJob } from './common';
interface StartActionProps {
item: DataFrameJobListRow;
startJob(d: DataFrameJobListRow): void;
}
export const StartAction: SFC<StartActionProps> = ({ startJob, item }) => {
const canStartStopDataFrameJob: boolean = checkPermission('canStartStopDataFrameJob');
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const startAndCloseModal = () => {
setModalVisible(false);
startJob(item);
};
const openModal = () => setModalVisible(true);
const buttonStartText = i18n.translate('xpack.ml.dataframe.jobsList.startActionName', {
defaultMessage: 'Start',
});
// Disable start for batch jobs which have completed.
const completedBatchJob = isCompletedBatchJob(item);
let startButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={!canStartStopDataFrameJob || completedBatchJob}
iconType="play"
onClick={openModal}
aria-label={buttonStartText}
>
{buttonStartText}
</EuiButtonEmpty>
);
if (!canStartStopDataFrameJob || completedBatchJob) {
startButton = (
<EuiToolTip
position="top"
content={
!canStartStopDataFrameJob
? createPermissionFailureMessage('canStartStopDataFrameJob')
: i18n.translate('xpack.ml.dataframe.jobsList.completeBatchJobToolTip', {
defaultMessage: '{jobId} is a completed batch job and cannot be restarted.',
values: { jobId: item.config.id },
})
}
>
{startButton}
</EuiToolTip>
);
}
return (
<Fragment>
{startButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.jobsList.startModalTitle', {
defaultMessage: 'Start {jobId}',
values: { jobId: item.config.id },
})}
onCancel={closeModal}
onConfirm={startAndCloseModal}
cancelButtonText={i18n.translate('xpack.ml.dataframe.jobsList.startModalCancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('xpack.ml.dataframe.jobsList.startModalStartButton', {
defaultMessage: 'Start',
})}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="primary"
>
<p>
{i18n.translate('xpack.ml.dataframe.jobsList.startModalBody', {
defaultMessage:
'A data frame job will increase search and indexing load in your cluster. Please stop the job if excessive load is experienced. Are you sure you want to start this job?',
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};

View file

@ -4,28 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { DataFrameJobListRow } from './common';
import { DeleteAction, getActions } from './actions';
import dataFrameJobListRow from './__mocks__/data_frame_job_list_row.json';
describe('Data Frame: Job List Actions <DeleteAction />', () => {
test('Minimal initialization', () => {
const item: DataFrameJobListRow = dataFrameJobListRow;
const props = {
disabled: false,
item,
deleteJob(d: DataFrameJobListRow) {},
};
const wrapper = shallow(<DeleteAction {...props} />);
expect(wrapper).toMatchSnapshot();
});
});
import { getActions } from './actions';
describe('Data Frame: Job List Actions', () => {
test('getActions()', () => {

View file

@ -4,15 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, SFC, useState } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import {
checkPermission,
@ -22,96 +16,8 @@ import {
import { DataFrameJobListRow, DATA_FRAME_RUNNING_STATE } from './common';
import { deleteJobFactory, startJobFactory, stopJobFactory } from './job_service';
interface DeleteActionProps {
disabled: boolean;
item: DataFrameJobListRow;
deleteJob(d: DataFrameJobListRow): void;
}
export const DeleteAction: SFC<DeleteActionProps> = ({ deleteJob, disabled, item }) => {
const canDeleteDataFrameJob: boolean = checkPermission('canDeleteDataFrameJob');
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setModalVisible(false);
deleteJob(item);
};
const openModal = () => setModalVisible(true);
const buttonDeleteText = i18n.translate('xpack.ml.dataframe.jobsList.deleteActionName', {
defaultMessage: 'Delete',
});
let deleteButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={disabled || !canDeleteDataFrameJob}
iconType="trash"
onClick={openModal}
aria-label={buttonDeleteText}
>
{buttonDeleteText}
</EuiButtonEmpty>
);
if (disabled || !canDeleteDataFrameJob) {
deleteButton = (
<EuiToolTip
position="top"
content={
disabled
? i18n.translate('xpack.ml.dataframe.jobsList.deleteActionDisabledToolTipContent', {
defaultMessage: 'Stop the data frame job in order to delete it.',
})
: createPermissionFailureMessage('canStartStopDataFrameJob')
}
>
{deleteButton}
</EuiToolTip>
);
}
return (
<Fragment>
{deleteButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.jobsList.deleteModalTitle', {
defaultMessage: 'Delete {jobId}',
values: { jobId: item.config.id },
})}
onCancel={closeModal}
onConfirm={deleteAndCloseModal}
cancelButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalCancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalDeleteButton',
{
defaultMessage: 'Delete',
}
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="danger"
>
<p>
{i18n.translate('xpack.ml.dataframe.jobsList.deleteModalBody', {
defaultMessage: 'Are you sure you want to delete this job?',
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};
import { StartAction } from './action_start';
import { DeleteAction } from './action_delete';
export const getActions = (getJobs: () => void) => {
const canStartStopDataFrameJob: boolean = checkPermission('canStartStopDataFrameJob');
@ -125,35 +31,7 @@ export const getActions = (getJobs: () => void) => {
isPrimary: true,
render: (item: DataFrameJobListRow) => {
if (item.state.task_state !== DATA_FRAME_RUNNING_STATE.STARTED) {
const buttonStartText = i18n.translate('xpack.ml.dataframe.jobsList.startActionName', {
defaultMessage: 'Start',
});
const startButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={!canStartStopDataFrameJob}
iconType="play"
onClick={() => startJob(item)}
aria-label={buttonStartText}
>
{buttonStartText}
</EuiButtonEmpty>
);
if (!canStartStopDataFrameJob) {
return (
<EuiToolTip
position="top"
content={createPermissionFailureMessage('canStartStopDataFrameJob')}
>
{startButton}
</EuiToolTip>
);
}
return startButton;
return <StartAction startJob={startJob} item={item} />;
}
const buttonStopText = i18n.translate('xpack.ml.dataframe.jobsList.stopActionName', {
@ -188,13 +66,7 @@ export const getActions = (getJobs: () => void) => {
},
{
render: (item: DataFrameJobListRow) => {
return (
<DeleteAction
deleteJob={deleteJob}
disabled={item.state.task_state === DATA_FRAME_RUNNING_STATE.STARTED}
item={item}
/>
);
return <DeleteAction deleteJob={deleteJob} item={item} />;
},
},
];

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import mockDataFrameJobListRow from './__mocks__/data_frame_job_list_row.json';
import { DATA_FRAME_RUNNING_STATE, isCompletedBatchJob } from './common';
describe('Data Frame: isCompletedBatchJob()', () => {
test('isCompletedBatchJob()', () => {
// check the job config/state against the conditions
// that will be used by isCompletedBatchJob()
// followed by a call to isCompletedBatchJob() itself
expect(mockDataFrameJobListRow.state.checkpoint === 1).toBe(true);
expect(mockDataFrameJobListRow.sync === undefined).toBe(true);
expect(mockDataFrameJobListRow.state.task_state === DATA_FRAME_RUNNING_STATE.STOPPED).toBe(
true
);
expect(isCompletedBatchJob(mockDataFrameJobListRow)).toBe(true);
// adapt the mock config to resemble a non-completed job.
mockDataFrameJobListRow.state.checkpoint = 0;
expect(isCompletedBatchJob(mockDataFrameJobListRow)).toBe(false);
});
});

View file

@ -12,6 +12,7 @@ export interface DataFrameJob {
dest: string;
id: JobId;
source: string;
sync?: object;
}
export enum DATA_FRAME_RUNNING_STATE {
@ -63,3 +64,13 @@ export enum DataFrameJobListColumn {
}
export type ItemIdToExpandedRowMap = Dictionary<JSX.Element>;
export function isCompletedBatchJob(item: DataFrameJobListRow) {
// If `checkpoint=1`, `sync` is missing from the config and state is stopped,
// then this is a completed batch data frame job.
return (
item.state.checkpoint === 1 &&
item.config.sync === undefined &&
item.state.task_state === DATA_FRAME_RUNNING_STATE.STOPPED
);
}

View file

@ -60,7 +60,7 @@ export const dataFrame = {
},
stopDataFrameTransformsJob(jobId) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}/_stop`,
url: `${basePath}/_data_frame/transforms/${jobId}/_stop?force=true`,
method: 'POST',
});
},

View file

@ -187,10 +187,13 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
ml.stopDataFrameTransformsJob = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>/_stop',
fmt: '/_data_frame/transforms/<%=jobId%>/_stop?&force=<%=force%>',
req: {
jobId: {
type: 'string'
},
force: {
type: 'boolean'
}
}
}

View file

@ -109,8 +109,14 @@ export function dataFrameRoutes(server, commonRouteConfig) {
path: '/api/ml/_data_frame/transforms/{jobId}/_stop',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.stopDataFrameTransformsJob', { jobId })
const options = {
jobId: request.params.jobId
};
const force = request.query.force;
if (force !== undefined) {
options.force = force;
}
return callWithRequest('ml.stopDataFrameTransformsJob', options)
.catch(resp => wrapError(resp));
},
config: {