[ML] Data Frame: Wizard Create Step Improvements (#36483) (#36696)

Revamps the job creation step of the data frame wizard.

- Create & start buttons now have additional descriptions
- Adds a "copy to clipboard" button to copy a Kibana Dev Console statement of the job creation command to the clipboard
- Adds a progress bar once the job is started
- Adds a card which links to Kibana Discover if an index pattern was created for the job
- Hides the wizard's Previous button once the job is successfully created.
This commit is contained in:
Walter Rafelsberger 2019-05-20 20:21:59 +02:00 committed by GitHub
parent f24301fb57
commit 1924ae616c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 348 additions and 41 deletions

View file

@ -7,3 +7,4 @@
export const DEFAULT_REFRESH_INTERVAL_MS = 30000;
export const MINIMUM_REFRESH_INTERVAL_MS = 1000;
export const DELETING_JOBS_REFRESH_INTERVAL_MS = 2000;
export const PROGRESS_JOBS_REFRESH_INTERVAL_MS = 2000;

View file

@ -11,6 +11,7 @@ import { StaticIndexPattern } from 'ui/index_patterns';
interface KibanaContextValue {
currentIndexPattern: StaticIndexPattern;
indexPatterns: any;
kbnBaseUrl: string;
kibanaConfig: any;
}
@ -25,6 +26,7 @@ export function isKibanaContext(arg: any): arg is KibanaContextValue {
return (
arg.currentIndexPattern !== undefined &&
arg.indexPatterns !== undefined &&
typeof arg.kbnBaseUrl === 'string' &&
arg.kibanaConfig !== undefined
);
}

View file

@ -4,6 +4,28 @@
* you may not use this file except in compliance with the Elastic License.
*/
import rison from 'rison-node';
import chrome from 'ui/chrome';
export function moveToDataFrameWizard() {
window.location.href = `#/data_frames/new_job`;
window.location.href = '#/data_frames/new_job';
}
export function moveToDataFrameJobsList() {
window.location.href = '#/data_frames';
}
export function moveToDiscover(indexPatternId: string, kbnBaseUrl: string) {
const _g = rison.encode({});
// Add the index pattern ID to the appState part of the URL.
const _a = rison.encode({
index: indexPatternId,
});
const baseUrl = chrome.addBasePath(kbnBaseUrl);
const hash = `#/discover?_g=${_g}&_a=${_a}`;
window.location.href = `${baseUrl}${hash}`;
}

View file

@ -10,6 +10,7 @@ exports[`Data Frame: <DefinePivotForm /> Minimal initialization 1`] = `
"title": "the-index-pattern-title",
},
"indexPatterns": Object {},
"kbnBaseUrl": "url",
"kibanaConfig": Object {},
}
}

View file

@ -10,6 +10,7 @@ exports[`Data Frame: <DefinePivotSummary /> Minimal initialization 1`] = `
"title": "the-index-pattern-title",
},
"indexPatterns": Object {},
"kbnBaseUrl": "url",
"kibanaConfig": Object {},
}
}

View file

