diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts new file mode 100644 index 0000000000000..bbf3616c05880 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -0,0 +1,22 @@ +/* + * 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 enum ANOMALY_SEVERITY { + CRITICAL = 'critical', + MAJOR = 'major', + MINOR = 'minor', + WARNING = 'warning', + LOW = 'low', + UNKNOWN = 'unknown', +} + +export enum ANOMALY_THRESHOLD { + CRITICAL = 75, + MAJOR = 50, + MINOR = 25, + WARNING = 3, + LOW = 0, +} diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index 43781a49c5cce..d9a4446588bfc 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -10,6 +10,7 @@ import { ML_JOB_AGGREGATION } from '../../common/constants/aggregation_types'; export type FieldId = string; export type AggId = ML_JOB_AGGREGATION; export type SplitField = Field | null; +export type DslName = string; export interface Field { id: FieldId; @@ -24,7 +25,7 @@ export interface Aggregation { id: AggId; title: string; kibanaName: string; - dslName: string; + dslName: DslName; type: string; mlModelPlotAgg: { min: string; @@ -38,3 +39,21 @@ export interface NewJobCaps { fields: Field[]; aggs: Aggregation[]; } + +export interface AggFieldPair { + agg: Aggregation; + field: Field; + by?: { + field: SplitField; + value: string | null; + }; +} + +export interface AggFieldNamePair { + agg: string; + field: string; + by?: { + field: string | null; + value: string | null; + }; +} diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.d.ts b/x-pack/plugins/ml/common/util/anomaly_utils.d.ts new file mode 100644 index 0000000000000..adeb6dc7dd5b9 --- /dev/null +++ b/x-pack/plugins/ml/common/util/anomaly_utils.d.ts @@ -0,0 +1,11 @@ +/* + * 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 { ANOMALY_SEVERITY } from '../constants/anomalies'; + +export function getSeverity(normalizedScore: number): string; +export function getSeverityType(normalizedScore: number): ANOMALY_SEVERITY; +export function getSeverityColor(normalizedScore: number): string; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.js b/x-pack/plugins/ml/common/util/anomaly_utils.js index 371d1d176394a..b4ce49e5639d8 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.js +++ b/x-pack/plugins/ml/common/util/anomaly_utils.js @@ -15,6 +15,8 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; +import { ANOMALY_SEVERITY } from '../constants/anomalies'; + // List of function descriptions for which actual values from record level results should be displayed. const DISPLAY_ACTUAL_FUNCTIONS = ['count', 'distinct_count', 'lat_long', 'mean', 'max', 'min', 'sum', @@ -71,6 +73,22 @@ export function getSeverity(normalizedScore) { } } +export function getSeverityType(normalizedScore) { + if (normalizedScore >= 75) { + return ANOMALY_SEVERITY.CRITICAL; + } else if (normalizedScore >= 50) { + return ANOMALY_SEVERITY.MAJOR; + } else if (normalizedScore >= 25) { + return ANOMALY_SEVERITY.MINOR; + } else if (normalizedScore >= 3) { + return ANOMALY_SEVERITY.WARNING; + } else if (normalizedScore >= 0) { + return ANOMALY_SEVERITY.LOW; + } else { + return ANOMALY_SEVERITY.UNKNOWN; + } +} + // Returns a severity label (one of critical, major, minor, warning, low or unknown) // for the supplied normalized anomaly score (a value between 0 and 100), where scores // less than 3 are assigned a severity of 'low'. diff --git a/x-pack/plugins/ml/common/util/group_color_utils.d.ts b/x-pack/plugins/ml/common/util/group_color_utils.d.ts new file mode 100644 index 0000000000000..4a1a6ebb8fdf3 --- /dev/null +++ b/x-pack/plugins/ml/common/util/group_color_utils.d.ts @@ -0,0 +1,6 @@ +/* + * 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 function tabColor(name: string): string; diff --git a/x-pack/plugins/ml/common/util/job_utils.d.ts b/x-pack/plugins/ml/common/util/job_utils.d.ts new file mode 100644 index 0000000000000..832db922cad5f --- /dev/null +++ b/x-pack/plugins/ml/common/util/job_utils.d.ts @@ -0,0 +1,13 @@ +/* + * 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 function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: number): number; + +export function isTimeSeriesViewJob(job: any): boolean; + +export const ML_MEDIAN_PERCENTS: number; + +export const ML_DATA_PREVIEW_COUNT: number; diff --git a/x-pack/plugins/ml/common/util/string_utils.d.ts b/x-pack/plugins/ml/common/util/string_utils.d.ts new file mode 100644 index 0000000000000..f8dbc00643d07 --- /dev/null +++ b/x-pack/plugins/ml/common/util/string_utils.d.ts @@ -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 function renderTemplate(str: string, data: string): string; +export function stringHash(str: string): string; diff --git a/x-pack/plugins/ml/public/components/full_time_range_selector/index.test.tsx b/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx similarity index 100% rename from x-pack/plugins/ml/public/components/full_time_range_selector/index.test.tsx rename to x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx diff --git a/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx b/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx new file mode 100644 index 0000000000000..0e39a0fbc4521 --- /dev/null +++ b/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx @@ -0,0 +1,35 @@ +/* + * 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 from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { Query } from 'ui/embeddable'; +import { IndexPattern } from 'ui/index_patterns'; +import { EuiButton } from '@elastic/eui'; +import { setFullTimeRange } from './full_time_range_selector_service'; + +interface Props { + indexPattern: IndexPattern; + query: Query; + disabled: boolean; +} + +// Component for rendering a button which automatically sets the range of the time filter +// to the time range of data in the index(es) mapped to the supplied Kibana index pattern or query. +export const FullTimeRangeSelector: React.SFC = ({ indexPattern, query, disabled }) => { + return ( + setFullTimeRange(indexPattern, query)}> + + + ); +}; diff --git a/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts b/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts index 32606d2db425e..08a0fdd3ce784 100644 --- a/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts +++ b/x-pack/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts @@ -11,8 +11,14 @@ import { IndexPattern } from 'ui/index_patterns'; import { toastNotifications } from 'ui/notify'; import { timefilter } from 'ui/timefilter'; import { Query } from 'src/legacy/core_plugins/data/public'; +import dateMath from '@elastic/datemath'; import { ml } from '../../services/ml_api_service'; +export interface TimeRange { + from: number; + to: number; +} + export function setFullTimeRange(indexPattern: IndexPattern, query: Query) { return ml .getTimeFieldRange({ @@ -34,3 +40,24 @@ export function setFullTimeRange(indexPattern: IndexPattern, query: Query) { ); }); } + +export function getTimeFilterRange(): TimeRange { + let from = 0; + let to = 0; + const fromString = timefilter.getTime().from; + const toString = timefilter.getTime().to; + if (typeof fromString === 'string' && typeof toString === 'string') { + const fromMoment = dateMath.parse(fromString); + const toMoment = dateMath.parse(toString); + if (typeof fromMoment !== 'undefined' && typeof toMoment !== 'undefined') { + const fromMs = fromMoment.valueOf(); + const toMs = toMoment.valueOf(); + from = fromMs; + to = toMs; + } + } + return { + to, + from, + }; +} diff --git a/x-pack/plugins/ml/public/components/full_time_range_selector/index.tsx b/x-pack/plugins/ml/public/components/full_time_range_selector/index.tsx index 9066fe0a0e8b9..59c185c76471f 100644 --- a/x-pack/plugins/ml/public/components/full_time_range_selector/index.tsx +++ b/x-pack/plugins/ml/public/components/full_time_range_selector/index.tsx @@ -3,33 +3,5 @@ * 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 from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPattern } from 'ui/index_patterns'; -import { EuiButton } from '@elastic/eui'; -import { Query } from 'src/legacy/core_plugins/data/public'; -import { setFullTimeRange } from './full_time_range_selector_service'; - -interface Props { - indexPattern: IndexPattern; - query: Query; - disabled: boolean; -} - -// Component for rendering a button which automatically sets the range of the time filter -// to the time range of data in the index(es) mapped to the supplied Kibana index pattern or query. -export const FullTimeRangeSelector: React.SFC = ({ indexPattern, query, disabled }) => { - return ( - setFullTimeRange(indexPattern, query)}> - - - ); -}; +export { FullTimeRangeSelector } from './full_time_range_selector'; +export { getTimeFilterRange } from './full_time_range_selector_service'; diff --git a/x-pack/plugins/ml/public/jobs/index.js b/x-pack/plugins/ml/public/jobs/index.js index cc1aeb85b8b59..9b0246240da68 100644 --- a/x-pack/plugins/ml/public/jobs/index.js +++ b/x-pack/plugins/ml/public/jobs/index.js @@ -14,3 +14,4 @@ import './new_job/simple/population'; import './new_job/simple/recognize'; import './new_job/wizard'; import 'plugins/ml/components/validate_job'; +import './new_job_new'; diff --git a/x-pack/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts b/x-pack/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts new file mode 100644 index 0000000000000..de99d1b82d171 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts @@ -0,0 +1,17 @@ +/* + * 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 { SavedSearch } from '../../../../../../../src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPatternWithType, IndexPatternTitle } from '../../../../common/types/kibana'; + +export interface SearchItems { + indexPattern: IndexPatternWithType; + savedSearch: SavedSearch; + query: any; + combinedQuery: any; +} + +export function SearchItemsProvider($route: Record, config: any): () => SearchItems; diff --git a/x-pack/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html b/x-pack/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html index da0bf3d38d4d5..a105dc6175f57 100644 --- a/x-pack/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html +++ b/x-pack/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html @@ -213,6 +213,102 @@ + + +
diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts new file mode 100644 index 0000000000000..c2f99d9989641 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts @@ -0,0 +1,137 @@ +/* + * 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 { SavedSearch } from '../../../../../../../../src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPatternWithType, IndexPatternTitle } from '../../../../../common/types/kibana'; +import { Field, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { ml } from '../../../../services/ml_api_service'; +import { mlResultsService } from '../../../../services/results_service'; +import { getCategoryFields } from './searches'; + +type DetectorIndex = number; +export interface LineChartPoint { + time: number | string; + value: number; +} +type SplitFieldValue = string | null; +export type LineChartData = Record; + +export class ChartLoader { + protected _indexPattern: IndexPatternWithType; + protected _savedSearch: SavedSearch; + protected _indexPatternTitle: IndexPatternTitle = ''; + protected _timeFieldName: string = ''; + protected _query: object = {}; + + constructor(indexPattern: IndexPatternWithType, savedSearch: SavedSearch, query: object) { + this._indexPattern = indexPattern; + this._savedSearch = savedSearch; + this._indexPatternTitle = indexPattern.title; + this._query = query; + + if (typeof indexPattern.timeFieldName === 'string') { + this._timeFieldName = indexPattern.timeFieldName; + } + } + + async loadLineCharts( + start: number, + end: number, + aggFieldPairs: AggFieldPair[], + splitField: SplitField, + splitFieldValue: SplitFieldValue, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const splitFieldName = splitField !== null ? splitField.name : null; + + const resp = await ml.jobs.newJobLineChart( + this._indexPatternTitle, + this._timeFieldName, + start, + end, + intervalMs, + this._query, + aggFieldPairs.map(af => ({ + agg: af.agg.dslName, + field: af.field.name, + })), + splitFieldName, + splitFieldValue + ); + return resp.results; + } + return {}; + } + + async loadPopulationCharts( + start: number, + end: number, + aggFieldPairs: AggFieldPair[], + splitField: SplitField, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const splitFieldName = splitField !== null ? splitField.name : ''; + + const resp = await ml.jobs.newJobPopulationsChart( + this._indexPatternTitle, + this._timeFieldName, + start, + end, + intervalMs, + this._query, + aggFieldPairs.map(af => { + const by = + af.by !== undefined && af.by.field !== null && af.by.value !== null + ? { field: af.by.field.name, value: af.by.value } + : { field: null, value: null }; + + return { + agg: af.agg.dslName, + field: af.field.name, + by, + }; + }), + splitFieldName + ); + return resp.results; + } + return {}; + } + + async loadEventRateChart( + start: number, + end: number, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const resp = await mlResultsService.getEventRateData( + this._indexPatternTitle, + this._query, + this._timeFieldName, + start, + end, + intervalMs * 3 + ); + return Object.entries(resp.results).map(([time, value]) => ({ + time: +time, + value: value as number, + })); + } + return []; + } + + async loadFieldExampleValues(field: Field): Promise { + const { results } = await getCategoryFields( + this._indexPatternTitle, + field.name, + 10, + this._query + ); + return results; + } +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts new file mode 100644 index 0000000000000..73e8f88df479b --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { ChartLoader, LineChartData, LineChartPoint } from './chart_loader'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts new file mode 100644 index 0000000000000..f40b93fd8de89 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts @@ -0,0 +1,50 @@ +/* + * 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 { get } from 'lodash'; + +import { ml } from '../../../../services/ml_api_service'; + +interface CategoryResults { + success: boolean; + results: string[]; +} + +export function getCategoryFields( + indexPatternName: string, + fieldName: string, + size: number, + query: any +): Promise { + return new Promise((resolve, reject) => { + ml.esSearch({ + index: indexPatternName, + size: 0, + body: { + query, + aggs: { + catFields: { + terms: { + field: fieldName, + size, + }, + }, + }, + }, + }) + .then((resp: any) => { + const catFields = get(resp, ['aggregations', 'catFields', 'buckets'], []); + + resolve({ + success: true, + results: catFields.map((f: any) => f.key), + }); + }) + .catch((resp: any) => { + reject(resp); + }); + }); +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_settings.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_settings.ts index e6ba103a96d2f..fbb5efb718e2c 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_settings.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/chart_settings.ts @@ -25,7 +25,8 @@ export class ChartSettings { this._interval.setMaxBars(this.MAX_BARS); this._interval.setInterval('auto'); - const bounds = timefilter.getActiveBounds(); + const tf = timefilter as any; + const bounds = tf.getActiveBounds(); this._interval.setBounds(bounds); } diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/index.ts index cbb6d5a598ee5..81cc00155a64c 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/index.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/index.ts @@ -4,5 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './index_pattern_context'; export * from './job_creator'; export * from './job_runner'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts new file mode 100644 index 0000000000000..aa92536da8d1d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts @@ -0,0 +1,12 @@ +/* + * 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 from 'react'; + +import { StaticIndexPattern } from 'ui/index_patterns'; + +export type IndexPatternContextValue = StaticIndexPattern | null; +export const IndexPatternContext = React.createContext(null); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts index 0cfad2cb0c6d6..eca52c064ce67 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts @@ -8,3 +8,9 @@ export { JobCreator } from './job_creator'; export { SingleMetricJobCreator } from './single_metric_job_creator'; export { MultiMetricJobCreator } from './multi_metric_job_creator'; export { PopulationJobCreator } from './population_job_creator'; +export { + isSingleMetricJobCreator, + isMultiMetricJobCreator, + isPopulationJobCreator, +} from './type_guards'; +export { jobCreatorFactory } from './job_creator_factory'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts index 13c31c8554145..c7283d30d7259 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts @@ -7,11 +7,14 @@ import { SavedSearch } from '../../../../../../../../src/legacy/core_plugins/kibana/public/discover/types'; import { IndexPatternWithType, IndexPatternTitle } from '../../../../../common/types/kibana'; import { Job, Datafeed, Detector, JobId, DatafeedId, BucketSpan } from './configs'; -import { createEmptyJob, createEmptyDatafeed } from './util'; +import { Aggregation } from '../../../../../common/types/fields'; +import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; import { JobRunner, ProgressSubscriber } from '../job_runner'; +import { JOB_TYPE } from './util/constants'; export class JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; protected _indexPattern: IndexPatternWithType; protected _savedSearch: SavedSearch; protected _indexPatternTitle: IndexPatternTitle = ''; @@ -23,6 +26,10 @@ export class JobCreator { protected _start: number = 0; protected _end: number = 0; protected _subscribers: ProgressSubscriber[]; + protected _aggs: Aggregation[] = []; + private _stopAllRefreshPolls: { + stop: boolean; + }; constructor(indexPattern: IndexPatternWithType, savedSearch: SavedSearch, query: object) { this._indexPattern = indexPattern; @@ -40,26 +47,47 @@ export class JobCreator { this._datafeed_config.query = query; this._subscribers = []; + this._stopAllRefreshPolls = { stop: false }; } - protected _addDetector(detector: Detector) { + public get type(): JOB_TYPE { + return this._type; + } + + protected _addDetector(detector: Detector, agg: Aggregation) { this._detectors.push(detector); + this._aggs.push(agg); } - protected _editDetector(detector: Detector, index: number) { + protected _editDetector(detector: Detector, agg: Aggregation, index: number) { if (this._detectors[index] !== undefined) { this._detectors[index] = detector; + this._aggs[index] = agg; } } protected _removeDetector(index: number) { this._detectors.splice(index, 1); + this._aggs.splice(index, 1); + } + + public removeAllDetectors() { + this._detectors.length = 0; } public get detectors(): Detector[] { return this._detectors; } + public get aggregationsInDetectors(): Aggregation[] { + return this._aggs; + } + + public getAggregation(index: number): Aggregation | null { + const agg = this._aggs[index]; + return agg !== undefined ? agg : null; + } + public set bucketSpan(bucketSpan: BucketSpan) { this._job_config.analysis_config.bucket_span = bucketSpan; } @@ -74,6 +102,17 @@ export class JobCreator { } } + public removeInfluencer(influencer: string) { + const idx = this._influencers.indexOf(influencer); + if (idx !== -1) { + this._influencers.splice(idx, 1); + } + } + + public removeAllInfluencers() { + this._influencers.length = 0; + } + public get influencers(): string[] { return this._influencers; } @@ -114,6 +153,10 @@ export class JobCreator { return this._job_config.groups; } + public set groups(groups: string[]) { + this._job_config.groups = groups; + } + public set modelPlot(enable: boolean) { if (enable) { this._job_config.model_plot_config = { @@ -223,4 +266,20 @@ export class JobCreator { public subscribeToProgress(func: ProgressSubscriber) { this._subscribers.push(func); } + + public get jobConfig(): Job { + return this._job_config; + } + + public get datafeedConfig(): Datafeed { + return this._datafeed_config; + } + + public get stopAllRefreshPolls(): { stop: boolean } { + return this._stopAllRefreshPolls; + } + + public forceStopRefreshPolls() { + this._stopAllRefreshPolls.stop = true; + } } diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts new file mode 100644 index 0000000000000..f570ade457f2c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts @@ -0,0 +1,36 @@ +/* + * 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 { SingleMetricJobCreator } from './single_metric_job_creator'; +import { MultiMetricJobCreator } from './multi_metric_job_creator'; +import { PopulationJobCreator } from './population_job_creator'; +import { IndexPatternWithType } from '../../../../../common/types/kibana'; +import { SavedSearch } from '../../../../../../../../src/legacy/core_plugins/kibana/public/discover/types'; + +import { JOB_TYPE } from './util/constants'; + +export const jobCreatorFactory = (jobType: JOB_TYPE) => ( + indexPattern: IndexPatternWithType, + savedSearch: SavedSearch, + query: object +) => { + let jc; + switch (jobType) { + case JOB_TYPE.SINGLE_METRIC: + jc = SingleMetricJobCreator; + break; + case JOB_TYPE.MULTI_METRIC: + jc = MultiMetricJobCreator; + break; + case JOB_TYPE.POPULATION: + jc = PopulationJobCreator; + break; + default: + jc = SingleMetricJobCreator; + break; + } + return new jc(indexPattern, savedSearch, query); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts index 0ef97f2333ac4..a8efd2e1e8ee7 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts @@ -8,11 +8,13 @@ import { JobCreator } from './job_creator'; import { Field, Aggregation, SplitField } from '../../../../../common/types/fields'; import { Detector } from './configs'; import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE } from './util/constants'; export class MultiMetricJobCreator extends JobCreator { // a multi metric job has one optional overall partition field // which is the same for all detectors. private _splitField: SplitField = null; + protected _type: JOB_TYPE = JOB_TYPE.MULTI_METRIC; // set the split field, applying it to each detector public setSplitField(field: SplitField) { @@ -39,12 +41,12 @@ export class MultiMetricJobCreator extends JobCreator { public addDetector(agg: Aggregation, field: Field | null) { const dtr: Detector = this._createDetector(agg, field); - this._addDetector(dtr); + this._addDetector(dtr, agg); } public editDetector(agg: Aggregation, field: Field | null, index: number) { const dtr: Detector = this._createDetector(agg, field); - this._editDetector(dtr, index); + this._editDetector(dtr, agg, index); } // create a new detector object, applying the overall split field diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts index 2b8eeb8744122..b34586457ee74 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts @@ -8,12 +8,14 @@ import { JobCreator } from './job_creator'; import { Field, Aggregation, SplitField } from '../../../../../common/types/fields'; import { Detector } from './configs'; import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE } from './util/constants'; export class PopulationJobCreator extends JobCreator { // a population job has one overall over (split) field, which is the same for all detectors // each detector has an optional by field private _splitField: SplitField = null; private _byFields: SplitField[] = []; + protected _type: JOB_TYPE = JOB_TYPE.POPULATION; // add a by field to a specific detector public setByField(field: SplitField, index: number) { @@ -70,7 +72,7 @@ export class PopulationJobCreator extends JobCreator { public addDetector(agg: Aggregation, field: Field | null) { const dtr: Detector = this._createDetector(agg, field); - this._addDetector(dtr); + this._addDetector(dtr, agg); this._byFields.push(null); } @@ -84,7 +86,7 @@ export class PopulationJobCreator extends JobCreator { dtr.by_field_name = sp.id; } - this._editDetector(dtr, index); + this._editDetector(dtr, agg, index); } // create a detector object, adding the current over field diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts index a71820fbbb7ba..c1c2d2aa50036 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts @@ -10,10 +10,11 @@ import { Field, Aggregation } from '../../../../../common/types/fields'; import { Detector, BucketSpan } from './configs'; import { createBasicDetector } from './util/default_configs'; import { KIBANA_AGGREGATION } from '../../../../../common/constants/aggregation_types'; +import { JOB_TYPE } from './util/constants'; export class SingleMetricJobCreator extends JobCreator { private _field: Field | null = null; - private _agg: Aggregation | null = null; + protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; // only a single detector exists for this job type // therefore _addDetector and _editDetector merge into this @@ -22,33 +23,38 @@ export class SingleMetricJobCreator extends JobCreator { const dtr: Detector = createBasicDetector(agg, field); if (this._detectors.length === 0) { - this._addDetector(dtr); + this._addDetector(dtr, agg); } else { - this._editDetector(dtr, 0); + this._editDetector(dtr, agg, 0); } this._field = field; - this._agg = agg; - this._createAggregations(); + this._createDatafeedAggregations(); } public set bucketSpan(bucketSpan: BucketSpan) { this._job_config.analysis_config.bucket_span = bucketSpan; - this._createAggregations(); + this._createDatafeedAggregations(); + } + + // overriding set means we need to override get too + // JS doesn't do inheritance very well + public get bucketSpan(): BucketSpan { + return this._job_config.analysis_config.bucket_span; } // aggregations need to be recreated whenever the detector or bucket_span change - private _createAggregations() { + private _createDatafeedAggregations() { if ( this._detectors.length && typeof this._job_config.analysis_config.bucket_span === 'string' && - this._agg !== null + this._aggs.length > 0 ) { delete this._job_config.analysis_config.summary_count_field_name; delete this._datafeed_config.aggregations; - const functionName = this._agg.dslName; + const functionName = this._aggs[0].dslName; const timeField = this._job_config.data_description.time_field; const duration = parseInterval(this._job_config.analysis_config.bucket_span); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts new file mode 100644 index 0000000000000..9bba4981ea078 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts @@ -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 { SingleMetricJobCreator } from './single_metric_job_creator'; +import { MultiMetricJobCreator } from './multi_metric_job_creator'; +import { PopulationJobCreator } from './population_job_creator'; +import { JOB_TYPE } from './util/constants'; + +export function isSingleMetricJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is SingleMetricJobCreator { + return jobCreator.type === JOB_TYPE.SINGLE_METRIC; +} + +export function isMultiMetricJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is MultiMetricJobCreator { + return jobCreator.type === JOB_TYPE.MULTI_METRIC; +} + +export function isPopulationJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is PopulationJobCreator { + return jobCreator.type === JOB_TYPE.POPULATION; +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts new file mode 100644 index 0000000000000..5bbc1c78bb5d1 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts @@ -0,0 +1,11 @@ +/* + * 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 enum JOB_TYPE { + SINGLE_METRIC = 'single_metric', + MULTI_METRIC = 'multi_metric', + POPULATION = 'population', +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts index 80c619e0221b7..81ac531323e4c 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts @@ -21,10 +21,13 @@ export class JobRunner { private _start: number = 0; private _end: number = 0; private _datafeedState: DATAFEED_STATE = DATAFEED_STATE.STOPPED; - private _refreshInterval = REFRESH_INTERVAL_MS; + private _refreshInterval: number = REFRESH_INTERVAL_MS; private _progress$: BehaviorSubject; private _percentageComplete: Progress = 0; + private _stopRefreshPoll: { + stop: boolean; + }; constructor(jobCreator: JobCreator) { this._jobId = jobCreator.jobId; @@ -32,6 +35,7 @@ export class JobRunner { this._start = jobCreator.start; this._end = jobCreator.end; this._percentageComplete = 0; + this._stopRefreshPoll = jobCreator.stopAllRefreshPolls; this._progress$ = new BehaviorSubject(this._percentageComplete); // link the _subscribers list from the JobCreator @@ -74,12 +78,12 @@ export class JobRunner { this._percentageComplete = 0; const check = async () => { - const isRunning = await this.isRunning(); + const { isRunning, progress } = await this.getProgress(); - this._percentageComplete = await this.getProgress(); + this._percentageComplete = progress; this._progress$.next(this._percentageComplete); - if (isRunning) { + if (isRunning === true && this._stopRefreshPoll.stop === false) { setTimeout(async () => { await check(); }, this._refreshInterval); @@ -97,10 +101,8 @@ export class JobRunner { } } - public async getProgress(): Promise { - const lrts = await this.getLatestRecordTimeStamp(); - const progress = (lrts - this._start) / (this._end - this._start); - return Math.round(progress * 100); + public async getProgress(): Promise<{ progress: Progress; isRunning: boolean }> { + return await ml.jobs.getLookBackProgress(this._jobId, this._start, this._end); } public subscribeToProgress(func: ProgressSubscriber) { @@ -108,30 +110,7 @@ export class JobRunner { } public async isRunning(): Promise { - const state = await this.getDatafeedState(); - this._datafeedState = state; - return ( - state === DATAFEED_STATE.STARTED || - state === DATAFEED_STATE.STARTING || - state === DATAFEED_STATE.STOPPING - ); - } - - public async getDatafeedState(): Promise { - const stats = await ml.getDatafeedStats({ datafeedId: this._datafeedId }); - if (stats.datafeeds.length) { - return stats.datafeeds[0].state; - } - return DATAFEED_STATE.STOPPED; - } - - public async getLatestRecordTimeStamp(): Promise { - const stats = await ml.getJobStats({ jobId: this._jobId }); - - if (stats.jobs.length) { - const time = stats.jobs[0].data_counts.latest_record_timestamp; - return time === undefined ? 0 : time; - } - return 0; + const { isRunning } = await this.getProgress(); + return isRunning; } } diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts new file mode 100644 index 0000000000000..ef0b05f73fa31 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { ResultsLoader, Results, ModelItem, Anomaly } from './results_loader'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts new file mode 100644 index 0000000000000..cddf97fdcf41a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts @@ -0,0 +1,257 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../job_creator'; +import { mlResultsService } from '../../../../services/results_service'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { getSeverityType } from '../../../../../common/util/anomaly_utils'; +import { ANOMALY_SEVERITY } from '../../../../../common/constants/anomalies'; +import { getScoresByRecord } from './searches'; +import { JOB_TYPE } from '../job_creator/util/constants'; +import { ChartLoader } from '../chart_loader'; + +export interface Results { + progress: number; + model: Record; + anomalies: Record; +} + +export interface ModelItem { + time: number; + actual: number; + modelUpper: number; + modelLower: number; +} + +export interface Anomaly { + time: number; + value: number; + severity: ANOMALY_SEVERITY; +} + +const emptyModelItem = { + time: 0, + actual: 0, + modelUpper: 0, + modelLower: 0, +}; + +interface SplitFieldWithValue { + name: string; + value: string; +} + +const LAST_UPDATE_DELAY_MS = 500; + +export type ResultsSubscriber = (results: Results) => void; + +type AnyJobCreator = SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + +export class ResultsLoader { + private _results$: BehaviorSubject; + private _resultsSearchRunning = false; + private _jobCreator: AnyJobCreator; + private _chartInterval: MlTimeBuckets; + private _lastModelTimeStamp: number = 0; + private _lastResultsTimeout: any = null; + private _chartLoader: ChartLoader; + // private _detectorSplitFieldWithValue: DetectorSplitFieldFilter; + + private _results: Results = { + progress: 0, + model: [], + anomalies: [], + }; + + private _detectorSplitFieldFilters: SplitFieldWithValue | null = null; + private _splitFieldFiltersLoaded: boolean = false; + + constructor(jobCreator: AnyJobCreator, chartInterval: MlTimeBuckets, chartLoader: ChartLoader) { + this._jobCreator = jobCreator; + this._chartInterval = chartInterval; + this._results$ = new BehaviorSubject(this._results); + this._chartLoader = chartLoader; + + jobCreator.subscribeToProgress(this.progressSubscriber); + } + + progressSubscriber = async (progress: number) => { + if (this._resultsSearchRunning === false) { + if (progress - this._results.progress > 5 || progress === 100) { + if (this._splitFieldFiltersLoaded === false) { + this._splitFieldFiltersLoaded = true; + // load detector field filters if this is the first run. + await this._populateDetectorSplitFieldFilters(); + } + + this._updateData(progress, false); + + if (progress === 100) { + // after the job has finished, do one final update + // a while after the last 100% has been received. + // note, there may be multiple 100% progresses sent as they will only stop once the + // datafeed has stopped. + clearTimeout(this._lastResultsTimeout); + this._lastResultsTimeout = setTimeout(() => { + this._updateData(progress, true); + }, LAST_UPDATE_DELAY_MS); + } + } + } + }; + + private async _updateData(progress: number, fullRefresh: boolean) { + this._resultsSearchRunning = true; + + if (fullRefresh === true) { + this._clearResults(); + } + this._results.progress = progress; + + const getAnomalyData = + this._jobCreator.type === JOB_TYPE.SINGLE_METRIC + ? () => this._loadJobAnomalyData(0) + : () => this._loadDetectorsAnomalyData(); + + // TODO - load more that one model + const [model, anomalies] = await Promise.all([this._loadModelData(0), getAnomalyData()]); + this._results.model = model; + this._results.anomalies = anomalies; + + this._resultsSearchRunning = false; + this._results$.next(this._results); + } + + public subscribeToResults(func: ResultsSubscriber) { + this._results$.subscribe(func); + } + + public get progress() { + return this._results.progress; + } + + private _clearResults() { + this._results.model = {}; + this._results.anomalies = {}; + this._results.progress = 0; + this._lastModelTimeStamp = 0; + } + + private async _loadModelData(dtrIndex: number): Promise> { + if (this._jobCreator.modelPlot === false) { + return []; + } + + const agg = this._jobCreator.getAggregation(dtrIndex); + if (agg === null) { + return { [dtrIndex]: [emptyModelItem] }; + } + const resp = await mlResultsService.getModelPlotOutput( + this._jobCreator.jobId, + dtrIndex, + [], + this._lastModelTimeStamp, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + agg.mlModelPlotAgg + ); + + return this._createModel(resp, dtrIndex); + } + + private _createModel(resp: any, dtrIndex: number): Record { + if (this._results.model[dtrIndex] === undefined) { + this._results.model[dtrIndex] = []; + } + + // create ModelItem list from search results + const model = Object.entries(resp.results).map( + ([time, modelItems]) => + ({ + time: +time, + ...modelItems, + } as ModelItem) + ); + + if (model.length > 10) { + // discard the last 5 buckets in the previously loaded model to avoid partial results + // set the _lastModelTimeStamp to be 5 buckets behind so we load the correct + // section of results next time. + this._lastModelTimeStamp = model[model.length - 5].time; + for (let i = 0; i < 5; i++) { + this._results.model[dtrIndex].pop(); + } + } + + // return a new array from the old and new model + return { [dtrIndex]: this._results.model[dtrIndex].concat(model) }; + } + + private async _loadJobAnomalyData(dtrIndex: number): Promise> { + const resp = await mlResultsService.getScoresByBucket( + [this._jobCreator.jobId], + this._jobCreator.start, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + 1 + ); + + const results = resp.results[this._jobCreator.jobId]; + if (results === undefined) { + return []; + } + + const anomalies: Record = {}; + anomalies[0] = Object.entries(results).map( + ([time, value]) => + ({ time: +time, value, severity: getSeverityType(value as number) } as Anomaly) + ); + return anomalies; + } + + private async _loadDetectorsAnomalyData(): Promise> { + const resp = await getScoresByRecord( + this._jobCreator.jobId, + this._jobCreator.start, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + this._detectorSplitFieldFilters + ); + + const anomalies: Record = {}; + Object.entries(resp.results).forEach(([dtrIdx, results]) => { + anomalies[+dtrIdx] = results.map( + r => ({ ...r, severity: getSeverityType(r.value as number) } as Anomaly) + ); + }); + return anomalies; + } + + private async _populateDetectorSplitFieldFilters() { + if (isMultiMetricJobCreator(this._jobCreator) || isPopulationJobCreator(this._jobCreator)) { + if (this._jobCreator.splitField !== null) { + const fieldValues = await this._chartLoader.loadFieldExampleValues( + this._jobCreator.splitField + ); + if (fieldValues.length > 0) { + this._detectorSplitFieldFilters = { + name: this._jobCreator.splitField.name, + value: fieldValues[0], + }; + } + return; + } + } + this._detectorSplitFieldFilters = null; + } +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts new file mode 100644 index 0000000000000..bb47af0c4b27a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts @@ -0,0 +1,152 @@ +/* + * 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 { get } from 'lodash'; + +import { ML_RESULTS_INDEX_PATTERN } from './../../../../../common/constants/index_patterns'; +import { escapeForElasticsearchQuery } from '../../../../util/string_utils'; +import { ml } from '../../../../services/ml_api_service'; + +interface SplitFieldWithValue { + name: string; + value: string; +} + +type TimeStamp = number; + +interface Result { + time: TimeStamp; + value: Value; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +// detector swimlane search +export function getScoresByRecord( + jobId: string, + earliestMs: number, + latestMs: number, + interval: string, + firstSplitField: SplitFieldWithValue | null +): Promise { + return new Promise((resolve, reject) => { + const obj: ProcessedResults = { + success: true, + results: {}, + totalResults: 0, + }; + + let jobIdFilterStr = 'job_id: ' + jobId; + if (firstSplitField && firstSplitField.value !== undefined) { + // Escape any reserved characters for the query_string query, + // wrapping the value in quotes to do a phrase match. + // Backslash is a special character in JSON strings, so doubly escape + // any backslash characters which exist in the field value. + jobIdFilterStr += ` AND ${escapeForElasticsearchQuery(firstSplitField.name)}:`; + jobIdFilterStr += `"${String(firstSplitField.value).replace(/\\/g, '\\\\')}"`; + } + + ml.esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + }, + }, + { + bool: { + must: [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + query_string: { + query: jobIdFilterStr, + }, + }, + ], + }, + }, + ], + }, + }, + aggs: { + detector_index: { + terms: { + field: 'detector_index', + order: { + recordScore: 'desc', + }, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }, + }, + }) + .then((resp: any) => { + const detectorsByIndex = get(resp, ['aggregations', 'detector_index', 'buckets'], []); + detectorsByIndex.forEach((dtr: any) => { + const dtrResults: Result[] = []; + const dtrIndex = +dtr.key; + + const buckets = get(dtr, ['byTime', 'buckets'], []); + for (let j = 0; j < buckets.length; j++) { + const bkt: any = buckets[j]; + const time = bkt.key; + dtrResults.push({ + time, + value: get(bkt, ['recordScore', 'value']), + }); + } + obj.results[dtrIndex] = dtrResults; + }); + + resolve(obj); + }) + .catch((resp: any) => { + reject(resp); + }); + }); +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/index.ts new file mode 100644 index 0000000000000..d3feaf087524c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/index.ts @@ -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. + */ + +import './pages/new_job/route'; +import './pages/new_job/directive'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx new file mode 100644 index 0000000000000..1fef1d804e6f2 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx @@ -0,0 +1,102 @@ +/* + * 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 React, { Fragment, FC } from 'react'; +import { AnnotationDomainTypes, getAnnotationId, LineAnnotation } from '@elastic/charts'; +import { Anomaly } from '../../../../common/results_loader'; +import { getSeverityColor } from '../../../../../../../common/util/anomaly_utils'; +import { ANOMALY_THRESHOLD } from '../../../../../../../common/constants/anomalies'; + +interface Props { + anomalyData?: Anomaly[]; +} + +interface Severities { + critical: any[]; + major: any[]; + minor: any[]; + warning: any[]; + unknown: any[]; + low: any[]; +} + +function getAnomalyStyle(threshold: number) { + return { + line: { + stroke: getSeverityColor(threshold), + strokeWidth: 3, + opacity: 1, + }, + }; +} + +function splitAnomalySeverities(anomalies: Anomaly[]) { + const severities: Severities = { + critical: [], + major: [], + minor: [], + warning: [], + unknown: [], + low: [], + }; + anomalies.forEach(a => { + if (a.value !== 0) { + severities[a.severity].push({ dataValue: a.time }); + } + }); + return severities; +} + +export const Anomalies: FC = ({ anomalyData }) => { + const anomalies = anomalyData === undefined ? [] : anomalyData; + const severities: Severities = splitAnomalySeverities(anomalies); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx new file mode 100644 index 0000000000000..0d3b436caee77 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx @@ -0,0 +1,62 @@ +/* + * 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, { FC } from 'react'; +import { Chart, Settings, TooltipType } from '@elastic/charts'; +import { ModelItem, Anomaly } from '../../../../common/results_loader'; +import { Anomalies } from './anomalies'; +import { ModelBounds } from './model_bounds'; +import { Line } from './line'; +import { Scatter } from './scatter'; +import { Axes } from '../common/axes'; +import { getXRange } from '../common/utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; + +export enum CHART_TYPE { + LINE, + SCATTER, +} + +interface Props { + chartType: CHART_TYPE; + chartData: LineChartPoint[]; + modelData: ModelItem[]; + anomalyData: Anomaly[]; + height: string; + width: string; +} + +export const AnomalyChart: FC = ({ + chartType, + chartData, + modelData, + anomalyData, + height, + width, +}) => { + const data = chartType === CHART_TYPE.SCATTER ? flattenData(chartData) : chartData; + const xDomain = getXRange(data); + return ( +
+ + + + + + {chartType === CHART_TYPE.LINE && } + {chartType === CHART_TYPE.SCATTER && } + +
+ ); +}; + +function flattenData(data: any): LineChartPoint[] { + const chartData = data.reduce((p: any[], c: any) => { + p.push(...c.values.map((v: any) => ({ time: c.time, value: v.value }))); + return p; + }, []); + return chartData; +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts new file mode 100644 index 0000000000000..46880334f3e9a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { AnomalyChart, CHART_TYPE } from './anomaly_chart'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx new file mode 100644 index 0000000000000..3da84da900a2d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx @@ -0,0 +1,37 @@ +/* + * 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, { FC } from 'react'; +import { LineSeries, getSpecId, ScaleType, CurveType } from '@elastic/charts'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, LINE_COLOR } from '../common/settings'; + +interface Props { + chartData: any[]; +} + +const SPEC_ID = 'line'; + +const lineSeriesStyle = { + ...seriesStyle, +}; + +export const Line: FC = ({ chartData }) => { + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx new file mode 100644 index 0000000000000..0d76b50b80b97 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx @@ -0,0 +1,50 @@ +/* + * 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, { FC } from 'react'; +import { getSpecId, ScaleType, AreaSeries, CurveType } from '@elastic/charts'; +import { ModelItem } from '../../../../common/results_loader'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, MODEL_COLOR } from '../common/settings'; + +interface Props { + modelData?: ModelItem[]; +} + +const SPEC_ID = 'model'; + +const areaSeriesStyle = { + ...seriesStyle, + area: { + ...seriesStyle.area, + visible: true, + }, + line: { + ...seriesStyle.line, + strokeWidth: 1, + opacity: 0.4, + }, +}; + +export const ModelBounds: FC = ({ modelData }) => { + const model = modelData === undefined ? [] : modelData; + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx new file mode 100644 index 0000000000000..3a8fb9dbfb4d7 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx @@ -0,0 +1,45 @@ +/* + * 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, { FC } from 'react'; +import { LineSeries, getSpecId, ScaleType, CurveType } from '@elastic/charts'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, LINE_COLOR } from '../common/settings'; + +interface Props { + chartData: any[]; +} + +const SPEC_ID = 'scatter'; + +const scatterSeriesStyle = { + ...seriesStyle, + line: { + ...seriesStyle.line, + visible: false, + }, + point: { + ...seriesStyle.point, + visible: true, + }, +}; + +export const Scatter: FC = ({ chartData }) => { + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx new file mode 100644 index 0000000000000..d1b8f1fe284b8 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx @@ -0,0 +1,42 @@ +/* + * 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, { FC, Fragment } from 'react'; +import { Axis, getAxisId, Position, timeFormatter, niceTimeFormatByDay } from '@elastic/charts'; +import { getYRange } from './utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; + +const dateFormatter = timeFormatter(niceTimeFormatByDay(3)); + +interface Props { + chartData?: LineChartPoint[]; +} + +// round to 2dp +function tickFormatter(d: number): string { + return Math.round((d * 100) / 100).toString(); +} + +export const Axes: FC = ({ chartData }) => { + const yDomain = chartData !== undefined ? getYRange(chartData) : undefined; + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts new file mode 100644 index 0000000000000..288600d52d29e --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts @@ -0,0 +1,48 @@ +/* + * 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 const LINE_COLOR = '#006BB4'; +export const MODEL_COLOR = '#006BB4'; + +export interface ChartSettings { + width: string; + height: string; + cols: number; + intervalMs: number; +} + +export const defaultChartSettings: ChartSettings = { + width: '100%', + height: '300px', + cols: 1, + intervalMs: 0, +}; + +export const seriesStyle = { + line: { + stroke: '', + strokeWidth: 2, + visible: true, + opacity: 1, + }, + border: { + visible: false, + strokeWidth: 0, + stroke: '', + }, + point: { + visible: false, + radius: 2, + stroke: '', + strokeWidth: 4, + opacity: 0.5, + }, + area: { + fill: '', + opacity: 0.25, + visible: false, + }, +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts new file mode 100644 index 0000000000000..74d01b00f9254 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts @@ -0,0 +1,40 @@ +/* + * 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 { getSpecId, CustomSeriesColorsMap, DataSeriesColorsValues } from '@elastic/charts'; + +export function getCustomColor(specId: string, color: string): CustomSeriesColorsMap { + const lineDataSeriesColorValues: DataSeriesColorsValues = { + colorValues: [], + specId: getSpecId(specId), + }; + return new Map([[lineDataSeriesColorValues, color]]); +} + +export function getYRange(chartData: any) { + let max: number = Number.MIN_VALUE; + let min: number = Number.MAX_VALUE; + chartData.forEach((r: any) => { + max = Math.max(r.value, max); + min = Math.min(r.value, min); + }); + + const padding = (max - min) * 0.1; + max += padding; + min -= padding; + + return { + min, + max, + }; +} + +export function getXRange(lineChartData: any) { + return { + min: lineChartData[0].time, + max: lineChartData[lineChartData.length - 1].time, + }; +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx new file mode 100644 index 0000000000000..53df4772f2e1c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx @@ -0,0 +1,42 @@ +/* + * 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, { FC } from 'react'; +import { BarSeries, Chart, getSpecId, ScaleType, Settings, TooltipType } from '@elastic/charts'; +import { Axes } from '../common/axes'; +import { getCustomColor } from '../common/utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; + +interface Props { + eventRateChartData: LineChartPoint[]; + height: string; + width: string; + showAxis?: boolean; +} + +const SPEC_ID = 'event_rate'; +const COLOR = '#006BB4'; + +export const EventRateChart: FC = ({ eventRateChartData, height, width, showAxis }) => { + return ( +
+ + {showAxis === true && } + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts new file mode 100644 index 0000000000000..589e735f23519 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { EventRateChart } from './event_rate_chart'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts new file mode 100644 index 0000000000000..3974c73bfcb2c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts @@ -0,0 +1,45 @@ +/* + * 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 { createContext, Dispatch } from 'react'; +import { Field, Aggregation } from '../../../../../common/types/fields'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + PopulationJobCreator, +} from '../../common/job_creator'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; + +export interface ExistingJobsAndGroups { + jobs: string[]; + groups: string[]; +} + +export interface JobCreatorContextValue { + jobCreatorUpdated: number; + jobCreatorUpdate: Dispatch; + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + fields: Field[]; + aggs: Aggregation[]; + existingJobsAndGroups: ExistingJobsAndGroups; +} + +export const JobCreatorContext = createContext({ + jobCreatorUpdated: 0, + jobCreatorUpdate: () => {}, + jobCreator: {} as SingleMetricJobCreator, + chartLoader: {} as ChartLoader, + resultsLoader: {} as ResultsLoader, + chartInterval: {} as MlTimeBuckets, + fields: [], + aggs: [], + existingJobsAndGroups: {} as ExistingJobsAndGroups, +}); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/groups_input.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/groups_input.tsx new file mode 100644 index 0000000000000..06fed41923d86 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/groups_input.tsx @@ -0,0 +1,93 @@ +/* + * 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, FC, useContext } from 'react'; +import { + EuiDescribedFormGroup, + EuiFormRow, + EuiComboBox, + EuiComboBoxOptionProps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JobCreatorContext } from '../job_creator_context'; +import { tabColor } from '../../../../../../common/util/group_color_utils'; + +interface Props { + selectedGroupNames: string[]; + setSelectedGroupNames: (groupNames: string[]) => void; +} + +export const GroupsInput: FC = ({ selectedGroupNames, setSelectedGroupNames }) => { + const { existingJobsAndGroups } = useContext(JobCreatorContext); + + const groups: EuiComboBoxOptionProps[] = existingJobsAndGroups.groups.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + const selectedGroups: EuiComboBoxOptionProps[] = selectedGroupNames.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + function setSelectedGroups(options: EuiComboBoxOptionProps[]) { + setSelectedGroupNames(options.map(g => g.label)); + } + + function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionProps[]) { + const normalizedSearchValue = input.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newGroup: EuiComboBoxOptionProps = { + label: input, + color: tabColor(input), + }; + + if ( + flattenedOptions.findIndex( + option => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + groups.push(newGroup); + } + + setSelectedGroupNames([...selectedGroups, newGroup].map(g => g.label)); + } + + return ( + Groups} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts new file mode 100644 index 0000000000000..766ccdc7cc0d1 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { JobDetailsStep } from './job_details'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_description_input.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_description_input.tsx new file mode 100644 index 0000000000000..5d610759e509a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_description_input.tsx @@ -0,0 +1,36 @@ +/* + * 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, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow, EuiTextArea } from '@elastic/eui'; + +interface Props { + jobDescription: string; + setJobDescription: (id: string) => void; +} + +export const JobDescriptionInput: FC = ({ jobDescription, setJobDescription }) => { + return ( + Job description} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + setJobDescription(e.target.value)} + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx new file mode 100644 index 0000000000000..7b5085524f24d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx @@ -0,0 +1,98 @@ +/* + * 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, FC, useContext, useRef, useEffect, useState } from 'react'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormHelpText, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, + EuiSelect, + EuiComboBox, + EuiComboBoxOptionProps, + EuiFieldText, +} from '@elastic/eui'; +import { WizardNav } from '../../../../../data_frame/components/wizard_nav'; +import { JobIdInput } from './job_id_input'; +import { JobDescriptionInput } from './job_description_input'; +import { GroupsInput } from './groups_input'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { KibanaContext, isKibanaContext } from '../../../../../data_frame/common/kibana_context'; + +export const JobDetailsStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + + const [jobId, setJobId] = useState(jobCreator.jobId); + const [jobDescription, setJobDescription] = useState(jobCreator.description); + const [selectedGroups, setSelectedGroups] = useState(jobCreator.groups); + + useEffect( + () => { + jobCreator.jobId = jobId; + jobCreatorUpdate(); + }, + [jobId] + ); + + useEffect( + () => { + jobCreator.description = jobDescription; + jobCreatorUpdate(); + }, + [jobDescription] + ); + + useEffect( + () => { + jobCreator.groups = selectedGroups; + jobCreatorUpdate(); + }, + [selectedGroups.join()] + ); + + function nextActive(): boolean { + return jobId !== ''; + } + + return ( + + {isCurrentStep && ( + + + + + + + + + + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} + next={() => setCurrentStep(WIZARD_STEPS.SUMMARY)} + nextActive={nextActive()} + /> + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_id_input.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_id_input.tsx new file mode 100644 index 0000000000000..bea348097b260 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_id_input.tsx @@ -0,0 +1,32 @@ +/* + * 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, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow, EuiFieldText } from '@elastic/eui'; + +interface Props { + jobId: string; + setJobId: (id: string) => void; +} + +export const JobIdInput: FC = ({ jobId, setJobId }) => { + return ( + Job Id} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + setJobId(e.target.value)} /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx new file mode 100644 index 0000000000000..e406436d2aab0 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx @@ -0,0 +1,71 @@ +/* + * 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, FC } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; + +import { Field, Aggregation, AggFieldPair } from '../../../../../../../../common/types/fields'; + +// The display label used for an aggregation e.g. sum(bytes). +export type Label = string; + +// Label object structured for EUI's ComboBox. +export interface DropDownLabel { + label: Label; + agg: Aggregation; + field: Field; +} + +// Label object structure for EUI's ComboBox with support for nesting. +export interface DropDownOption { + label: Label; + options: DropDownLabel[]; +} + +export type DropDownProps = DropDownLabel[] | EuiComboBoxOptionProps[]; + +interface Props { + fields: Field[]; + changeHandler(d: EuiComboBoxOptionProps[]): void; + selectedOptions: EuiComboBoxOptionProps[]; + removeOptions: AggFieldPair[]; +} + +export const AggSelect: FC = ({ fields, changeHandler, selectedOptions, removeOptions }) => { + // create list of labels based on already selected detectors + // so they can be removed from the dropdown list + const removeLabels = removeOptions.map(o => `${o.agg.title}(${o.field.name})`); + + const options: EuiComboBoxOptionProps[] = fields.map(f => { + const aggOption: DropDownOption = { label: f.name, options: [] }; + if (typeof f.aggs !== 'undefined') { + aggOption.options = f.aggs + .map( + a => + ({ + label: `${a.title}(${f.name})`, + agg: a, + field: f, + } as DropDownLabel) + ) + .filter(o => removeLabels.includes(o.label) === false); + } + return aggOption; + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts new file mode 100644 index 0000000000000..43b1c86e76a3f --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { AggSelect, DropDownLabel, DropDownOption, DropDownProps } from './agg_select'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx new file mode 100644 index 0000000000000..d785aeb9fda47 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx @@ -0,0 +1,32 @@ +/* + * 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, { FC, useContext, useEffect, useState } from 'react'; + +import { BucketSpanInput } from './bucket_span_input'; +import { JobCreatorContext } from '../../../job_creator_context'; + +export const BucketSpan: FC = () => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect( + () => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + }, + [bucketSpan] + ); + + useEffect( + () => { + setBucketSpan(jobCreator.bucketSpan); + }, + [jobCreatorUpdated] + ); + + return ; +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx new file mode 100644 index 0000000000000..14f47a66ed9ba --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx @@ -0,0 +1,36 @@ +/* + * 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, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow, EuiFieldText } from '@elastic/eui'; + +interface Props { + bucketSpan: string; + setBucketSpan: (bs: string) => void; +} + +export const BucketSpanInput: FC = ({ bucketSpan, setBucketSpan }) => { + return ( + Bucket span} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + setBucketSpan(e.target.value)} + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts new file mode 100644 index 0000000000000..14baca0da4599 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { BucketSpan } from './bucket_span'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx new file mode 100644 index 0000000000000..aca883b552aab --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx @@ -0,0 +1,55 @@ +/* + * 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, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; + +import { Field, Aggregation, SplitField } from '../../../../../../../../common/types/fields'; + +interface DetectorTitleProps { + index: number; + agg: Aggregation; + field: Field; + splitField: SplitField; + deleteDetector?: (dtrIds: number) => void; +} + +export const DetectorTitle: FC = ({ + index, + agg, + field, + splitField, + deleteDetector, +}) => { + return ( + + + {getTitle(agg, field, splitField)} + + + + {deleteDetector !== undefined && ( + deleteDetector(index)} + iconType="cross" + size="s" + aria-label="Next" + /> + )} + + + ); +}; + +function getTitle(agg: Aggregation, field: Field, splitField: SplitField): string { + // let title = ${agg.title}(${field.name})`; + // if (splitField !== null) { + // title += ` split by ${splitField.name}`; + // } + // return title; + return `${agg.title}(${field.name})`; +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts new file mode 100644 index 0000000000000..1eb9cac39ed93 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { DetectorTitle } from './detector_title'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts new file mode 100644 index 0000000000000..10dd3978d79d3 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { Influencers } from './influencers'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx new file mode 100644 index 0000000000000..18bfb7c1e2da4 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx @@ -0,0 +1,65 @@ +/* + * 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, { FC, Fragment, useContext, useEffect, useState } from 'react'; + +import { InfluencersSelect } from './influencers_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; + +export const Influencers: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return ; + } + + const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; + const { fields } = newJobCapsService; + const [influencers, setInfluencers] = useState([...jobCreator.influencers]); + const [splitField, setSplitField] = useState(jobCreator.splitField); + + useEffect( + () => { + jobCreator.removeAllInfluencers(); + influencers.forEach(i => jobCreator.addInfluencer(i)); + jobCreatorUpdate(); + }, + [influencers.join()] + ); + + useEffect( + () => { + // if the split field has changed auto add it to the influencers + if (splitField !== null && influencers.includes(splitField.name) === false) { + setInfluencers([...influencers, splitField.name]); + } + }, + [splitField] + ); + + useEffect( + () => { + setSplitField(jobCreator.splitField); + setInfluencers([...jobCreator.influencers]); + }, + [jobCreatorUpdated] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx new file mode 100644 index 0000000000000..33d4bb3529e9b --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx @@ -0,0 +1,61 @@ +/* + * 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, FC } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionProps, + EuiDescribedFormGroup, + EuiFormRow, +} from '@elastic/eui'; + +import { Field, SplitField } from '../../../../../../../../common/types/fields'; + +interface Props { + fields: Field[]; + changeHandler(i: string[]): void; + selectedInfluencers: string[]; + splitField: SplitField; +} + +export const InfluencersSelect: FC = ({ + fields, + changeHandler, + selectedInfluencers, + splitField, +}) => { + const options: EuiComboBoxOptionProps[] = fields.map(f => ({ + label: f.name, + })); + const selection: EuiComboBoxOptionProps[] = selectedInfluencers.map(i => ({ label: i })); + + function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + changeHandler(selectedOptions.map(o => o.label)); + } + + return ( + Influencers} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/index.ts new file mode 100644 index 0000000000000..648e1da5ba1f1 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/index.ts @@ -0,0 +1,6 @@ +/* + * 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 { JobProgress } from './job_progress'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/job_progress.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/job_progress.tsx new file mode 100644 index 0000000000000..d270f37ab48f1 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/job_progress/job_progress.tsx @@ -0,0 +1,20 @@ +/* + * 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, { FC } from 'react'; +import { EuiProgress } from '@elastic/eui'; + +interface Props { + progress: number; +} + +export const JobProgress: FC = ({ progress }) => { + if (progress > 0 && progress < 100) { + return ; + } else { + return null; + } +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts new file mode 100644 index 0000000000000..42af66e54af0d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { MultiMetricView } from './multi_metric_view'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx new file mode 100644 index 0000000000000..17597f63b3384 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -0,0 +1,270 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { MultiMetricJobCreator, isMultiMetricJobCreator } from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { DropDownLabel, DropDownProps } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { DetectorTitle } from '../detector_title'; +import { JobProgress } from '../job_progress'; +import { SplitCards } from '../split_cards'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +export const MultiMetricDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isMultiMetricJobCreator(jc) === false) { + return ; + } + const jobCreator = jc as MultiMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState([]); + const [lineChartsData, setLineChartsData] = useState({}); + const [modelData, setModelData] = useState>([]); + const [anomalyData, setAnomalyData] = useState>([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [progress, setProgress] = useState(resultsLoader.progress); + const [chartSettings, setChartSettings] = useState(defaultChartSettings); + const [splitField, setSplitField] = useState(jobCreator.splitField); + const [fieldValues, setFieldValues] = useState([]); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + addDetector(selectedOptionsIn); + } + + function addDetector(selectedOptionsIn: DropDownLabel[]) { + if (selectedOptionsIn !== null && selectedOptionsIn.length) { + const option = selectedOptionsIn[0] as DropDownLabel; + if (typeof option !== 'undefined') { + const newPair = { agg: option.agg, field: option.field }; + setAggFieldPairList([...aggFieldPairList, newPair]); + setSelectedOptions([{ label: '' }]); + } else { + setAggFieldPairList([]); + } + } + } + + function deleteDetector(index: number) { + aggFieldPairList.splice(index, 1); + setAggFieldPairList([...aggFieldPairList]); + } + + function setResultsWrapper(results: Results) { + setModelData(results.model); + setAnomalyData(results.anomalies); + } + + // subscribe to progress + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + // subscribe to results + useEffect(() => { + resultsLoader.subscribeToResults(setResultsWrapper); + }, []); + + // watch for changes in detector list length + useEffect( + () => { + jobCreator.removeAllDetectors(); + aggFieldPairList.forEach(pair => { + jobCreator.addDetector(pair.agg, pair.field); + }); + jobCreatorUpdate(); + loadCharts(); + setIsValid(aggFieldPairList.length > 0); + }, + [aggFieldPairList.length] + ); + + // watch for change in jobCreator + useEffect( + () => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadCharts(); + } + setSplitField(jobCreator.splitField); + }, + [jobCreatorUpdated] + ); + + // watch for changes in split field. + // load example field values + // changes to fieldValues here will trigger the card effect + useEffect( + () => { + if (splitField !== null) { + chartLoader + .loadFieldExampleValues(splitField) + .then(setFieldValues) + .catch(() => {}); + } else { + setFieldValues([]); + } + }, + [splitField] + ); + + // watch for changes in the split field values + // reload the charts + useEffect( + () => { + loadCharts(); + }, + [fieldValues] + ); + + function getChartSettings(): ChartSettings { + const cs = { + ...defaultChartSettings, + intervalMs: chartInterval.getInterval().asMilliseconds(), + }; + if (aggFieldPairList.length > 2) { + cs.cols = 3; + cs.height = '150px'; + cs.intervalMs = cs.intervalMs * 3; + } else if (aggFieldPairList.length > 1) { + cs.cols = 2; + cs.height = '200px'; + cs.intervalMs = cs.intervalMs * 2; + } + return cs; + } + + async function loadCharts() { + const cs = getChartSettings(); + setChartSettings(cs); + + if (aggFieldPairList.length > 0) { + const resp: LineChartData = await chartLoader.loadLineCharts( + jobCreator.start, + jobCreator.end, + aggFieldPairList, + jobCreator.splitField, + fieldValues.length > 0 ? fieldValues[0] : null, + cs.intervalMs + ); + + setLineChartsData(resp); + } + } + + return ( + + {lineChartsData && ( + + )} + {isActive && ( + + )} + {isActive === false && ( + {lineChartsData && } + )} + + ); +}; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + fieldValues: string[]; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; +} + +const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + fieldValues, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, +}) => { + return ( + + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + )} + + ))} + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx new file mode 100644 index 0000000000000..6b56172e4100a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx @@ -0,0 +1,41 @@ +/* + * 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, { FC } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Field, AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AggSelect, DropDownLabel, DropDownProps } from '../agg_select'; + +interface Props { + fields: Field[]; + detectorChangeHandler: (options: DropDownLabel[]) => void; + selectedOptions: DropDownProps; + maxWidth: number; + removeOptions: AggFieldPair[]; +} + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx new file mode 100644 index 0000000000000..4fa13335ebd5a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx @@ -0,0 +1,40 @@ +/* + * 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, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { MultiMetricDetectors } from './metric_selection'; +import { MultiMetricSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed: (proceed: boolean) => void; +} + +export const MultiMetricView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect( + () => { + setCanProceed(metricsValid && settingsValid); + }, + [metricsValid, settingsValid] + ); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx new file mode 100644 index 0000000000000..2d900230de823 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx @@ -0,0 +1,62 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; +import { SplitFieldSelector } from '../split_field'; +import { Influencers } from '../influencers'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const MultiMetricSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect( + () => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, + [bucketSpan] + ); + + useEffect( + () => { + setBucketSpan(jobCreator.bucketSpan); + }, + [jobCreatorUpdated] + ); + + return ( + + {isActive && ( + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts new file mode 100644 index 0000000000000..2397767fe4650 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { PopulationView } from './population_view'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx new file mode 100644 index 0000000000000..d57fa83ef410d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -0,0 +1,348 @@ +/* + * 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, FC, useContext, useEffect, useState, useReducer } from 'react'; +import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { PopulationJobCreator, isPopulationJobCreator } from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { DropDownLabel, DropDownProps } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { Field, AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { DetectorTitle } from '../detector_title'; +import { JobProgress } from '../job_progress'; +import { SplitCards } from '../split_cards'; +import { SplitFieldSelector, ByFieldSelector } from '../split_field'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; +import { MlTimeBuckets } from '../../../../../../../util/ml_time_buckets'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +type DetectorFieldValues = Record; + +export const PopulationDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isPopulationJobCreator(jc) === false) { + return ; + } + const jobCreator = jc as PopulationJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState([]); + const [lineChartsData, setLineChartsData] = useState({}); + const [modelData, setModelData] = useState>([]); + const [anomalyData, setAnomalyData] = useState>([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [progress, setProgress] = useState(resultsLoader.progress); + const [chartSettings, setChartSettings] = useState(defaultChartSettings); + const [splitField, setSplitField] = useState(jobCreator.splitField); + const [fieldValuesPerDetector, setFieldValuesPerDetector] = useState({}); + const [byFieldsUpdated, setByFieldsUpdated] = useReducer<(s: number) => number>(s => s + 1, 0); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + addDetector(selectedOptionsIn); + } + + function addDetector(selectedOptionsIn: DropDownLabel[]) { + if (selectedOptionsIn !== null && selectedOptionsIn.length) { + const option = selectedOptionsIn[0] as DropDownLabel; + if (typeof option !== 'undefined') { + const newPair = { agg: option.agg, field: option.field, by: { field: null, value: null } }; + setAggFieldPairList([...aggFieldPairList, newPair]); + setSelectedOptions([{ label: '' }]); + } else { + setAggFieldPairList([]); + } + } + } + + function deleteDetector(index: number) { + aggFieldPairList.splice(index, 1); + setAggFieldPairList([...aggFieldPairList]); + } + + function setResultsWrapper(results: Results) { + setModelData(results.model); + setAnomalyData(results.anomalies); + } + + // subscribe to progress + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + // subscribe to results + useEffect(() => { + resultsLoader.subscribeToResults(setResultsWrapper); + }, []); + + // watch for changes in detector list length + useEffect( + () => { + jobCreator.removeAllDetectors(); + aggFieldPairList.forEach((pair, i) => { + jobCreator.addDetector(pair.agg, pair.field); + if (pair.by !== undefined) { + // re-add by fields + jobCreator.setByField(pair.by.field, i); + } + }); + jobCreatorUpdate(); + loadCharts(); + setIsValid(aggFieldPairList.length > 0); + }, + [aggFieldPairList.length] + ); + + // watch for changes in by field values + // redraw the charts if they change. + // triggered when example fields have been loaded + // if the split field or by fields have changed + useEffect( + () => { + loadCharts(); + }, + [JSON.stringify(fieldValuesPerDetector)] + ); + + // watch for change in jobCreator + useEffect( + () => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadCharts(); + } + setSplitField(jobCreator.splitField); + + // update by fields and their by fields + let update = false; + const newList = [...aggFieldPairList]; + newList.forEach((pair, i) => { + const bf = jobCreator.getByField(i); + if (pair.by !== undefined && pair.by.field !== bf) { + pair.by.field = bf; + update = true; + } + }); + if (update) { + setAggFieldPairList(newList); + setByFieldsUpdated(0); + } + }, + [jobCreatorUpdated] + ); + + // watch for changes in split field or by fields. + // load example field values + // changes to fieldValues here will trigger the card effect via setFieldValuesPerDetector + useEffect( + () => { + loadFieldExamples(); + }, + [splitField, byFieldsUpdated] + ); + + function getChartSettings(): ChartSettings { + const interval = new MlTimeBuckets(); + interval.setInterval('auto'); + interval.setBounds(chartInterval.getBounds()); + + const cs = { + ...defaultChartSettings, + intervalMs: interval.getInterval().asMilliseconds(), + }; + if (aggFieldPairList.length > 2) { + cs.cols = 3; + cs.height = '150px'; + cs.intervalMs = cs.intervalMs * 3; + } else if (aggFieldPairList.length > 1) { + cs.cols = 2; + cs.height = '200px'; + cs.intervalMs = cs.intervalMs * 2; + } + return cs; + } + + async function loadCharts() { + const cs = getChartSettings(); + setChartSettings(cs); + + if (aggFieldPairList.length > 0) { + const resp: LineChartData = await chartLoader.loadPopulationCharts( + jobCreator.start, + jobCreator.end, + aggFieldPairList, + jobCreator.splitField, + cs.intervalMs + ); + + setLineChartsData(resp); + } + } + + async function loadFieldExamples() { + const promises: any[] = []; + aggFieldPairList.forEach((af, i) => { + if (af.by !== undefined && af.by.field !== null) { + promises.push( + (async (index: number, field: Field) => { + return { + index, + fields: await chartLoader.loadFieldExampleValues(field), + }; + })(i, af.by.field) + ); + } + }); + const results = await Promise.all(promises); + const fieldValues = results.reduce((p, c) => { + p[c.index] = c.fields; + return p; + }, {}) as DetectorFieldValues; + + const newPairs = aggFieldPairList.map((pair, i) => ({ + ...pair, + ...(pair.by === undefined || pair.by.field === null + ? {} + : { + by: { + ...pair.by, + value: fieldValues[i][0], + }, + }), + })); + setAggFieldPairList([...newPairs]); + setFieldValuesPerDetector(fieldValues); + } + + return ( + + {isActive === true && ( + + + {splitField !== null && } + + )} + + {isActive === false && splitField === null && ( + + Population label TODO + {splitField !== null && } + + )} + + {lineChartsData && splitField !== null && ( + + )} + {isActive === true && splitField !== null && ( + + )} + {isActive === false && ( + {lineChartsData && } + )} + + ); +}; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; + fieldValuesPerDetector: DetectorFieldValues; +} + +const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, + fieldValuesPerDetector, +}) => { + return ( + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + + + {deleteDetector !== undefined && } + + + + + + + )} + + ))} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx new file mode 100644 index 0000000000000..6b56172e4100a --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx @@ -0,0 +1,41 @@ +/* + * 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, { FC } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Field, AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AggSelect, DropDownLabel, DropDownProps } from '../agg_select'; + +interface Props { + fields: Field[]; + detectorChangeHandler: (options: DropDownLabel[]) => void; + selectedOptions: DropDownProps; + maxWidth: number; + removeOptions: AggFieldPair[]; +} + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx new file mode 100644 index 0000000000000..475c85bed8630 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx @@ -0,0 +1,40 @@ +/* + * 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, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { PopulationDetectors } from './metric_selection'; +import { PopulationSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed: (proceed: boolean) => void; +} + +export const PopulationView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect( + () => { + setCanProceed(metricsValid && settingsValid); + }, + [metricsValid, settingsValid] + ); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx new file mode 100644 index 0000000000000..510fe3dd3db9c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx @@ -0,0 +1,55 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; +import { Influencers } from '../influencers'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const PopulationSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect( + () => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, + [bucketSpan] + ); + + useEffect( + () => { + setBucketSpan(jobCreator.bucketSpan); + }, + [jobCreatorUpdated] + ); + + return ( + + {isActive && ( + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts new file mode 100644 index 0000000000000..3d45f053cd6e9 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SingleMetricView } from './single_metric_view'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx new file mode 100644 index 0000000000000..ea4f4b4f32291 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -0,0 +1,166 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { + SingleMetricJobCreator, + isSingleMetricJobCreator, +} from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { AggSelect, DropDownLabel, DropDownProps } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; +import { JobProgress } from '../job_progress'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +const DTR_IDX = 0; + +export const SingleMetricDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isSingleMetricJobCreator(jc) === false) { + return ; + } + const jobCreator = jc as SingleMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPair, setAggFieldPair] = useState(null); + const [lineChartsData, setLineChartData] = useState([]); + const [modelData, setModelData] = useState([]); + const [anomalyData, setAnomalyData] = useState([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [progress, setProgress] = useState(resultsLoader.progress); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + setSelectedOptions(selectedOptionsIn); + if (selectedOptionsIn.length) { + const option = selectedOptionsIn[0]; + if (typeof option !== 'undefined') { + setAggFieldPair({ agg: option.agg, field: option.field }); + } else { + setAggFieldPair(null); + } + } + } + + function setResultsWrapper(results: Results) { + const model = results.model[DTR_IDX]; + if (model !== undefined) { + setModelData(model); + } + const anomalies = results.anomalies[DTR_IDX]; + if (anomalies !== undefined) { + setAnomalyData(anomalies); + } + } + + // subscribe to progress + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + // subscribe to results + useEffect(() => { + resultsLoader.subscribeToResults(setResultsWrapper); + }, []); + + useEffect( + () => { + if (aggFieldPair !== null) { + jobCreator.setDetector(aggFieldPair.agg, aggFieldPair.field); + jobCreatorUpdate(); + loadChart(); + setIsValid(aggFieldPair !== null); + } + }, + [aggFieldPair] + ); + + useEffect( + () => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadChart(); + } + }, + [jobCreatorUpdated] + ); + + async function loadChart() { + if (aggFieldPair !== null) { + const resp: LineChartData = await chartLoader.loadLineCharts( + jobCreator.start, + jobCreator.end, + [aggFieldPair], + null, + null, + chartInterval.getInterval().asMilliseconds() + ); + if (resp[DTR_IDX] !== undefined) { + setLineChartData(resp); + } + } + } + + return ( + + {isActive && ( + + + {lineChartsData[DTR_IDX] !== undefined && ( + + )} + + )} + {isActive === false && ( + + {lineChartsData[DTR_IDX] !== undefined && ( + + + + + )} + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx new file mode 100644 index 0000000000000..a125c1d94cc3d --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -0,0 +1,37 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const SingleMetricSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect( + () => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, + [bucketSpan] + ); + + useEffect( + () => { + setBucketSpan(jobCreator.bucketSpan); + }, + [jobCreatorUpdated] + ); + + return {isActive && }; +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx new file mode 100644 index 0000000000000..645716ef15525 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx @@ -0,0 +1,40 @@ +/* + * 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, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { SingleMetricDetectors } from './metric_selection'; +import { SingleMetricSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed: (proceed: boolean) => void; +} + +export const SingleMetricView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect( + () => { + setCanProceed(metricsValid && settingsValid); + }, + [metricsValid, settingsValid] + ); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts new file mode 100644 index 0000000000000..11c60fd18ff07 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SplitCards } from './split_cards'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx new file mode 100644 index 0000000000000..0f194b76f749c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -0,0 +1,99 @@ +/* + * 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, { FC, ReactChild, memo, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import { SplitField } from '../../../../../../../../common/types/fields'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + fieldValues: string[]; + splitField: SplitField; + numberOfDetectors: number; + children: ReactChild; + jobType: JOB_TYPE; +} + +interface Panel { + panel: HTMLDivElement; + marginBottom: number; +} + +export const SplitCards: FC = memo( + ({ fieldValues, splitField, children, numberOfDetectors, jobType }) => { + const panels: Panel[] = []; + + function storePanels(panel: HTMLDivElement | null, marginBottom: number) { + if (panel !== null) { + panels.push({ panel, marginBottom }); + } + } + + function getBackPanels() { + panels.length = 0; + + const fieldValuesCopy = [...fieldValues]; + fieldValuesCopy.shift(); + + let margin = 5; + const sideMargins = fieldValuesCopy.map((f, i) => (margin += 10 - i)).reverse(); + + setTimeout(() => { + panels.forEach(p => (p.panel.style.marginBottom = `${p.marginBottom}px`)); + }, 100); + + const SPACING = 100; + const SPLIT_HEIGHT_MULTIPLIER = 1.6; + return fieldValuesCopy.map((fieldName, i) => { + const diff = (i + 1) * (SPLIT_HEIGHT_MULTIPLIER * (10 / fieldValuesCopy.length)); + const marginBottom = -SPACING + diff; + + const sideMargin = sideMargins[i]; + + const style = { + height: `${SPACING}px`, + marginBottom: `-${SPACING}px`, + marginLeft: `${sideMargin}px`, + marginRight: `${sideMargin}px`, + transition: 'margin 0.2s', + }; + return ( +
storePanels(ref, marginBottom)} style={style}> + +
{fieldName}
+
+
+ ); + }); + } + + return ( + + + {(fieldValues.length === 0 || numberOfDetectors === 0) && {children}} + {fieldValues.length > 0 && numberOfDetectors > 0 && splitField !== null && ( + + {jobType === JOB_TYPE.MULTI_METRIC && ( + +
Data split by {splitField.name}
+ +
+ )} + + {getBackPanels()} + +
{fieldValues[0]}
+ + {children} +
+
+ )} +
+
+ ); + } +); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx new file mode 100644 index 0000000000000..823d42245f999 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -0,0 +1,52 @@ +/* + * 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, { FC, Fragment, useContext, useEffect, useState } from 'react'; + +import { SplitFieldSelect } from './split_field_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; + +interface Props { + detectorIndex: number; +} + +export const ByFieldSelector: FC = ({ detectorIndex }) => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return ; + } + const jobCreator = jc as PopulationJobCreator; + + const { categoryFields } = newJobCapsService; + + const [byField, setByField] = useState(jobCreator.getByField(detectorIndex)); + + useEffect( + () => { + jobCreator.setByField(byField, detectorIndex); + jobCreatorUpdate(); + }, + [byField] + ); + + useEffect( + () => { + const bf = jobCreator.getByField(detectorIndex); + setByField(bf); + }, + [jobCreatorUpdated] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx new file mode 100644 index 0000000000000..16a8ee9872ccc --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx @@ -0,0 +1,55 @@ +/* + * 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, ReactChild, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + children: ReactChild; + jobType: JOB_TYPE; +} + +export const Description: FC = ({ children, jobType }) => { + if (jobType === JOB_TYPE.MULTI_METRIC) { + return ( + Split field} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); + } else if (jobType === JOB_TYPE.POPULATION) { + return ( + Population} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); + } else { + return ; + } +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts new file mode 100644 index 0000000000000..e5a1f2aebdaa0 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { ByFieldSelector } from './by_field'; +export { SplitFieldSelector } from './split_field'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx new file mode 100644 index 0000000000000..6c570ec0abecc --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx @@ -0,0 +1,54 @@ +/* + * 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, { FC, Fragment, useContext, useEffect, useState } from 'react'; + +import { SplitFieldSelect } from './split_field_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { Description } from './description'; +import { + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; + +export const SplitFieldSelector: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return ; + } + const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; + + const { categoryFields } = newJobCapsService; + const [splitField, setSplitField] = useState(jobCreator.splitField); + + useEffect( + () => { + jobCreator.setSplitField(splitField); + jobCreatorUpdate(); + }, + [splitField] + ); + + useEffect( + () => { + setSplitField(jobCreator.splitField); + }, + [jobCreatorUpdated] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx new file mode 100644 index 0000000000000..069edb7eaeafd --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx @@ -0,0 +1,55 @@ +/* + * 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, { FC } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; + +import { Field, SplitField } from '../../../../../../../../common/types/fields'; + +interface DropDownLabel { + label: string; + field: Field; +} + +interface Props { + fields: Field[]; + changeHandler(f: SplitField): void; + selectedField: SplitField; +} + +export const SplitFieldSelect: FC = ({ fields, changeHandler, selectedField }) => { + const options: EuiComboBoxOptionProps[] = fields.map( + f => + ({ + label: f.name, + field: f, + } as DropDownLabel) + ); + + const selection: EuiComboBoxOptionProps[] = []; + if (selectedField !== null) { + selection.push({ label: selectedField.name, field: selectedField } as DropDownLabel); + } + + function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + const option = selectedOptions[0] as DropDownLabel; + if (typeof option !== 'undefined') { + changeHandler(option.field); + } else { + changeHandler(null); + } + } + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts new file mode 100644 index 0000000000000..3cd2e15c10bd7 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { PickFieldsStep } from './pick_fields'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx new file mode 100644 index 0000000000000..a98394db29b81 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx @@ -0,0 +1,51 @@ +/* + * 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, FC, useContext, useEffect, useState } from 'react'; + +import { JobCreatorContext } from '../job_creator_context'; +import { WizardNav } from '../../../../../data_frame/components/wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JOB_TYPE } from '../../../common/job_creator/util/constants'; +import { SingleMetricView } from './components/single_metric_view'; +import { MultiMetricView } from './components/multi_metric_view'; +import { PopulationView } from './components/population_view'; + +export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobCreatorUpdated } = useContext(JobCreatorContext); + const [nextActive, setNextActive] = useState(false); + const [jobType, setJobType] = useState(jobCreator.type); + + // this shouldn't really change, but just in case we need to... + useEffect( + () => { + setJobType(jobCreator.type); + }, + [jobCreatorUpdated] + ); + + return ( + + {jobType === JOB_TYPE.SINGLE_METRIC && ( + + )} + {jobType === JOB_TYPE.MULTI_METRIC && ( + + )} + {jobType === JOB_TYPE.POPULATION && ( + + )} + + {isCurrentStep && ( + setCurrentStep(WIZARD_STEPS.TIME_RANGE)} + next={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} + nextActive={nextActive} + /> + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts new file mode 100644 index 0000000000000..b25d8b23b0bb3 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts @@ -0,0 +1,17 @@ +/* + * 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 enum WIZARD_STEPS { + TIME_RANGE, + PICK_FIELDS, + JOB_DETAILS, + SUMMARY, +} + +export interface StepProps { + isCurrentStep: boolean; + setCurrentStep: React.Dispatch>; +} diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts similarity index 85% rename from x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/index.ts rename to x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts index f58978bef5c6a..f0f441d48afcb 100644 --- a/x-pack/plugins/ml/public/jobs/new_job_new/common/job_creator/util/index.ts +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './default_configs'; +export { SummaryStep } from './summary'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx new file mode 100644 index 0000000000000..bcd9cf1ba7c66 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx @@ -0,0 +1,104 @@ +/* + * 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, FC, useContext, useRef, useState, useEffect } from 'react'; +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormHelpText, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, + EuiSelect, + EuiComboBox, + EuiComboBoxOptionProps, + EuiFieldText, + EuiButton, + EuiProgress, + EuiHorizontalRule, +} from '@elastic/eui'; +import { WizardNav } from '../../../../../data_frame/components/wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { KibanaContext, isKibanaContext } from '../../../../../data_frame/common/kibana_context'; +import { mlJobService } from '../../../../../services/job_service'; + +export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [progress, setProgress] = useState(0); + + function setProgressWrapper(p: number) { + setProgress(p); + } + + useEffect(() => { + jobCreator.subscribeToProgress(setProgressWrapper); + }, []); + + function start() { + jobCreator.createAndStartJob(); + } + + function viewResults() { + const url = mlJobService.createResultsUrl( + [jobCreator.jobId], + jobCreator.start, + jobCreator.end, + 'timeseriesexplorer' + ); + window.open(url, '_blank'); + } + + return ( + + {isCurrentStep && ( + + + {jobCreator.jobId} +
+ {jobCreator.start} : {jobCreator.end} +
+ {JSON.stringify(jobCreator.detectors, null, 2)} +
+ {jobCreator.bucketSpan} +
+ {progress === 0 && ( + setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} /> + )} + + {progress < 100 && ( + 0}> + Create job + + )} + {progress === 100 && ( + + View results + + )} +
+ )} + {isCurrentStep === false && ( + + {jobCreator.jobId} +
+ {jobCreator.start} : {jobCreator.end} +
+ {JSON.stringify(jobCreator.detectors, null, 2)} +
+ {jobCreator.bucketSpan} +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts new file mode 100644 index 0000000000000..05ce3a68a1b6c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { TimeRangeStep } from './time_range'; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/test_inputs.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/test_inputs.tsx new file mode 100644 index 0000000000000..1f25bf4857870 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/test_inputs.tsx @@ -0,0 +1,41 @@ +/* + * 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, FC, useContext, useRef, useState } from 'react'; + +import { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormHelpText, + EuiFormRow, + EuiSpacer, + EuiFieldNumber, +} from '@elastic/eui'; + +interface Props { + start: number; + end: number; + setStart: React.Dispatch>; + setEnd: React.Dispatch>; +} + +export const TestInputs: FC = ({ start, end, setStart, setEnd }) => { + function startChange(e: any) { + setStart(+e.target.value); + } + function endChange(e: any) { + setEnd(+e.target.value); + } + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx new file mode 100644 index 0000000000000..dabbb4e18a74f --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx @@ -0,0 +1,128 @@ +/* + * 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, FC, useContext, useState, useEffect } from 'react'; +import { timefilter } from 'ui/timefilter'; +import moment from 'moment'; +// import dateMath from '@elastic/datemath'; +import { WizardNav } from '../../../../../data_frame/components/wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +// import { TestInputs } from './test_inputs'; +import { JobCreatorContext } from '../job_creator_context'; +import { KibanaContext, isKibanaContext } from '../../../../../data_frame/common/kibana_context'; +import { + FullTimeRangeSelector, + getTimeFilterRange, +} from '../../../../../components/full_time_range_selector'; +import { EventRateChart } from '../charts/event_rate_chart'; +import { LineChartPoint } from '../../../common/chart_loader'; + +export const TimeRangeStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const { + jobCreator, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + } = useContext(JobCreatorContext); + + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [eventRateChartData, setEventRateChartData] = useState([]); + + async function loadChart() { + const resp = await chartLoader.loadEventRateChart( + jobCreator.start, + jobCreator.end, + chartInterval.getInterval().asMilliseconds() + ); + setEventRateChartData(resp); + } + + useEffect( + () => { + jobCreator.setTimeRange(start, end); + chartInterval.setBounds({ + min: moment(start), + max: moment(end), + }); + jobCreatorUpdate(); + loadChart(); + }, + [start, end] + ); + + useEffect( + () => { + setStart(jobCreator.start); + setEnd(jobCreator.end); + }, + [jobCreatorUpdated] + ); + + const timefilterChange = () => { + const { to, from } = getTimeFilterRange(); + if (to >= from) { + setStart(from); + setEnd(to); + } + }; + + useEffect(() => { + timefilter.on('timeUpdate', timefilterChange); + return () => { + timefilter.off('timeUpdate', timefilterChange); + }; + }, []); + + return ( + +
+ +
+
+ +
+ + {isCurrentStep && ( + + {/* setStart(v)} setEnd={v => setEnd(v)} /> */} + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} nextActive={true} /> + + )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx new file mode 100644 index 0000000000000..14616be6e0c64 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx @@ -0,0 +1,77 @@ +/* + * 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 from 'react'; +import ReactDOM from 'react-dom'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); +import { timefilter } from 'ui/timefilter'; + +import { I18nContext } from 'ui/i18n'; +import { IPrivate } from 'ui/private'; +import { InjectorService } from '../../../../../common/types/angular'; + +import { SearchItemsProvider } from '../../../new_job/utils/new_job_utils'; +import { Page, PageProps } from './page'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; + +import { KibanaContext } from '../../../../data_frame/common/kibana_context'; + +module.directive('mlNewJobPage', ($injector: InjectorService) => { + return { + scope: {}, + restrict: 'E', + link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => { + timefilter.enableTimeRangeSelector(); + timefilter.disableAutoRefreshSelector(); + + const indexPatterns = $injector.get('indexPatterns'); + const kbnBaseUrl = $injector.get('kbnBaseUrl'); + const kibanaConfig = $injector.get('config'); + const Private: IPrivate = $injector.get('Private'); + const $route = $injector.get('$route'); + const existingJobsAndGroups = $route.current.locals.existingJobsAndGroups; + + if ($route.current.locals.jobType === undefined) { + return; + } + const jobType: JOB_TYPE = $route.current.locals.jobType; + + const createSearchItems = Private(SearchItemsProvider); + const { indexPattern, savedSearch, combinedQuery } = createSearchItems(); + + const kibanaContext = { + combinedQuery, + currentIndexPattern: indexPattern, + currentSavedSearch: savedSearch, + indexPatterns, + kbnBaseUrl, + kibanaConfig, + }; + + const props: PageProps = { + existingJobsAndGroups, + jobType, + }; + + ReactDOM.render( + + + {React.createElement(Page, props)} + + , + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx new file mode 100644 index 0000000000000..4352df146205c --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx @@ -0,0 +1,84 @@ +/* + * 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, { FC, useContext, useEffect, Fragment } from 'react'; + +import { EuiPage, EuiPageBody, EuiPageContentBody, EuiSpacer } from '@elastic/eui'; +import { Wizard } from './wizard'; +import { jobCreatorFactory } from '../../common/job_creator'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { KibanaContext, isKibanaContext } from '../../../../data_frame/common/kibana_context'; +import { getTimeFilterRange } from '../../../../components/full_time_range_selector'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; + +const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width(); +const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2; +const MAX_BARS = BAR_TARGET + (BAR_TARGET / 100) * 100; // 100% larger than bar target + +export interface PageProps { + existingJobsAndGroups: any; + jobType: JOB_TYPE; +} + +export const Page: FC = ({ existingJobsAndGroups, jobType }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const jobCreator = jobCreatorFactory(jobType)( + kibanaContext.currentIndexPattern, + kibanaContext.currentSavedSearch, + kibanaContext.combinedQuery + ); + jobCreator.bucketSpan = '15m'; + const { from, to } = getTimeFilterRange(); + jobCreator.setTimeRange(from, to); + + if (jobType === JOB_TYPE.SINGLE_METRIC) { + jobCreator.modelPlot = true; + } + + const chartInterval = new MlTimeBuckets(); + chartInterval.setBarTarget(BAR_TARGET); + chartInterval.setMaxBars(MAX_BARS); + chartInterval.setInterval('auto'); + + const chartLoader = new ChartLoader( + kibanaContext.currentIndexPattern, + kibanaContext.currentSavedSearch, + kibanaContext.combinedQuery + ); + + const resultsLoader = new ResultsLoader(jobCreator, chartInterval, chartLoader); + + useEffect(() => { + return () => { + jobCreator.forceStopRefreshPolls(); + }; + }); + + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts new file mode 100644 index 0000000000000..10502541469f8 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts @@ -0,0 +1,64 @@ +/* + * 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 uiRoutes from 'ui/routes'; + +// @ts-ignore +import { checkFullLicense } from '../../../../license/check_license'; +// @ts-ignore +import { checkGetJobsPrivilege } from '../../../../privilege/check_privilege'; +// @ts-ignore +import { loadCurrentIndexPattern, loadCurrentSavedSearch } from '../../../../util/index_utils'; + +import { + getCreateSingleMetricJobBreadcrumbs, + getCreateMultiMetricJobBreadcrumbs, + getCreatePopulationJobBreadcrumbs, + // @ts-ignore +} from '../../../breadcrumbs'; + +import { loadNewJobCapabilities } from '../../../../services/new_job_capabilities_service'; + +import { mlJobService } from '../../../../services/job_service'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; + +const template = ``; + +interface Route { + id: JOB_TYPE; + k7Breadcrumbs: () => any; +} + +const routes: Route[] = [ + { + id: JOB_TYPE.SINGLE_METRIC, + k7Breadcrumbs: getCreateSingleMetricJobBreadcrumbs, + }, + { + id: JOB_TYPE.MULTI_METRIC, + k7Breadcrumbs: getCreateMultiMetricJobBreadcrumbs, + }, + { + id: JOB_TYPE.POPULATION, + k7Breadcrumbs: getCreatePopulationJobBreadcrumbs, + }, +]; + +routes.forEach((route: Route) => { + uiRoutes.when(`/jobs/new_job/new_new_job/${route.id}`, { + template, + k7Breadcrumbs: route.k7Breadcrumbs, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + indexPattern: loadCurrentIndexPattern, + savedSearch: loadCurrentSavedSearch, + loadNewJobCapabilities, + existingJobsAndGroups: mlJobService.getJobAndGroupIds, + jobType: () => route.id, + }, + }); +}); diff --git a/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx new file mode 100644 index 0000000000000..11a4e83b757b3 --- /dev/null +++ b/x-pack/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx @@ -0,0 +1,131 @@ +/* + * 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, { FC, useContext, useReducer, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiSteps, EuiStepStatus } from '@elastic/eui'; +import { WIZARD_STEPS } from '../components/step_types'; + +import { TimeRangeStep } from '../components/time_range_step'; + +import { PickFieldsStep } from '../components/pick_fields_step'; +import { JobDetailsStep } from '../components/job_details_step'; +import { SummaryStep } from '../components/summary_step'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; + +import { + JobCreatorContext, + JobCreatorContextValue, + ExistingJobsAndGroups, +} from '../components/job_creator_context'; +import { KibanaContext, isKibanaContext } from '../../../../data_frame/common/kibana_context'; + +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + PopulationJobCreator, +} from '../../common/job_creator'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; + +interface Props { + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + existingJobsAndGroups: ExistingJobsAndGroups; +} + +export const Wizard: FC = ({ + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + existingJobsAndGroups, +}) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const [jobCreatorUpdated, jobCreatorUpdate] = useReducer<(s: number) => number>(s => s + 1, 0); + + const jobCreatorContext: JobCreatorContextValue = { + jobCreatorUpdated, + jobCreatorUpdate, + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + fields: newJobCapsService.fields, + aggs: newJobCapsService.aggs, + existingJobsAndGroups, + }; + + const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.TIME_RANGE); + + const stepsConfig = [ + { + title: i18n.translate('xpack.ml.dataframe.transformsWizard.definePivotStepTitle', { + defaultMessage: 'Time range', + }), + children: ( + + ), + status: currentStep >= WIZARD_STEPS.TIME_RANGE ? undefined : ('incomplete' as EuiStepStatus), + }, + { + title: i18n.translate('xpack.ml.dataframe.transformsWizard.definePivotStepTitle', { + defaultMessage: 'Pick fields', + }), + children: ( + + ), + status: currentStep >= WIZARD_STEPS.PICK_FIELDS ? undefined : ('incomplete' as EuiStepStatus), + }, + + { + title: i18n.translate('xpack.ml.dataframe.transformsWizard.definePivotStepTitle', { + defaultMessage: 'Job details', + }), + children: ( + + ), + status: currentStep >= WIZARD_STEPS.JOB_DETAILS ? undefined : ('incomplete' as EuiStepStatus), + }, + + { + title: i18n.translate('xpack.ml.dataframe.transformsWizard.definePivotStepTitle', { + defaultMessage: 'Summary', + }), + children: ( + + ), + status: currentStep >= WIZARD_STEPS.SUMMARY ? undefined : ('incomplete' as EuiStepStatus), + }, + ]; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/services/job_service.d.ts b/x-pack/plugins/ml/public/services/job_service.d.ts index 432a8d5ae3274..66cf9cd6a3fa7 100644 --- a/x-pack/plugins/ml/public/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/services/job_service.d.ts @@ -9,6 +9,8 @@ declare interface JobService { openJob(jobId: string): Promise; saveNewDatafeed(datafeedConfig: any, jobId: string): Promise; startDatafeed(datafeedId: string, jobId: string, start: number, end: number): Promise; + createResultsUrl(jobId: string[], start: number, end: number, location: string): string; + getJobAndGroupIds(): { jobs: string[]; groups: string[] }; } export const mlJobService: JobService; diff --git a/x-pack/plugins/ml/public/services/job_service.js b/x-pack/plugins/ml/public/services/job_service.js index 09f3a8b262e1b..fb1fc4291075d 100644 --- a/x-pack/plugins/ml/public/services/job_service.js +++ b/x-pack/plugins/ml/public/services/job_service.js @@ -742,6 +742,20 @@ class JobService { } + async getJobAndGroupIds() { + const existingJobsAndGroups = { + jobs: [], + groups: [], + }; + try { + const { jobs: tempJobs, groups } = await ml.jobs.getAllJobAndGroupIds(); + existingJobsAndGroups.jobs = tempJobs; + existingJobsAndGroups.groups = groups; + return existingJobsAndGroups; + } catch (error) { + return existingJobsAndGroups; + } + } } // private function used to check the job saving response diff --git a/x-pack/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/plugins/ml/public/services/ml_api_service/index.d.ts index c9521546956bc..9899a9d44d4e5 100644 --- a/x-pack/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/plugins/ml/public/services/ml_api_service/index.d.ts @@ -5,6 +5,7 @@ */ import { Annotation } from '../../../common/types/annotations'; +import { DslName, AggFieldNamePair } from '../../../common/types/fields'; // TODO This is not a complete representation of all methods of `ml.*`. // It just satisfies needs for other parts of the code area which use @@ -50,6 +51,33 @@ declare interface Ml { jobAuditMessages(jobId: string, from: string): Promise; deletingJobTasks(): Promise; newJobCaps(indexPatternTitle: string, isRollup: boolean): Promise; + newJobLineChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null + ): Promise; + newJobPopulationsChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string + ): Promise; + getAllJobAndGroupIds(): Promise; + getLookBackProgress( + jobId: string, + start: number, + end: number + ): Promise<{ progress: number; isRunning: boolean }>; }; } diff --git a/x-pack/plugins/ml/public/services/ml_api_service/jobs.js b/x-pack/plugins/ml/public/services/ml_api_service/jobs.js index f8bc87e1896b4..39b646998b426 100644 --- a/x-pack/plugins/ml/public/services/ml_api_service/jobs.js +++ b/x-pack/plugins/ml/public/services/ml_api_service/jobs.js @@ -134,4 +134,77 @@ export const jobs = { method: 'GET', }); }, + + newJobLineChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + ) { + return http({ + url: `${basePath}/jobs/new_job_line_chart`, + method: 'POST', + data: { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + } + }); + }, + + newJobPopulationsChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + ) { + return http({ + url: `${basePath}/jobs/new_job_population_chart`, + method: 'POST', + data: { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + } + }); + }, + + getAllJobAndGroupIds() { + return http({ + url: `${basePath}/jobs/all_jobs_and_group_ids`, + method: 'GET', + }); + }, + + getLookBackProgress(jobId, start, end) { + return http({ + url: `${basePath}/jobs/look_back_progress`, + method: 'POST', + data: { + jobId, + start, + end, + } + }); + }, }; diff --git a/x-pack/plugins/ml/public/services/new_job_capabilities_service.ts b/x-pack/plugins/ml/public/services/new_job_capabilities_service.ts index 55f0a009cb94d..75265468152cd 100644 --- a/x-pack/plugins/ml/public/services/new_job_capabilities_service.ts +++ b/x-pack/plugins/ml/public/services/new_job_capabilities_service.ts @@ -6,6 +6,7 @@ import { IndexPatternWithType } from '../../common/types/kibana'; import { Field, Aggregation, AggId, FieldId, NewJobCaps } from '../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../common/constants/field_types'; import { ml } from './ml_api_service'; // called in the angular routing resolve block to initialize the @@ -24,6 +25,8 @@ export function loadNewJobCapabilities(indexPatterns: any, $route: Record categoryFieldTypes.includes(f.type)); + } + public async initializeFromIndexPattern(indexPattern: IndexPatternWithType) { try { const resp = await ml.jobs.newJobCaps(indexPattern.title, indexPattern.type === 'rollup'); diff --git a/x-pack/plugins/ml/public/services/results_service.d.ts b/x-pack/plugins/ml/public/services/results_service.d.ts new file mode 100644 index 0000000000000..b30a13ad175cf --- /dev/null +++ b/x-pack/plugins/ml/public/services/results_service.d.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ + +declare interface MlResultsService { + getScoresByBucket: ( + jobIds: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + maxResults: number + ) => Promise; + getScheduledEventsByBucket: () => Promise; + getTopInfluencers: () => Promise; + getTopInfluencerValues: () => Promise; + getOverallBucketScores: () => Promise; + getInfluencerValueMaxScoreByTime: () => Promise; + getRecordInfluencers: () => Promise; + getRecordsForInfluencer: () => Promise; + getRecordsForDetector: () => Promise; + getRecords: () => Promise; + getRecordsForCriteria: () => Promise; + getMetricData: () => Promise; + getEventRateData: ( + index: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | number + ) => Promise; + getEventDistributionData: () => Promise; + getModelPlotOutput: ( + jobId: string, + detectorIndex: number, + criteriaFields: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + aggType: { + min: string; + max: string; + } + ) => Promise; + getRecordMaxScoreByTime: () => Promise; +} + +export const mlResultsService: MlResultsService; diff --git a/x-pack/plugins/ml/public/util/ml_time_buckets.d.ts b/x-pack/plugins/ml/public/util/ml_time_buckets.d.ts new file mode 100644 index 0000000000000..093076f9a09e8 --- /dev/null +++ b/x-pack/plugins/ml/public/util/ml_time_buckets.d.ts @@ -0,0 +1,23 @@ +/* + * 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 { Moment } from 'moment'; + +declare interface TimefilterBounds { + min: Moment; + max: Moment; +} + +export class MlTimeBuckets { + setBarTarget: (barTarget: number) => void; + setMaxBars: (maxBars: number) => void; + setInterval: (interval: string) => void; + setBounds: (bounds: TimefilterBounds) => void; + getBounds: () => { min: any; max: any }; + getInterval: () => { + asMilliseconds: () => number; + }; +} diff --git a/x-pack/plugins/ml/public/util/string_utils.d.ts b/x-pack/plugins/ml/public/util/string_utils.d.ts new file mode 100644 index 0000000000000..10b0cbbe9aeb0 --- /dev/null +++ b/x-pack/plugins/ml/public/util/string_utils.d.ts @@ -0,0 +1,7 @@ +/* + * 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 function escapeForElasticsearchQuery(str: string): string; diff --git a/x-pack/plugins/ml/server/models/job_service/index.js b/x-pack/plugins/ml/server/models/job_service/index.js index 9cae2ee0a958a..c180a205cb850 100644 --- a/x-pack/plugins/ml/server/models/job_service/index.js +++ b/x-pack/plugins/ml/server/models/job_service/index.js @@ -9,6 +9,7 @@ import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; import { newJobCapsProvider } from './new_job_caps'; +import { newJobChartsProvider } from './new_job'; export function jobServiceProvider(callWithRequest, request) { return { @@ -16,5 +17,6 @@ export function jobServiceProvider(callWithRequest, request) { ...jobsProvider(callWithRequest), ...groupsProvider(callWithRequest), ...newJobCapsProvider(callWithRequest, request), + ...newJobChartsProvider(callWithRequest, request), }; } diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.js b/x-pack/plugins/ml/server/models/job_service/jobs.js index 404b0d572c01b..9323afef742f8 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.js +++ b/x-pack/plugins/ml/server/models/job_service/jobs.js @@ -12,6 +12,7 @@ import { jobAuditMessagesProvider } from '../job_audit_messages'; import { CalendarManager } from '../calendar'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; import { isTimeSeriesViewJob } from '../../../common/util/job_utils'; +import { groupsProvider } from './groups'; import moment from 'moment'; import { uniq } from 'lodash'; @@ -349,6 +350,50 @@ export function jobsProvider(callWithRequest) { return results; } + async function getAllJobAndGroupIds() { + const { getAllGroups } = groupsProvider(callWithRequest); + const jobs = await callWithRequest('ml.jobs'); + const allJobIds = jobs.jobs.map(job => job.job_id); + const groups = await getAllGroups(); + const allGroupIds = groups.map(group => group.id); + + return { + jobs: allJobIds, + groups: allGroupIds, + }; + } + + async function getLookBackProgress(jobId, start, end) { + const datafeedId = `datafeed-${jobId}`; + const [jobStats, isRunning] = await Promise.all([ + callWithRequest('ml.jobStats', { jobId: [jobId] }), + isDatafeedRunning(datafeedId) + ]); + + if (jobStats.jobs.length) { + const time = jobStats.jobs[0].data_counts.latest_record_timestamp; + const progress = (time - start) / (end - start); + return { + progress: (progress > 0 ? Math.round(progress * 100) : 0), + isRunning + }; + } + return { progress: 0, isRunning: false }; + } + + async function isDatafeedRunning(datafeedId) { + const stats = await callWithRequest('ml.datafeedStats', { datafeedId: [datafeedId] }); + if (stats.datafeeds.length) { + const state = stats.datafeeds[0].state; + return ( + state === DATAFEED_STATE.STARTED || + state === DATAFEED_STATE.STARTING || + state === DATAFEED_STATE.STOPPING + ); + } + return false; + } + return { forceDeleteJob, deleteJobs, @@ -358,5 +403,7 @@ export function jobsProvider(callWithRequest) { createFullJobsList, deletingJobTasks, jobsExist, + getAllJobAndGroupIds, + getLookBackProgress, }; } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts new file mode 100644 index 0000000000000..a8b6ba494a070 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts @@ -0,0 +1,19 @@ +/* + * 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 { newJobLineChartProvider } from './line_chart'; +import { newJobPopulationChartProvider } from './population_chart'; +export type callWithRequestType = (action: string, params: any) => Promise; + +export function newJobChartsProvider(callWithRequest: callWithRequestType) { + const { newJobLineChart } = newJobLineChartProvider(callWithRequest); + const { newJobPopulationChart } = newJobPopulationChartProvider(callWithRequest); + + return { + newJobLineChart, + newJobPopulationChart, + }; +} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/index.ts b/x-pack/plugins/ml/server/models/job_service/new_job/index.ts new file mode 100644 index 0000000000000..758b834ed7b3a --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_service/new_job/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { newJobChartsProvider } from './charts'; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts new file mode 100644 index 0000000000000..e319f5a115140 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -0,0 +1,218 @@ +/* + * 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 { get } from 'lodash'; +import { DslName, AggFieldNamePair } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +const EVENT_RATE_COUNT_FIELD = '__ml_event_rate_count__'; +const OVER_FIELD_EXAMPLES_COUNT = 40; + +type DtrIndex = number; +type TimeStamp = number; +type Value = number | undefined | null; +interface Result { + time: TimeStamp; + value: Value; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +export function newJobLineChartProvider(callWithRequest: callWithRequestType) { + async function newJobLineChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null + ) { + const json: object = getSearchJsonFromConfig( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + ); + + const results = await callWithRequest('search', json); + return processSearchResults(results, aggFieldNamePairs.map(af => af.field)); + } + + return { + newJobLineChart, + }; +} + +function processSearchResults(resp: any, fields: string[]): ProcessedResults { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + // let highestValue: number; + // let lowestValue: number; + + const tempResults: Record = {}; + fields.forEach((f, i) => (tempResults[i] = [])); + + aggregationsByTime.forEach((dataForTime: any) => { + const time: TimeStamp = +dataForTime.key; + const docCount = +dataForTime.doc_count; + + fields.forEach((field, i) => { + let value; + if (field === EVENT_RATE_COUNT_FIELD) { + value = docCount; + } else if (typeof dataForTime[i].value !== 'undefined') { + value = dataForTime[i].value; + } else if (typeof dataForTime[i].values !== 'undefined') { + value = dataForTime[i].values[ML_MEDIAN_PERCENTS]; + } + + // let value: Value = get(dataForTime, ['field_value', 'value']); + + // if (value === undefined && field !== null) { + // value = get(dataForTime, ['field_value', 'values', ML_MEDIAN_PERCENTS]); + // } + + // if (value === undefined && field === null) { + // value = dataForTime.doc_count; + // } + // if ( + // (value !== null && value !== undefined && !isFinite(value)) || + // dataForTime.doc_count === 0 + // ) { + // value = null; + // } + + // if (value !== null && value !== undefined) { + // highestValue = highestValue === undefined ? value : Math.max(value, highestValue); + // lowestValue = lowestValue === undefined ? value : Math.min(value, lowestValue); + // } + + tempResults[i].push({ + time, + value, + }); + }); + }); + + // const results: Record = {}; + // Object.entries(tempResults).forEach(([fieldIdx, results2]) => { + // results[+fieldIdx] = results2.sort((a, b) => a.time - b.time); + // }); + + return { + success: true, + results: tempResults, + totalResults: resp.hits.total, + }; +} + +function getSearchJsonFromConfig( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: any, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null +): object { + const json = { + index: indexPatternTitle, + size: 0, + rest_total_hits_as_int: true, + body: { + query: {}, + aggs: { + times: { + date_histogram: { + field: timeField, + interval: intervalMs, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: {}, + }, + }, + }, + }; + + query.bool.must.push({ + range: { + [timeField]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }); + + if (splitFieldName !== null && splitFieldValue !== null) { + query.bool.must.push({ + term: { + [splitFieldName]: splitFieldValue, + }, + }); + } + + json.body.query = query; + + const aggs: Record> = {}; + + aggFieldNamePairs.forEach(({ agg, field }, i) => { + if (field !== null) { + aggs[i] = { + [agg]: { field }, + }; + + if (agg === 'percentiles') { + aggs[i][agg].percents = [ML_MEDIAN_PERCENTS]; + } + } + }); + + json.body.aggs.times.aggs = aggs; + // json.body.aggs.times.aggs = { + // field_value: { + // [agg]: { field }, + // }, + // }; + + // if (Object.keys(formConfig.fields).length) { + // json.body.aggs.times.aggs = {}; + // _.each(formConfig.fields, field => { + // if (field.id !== EVENT_RATE_COUNT_FIELD) { + // json.body.aggs.times.aggs[field.id] = { + // [field.agg.type.dslName]: { field: field.name }, + // }; + + // if (field.agg.type.dslName === 'percentiles') { + // json.body.aggs.times.aggs[field.id][field.agg.type.dslName].percents = [ + // ML_MEDIAN_PERCENTS, + // ]; + // } + // } + // }); + // } + + return json; +} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts new file mode 100644 index 0000000000000..88e9f3e513f5b --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -0,0 +1,275 @@ +/* + * 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 { get } from 'lodash'; +import { DslName, AggFieldNamePair } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +const EVENT_RATE_COUNT_FIELD = '__ml_event_rate_count__'; +const OVER_FIELD_EXAMPLES_COUNT = 40; + +type DtrIndex = number; +type TimeStamp = number; +type Value = number | undefined | null; +interface Thing { + label: string; + value: Value; +} +interface Result { + time: TimeStamp; + values: Thing[]; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +export function newJobPopulationChartProvider(callWithRequest: callWithRequestType) { + async function newJobPopulationChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null + ) { + const json: object = getPopulationSearchJsonFromConfig( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName + ); + + try { + const results = await callWithRequest('search', json); + return processSearchResults(results, aggFieldNamePairs.map(af => af.field)); + } catch (error) { + return { error }; + } + } + + return { + newJobPopulationChart, + }; +} + +function processSearchResults(resp: any, fields: string[]): ProcessedResults { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + // let highestValue: number; + // let lowestValue: number; + + const tempResults: Record = {}; + fields.forEach((f, i) => (tempResults[i] = [])); + + aggregationsByTime.forEach((dataForTime: any) => { + const time: TimeStamp = +dataForTime.key; + const docCount = +dataForTime.doc_count; + + fields.forEach((field, i) => { + // FIX ALL OF THIS!!!!!! + // let value; + // if (fields[i] === EVENT_RATE_COUNT_FIELD) { + // value = docCount; + // } else if (typeof dataForTime[i].value !== 'undefined') { + // value = dataForTime[i].value; + // } else if (typeof dataForTime[i].values !== 'undefined') { + // value = dataForTime[i].values[ML_MEDIAN_PERCENTS]; + // } + const populationBuckets = get(dataForTime, ['population', 'buckets'], []); + const values: Thing[] = []; + if (field === EVENT_RATE_COUNT_FIELD) { + // populationBuckets.forEach(b => { + // // check to see if the data is split. + // if (b[i] === undefined) { + // values.push({ label: b.key, value: b.doc_count }); + // } else { + // // a split is being used, so an additional filter was added to the search + // values.push({ label: b.key, value: b[i].doc_count }); + // } + // }); + } else if (typeof dataForTime.population !== 'undefined') { + populationBuckets.forEach((b: any) => { + const tempBucket = b[i]; + let value = null; + // check to see if the data is split + // if the field has been split, an additional filter and aggregation + // has been added to the search in the form of splitValue + const tempValue = + tempBucket.value === undefined && tempBucket.splitValue !== undefined + ? tempBucket.splitValue + : tempBucket; + + // check to see if values is exists rather than value. + // if values exists, the aggregation was median + if (tempValue.value === undefined && tempValue.values !== undefined) { + value = tempValue.values[ML_MEDIAN_PERCENTS]; + } else { + value = tempValue.value; + } + values.push({ label: b.key, value: isFinite(value) ? value : null }); + }); + } + + // const highestValueField = _.reduce(values, (p, c) => (c.value > p.value ? c : p), { + // value: 0, + // }); + + tempResults[i].push({ + time, + values, + }); + + // if (this.chartData.detectors[i]) { + // this.chartData.detectors[i].line.push({ + // date, + // time, + // values, + // }); + + // init swimlane + // this.chartData.detectors[i].swimlane.push({ + // date, + // time, + // value: 0, + // color: '', + // percentComplete: 0, + // }); + + // this.chartData.detectors[i].highestValue = Math.ceil( + // Math.max(this.chartData.detectors[i].highestValue, Math.abs(highestValueField.value)) + // ); + // } + + // tempResults[i].push({ + // time, + // value, + // }); + }); + }); + + // const results: Record = {}; + // Object.entries(tempResults).forEach(([fieldIdx, results2]) => { + // results[+fieldIdx] = results2.sort((a, b) => a.time - b.time); + // }); + + return { + success: true, + results: tempResults, + totalResults: resp.hits.total, + }; +} + +function getPopulationSearchJsonFromConfig( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: any, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null +): object { + const json = { + index: indexPatternTitle, + size: 0, + rest_total_hits_as_int: true, + body: { + query: {}, + aggs: { + times: { + date_histogram: { + field: timeField, + interval: intervalMs, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: {}, + }, + }, + }, + }; + + query.bool.must.push({ + range: { + [timeField]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }); + + json.body.query = query; + + // const aggs: Record> = {}; + const aggs: any = {}; + + aggFieldNamePairs.forEach(({ agg, field, by }, i) => { + if (field === EVENT_RATE_COUNT_FIELD) { + const h = 7; + } else { + if (by !== undefined && by.field !== null && by.value !== null) { + // if the field is split, add a filter to the aggregation to just select the + // fields which match the first split value (the front chart + aggs[i] = { + filter: { + term: { + [by.field]: by.value, + }, + }, + aggs: { + splitValue: { + [agg]: { field }, + }, + }, + }; + if (agg === 'percentiles') { + aggs[i].aggs.splitValue[agg].percents = [ML_MEDIAN_PERCENTS]; + } + } else { + aggs[i] = { + [agg]: { field }, + }; + + if (agg === 'percentiles') { + aggs[i][agg].percents = [ML_MEDIAN_PERCENTS]; + } + } + } + }); + + if (splitFieldName !== undefined) { + // the over field should not be undefined. the user should not have got this far if it is. + // add the wrapping terms based aggregation to divide the results up into + // over field values. + // we just want the first 40, or whatever OVER_FIELD_EXAMPLES_COUNT is set to. + json.body.aggs.times.aggs = { + population: { + terms: { + field: splitFieldName, + size: OVER_FIELD_EXAMPLES_COUNT, + }, + aggs, + }, + }; + } else { + json.body.aggs.times.aggs = aggs; + } + return json; +} diff --git a/x-pack/plugins/ml/server/routes/job_service.js b/x-pack/plugins/ml/server/routes/job_service.js index fa99fdb539d4f..6311d341618be 100644 --- a/x-pack/plugins/ml/server/routes/job_service.js +++ b/x-pack/plugins/ml/server/routes/job_service.js @@ -180,11 +180,11 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route } }); - server.route({ + route({ method: 'GET', path: '/api/ml/jobs/new_job_caps/{indexPattern}', handler(request) { - const callWithRequest = callWithRequestFactory(server, request); + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); const { indexPattern } = request.params; const isRollup = (request.query.rollup === 'true'); const { newJobCaps } = jobServiceProvider(callWithRequest, request); @@ -196,4 +196,99 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route } }); + route({ + method: 'POST', + path: '/api/ml/jobs/new_job_line_chart', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + } = request.payload; + const { newJobLineChart } = jobServiceProvider(callWithRequest, request); + return newJobLineChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue, + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/new_job_population_chart', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + } = request.payload; + const { newJobPopulationChart } = jobServiceProvider(callWithRequest, request); + return newJobPopulationChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'GET', + path: '/api/ml/jobs/all_jobs_and_group_ids', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { getAllJobAndGroupIds } = jobServiceProvider(callWithRequest); + return getAllJobAndGroupIds() + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/look_back_progress', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { getLookBackProgress } = jobServiceProvider(callWithRequest); + const { jobId, start, end } = request.payload; + return getLookBackProgress(jobId, start, end) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + }