From 340178a77f87d76679e5fabfb4f28eda115db1c2 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 24 Jul 2018 14:56:13 +0100 Subject: [PATCH] [ML] Create watch from new jobs list (#21112) (#21132) * [ML] [WIP] Create watch from new jobs list * removing comments * adding interval calculation * adding checkbox to start datafeed modal * adding proptypes check to SelectSeverity * fixing typo * changes based on review * correcting input labels --- .../controls/select_severity/index.js | 1 - .../select_severity/select_severity.js | 6 + .../create_watch_flyout.js | 176 +++++++++++++++ .../components/create_watch_flyout/index.js | 8 + .../create_watch_flyout/styles/main.less | 0 .../edit_job_flyout/edit_job_flyout.js | 8 +- .../jobs_list_view/jobs_list_view.js | 19 ++ .../start_datafeed_modal.js | 53 ++++- .../time_range_selector.js | 3 + .../components/watcher/create_watch.html | 2 +- .../watcher/create_watch_service.js | 5 +- .../components/watcher/create_watch_view.js | 209 ++++++++++++++++++ .../components/watcher/styles/main.less | 11 + 13 files changed, 485 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/create_watch_flyout.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/index.js create mode 100644 x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/styles/main.less create mode 100644 x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js diff --git a/x-pack/plugins/ml/public/components/controls/select_severity/index.js b/x-pack/plugins/ml/public/components/controls/select_severity/index.js index 070cc16d19313..618edf599e509 100644 --- a/x-pack/plugins/ml/public/components/controls/select_severity/index.js +++ b/x-pack/plugins/ml/public/components/controls/select_severity/index.js @@ -6,4 +6,3 @@ import './select_severity_directive'; -import './styles/main.less'; diff --git a/x-pack/plugins/ml/public/components/controls/select_severity/select_severity.js b/x-pack/plugins/ml/public/components/controls/select_severity/select_severity.js index 59ce7af3272bf..a27b594f1aac2 100644 --- a/x-pack/plugins/ml/public/components/controls/select_severity/select_severity.js +++ b/x-pack/plugins/ml/public/components/controls/select_severity/select_severity.js @@ -9,6 +9,7 @@ /* * React component for rendering a select element with threshold levels. */ +import PropTypes from 'prop-types'; import _ from 'lodash'; import React, { Component } from 'react'; @@ -18,6 +19,8 @@ import { EuiHealth, } from '@elastic/eui'; +import './styles/main.less'; + import { getSeverityColor } from 'plugins/ml/../common/util/anomaly_utils'; const OPTIONS = [ @@ -103,5 +106,8 @@ class SelectSeverity extends Component { ); } } +SelectSeverity.propTypes = { + mlSelectSeverityService: PropTypes.object.isRequired, +}; export { SelectSeverity }; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/create_watch_flyout.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/create_watch_flyout.js new file mode 100644 index 0000000000000..ac432e8323d05 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/create_watch_flyout.js @@ -0,0 +1,176 @@ +/* + * 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 PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { toastNotifications } from 'ui/notify'; +import { loadFullJob } from '../utils'; +import { mlCreateWatchService } from '../../../../jobs/new_job/simple/components/watcher/create_watch_service'; +import { CreateWatch } from '../../../../jobs/new_job/simple/components/watcher/create_watch_view'; + + +function getSuccessToast(id, url) { + return { + title: `Watch ${id} created successfully`, + text: ( + + + + + Edit watch + + + + + ) + }; +} + +export class CreateWatchFlyout extends Component { + constructor(props) { + super(props); + + this.state = { + jobId: null, + bucketSpan: null, + }; + } + + componentDidMount() { + if (typeof this.props.setShowFunction === 'function') { + this.props.setShowFunction(this.showFlyout); + } + } + + componentWillUnmount() { + if (typeof this.props.unsetShowFunction === 'function') { + this.props.unsetShowFunction(); + } + } + + closeFlyout = () => { + this.setState({ isFlyoutVisible: false }); + } + + showFlyout = (jobId) => { + loadFullJob(jobId) + .then((job) => { + const bucketSpan = job.analysis_config.bucket_span; + mlCreateWatchService.config.includeInfluencers = (job.analysis_config.influencers.length > 0); + + this.setState({ + job, + jobId, + bucketSpan, + isFlyoutVisible: true, + }); + }) + .catch((error) => { + console.error(error); + }); + } + + save = () => { + mlCreateWatchService.createNewWatch(this.state.jobId) + .then((resp) => { + toastNotifications.addSuccess(getSuccessToast(resp.id, resp.url)); + this.closeFlyout(); + }) + .catch((error) => { + toastNotifications.addDanger(`Could not save watch`); + console.error(error); + }); + } + + + render() { + const { + jobId, + bucketSpan + } = this.state; + + let flyout; + + if (this.state.isFlyoutVisible) { + flyout = ( + + + +

+ Create watch for {jobId} +

+
+
+ + + + + + + + + + Close + + + + + Save + + + + +
+ ); + } + return ( +
+ {flyout} +
+ ); + + } +} +CreateWatchFlyout.propTypes = { + setShowFunction: PropTypes.func.isRequired, + unsetShowFunction: PropTypes.func.isRequired, +}; + diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/index.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/index.js new file mode 100644 index 0000000000000..e8e7ee52b3c5f --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/index.js @@ -0,0 +1,8 @@ +/* + * 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. + */ + + +export { CreateWatchFlyout } from './create_watch_flyout'; diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/styles/main.less b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/create_watch_flyout/styles/main.less new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/edit_job_flyout.js index cc887e4a857c3..68a715fef4cd8 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/edit_job_flyout/edit_job_flyout.js @@ -36,7 +36,7 @@ export class EditJobFlyout extends Component { this.state = { job: {}, hasDatafeed: false, - isModalVisible: false, + isFlyoutVisible: false, jobDescription: '', jobGroups: [], jobModelMemoryLimit: '', @@ -68,7 +68,7 @@ export class EditJobFlyout extends Component { } closeFlyout = () => { - this.setState({ isModalVisible: false }); + this.setState({ isFlyoutVisible: false }); } showFlyout = (jobLite) => { @@ -78,7 +78,7 @@ export class EditJobFlyout extends Component { this.extractJob(job, hasDatafeed); this.setState({ job, - isModalVisible: true, + isFlyoutVisible: true, }); }) .catch((error) => { @@ -185,7 +185,7 @@ export class EditJobFlyout extends Component { render() { let flyout; - if (this.state.isModalVisible) { + if (this.state.isFlyoutVisible) { const { job, jobDescription, diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js index 89a1e7874ebda..bc84a388d7dde 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/jobs_list_view/jobs_list_view.js @@ -16,6 +16,7 @@ import { JobFilterBar } from '../job_filter_bar'; import { EditJobFlyout } from '../edit_job_flyout'; import { DeleteJobModal } from '../delete_job_modal'; import { StartDatafeedModal } from '../start_datafeed_modal'; +import { CreateWatchFlyout } from '../create_watch_flyout'; import { MultiJobActions } from '../multi_job_actions'; import PropTypes from 'prop-types'; @@ -45,6 +46,7 @@ export class JobsListView extends Component { this.showEditJobFlyout = () => {}; this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; + this.showCreateWatchFlyout = () => {}; this.blockRefresh = false; } @@ -191,6 +193,17 @@ export class JobsListView extends Component { this.showStartDatafeedModal = () => {}; } + setShowCreateWatchFlyoutFunction = (func) => { + this.showCreateWatchFlyout = func; + } + unsetShowCreateWatchFlyoutFunction = () => { + this.showCreateWatchFlyout = () => {}; + } + getShowCreateWatchFlyoutFunction = () => { + return this.showCreateWatchFlyout; + } + + selectJobChange = (selectedJobs) => { this.setState({ selectedJobs }); } @@ -281,8 +294,14 @@ export class JobsListView extends Component { this.refreshJobSummaryList(true)} /> + ); } diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/start_datafeed_modal.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/start_datafeed_modal.js index 0a7a83ae21d95..e420e11b06b4d 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/start_datafeed_modal.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/start_datafeed_modal.js @@ -19,6 +19,9 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiOverlayMask, + EuiHorizontalRule, + EuiCheckbox, + } from '@elastic/eui'; import moment from 'moment'; @@ -36,20 +39,20 @@ export class StartDatafeedModal extends Component { isModalVisible: false, startTime: moment(), endTime: moment(), + createWatch: false, + allowCreateWatch: false, initialSpecifiedStartTime: moment() }; this.initialSpecifiedStartTime = moment(); this.refreshJobs = this.props.refreshJobs; + this.getShowCreateWatchFlyoutFunction = this.props.getShowCreateWatchFlyoutFunction; } componentDidMount() { if (typeof this.props.setShowFunction === 'function') { this.props.setShowFunction(this.showModal); } - if (typeof this.props.saveFunction === 'function') { - this.externalSave = this.props.saveFunction; - } } componentWillUnmount() { @@ -66,32 +69,52 @@ export class StartDatafeedModal extends Component { this.setState({ endTime: time }); } + setCreateWatch = (e) => { + this.setState({ createWatch: e.target.checked }); + } + closeModal = () => { this.setState({ isModalVisible: false }); } - showModal = (jobs) => { + showModal = (jobs, showCreateWatchFlyout) => { const startTime = undefined; const endTime = moment(); const initialSpecifiedStartTime = getLowestLatestTime(jobs); + const allowCreateWatch = (jobs.length === 1); this.setState({ jobs, isModalVisible: true, startTime, endTime, - initialSpecifiedStartTime + initialSpecifiedStartTime, + showCreateWatchFlyout, + allowCreateWatch, + createWatch: false, }); } save = () => { + const { jobs } = this.state; const start = moment.isMoment(this.state.startTime) ? this.state.startTime.valueOf() : this.state.startTime; const end = moment.isMoment(this.state.endTime) ? this.state.endTime.valueOf() : this.state.endTime; - forceStartDatafeeds(this.state.jobs, start, end, this.refreshJobs); + forceStartDatafeeds(jobs, start, end, () => { + if (this.state.createWatch && jobs.length === 1) { + const jobId = jobs[0].id; + this.getShowCreateWatchFlyoutFunction()(jobId); + } + this.refreshJobs(); + }); this.closeModal(); } render() { - const { jobs } = this.state; + const { + jobs, + initialSpecifiedStartTime, + endTime, + createWatch + } = this.state; const startableJobs = (jobs !== undefined) ? jobs.filter(j => j.hasDatafeed) : []; let modal; @@ -110,11 +133,23 @@ export class StartDatafeedModal extends Component { + { + this.state.endTime === undefined && +
+ + +
+ }
diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/time_range_selector/time_range_selector.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/time_range_selector/time_range_selector.js index c7a3ece449ef5..b6bd4922f845c 100644 --- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/time_range_selector/time_range_selector.js +++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/start_datafeed_modal/time_range_selector/time_range_selector.js @@ -51,6 +51,9 @@ export class TimeRangeSelector extends Component { case 0: this.setEndTime(undefined); break; + case 1: + this.setEndTime(moment()); + break; default: break; } diff --git a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch.html b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch.html index 5f0b3626c1a81..f9bb775e99279 100644 --- a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch.html +++ b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch.html @@ -31,7 +31,7 @@
- Warning, watch ml-{{jobId}} already exists, clicking apply with overwrite the original. + Warning, watch ml-{{jobId}} already exists, clicking apply will overwrite the original.
diff --git a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js index 93152964d81cc..33149d05d33dc 100644 --- a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js +++ b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_service.js @@ -124,7 +124,10 @@ class CreateWatchService { this.status.watch = this.STATUS.SAVED; this.config.watcherEditURL = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/watcher/watches/watch/${id}/edit?_g=()`; - resolve(); + resolve({ + id, + url: this.config.watcherEditURL, + }); }) .catch((resp) => { this.status.watch = this.STATUS.SAVE_FAILED; diff --git a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js new file mode 100644 index 0000000000000..c2c1b887f99c8 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/create_watch_view.js @@ -0,0 +1,209 @@ +/* + * 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. + */ + + +/* + * 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 PropTypes from 'prop-types'; +import React, { + Component, +} from 'react'; + +import { + EuiCheckbox, + EuiFieldText, + EuiCallOut, +} from '@elastic/eui'; + +import { has } from 'lodash'; + +import { parseInterval } from 'ui/utils/parse_interval'; + +import { ml } from '../../../../../services/ml_api_service'; +import { SelectSeverity } from '../../../../../components/controls/select_severity/select_severity'; +import { mlCreateWatchService } from './create_watch_service'; +const STATUS = mlCreateWatchService.STATUS; + +export class CreateWatch extends Component { + constructor(props) { + super(props); + mlCreateWatchService.reset(); + this.config = mlCreateWatchService.config; + + this.state = { + jobId: this.props.jobId, + bucketSpan: this.props.bucketSpan, + interval: this.config.interval, + threshold: this.config.threshold, + includeEmail: this.config.emailIncluded, + email: this.config.email, + emailEnabled: false, + status: null, + watchAlreadyExists: false, + }; + + } + + componentDidMount() { + // make the interval 2 times the bucket span + if (this.state.bucketSpan) { + const intervalObject = parseInterval(this.state.bucketSpan); + let bs = intervalObject.asMinutes() * 2; + if (bs < 1) { + bs = 1; + } + + const interval = `${bs}m`; + this.setState({ interval }, () => { + this.config.interval = interval; + }); + } + + // load elasticsearch settings to see if email has been configured + ml.getNotificationSettings().then((resp) => { + if (has(resp, 'defaults.xpack.notification.email')) { + this.setState({ emailEnabled: true }); + } + }); + + mlCreateWatchService.loadWatch(this.state.jobId) + .then(() => { + this.setState({ watchAlreadyExists: true }); + }) + .catch(() => { + this.setState({ watchAlreadyExists: false }); + }); + } + + onThresholdChange = (threshold) => { + this.setState({ threshold }, () => { + this.config.threshold = threshold; + }); + } + + onIntervalChange = (e) => { + const interval = e.target.value; + this.setState({ interval }, () => { + this.config.interval = interval; + }); + } + + onIncludeEmailChanged = (e) => { + const includeEmail = e.target.checked; + this.setState({ includeEmail }, () => { + this.config.includeEmail = includeEmail; + }); + } + + onEmailChange = (e) => { + const email = e.target.value; + this.setState({ email }, () => { + this.config.email = email; + }); + } + + render() { + const mlSelectSeverityService = { + state: { + set: (name, threshold) => { + this.onThresholdChange(threshold); + return { + changed: () => {} + }; + }, + get: () => { + return this.config.threshold; + }, + } + }; + const { status } = this.state; + + if (status === null || status === STATUS.SAVING || status === STATUS.SAVE_FAILED) { + return ( +
+
+
+
+ +
+ Now - +
+ +
+
+ +
+
+ +
+
+
+ { + this.state.emailEnabled && + +
+ + { + this.state.includeEmail && +
+ +
+ } +
+ } + { + this.state.watchAlreadyExists && + + } +
+ ); + } else if (status === STATUS.SAVED) { + return ( +
Success
+ ); + } else { + return (
); + } + } +} +CreateWatch.propTypes = { + jobId: PropTypes.string.isRequired, + bucketSpan: PropTypes.string.isRequired, +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/styles/main.less b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/styles/main.less index 7ffbf2f6b4bfd..ec7cbd09e68bc 100644 --- a/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/styles/main.less +++ b/x-pack/plugins/ml/public/jobs/new_job/simple/components/watcher/styles/main.less @@ -11,6 +11,17 @@ } } + .form-group-flex { + display: flex; + } + + .sub-form-group:first-child { + .euiFormControlLayout { + display: inline-block; + width: 70px; + } + } + .email-section { padding: 10px; padding-left: 0px;