@ -10,6 +10,7 @@ exports[`Data Frame: <PivotPreview /> Minimal initialization 1`] = `
"title": "the-index-pattern-title",
},
"indexPatterns": Object {},
"kbnBaseUrl": "url",
"kibanaConfig": Object {},
}
}

View file

@ -28,7 +28,7 @@ describe('Data Frame: <DefinePivotForm />', () => {
const wrapper = shallow(
<div>
<KibanaContext.Provider
value={{ currentIndexPattern, indexPatterns: {}, kibanaConfig: {} }}
value={{ currentIndexPattern, indexPatterns: {}, kbnBaseUrl: 'url', kibanaConfig: {} }}
>
<DefinePivotForm onChange={() => {}} />
</KibanaContext.Provider>

View file

@ -53,7 +53,7 @@ describe('Data Frame: <DefinePivotSummary />', () => {
const wrapper = shallow(
<div>
<KibanaContext.Provider
value={{ currentIndexPattern, indexPatterns: {}, kibanaConfig: {} }}
value={{ currentIndexPattern, indexPatterns: {}, kbnBaseUrl: 'url', kibanaConfig: {} }}
>
<DefinePivotSummary {...props} />
</KibanaContext.Provider>

View file

@ -52,7 +52,7 @@ describe('Data Frame: <PivotPreview />', () => {
const wrapper = shallow(
<div>
<KibanaContext.Provider
value={{ currentIndexPattern, indexPatterns: {}, kibanaConfig: {} }}
value={{ currentIndexPattern, indexPatterns: {}, kbnBaseUrl: 'url', kibanaConfig: {} }}
>
<PivotPreview {...props} />
</KibanaContext.Provider>

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: <JobCreateForm /> Minimal initialization 1`] = `
<div>
<ContextProvider
value={
Object {
"currentIndexPattern": Object {
"fields": Array [],
"title": "the-index-pattern-title",
},
"indexPatterns": Object {},
"kbnBaseUrl": "url",
"kibanaConfig": Object {},
}
}
>
<Component
createIndexPattern={false}
jobConfig={Object {}}
jobId="the-job-id"
onChange={[Function]}
overrides={
Object {
"created": false,
"indexPatternId": undefined,
"started": false,
}
}
/>
</ContextProvider>
</div>
`;

View file

@ -0,0 +1,49 @@
/*
* 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 { KibanaContext } from '../../common';
import { JobCreateForm } from './job_create_form';
// workaround to make React.memo() work with enzyme
jest.mock('react', () => {
const r = jest.requireActual('react');
return { ...r, memo: (x: any) => x };
});
describe('Data Frame: <JobCreateForm />', () => {
test('Minimal initialization', () => {
const props = {
createIndexPattern: false,
jobId: 'the-job-id',
jobConfig: {},
overrides: { created: false, started: false, indexPatternId: undefined },
onChange() {},
};
const currentIndexPattern = {
title: 'the-index-pattern-title',
fields: [],
};
// Using a wrapping <div> element because shallow() would fail
// with the Provider being the outer most component.
const wrapper = shallow(
<div>
<KibanaContext.Provider
value={{ currentIndexPattern, indexPatterns: {}, kbnBaseUrl: 'url', kibanaConfig: {} }}
>
<JobCreateForm {...props} />
</KibanaContext.Provider>
</div>
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -13,31 +13,43 @@ import {
// Module '"@elastic/eui"' has no exported member 'EuiCard'.
// @ts-ignore
EuiCard,
EuiCopy,
// Module '"@elastic/eui"' has no exported member 'EuiDescribedFormGroup'.
// @ts-ignore
EuiDescribedFormGroup,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiHorizontalRule,
EuiIcon,
EuiPanel,
EuiProgress,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { ml } from '../../../services/ml_api_service';
import { PROGRESS_JOBS_REFRESH_INTERVAL_MS } from '../../../../common/constants/jobs_list';
import { moveToDataFrameJobsList, moveToDiscover } from '../../common';
import { KibanaContext, isKibanaContext } from '../../common';
export interface JobDetailsExposedState {
created: boolean;
started: boolean;
indexPatternId: string | undefined;
}
export function getDefaultJobCreateState(): JobDetailsExposedState {
return {
created: false,
started: false,
indexPatternId: undefined,
};
}
function gotToDataFrameJobManagement() {
window.location.href = '#/data_frames';
}
interface Props {
createIndexPattern: boolean;
jobId: string;
@ -52,6 +64,10 @@ export const JobCreateForm: SFC<Props> = React.memo(
const [created, setCreated] = useState(defaults.created);
const [started, setStarted] = useState(defaults.started);
const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId);
const [progressPercentComplete, setProgressPercentComplete] = useState<undefined | number>(
undefined
);
const kibanaContext = useContext(KibanaContext);
@ -61,9 +77,9 @@ export const JobCreateForm: SFC<Props> = React.memo(
useEffect(
() => {
onChange({ created, started });
onChange({ created, started, indexPatternId });
},
[created, started]
[created, started, indexPatternId]
);
async function createDataFrame() {
@ -149,6 +165,8 @@ export const JobCreateForm: SFC<Props> = React.memo(
values: { indexPatternName },
})
);
setIndexPatternId(id);
return true;
} catch (e) {
toastNotifications.addDanger(
@ -162,38 +180,155 @@ export const JobCreateForm: SFC<Props> = React.memo(
}
};
if (started === true && progressPercentComplete === undefined) {
// wrapping in function so we can keep the interval id in local scope
function startProgressBar() {
const interval = setInterval(async () => {
try {
const stats = await ml.dataFrame.getDataFrameTransformsStats(jobId);
const percent = Math.round(stats.transforms[0].state.progress.percent_complete);
setProgressPercentComplete(percent);
if (percent >= 100) {
clearInterval(interval);
}
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobCreateForm.progressErrorMessage', {
defaultMessage: 'An error occurred getting the progress percentage: {error}',
values: { error: JSON.stringify(e) },
})
);
clearInterval(interval);
}
}, PROGRESS_JOBS_REFRESH_INTERVAL_MS);
setProgressPercentComplete(0);
}
startProgressBar();
}
function getJobConfigDevConsoleStatement() {
return `PUT _data_frame/transforms/${jobId}\n${JSON.stringify(jobConfig, null, 2)}\n\n`;
}
const ITEM_STYLE = { width: '300px' };
return (
<Fragment>
<EuiButton isDisabled={created} onClick={createDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createDataFrameButton', {
defaultMessage: 'Create data frame',
})}
</EuiButton>
&nbsp;
<EuiForm>
{!created && (
<EuiButton fill isDisabled={created && started} onClick={createAndStartDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createAndStartDataFrameButton', {
defaultMessage: 'Create and start data frame',
})}
</EuiButton>
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiButton fill isDisabled={created && started} onClick={createAndStartDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createAndStartDataFrameButton', {
defaultMessage: 'Create & start',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate(
'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.',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
{created && (
<EuiButton isDisabled={created && started} onClick={startDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.startDataFrameButton', {
defaultMessage: 'Start data frame',
})}
</EuiButton>
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiButton isDisabled={created && started} onClick={startDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.startDataFrameButton', {
defaultMessage: 'Start',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<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.',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
{created && started && (
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiButton isDisabled={created} onClick={createDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createDataFrameButton', {
defaultMessage: 'Create',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createDataFrameDescription', {
defaultMessage:
'Create the data frame job without starting it. You will be able to start the job later by returning to the data frame jobs list.',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup alignItems="center" style={{ maxWidth: '800px' }}>
<EuiFlexItem grow={false} style={{ width: '200px' }}>
<EuiCopy textToCopy={getJobConfigDevConsoleStatement()}>
{(copy: () => void) => (
<EuiButton onClick={copy} style={{ width: '100%' }}>
{i18n.translate(
'xpack.ml.dataframe.jobCreateForm.copyJobConfigToClipBoardButton',
{
defaultMessage: 'Copy to clipboard',
}
)}
</EuiButton>
)}
</EuiCopy>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.ml.dataframe.jobCreateForm.copyJobConfigToClipBoardDescription',
{
defaultMessage:
'Copies to the clipboard the Kibana Dev Console command for creating the job.',
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
{progressPercentComplete !== undefined && (
<Fragment>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="l">
<EuiText size="xs">
<strong>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.progressTitle', {
defaultMessage: 'Progress',
})}
</strong>
</EuiText>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem style={{ width: '400px' }} grow={false}>
<EuiProgress size="l" color="primary" value={progressPercentComplete} max={100} />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">{progressPercentComplete}%</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
)}
{created && (
<Fragment>
<EuiHorizontalRule />
<EuiFlexGrid gutterSize="l">
<EuiFlexItem style={ITEM_STYLE}>
<EuiCard
icon={<EuiIcon size="xxl" type="list" />}
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.jobManagementCardTitle', {
defaultMessage: 'Job management',
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.jobsListCardTitle', {
defaultMessage: 'Data frame jobs',
})}
description={i18n.translate(
'xpack.ml.dataframe.jobCreateForm.jobManagementCardDescription',
@ -201,13 +336,47 @@ export const JobCreateForm: SFC<Props> = React.memo(
defaultMessage: 'Return to the data frame job management page.',
}
)}
onClick={gotToDataFrameJobManagement}
onClick={moveToDataFrameJobsList}
/>
</EuiFlexItem>
</EuiFlexGroup>
{started === true && createIndexPattern === true && indexPatternId === undefined && (
<EuiFlexItem style={ITEM_STYLE}>
<EuiPanel style={{ position: 'relative' }}>
<EuiProgress size="xs" color="primary" position="absolute" />
<EuiText color="subdued" size="s">
<p>
{i18n.translate(
'xpack.ml.dataframe.jobCreateForm.creatingIndexPatternMessage',
{
defaultMessage: 'Creating Kibana index pattern ...',
}
)}
</p>
</EuiText>
</EuiPanel>
</EuiFlexItem>
)}
{started === true && indexPatternId !== undefined && (
<EuiFlexItem style={ITEM_STYLE}>
<EuiCard
icon={<EuiIcon size="xxl" type="discoverApp" />}
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.discoverCardTitle', {
defaultMessage: 'Discover',
})}
description={i18n.translate(
'xpack.ml.dataframe.jobCreateForm.discoverCardDescription',
{
defaultMessage: 'Use Discover to explore the data frame pivot.',
}
)}
onClick={() => moveToDiscover(indexPatternId, kibanaContext.kbnBaseUrl)}
/>
</EuiFlexItem>
)}
</EuiFlexGrid>
</Fragment>
)}
</Fragment>
</EuiForm>
);
}
);

View file

@ -30,6 +30,7 @@ module.directive('mlNewDataFrame', ($injector: InjectorService) => {
restrict: 'E',
link: (scope: ng.IScope, element: ng.IAugmentedJQuery) => {
const indexPatterns = $injector.get('indexPatterns');
const kbnBaseUrl = $injector.get<string>('kbnBaseUrl');
const kibanaConfig = $injector.get('config');
const Private: IPrivate = $injector.get('Private');
@ -39,6 +40,7 @@ module.directive('mlNewDataFrame', ($injector: InjectorService) => {
const kibanaContext = {
currentIndexPattern: indexPattern,
indexPatterns,
kbnBaseUrl,
kibanaConfig,
};

View file

@ -167,11 +167,8 @@ export const Wizard: SFC = React.memo(() => {
children: (
<Fragment>
{jobCreate}
{currentStep === WIZARD_STEPS.JOB_CREATE && (
<WizardNav
previous={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)}
previousActive={!jobCreateState.created}
/>
{currentStep === WIZARD_STEPS.JOB_CREATE && !jobCreateState.created && (
<WizardNav previous={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} />
)}
</Fragment>
),

View file

@ -19,7 +19,14 @@ export const dataFrame = {
method: 'GET'
});
},
getDataFrameTransformsStats() {
getDataFrameTransformsStats(jobId) {
if (jobId !== undefined) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}/_stats`,
method: 'GET'
});
}
return http({
url: `${basePath}/_data_frame/transforms/_stats`,
method: 'GET'

View file

@ -22,7 +22,7 @@ declare interface Ml {
dataFrame: {
getDataFrameTransforms(): Promise<any>;
getDataFrameTransformsStats(): Promise<any>;
getDataFrameTransformsStats(jobId?: string): Promise<any>;
createDataFrameTransformsJob(jobId: string, jobConfig: any): Promise<any>;
deleteDataFrameTransformsJob(jobId: string): Promise<any>;
getDataFrameTransformsPreview(payload: any): Promise<any>;

View file

@ -116,6 +116,14 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
ml.getDataFrameTransformsStats = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>/_stats',
req: {
jobId: {
type: 'string'
}
}
},
{
fmt: '/_data_frame/transforms/_stats',
}

View file

@ -35,6 +35,20 @@ export function dataFrameRoutes(server, commonRouteConfig) {
}
});
server.route({
method: 'GET',
path: '/api/ml/_data_frame/transforms/{jobId}/_stats',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.getDataFrameTransformsStats', { jobId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'PUT',
path: '/api/ml/_data_frame/transforms/{jobId}',