diff --git a/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts b/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts new file mode 100644 index 0000000000000..09173247237ac --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts @@ -0,0 +1,43 @@ +/* + * 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 ML_JOB_AGGREGATION { + COUNT = 'count', + HIGH_COUNT = 'high_count', + LOW_COUNT = 'low_count', + MEAN = 'mean', + HIGH_MEAN = 'high_mean', + LOW_MEAN = 'low_mean', + SUM = 'sum', + HIGH_SUM = 'high_sum', + LOW_SUM = 'low_sum', + MEDIAN = 'median', + HIGH_MEDIAN = 'high_median', + LOW_MEDIAN = 'low_median', + MIN = 'min', + MAX = 'max', + DISTINCT_COUNT = 'distinct_count', +} + +export enum KIBANA_AGGREGATION { + COUNT = 'count', + AVG = 'avg', + MAX = 'max', + MIN = 'min', + SUM = 'sum', + MEDIAN = 'median', + CARDINALITY = 'cardinality', +} + +export enum ES_AGGREGATION { + COUNT = 'count', + AVG = 'avg', + MAX = 'max', + MIN = 'min', + SUM = 'sum', + PERCENTILES = 'percentiles', + CARDINALITY = 'cardinality', +} diff --git a/x-pack/legacy/plugins/ml/common/constants/anomalies.ts b/x-pack/legacy/plugins/ml/common/constants/anomalies.ts new file mode 100644 index 0000000000000..bbf3616c05880 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/common/constants/states.js b/x-pack/legacy/plugins/ml/common/constants/states.js deleted file mode 100644 index 4584171c713f2..0000000000000 --- a/x-pack/legacy/plugins/ml/common/constants/states.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 DATAFEED_STATE = { - STARTED: 'started', - STARTING: 'starting', - STOPPED: 'stopped', - STOPPING: 'stopping', - DELETED: 'deleted', -}; - -export const FORECAST_REQUEST_STATE = { - FAILED: 'failed', - FINISHED: 'finished', - SCHEDULED: 'scheduled', - STARTED: 'started', -}; - -export const JOB_STATE = { - CLOSED: 'closed', - CLOSING: 'closing', - FAILED: 'failed', - OPENED: 'opened', - OPENING: 'opening', - DELETED: 'deleted', -}; diff --git a/x-pack/legacy/plugins/ml/common/constants/states.ts b/x-pack/legacy/plugins/ml/common/constants/states.ts new file mode 100644 index 0000000000000..30c3e44b7f434 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/constants/states.ts @@ -0,0 +1,29 @@ +/* + * 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 DATAFEED_STATE { + STARTED = 'started', + STARTING = 'starting', + STOPPED = 'stopped', + STOPPING = 'stopping', + DELETED = 'deleted', +} + +export enum FORECAST_REQUEST_STATE { + FAILED = 'failed', + FINISHED = 'finished', + SCHEDULED = 'scheduled', + STARTED = 'started', +} + +export enum JOB_STATE { + CLOSED = 'closed', + CLOSING = 'closing', + FAILED = 'failed', + OPENED = 'opened', + OPENING = 'opening', + DELETED = 'deleted', +} diff --git a/x-pack/legacy/plugins/ml/common/constants/validation.js b/x-pack/legacy/plugins/ml/common/constants/validation.ts similarity index 75% rename from x-pack/legacy/plugins/ml/common/constants/validation.js rename to x-pack/legacy/plugins/ml/common/constants/validation.ts index 914a1ab3215db..c71db4dca3c99 100644 --- a/x-pack/legacy/plugins/ml/common/constants/validation.js +++ b/x-pack/legacy/plugins/ml/common/constants/validation.ts @@ -4,15 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ - - - -export const VALIDATION_STATUS = { - ERROR: 'error', - INFO: 'info', - SUCCESS: 'success', - WARNING: 'warning' -}; +export enum VALIDATION_STATUS { + ERROR = 'error', + INFO = 'info', + SUCCESS = 'success', + WARNING = 'warning', +} export const SKIP_BUCKET_SPAN_ESTIMATION = true; diff --git a/x-pack/legacy/plugins/ml/common/types/fields.ts b/x-pack/legacy/plugins/ml/common/types/fields.ts new file mode 100644 index 0000000000000..7e09f14515a13 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/types/fields.ts @@ -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 { ES_FIELD_TYPES } from '../../common/constants/field_types'; +import { ML_JOB_AGGREGATION } from '../../common/constants/aggregation_types'; + +export const EVENT_RATE_FIELD_ID = '__ml_event_rate_count__'; + +export type FieldId = string; +export type AggId = ML_JOB_AGGREGATION; +export type SplitField = Field | null; +export type DslName = string; + +export interface Field { + id: FieldId; + name: string; + type: ES_FIELD_TYPES; + aggregatable: boolean; + aggIds?: AggId[]; + aggs?: Aggregation[]; +} + +export interface Aggregation { + id: AggId; + title: string; + kibanaName: string; + dslName: DslName; + type: string; + mlModelPlotAgg: { + min: string; + max: string; + }; + fieldIds?: FieldId[]; + fields?: Field[]; +} + +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/legacy/plugins/ml/common/types/kibana.ts b/x-pack/legacy/plugins/ml/common/types/kibana.ts index 88e5f68fb4a38..86db2ce59d7e7 100644 --- a/x-pack/legacy/plugins/ml/common/types/kibana.ts +++ b/x-pack/legacy/plugins/ml/common/types/kibana.ts @@ -4,4 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +// custom edits or fixes for default kibana types which are incomplete + +export type IndexPatternTitle = string; + export type callWithRequestType = (action: string, params?: any) => Promise; + +export interface Route { + id: string; + k7Breadcrumbs: () => any; +} diff --git a/x-pack/legacy/plugins/ml/common/util/anomaly_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.d.ts new file mode 100644 index 0000000000000..adeb6dc7dd5b9 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/common/util/anomaly_utils.js b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js index 371d1d176394a..d201a971dda5c 100644 --- a/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js +++ b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js @@ -4,25 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ - - /* -* Contains functions for operations commonly performed on anomaly data -* to extract information for display in dashboards. -*/ + * Contains functions for operations commonly performed on anomaly data + * to extract information for display in dashboards. + */ -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, ANOMALY_THRESHOLD } 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', - 'median', 'varp', 'info_content', 'time']; +const DISPLAY_ACTUAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; // List of function descriptions for which typical values from record level results should be displayed. -const DISPLAY_TYPICAL_FUNCTIONS = ['count', 'distinct_count', 'lat_long', 'mean', 'max', 'min', 'sum', - 'median', 'varp', 'info_content', 'time']; +const DISPLAY_TYPICAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; let severityTypes; @@ -31,26 +51,44 @@ function getSeverityTypes() { return severityTypes; } - return severityTypes = { - critical: { id: 'critical', label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { - defaultMessage: 'critical', - }) }, - major: { id: 'major', label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { - defaultMessage: 'major', - }) }, - minor: { id: 'minor', label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { - defaultMessage: 'minor', - }) }, - warning: { id: 'warning', label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { - defaultMessage: 'warning', - }) }, - unknown: { id: 'unknown', label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { - defaultMessage: 'unknown', - }) }, - low: { id: 'low', label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { - defaultMessage: 'low', - }) }, - }; + return (severityTypes = { + critical: { + id: ANOMALY_SEVERITY.CRITICAL, + label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { + defaultMessage: 'critical', + }), + }, + major: { + id: ANOMALY_SEVERITY.MAJOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { + defaultMessage: 'major', + }), + }, + minor: { + id: ANOMALY_SEVERITY.MINOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { + defaultMessage: 'minor', + }), + }, + warning: { + id: ANOMALY_SEVERITY.WARNING, + label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { + defaultMessage: 'warning', + }), + }, + unknown: { + id: ANOMALY_SEVERITY.UNKNOWN, + label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { + defaultMessage: 'unknown', + }), + }, + low: { + id: ANOMALY_SEVERITY.LOW, + label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { + defaultMessage: 'low', + }), + }, + }); } // Returns a severity label (one of critical, major, minor, warning or unknown) @@ -58,34 +96,50 @@ function getSeverityTypes() { export function getSeverity(normalizedScore) { const severityTypesList = getSeverityTypes(); - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return severityTypesList.critical; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return severityTypesList.major; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return severityTypesList.minor; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return severityTypesList.warning; } else { return severityTypesList.unknown; } } +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'. export function getSeverityWithLow(normalizedScore) { const severityTypesList = getSeverityTypes(); - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return severityTypesList.critical; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return severityTypesList.major; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return severityTypesList.minor; - } else if (normalizedScore >= 3) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { return severityTypesList.warning; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return severityTypesList.low; } else { return severityTypesList.unknown; @@ -95,15 +149,15 @@ export function getSeverityWithLow(normalizedScore) { // Returns a severity RGB color (one of critical, major, minor, warning, low_warning or unknown) // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverityColor(normalizedScore) { - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return '#fe5050'; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return '#fba740'; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return '#fdec25'; - } else if (normalizedScore >= 3) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { return '#8bc8fb'; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return '#d2e9f7'; } else { return '#ffffff'; @@ -139,15 +193,15 @@ export function getMultiBucketImpactLabel(multiBucketImpact) { export function getEntityFieldName(record) { // Analyses with by and over fields, will have a top-level by_field_name, but // the by_field_value(s) will be in the nested causes array. - if (_.has(record, 'by_field_name') && _.has(record, 'by_field_value')) { + if (record.by_field_name !== undefined && record.by_field_value !== undefined) { return record.by_field_name; } - if (_.has(record, 'over_field_name')) { + if (record.over_field_name !== undefined) { return record.over_field_name; } - if (_.has(record, 'partition_field_name')) { + if (record.partition_field_name !== undefined) { return record.partition_field_name; } @@ -158,15 +212,15 @@ export function getEntityFieldName(record) { // obtained from Elasticsearch. The function looks first for a by_field, then over_field, // then partition_field, returning undefined if none of these fields are present. export function getEntityFieldValue(record) { - if (_.has(record, 'by_field_value')) { + if (record.by_field_value !== undefined) { return record.by_field_value; } - if (_.has(record, 'over_field_value')) { + if (record.over_field_value !== undefined) { return record.over_field_value; } - if (_.has(record, 'partition_field_value')) { + if (record.partition_field_value !== undefined) { return record.partition_field_value; } @@ -181,7 +235,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.partition_field_name, fieldValue: record.partition_field_value, - fieldType: 'partition' + fieldType: 'partition', }); } @@ -189,7 +243,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.over_field_name, fieldValue: record.over_field_value, - fieldType: 'over' + fieldType: 'over', }); } @@ -200,7 +254,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.by_field_name, fieldValue: record.by_field_value, - fieldType: 'by' + fieldType: 'by', }); } @@ -211,22 +265,24 @@ export function getEntityFieldList(record) { // Note that the 'function' field in a record contains what the user entered e.g. 'high_count', // whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. export function showActualForFunction(functionDescription) { - return _.indexOf(DISPLAY_ACTUAL_FUNCTIONS, functionDescription) > -1; + return DISPLAY_ACTUAL_FUNCTIONS.indexOf(functionDescription) > -1; } // Returns whether typical values should be displayed for a record with the specified function description. // Note that the 'function' field in a record contains what the user entered e.g. 'high_count', // whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. export function showTypicalForFunction(functionDescription) { - return _.indexOf(DISPLAY_TYPICAL_FUNCTIONS, functionDescription) > -1; + return DISPLAY_TYPICAL_FUNCTIONS.indexOf(functionDescription) > -1; } // Returns whether a rule can be configured against the specified anomaly. export function isRuleSupported(record) { // A rule can be configured with a numeric condition if the function supports it, // and/or with scope if there is a partitioning fields. - return (CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1) || - (getEntityFieldName(record) !== undefined); + return ( + CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1 || + getEntityFieldName(record) !== undefined + ); } // Two functions for converting aggregation type names. @@ -272,5 +328,5 @@ export const aggregationTypeTransform = { } return newAggType; - } + }, }; diff --git a/x-pack/legacy/plugins/ml/common/util/group_color_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/group_color_utils.d.ts new file mode 100644 index 0000000000000..4a1a6ebb8fdf3 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/common/util/job_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/job_utils.d.ts new file mode 100644 index 0000000000000..0217ddcdc2cfe --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/job_utils.d.ts @@ -0,0 +1,29 @@ +/* + * 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 interface ValidationMessage { + id: string; +} +export interface ValidationResults { + messages: ValidationMessage[]; + valid: boolean; + contains: (id: string) => boolean; + find: (id: string) => ValidationMessage | undefined; +} +export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: number): number; + +// TODO - use real types for job. Job interface first needs to move to a common location +export function isTimeSeriesViewJob(job: any): boolean; +export function basicJobValidation( + job: any, + fields: any[] | undefined, + limits: any, + skipMmlCheck?: boolean +): ValidationResults; + +export const ML_MEDIAN_PERCENTS: number; + +export const ML_DATA_PREVIEW_COUNT: number; diff --git a/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts b/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts new file mode 100644 index 0000000000000..b3537b94d1c70 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +interface Duration { + asSeconds(): number; +} +export function parseInterval(interval: string): Duration; diff --git a/x-pack/legacy/plugins/ml/common/util/string_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/string_utils.d.ts new file mode 100644 index 0000000000000..f8dbc00643d07 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/index.test.tsx.snap rename to x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.test.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx similarity index 100% rename from x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.test.tsx rename to x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx new file mode 100644 index 0000000000000..93ad4a5d80867 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx @@ -0,0 +1,43 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { Query } from 'src/legacy/core_plugins/data/public'; +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; + callback?: (a: any) => void; +} + +// 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: FC = ({ indexPattern, query, disabled, callback }) => { + // wrapper around setFullTimeRange to allow for the calling of the optional callBack prop + async function setRange(i: IndexPattern, q: Query) { + const fullTimeRange = await setFullTimeRange(i, q); + if (typeof callback === 'function') { + callback(fullTimeRange); + } + } + return ( + setRange(indexPattern, query)}> + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts index 32606d2db425e..9ed908f9ffcee 100644 --- a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts @@ -11,26 +11,56 @@ 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 { ml } from '../../services/ml_api_service'; +import dateMath from '@elastic/datemath'; +import { ml, GetTimeFieldRangeResponse } from '../../services/ml_api_service'; -export function setFullTimeRange(indexPattern: IndexPattern, query: Query) { - return ml - .getTimeFieldRange({ +export interface TimeRange { + from: number; + to: number; +} + +export async function setFullTimeRange( + indexPattern: IndexPattern, + query: Query +): Promise { + try { + const resp = await ml.getTimeFieldRange({ index: indexPattern.title, timeFieldName: indexPattern.timeFieldName, query, - }) - .then(resp => { - timefilter.setTime({ - from: moment(resp.start.epoch).toISOString(), - to: moment(resp.end.epoch).toISOString(), - }); - }) - .catch(resp => { - toastNotifications.addDanger( - i18n.translate('xpack.ml.fullTimeRangeSelector.errorSettingTimeRangeNotification', { - defaultMessage: 'An error occurred setting the time range.', - }) - ); }); + timefilter.setTime({ + from: moment(resp.start.epoch).toISOString(), + to: moment(resp.end.epoch).toISOString(), + }); + return resp; + } catch (resp) { + toastNotifications.addDanger( + i18n.translate('xpack.ml.fullTimeRangeSelector.errorSettingTimeRangeNotification', { + defaultMessage: 'An error occurred setting the time range.', + }) + ); + return resp; + } +} + +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/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx index 9066fe0a0e8b9..0c60f74c9068b 100644 --- a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx +++ b/x-pack/legacy/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, TimeRange } from './full_time_range_selector_service'; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap index 44b0091971f8a..2eda2462a6aed 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap @@ -1,167 +1,173 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ValidateJob renders button and modal with a message 1`] = ` -
- - - - +
+ - } - > - - - - - - + + } + > + - - , + "fieldName": "airline", + "id": "over_field_low_cardinality", + "status": "warning", + "text": "Cardinality of over_field \\"airline\\" is low and therefore less suitable for population analysis.", + "url": "https://www.elastic.co/blog/sizing-machine-learning-with-elasticsearch", } } /> - - -
+ + + + + + + , + } + } + /> + +
+
+ `; exports[`ValidateJob renders the button 1`] = ` -
- - - -
+ +
+ + + +
+
`; exports[`ValidateJob renders the button and modal with a success message 1`] = ` -
- - - - - } - > - + +
+ - - - - - , + + - - -
+ /> + } + > + + + + + + + , + } + } + /> + +
+
+ `; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts new file mode 100644 index 0000000000000..43e0a5f3eac78 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts @@ -0,0 +1,15 @@ +/* + * 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 { FC } from 'react'; +declare const ValidateJob: FC<{ + getJobConfig: any; + getDuration: any; + mlJobService: any; + embedded?: boolean; + setIsValid?: (valid: boolean) => void; + idFilterList?: string[]; +}>; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js index d4ad8f946924b..88e85cc5e530f 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js @@ -10,7 +10,8 @@ import PropTypes from 'prop-types'; import React, { - Component + Component, + Fragment } from 'react'; import { @@ -172,13 +173,19 @@ class ValidateJob extends Component { this.state = getDefaultState(); } + componentDidMount() { + if(this.props.embedded === true) { + this.validate(); + } + } + closeModal = () => { const newState = getDefaultState(); newState.ui.iconType = this.state.ui.iconType; this.setState(newState); }; - openModal = () => { + validate = () => { const job = this.props.getJobConfig(); const getDuration = this.props.getDuration; const duration = (typeof getDuration === 'function') ? getDuration() : undefined; @@ -204,6 +211,9 @@ class ValidateJob extends Component { data, title: job.job_id }); + if (typeof this.props.setIsValid === 'function') { + this.props.setIsValid(data.messages.some(m => m.status === VALIDATION_STATUS.ERROR) === false); + } }); // wait for 250ms before triggering the loading indicator @@ -231,62 +241,79 @@ class ValidateJob extends Component { // default to false if not explicitly set to true const isCurrentJobConfig = (this.props.isCurrentJobConfig !== true) ? false : true; const isDisabled = (this.props.isDisabled !== true) ? false : true; + const embedded = (this.props.embedded === true); + const idFilterList = this.props.idFilterList || []; return ( -
- - - - - {!isDisabled && this.state.ui.isModalVisible && - } - > - {this.state.data.messages.map( - (m, i) => - )} - - - - + + {embedded === false && +
+ - - - ) - }} + id="xpack.ml.validateJob.validateJobButtonLabel" + defaultMessage="Validate Job" /> - - + + + {!isDisabled && this.state.ui.isModalVisible && + } + > + { + this.state.data.messages + .filter(m => idFilterList.includes(m.id) === false) + .map((m, i) => ) + } + + + + + + + + ) + }} + /> + + + } +
+ } + {embedded === true && +
+ { + this.state.data.messages + .filter(m => idFilterList.includes(m.id) === false) + .map((m, i) => ) + } +
} -
+ ); } } @@ -297,7 +324,10 @@ ValidateJob.propTypes = { getJobConfig: PropTypes.func.isRequired, isCurrentJobConfig: PropTypes.bool, isDisabled: PropTypes.bool, - mlJobService: PropTypes.object.isRequired + mlJobService: PropTypes.object.isRequired, + embedded: PropTypes.bool, + setIsValid: PropTypes.func, + idFilterList: PropTypes.array, }; export { ValidateJob }; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js index 8ec8e9af3c319..b87211f01ca03 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js @@ -42,7 +42,7 @@ describe('ValidateJob', () => { }); test('renders the button and modal with a success message', () => { - test1.wrapper.instance().openModal(); + test1.wrapper.instance().validate(); test1.p.then(() => { test1.wrapper.update(); expect(test1.wrapper).toMatchSnapshot(); @@ -63,7 +63,7 @@ describe('ValidateJob', () => { }); test('renders button and modal with a message', () => { - test2.wrapper.instance().openModal(); + test2.wrapper.instance().validate(); test2.p.then(() => { test2.wrapper.update(); expect(test2.wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/ml/public/jobs/index.js b/x-pack/legacy/plugins/ml/public/jobs/index.js index cc1aeb85b8b59..9b0246240da68 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/index.js +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts similarity index 99% rename from x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts index dc1bb83bf3f4d..913dc4a9510f3 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - export { MLJobEditor, EDITOR_MODE } from './ml_job_editor'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.d.ts b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.d.ts new file mode 100644 index 0000000000000..a5af8a872f754 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.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 MLJobEditor(props: any): any; +export const EDITOR_MODE: any; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts new file mode 100644 index 0000000000000..4e37e01b79fd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +// TODO - add correct types for these return settings +export function loadNewJobDefaults(): Promise; +export function newJobDefaults(): any; +export function newJobLimits(): any; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts new file mode 100644 index 0000000000000..35596917960c5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts @@ -0,0 +1,18 @@ +/* + * 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 { IndexPattern } from 'ui/index_patterns'; +import { IndexPatternTitle } from '../../../../common/types/kibana'; + +export interface SearchItems { + indexPattern: IndexPattern; + savedSearch: SavedSearch; + query: any; + combinedQuery: any; +} + +export function SearchItemsProvider($route: Record, config: any): () => SearchItems; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html index 57c7a3bd2bfbe..dd173f98343b6 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html @@ -213,6 +213,102 @@ + + +
diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts new file mode 100644 index 0000000000000..101841c3b7066 --- /dev/null +++ b/x-pack/legacy/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 { IndexPattern } from 'ui/index_patterns'; +import { 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: IndexPattern; + protected _savedSearch: SavedSearch; + protected _indexPatternTitle: IndexPatternTitle = ''; + protected _timeFieldName: string = ''; + protected _query: object = {}; + + constructor(indexPattern: IndexPattern, 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(getAggFieldPairNames), + 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(getAggFieldPairNames), + 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; + } +} + +export function getAggFieldPairNames(af: AggFieldPair) { + const by = + af.by !== undefined && af.by.field !== null && af.by.value !== null + ? { field: af.by.field.id, value: af.by.value } + : { field: null, value: null }; + + return { + agg: af.agg.dslName, + field: af.field.id, + by, + }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts new file mode 100644 index 0000000000000..73e8f88df479b --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts new file mode 100644 index 0000000000000..f40b93fd8de89 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts new file mode 100644 index 0000000000000..81cc00155a64c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts @@ -0,0 +1,9 @@ +/* + * 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 * from './index_pattern_context'; +export * from './job_creator'; +export * from './job_runner'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts new file mode 100644 index 0000000000000..aa92536da8d1d --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.ts new file mode 100644 index 0000000000000..d0afbc757baf3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.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 { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { JobId } from './job'; +export type DatafeedId = string; + +export interface Datafeed { + datafeed_id: DatafeedId; + aggregations?: object; + chunking_config?: ChunkingConfig; + frequency?: string; + indices: IndexPatternTitle[]; + job_id?: JobId; + query: object; + query_delay?: string; + script_fields?: object; + scroll_size?: number; + delayed_data_check_config?: object; +} + +export interface ChunkingConfig { + mode: 'auto' | 'manual' | 'off'; + time_span?: string; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/index.ts new file mode 100644 index 0000000000000..f4502d7dc5873 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/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. + */ + +export * from './job'; +export * from './datafeed'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts new file mode 100644 index 0000000000000..cf9407e0c9511 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts @@ -0,0 +1,72 @@ +/* + * 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 type JobId = string; +export type BucketSpan = string; + +export interface Job { + job_id: JobId; + analysis_config: AnalysisConfig; + analysis_limits?: AnalysisLimits; + background_persist_interval?: string; + custom_settings?: any; + data_description: DataDescription; + description: string; + groups: string[]; + calendars?: string[]; + model_plot_config?: ModelPlotConfig; + model_snapshot_retention_days?: number; + renormalization_window_days?: number; + results_index_name?: string; + results_retention_days?: number; +} + +export interface AnalysisConfig { + bucket_span: BucketSpan; + categorization_field_name?: string; + categorization_filters?: string[]; + categorization_analyzer?: object | string; + detectors: Detector[]; + influencers: string[]; + latency?: number; + multivariate_by_fields?: boolean; + summary_count_field_name?: string; +} + +export interface Detector { + by_field_name?: string; + detector_description?: string; + detector_index?: number; + exclude_frequent?: string; + field_name?: string; + function: string; + over_field_name?: string; + partition_field_name?: string; + use_null?: string; + custom_rules?: CustomRule[]; +} +export interface AnalysisLimits { + categorization_examples_limit?: number; + model_memory_limit: string; +} + +export interface DataDescription { + format?: string; + time_field: string; + time_format?: string; +} + +export interface ModelPlotConfig { + enabled: boolean; + terms?: string; +} + +// TODO, finish this when it's needed +export interface CustomRule { + actions: any; + scope: object; + conditions: object; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts new file mode 100644 index 0000000000000..eca52c064ce67 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { 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/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts new file mode 100644 index 0000000000000..43246fa71911b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts @@ -0,0 +1,359 @@ +/* + * 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 { IndexPattern } from 'ui/index_patterns'; +import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { Job, Datafeed, Detector, JobId, DatafeedId, BucketSpan } from './configs'; +import { Aggregation, Field } 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: IndexPattern; + protected _savedSearch: SavedSearch; + protected _indexPatternTitle: IndexPatternTitle = ''; + protected _job_config: Job; + protected _datafeed_config: Datafeed; + protected _detectors: Detector[]; + protected _influencers: string[]; + protected _useDedicatedIndex: boolean = false; + protected _start: number = 0; + protected _end: number = 0; + protected _subscribers: ProgressSubscriber[]; + protected _aggs: Aggregation[] = []; + protected _fields: Field[] = []; + private _stopAllRefreshPolls: { + stop: boolean; + }; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + this._indexPattern = indexPattern; + this._savedSearch = savedSearch; + this._indexPatternTitle = indexPattern.title; + + this._job_config = createEmptyJob(); + this._datafeed_config = createEmptyDatafeed(this._indexPatternTitle); + this._detectors = this._job_config.analysis_config.detectors; + this._influencers = this._job_config.analysis_config.influencers; + + if (typeof indexPattern.timeFieldName === 'string') { + this._job_config.data_description.time_field = indexPattern.timeFieldName; + } + + this._datafeed_config.query = query; + this._subscribers = []; + this._stopAllRefreshPolls = { stop: false }; + } + + public get type(): JOB_TYPE { + return this._type; + } + + protected _addDetector(detector: Detector, agg: Aggregation, field: Field) { + this._detectors.push(detector); + this._aggs.push(agg); + this._fields.push(field); + } + + protected _editDetector(detector: Detector, agg: Aggregation, field: Field, index: number) { + if (this._detectors[index] !== undefined) { + this._detectors[index] = detector; + this._aggs[index] = agg; + this._fields[index] = field; + } + } + + protected _removeDetector(index: number) { + this._detectors.splice(index, 1); + this._aggs.splice(index, 1); + this._fields.splice(index, 1); + } + + public removeAllDetectors() { + this._detectors.length = 0; + this._aggs.length = 0; + this._fields.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 getField(index: number): Field | null { + const field = this._fields[index]; + return field !== undefined ? field : null; + } + + public set bucketSpan(bucketSpan: BucketSpan) { + this._job_config.analysis_config.bucket_span = bucketSpan; + } + + public get bucketSpan(): BucketSpan { + return this._job_config.analysis_config.bucket_span; + } + + public addInfluencer(influencer: string) { + if (this._influencers.includes(influencer) === false) { + this._influencers.push(influencer); + } + } + + 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; + } + + public set jobId(jobId: JobId) { + this._job_config.job_id = jobId; + this._datafeed_config.job_id = jobId; + this._datafeed_config.datafeed_id = `datafeed-${jobId}`; + + if (this._useDedicatedIndex) { + this._job_config.results_index_name = jobId; + } + } + + public get jobId(): JobId { + return this._job_config.job_id; + } + + public get datafeedId(): DatafeedId { + return this._datafeed_config.datafeed_id; + } + + public set description(description: string) { + this._job_config.description = description; + } + + public get description(): string { + return this._job_config.description; + } + + public get groups(): string[] { + return this._job_config.groups; + } + + public set groups(groups: string[]) { + this._job_config.groups = groups; + } + + public get calendars(): string[] { + return this._job_config.calendars || []; + } + + public set calendars(calendars: string[]) { + this._job_config.calendars = calendars; + } + + public set modelPlot(enable: boolean) { + if (enable) { + this._job_config.model_plot_config = { + enabled: true, + }; + } else { + delete this._job_config.model_plot_config; + } + } + + public get modelPlot() { + return ( + this._job_config.model_plot_config !== undefined && + this._job_config.model_plot_config.enabled === true + ); + } + + public set useDedicatedIndex(enable: boolean) { + this._useDedicatedIndex = enable; + if (enable) { + this._job_config.results_index_name = this._job_config.job_id; + } else { + delete this._job_config.results_index_name; + } + } + + public get useDedicatedIndex(): boolean { + return this._useDedicatedIndex; + } + + public set modelMemoryLimit(mml: string | null) { + if (mml !== null) { + this._job_config.analysis_limits = { + model_memory_limit: mml, + }; + } else { + delete this._job_config.analysis_limits; + } + } + + public get modelMemoryLimit(): string | null { + if ( + this._job_config.analysis_limits && + this._job_config.analysis_limits.model_memory_limit !== undefined + ) { + return this._job_config.analysis_limits.model_memory_limit; + } else { + return null; + } + } + + public setTimeRange(start: number, end: number) { + this._start = start; + this._end = end; + } + + public get start(): number { + return this._start; + } + + public get end(): number { + return this._end; + } + + public get query(): object { + return this._datafeed_config.query; + } + + public set query(query: object) { + this._datafeed_config.query = query; + } + + public get subscribers(): ProgressSubscriber[] { + return this._subscribers; + } + + public async createAndStartJob() { + try { + await this.createJob(); + await this.createDatafeed(); + await this.startDatafeed(); + } catch (error) { + throw error; + } + } + + public async createJob(): Promise { + try { + const { success, resp } = await mlJobService.saveNewJob(this._job_config); + if (success === true) { + return resp; + } else { + throw resp; + } + } catch (error) { + throw error; + } + } + + public async createDatafeed(): Promise { + try { + return await mlJobService.saveNewDatafeed(this._datafeed_config, this._job_config.job_id); + } catch (error) { + throw error; + } + } + + // create a jobRunner instance, start it and return it + public async startDatafeed(): Promise { + const jobRunner = new JobRunner(this); + await jobRunner.startDatafeed(); + return jobRunner; + } + + 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; + } + + private _setCustomSetting(setting: string, value: string | object | null) { + if (value === null) { + // if null is passed in, delete the custom setting + if ( + this._job_config.custom_settings !== undefined && + this._job_config.custom_settings[setting] !== undefined + ) { + delete this._job_config.custom_settings[setting]; + + if (Object.keys(this._job_config.custom_settings).length === 0) { + // clean up custom_settings if there's nothing else in there + delete this._job_config.custom_settings; + } + } + } else { + if (this._job_config.custom_settings === undefined) { + // if custom_settings doesn't exist, create it. + this._job_config.custom_settings = { + [setting]: value, + }; + } else { + this._job_config.custom_settings[setting] = value; + } + } + } + + private _getCustomSetting(setting: string): string | object | null { + if ( + this._job_config.custom_settings !== undefined && + this._job_config.custom_settings[setting] !== undefined + ) { + return this._job_config.custom_settings[setting]; + } + return null; + } + + public set createdBy(createdBy: string | null) { + this._setCustomSetting('created_by', createdBy); + } + + public get createdBy(): string | null { + return this._getCustomSetting('created_by') as string | null; + } + + public get formattedJobJson() { + return JSON.stringify(this._job_config, null, 2); + } + + public get formattedDatafeedJson() { + return JSON.stringify(this._datafeed_config, null, 2); + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts new file mode 100644 index 0000000000000..d2194b57e2f1b --- /dev/null +++ b/x-pack/legacy/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 { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +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 const jobCreatorFactory = (jobType: JOB_TYPE) => ( + indexPattern: IndexPattern, + 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/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts new file mode 100644 index 0000000000000..2116511b53a75 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts @@ -0,0 +1,139 @@ +/* + * 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 { IndexPattern } from 'ui/index_patterns'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE, CREATED_BY_LABEL, DEFAULT_MODEL_MEMORY_LIMIT } from './util/constants'; +import { ml } from '../../../../services/ml_api_service'; + +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; + private _lastEstimatedModelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + protected _type: JOB_TYPE = JOB_TYPE.MULTI_METRIC; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.MULTI_METRIC; + } + + // set the split field, applying it to each detector + public setSplitField(field: SplitField) { + this._splitField = field; + + if (this._splitField === null) { + this.removeSplitField(); + } else { + for (let i = 0; i < this._detectors.length; i++) { + this._detectors[i].partition_field_name = this._splitField.id; + } + } + } + + public removeSplitField() { + this._detectors.forEach(d => { + delete d.partition_field_name; + }); + } + + public get splitField(): SplitField { + return this._splitField; + } + + public addDetector(agg: Aggregation, field: Field) { + const dtr: Detector = this._createDetector(agg, field); + this._addDetector(dtr, agg, field); + } + + public editDetector(agg: Aggregation, field: Field, index: number) { + const dtr: Detector = this._createDetector(agg, field); + this._editDetector(dtr, agg, field, index); + } + + // create a new detector object, applying the overall split field + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (this._splitField !== null) { + dtr.partition_field_name = this._splitField.id; + } + return dtr; + } + + public removeDetector(index: number) { + this._removeDetector(index); + } + + // called externally to set the model memory limit based current detector configuration + public async calculateModelMemoryLimit() { + if (this._splitField === null) { + // not split field, use the default + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } else { + const fieldNames = this._detectors.map(d => d.field_name).filter(fn => fn !== undefined); + const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ + indexPattern: this._indexPatternTitle, + splitFieldName: this._splitField.name, + query: this._datafeed_config.query, + fieldNames, + influencerNames: this._influencers, + timeFieldName: this._job_config.data_description.time_field, + earliestMs: this._start, + latestMs: this._end, + }); + + try { + if (this.modelMemoryLimit === null) { + this.modelMemoryLimit = modelMemoryLimit; + } else { + // To avoid overwriting a possible custom set model memory limit, + // it only gets set to the estimation if the current limit is either + // the default value or the value of the previous estimation. + // That's our best guess if the value hasn't been customized. + // It doesn't get it if the user intentionally for whatever reason (re)set + // the value to either the default or pervious estimate. + // Because the string based limit could contain e.g. MB/Mb/mb + // all strings get lower cased for comparison. + const currentModelMemoryLimit = this.modelMemoryLimit.toLowerCase(); + const defaultModelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT.toLowerCase(); + if ( + currentModelMemoryLimit === defaultModelMemoryLimit || + currentModelMemoryLimit === this._lastEstimatedModelMemoryLimit + ) { + this.modelMemoryLimit = modelMemoryLimit; + } + } + this._lastEstimatedModelMemoryLimit = modelMemoryLimit.toLowerCase(); + } catch (error) { + if (this.modelMemoryLimit === null) { + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } else { + // To avoid overwriting a possible custom set model memory limit, + // the limit is reset to the default only if the current limit matches + // the previous estimated limit. + const currentModelMemoryLimit = this.modelMemoryLimit.toLowerCase(); + if (currentModelMemoryLimit === this._lastEstimatedModelMemoryLimit) { + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } + // eslint-disable-next-line no-console + console.error('Model memory limit could not be calculated', error); + } + } + } + } + + public get aggFieldPairs(): AggFieldPair[] { + return this.detectors.map((d, i) => ({ + field: this._fields[i], + agg: this._aggs[i], + })); + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts new file mode 100644 index 0000000000000..31b29231377e6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts @@ -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 { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE, CREATED_BY_LABEL } 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; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.POPULATION; + } + + // add a by field to a specific detector + public setByField(field: SplitField, index: number) { + if (field === null) { + this.removeByField(index); + } else { + if (this._detectors[index] !== undefined) { + this._byFields[index] = field; + this._detectors[index].by_field_name = field.id; + } + } + } + + // remove a by field from a specific detector + public removeByField(index: number) { + if (this._detectors[index] !== undefined) { + this._byFields[index] = null; + delete this._detectors[index].by_field_name; + } + } + + // get the by field for a specific detector + public getByField(index: number): SplitField { + if (this._byFields[index] === undefined) { + return null; + } + return this._byFields[index]; + } + + // add an over field to all detectors + public setSplitField(field: SplitField) { + this._splitField = field; + + if (this._splitField === null) { + this.removeSplitField(); + } else { + for (let i = 0; i < this._detectors.length; i++) { + this._detectors[i].over_field_name = this._splitField.id; + } + } + } + + // remove over field from all detectors + public removeSplitField() { + this._detectors.forEach(d => { + delete d.over_field_name; + }); + } + + public get splitField(): SplitField { + return this._splitField; + } + + public addDetector(agg: Aggregation, field: Field) { + const dtr: Detector = this._createDetector(agg, field); + + this._addDetector(dtr, agg, field); + this._byFields.push(null); + } + + // edit a specific detector, reapplying the by field + // already set on the the detector at that index + public editDetector(agg: Aggregation, field: Field, index: number) { + const dtr: Detector = this._createDetector(agg, field); + + const sp = this._byFields[index]; + if (sp !== undefined && sp !== null) { + dtr.by_field_name = sp.id; + } + + this._editDetector(dtr, agg, field, index); + } + + // create a detector object, adding the current over field + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (field !== null) { + dtr.field_name = field.id; + } + + if (this._splitField !== null) { + dtr.over_field_name = this._splitField.id; + } + return dtr; + } + + public removeDetector(index: number) { + this._removeDetector(index); + this._byFields.splice(index, 1); + } + + public get aggFieldPairs(): AggFieldPair[] { + return this.detectors.map((d, i) => ({ + field: this._fields[i], + agg: this._aggs[i], + by: { + field: this._byFields[i], + value: null, + }, + })); + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts new file mode 100644 index 0000000000000..6bba0f6de9be9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts @@ -0,0 +1,179 @@ +/* + * 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 { IndexPattern } from 'ui/index_patterns'; +import { parseInterval } from 'ui/utils/parse_interval'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, AggFieldPair } 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, CREATED_BY_LABEL } from './util/constants'; + +export class SingleMetricJobCreator extends JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.SINGLE_METRIC; + } + + // only a single detector exists for this job type + // therefore _addDetector and _editDetector merge into this + // single setDetector function + public setDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (this._detectors.length === 0) { + this._addDetector(dtr, agg, field); + } else { + this._editDetector(dtr, agg, field, 0); + } + + this._createDatafeedAggregations(); + } + + public set bucketSpan(bucketSpan: BucketSpan) { + this._job_config.analysis_config.bucket_span = bucketSpan; + 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 _createDatafeedAggregations() { + if ( + this._detectors.length && + typeof this._job_config.analysis_config.bucket_span === 'string' && + this._aggs.length > 0 + ) { + delete this._job_config.analysis_config.summary_count_field_name; + delete this._datafeed_config.aggregations; + + 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); + if (duration === null) { + return; + } + + const bucketSpanSeconds = duration.asSeconds(); + const interval = bucketSpanSeconds * 1000; + + let field = null; + + switch (functionName) { + case KIBANA_AGGREGATION.COUNT: + this._job_config.analysis_config.summary_count_field_name = 'doc_count'; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval}ms`, + }, + aggregations: { + [timeField]: { + max: { + field: timeField, + }, + }, + }, + }, + }; + break; + case KIBANA_AGGREGATION.AVG: + case KIBANA_AGGREGATION.MEDIAN: + case KIBANA_AGGREGATION.SUM: + case KIBANA_AGGREGATION.MIN: + case KIBANA_AGGREGATION.MAX: + field = this._fields[0]; + if (field !== null) { + const fieldName = field.name; + this._job_config.analysis_config.summary_count_field_name = 'doc_count'; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval * 0.1}ms`, // use 10% of bucketSpan to allow for better sampling + }, + aggregations: { + [fieldName]: { + [functionName]: { + field: fieldName, + }, + }, + [timeField]: { + max: { + field: timeField, + }, + }, + }, + }, + }; + } + break; + case KIBANA_AGGREGATION.CARDINALITY: + field = this._fields[0]; + if (field !== null) { + const fieldName = field.name; + + this._job_config.analysis_config.summary_count_field_name = `dc_${fieldName}`; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval}ms`, + }, + aggregations: { + [timeField]: { + max: { + field: timeField, + }, + }, + [this._job_config.analysis_config.summary_count_field_name]: { + [functionName]: { + field: fieldName, + }, + }, + }, + }, + }; + + const dtr = this._detectors[0]; + // finally, modify the detector before saving + dtr.function = 'non_zero_count'; + // add a description using the original function name rather 'non_zero_count' + // as the user may not be aware it's been changed + dtr.detector_description = `${functionName} (${fieldName})`; + delete dtr.field_name; + } + break; + default: + break; + } + } + } + + public get aggFieldPair(): AggFieldPair | null { + if (this._aggs.length === 0) { + return null; + } else { + return { + agg: this._aggs[0], + field: this._fields[0], + }; + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts new file mode 100644 index 0000000000000..faa04dc17c845 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts @@ -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. + */ + +export enum JOB_TYPE { + SINGLE_METRIC = 'single_metric', + MULTI_METRIC = 'multi_metric', + POPULATION = 'population', +} + +export enum CREATED_BY_LABEL { + SINGLE_METRIC = 'single-metric-wizard', + MULTI_METRIC = 'multi-metric-wizard', + POPULATION = 'population-wizard', +} + +export const DEFAULT_MODEL_MEMORY_LIMIT = '10MB'; +export const DEFAULT_BUCKET_SPAN = '15m'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.ts new file mode 100644 index 0000000000000..2a09415c50bc4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.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 { Job, Datafeed } from '../configs'; +import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { Field, Aggregation, EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields'; +import { Detector } from '../configs'; + +export function createEmptyJob(): Job { + return { + job_id: '', + description: '', + groups: [], + analysis_config: { + bucket_span: '', + detectors: [], + influencers: [], + }, + data_description: { + time_field: '', + }, + }; +} + +export function createEmptyDatafeed(indexPatternTitle: IndexPatternTitle): Datafeed { + return { + datafeed_id: '', + indices: [indexPatternTitle], + query: {}, + }; +} + +export function createBasicDetector(agg: Aggregation, field: Field) { + const dtr: Detector = { + function: agg.id, + }; + + if (field.id !== EVENT_RATE_FIELD_ID) { + dtr.field_name = field.id; + } + return dtr; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/index.ts new file mode 100644 index 0000000000000..2ec2c3037ad7e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/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 { JobRunner, ProgressSubscriber } from './job_runner'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts new file mode 100644 index 0000000000000..3f8bdfa371d01 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts @@ -0,0 +1,108 @@ +/* + * 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 { ml } from '../../../../services/ml_api_service'; +import { mlJobService } from '../../../../services/job_service'; +import { JobCreator } from '../job_creator'; +import { DatafeedId, JobId } from '../job_creator/configs'; +import { DATAFEED_STATE } from '../../../../../common/constants/states'; + +const REFRESH_INTERVAL_MS = 100; +type Progress = number; +export type ProgressSubscriber = (progress: number) => void; + +export class JobRunner { + private _jobId: JobId; + private _datafeedId: DatafeedId; + private _start: number = 0; + private _end: number = 0; + private _datafeedState: DATAFEED_STATE = DATAFEED_STATE.STOPPED; + private _refreshInterval: number = REFRESH_INTERVAL_MS; + + private _progress$: BehaviorSubject; + private _percentageComplete: Progress = 0; + private _stopRefreshPoll: { + stop: boolean; + }; + + constructor(jobCreator: JobCreator) { + this._jobId = jobCreator.jobId; + this._datafeedId = jobCreator.datafeedId; + 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 + // to the progress BehaviorSubject. + jobCreator.subscribers.forEach(s => this._progress$.subscribe(s)); + } + + public get datafeedState(): DATAFEED_STATE { + return this._datafeedState; + } + + public set refreshInterval(v: number) { + this._refreshInterval = v; + } + + public resetInterval() { + this._refreshInterval = REFRESH_INTERVAL_MS; + } + + private async openJob(): Promise { + try { + await mlJobService.openJob(this._jobId); + } catch (error) { + throw error; + } + } + + // start the datafeed and then start polling for progress + // the complete percentage is added to an observable + // so all pre-subscribed listeners can follow along. + public async startDatafeed(): Promise { + try { + await this.openJob(); + await mlJobService.startDatafeed(this._datafeedId, this._jobId, this._start, this._end); + this._datafeedState = DATAFEED_STATE.STARTED; + this._percentageComplete = 0; + + const check = async () => { + const { isRunning, progress } = await this.getProgress(); + + this._percentageComplete = progress; + this._progress$.next(this._percentageComplete); + + if (isRunning === true && this._stopRefreshPoll.stop === false) { + setTimeout(async () => { + await check(); + }, this._refreshInterval); + } + }; + // wait for the first check to run and then return success. + // all subsequent checks will update the observable + await check(); + } catch (error) { + throw error; + } + } + + public async getProgress(): Promise<{ progress: Progress; isRunning: boolean }> { + return await ml.jobs.getLookBackProgress(this._jobId, this._start, this._end); + } + + public subscribeToProgress(func: ProgressSubscriber) { + this._progress$.subscribe(func); + } + + public async isRunning(): Promise { + const { isRunning } = await this.getProgress(); + return isRunning; + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/index.ts new file mode 100644 index 0000000000000..de8e2b67bfb0f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/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 { JobValidator, Validation, BasicValidations, ValidationSummary } from './job_validator'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts new file mode 100644 index 0000000000000..2d5680f1a61a0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts @@ -0,0 +1,138 @@ +/* + * 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 { basicJobValidation } from '../../../../../common/util/job_utils'; +import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { JobCreator } from '../job_creator'; +import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +// delay start of validation to allow the user to make changes +// e.g. if they are typing in a new value, try not to validate +// after every keystroke +const VALIDATION_DELAY_MS = 500; + +export interface ValidationSummary { + basic: boolean; + advanced: boolean; +} + +export interface Validation { + valid: boolean; + message?: string; +} + +export interface BasicValidations { + jobId: Validation; + groupIds: Validation; + modelMemoryLimit: Validation; + bucketSpan: Validation; + duplicateDetectors: Validation; +} + +export class JobValidator { + private _jobCreator: JobCreator; + private _validationSummary: ValidationSummary; + private _lastJobConfig: string; + private _validateTimeout: NodeJS.Timeout; + private _existingJobsAndGroups: ExistingJobsAndGroups; + private _basicValidations: BasicValidations = { + jobId: { valid: true }, + groupIds: { valid: true }, + modelMemoryLimit: { valid: true }, + bucketSpan: { valid: true }, + duplicateDetectors: { valid: true }, + }; + + constructor(jobCreator: JobCreator, existingJobsAndGroups: ExistingJobsAndGroups) { + this._jobCreator = jobCreator; + this._lastJobConfig = this._jobCreator.formattedJobJson; + this._validationSummary = { + basic: false, + advanced: false, + }; + this._validateTimeout = setTimeout(() => {}, 0); + this._existingJobsAndGroups = existingJobsAndGroups; + } + + public validate() { + const formattedJobConfig = this._jobCreator.formattedJobJson; + return new Promise((resolve: () => void) => { + // only validate if the config has changed + if (formattedJobConfig !== this._lastJobConfig) { + clearTimeout(this._validateTimeout); + this._lastJobConfig = formattedJobConfig; + this._validateTimeout = setTimeout(() => { + this._runBasicValidation(); + resolve(); + }, VALIDATION_DELAY_MS); + } else { + resolve(); + } + }); + } + + private _resetBasicValidations() { + this._validationSummary.basic = true; + Object.values(this._basicValidations).forEach(v => { + v.valid = true; + delete v.message; + }); + } + + private _runBasicValidation() { + this._resetBasicValidations(); + + const jobConfig = this._jobCreator.jobConfig; + const limits = newJobLimits(); + + // run standard basic validation + const basicResults = basicJobValidation(jobConfig, undefined, limits); + populateValidationMessages(basicResults, this._basicValidations, jobConfig); + + // run addition job and group id validation + const idResults = checkForExistingJobAndGroupIds( + this._jobCreator.jobId, + this._jobCreator.groups, + this._existingJobsAndGroups + ); + populateValidationMessages(idResults, this._basicValidations, jobConfig); + + this._validationSummary.basic = this._isOverallBasicValid(); + } + + private _isOverallBasicValid() { + return Object.values(this._basicValidations).some(v => v.valid === false) === false; + } + + public get validationSummary(): ValidationSummary { + return this._validationSummary; + } + + public get bucketSpan(): Validation { + return this._basicValidations.bucketSpan; + } + + public get duplicateDetectors(): Validation { + return this._basicValidations.duplicateDetectors; + } + + public get jobId(): Validation { + return this._basicValidations.jobId; + } + + public get groupIds(): Validation { + return this._basicValidations.groupIds; + } + + public get modelMemoryLimit(): Validation { + return this._basicValidations.modelMemoryLimit; + } + + public set advancedValid(valid: boolean) { + this._validationSummary.advanced = valid; + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts new file mode 100644 index 0000000000000..784114364c94d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts @@ -0,0 +1,160 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { BasicValidations } from './job_validator'; +import { Job } from '../job_creator/configs'; +import { ALLOWED_DATA_UNITS } from '../../../../../common/constants/validation'; +import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { ValidationResults, ValidationMessage } from '../../../../../common/util/job_utils'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +export function populateValidationMessages( + validationResults: ValidationResults, + basicValidations: BasicValidations, + jobConfig: Job +) { + const limits = newJobLimits(); + + if (validationResults.contains('job_id_empty')) { + basicValidations.jobId.valid = false; + } else if (validationResults.contains('job_id_invalid')) { + basicValidations.jobId.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.jobNameAllowedCharactersDescription', + { + defaultMessage: + 'Job name can contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; ' + + 'must start and end with an alphanumeric character', + } + ); + basicValidations.jobId.message = msg; + } else if (validationResults.contains('job_id_already_exists')) { + basicValidations.jobId.valid = false; + const msg = i18n.translate('xpack.ml.newJob.wizard.validateJob.jobNameAlreadyExists', { + defaultMessage: + 'Job ID already exists. A job ID cannot be the same as an existing job or group.', + }); + basicValidations.jobId.message = msg; + } + + if (validationResults.contains('job_group_id_invalid')) { + basicValidations.groupIds.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.jobGroupAllowedCharactersDescription', + { + defaultMessage: + 'Job group names can contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; ' + + 'must start and end with an alphanumeric character', + } + ); + basicValidations.groupIds.message = msg; + } else if (validationResults.contains('job_group_id_already_exists')) { + basicValidations.groupIds.valid = false; + const msg = i18n.translate('xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists', { + defaultMessage: + 'Group ID already exists. A group ID cannot be the same as an existing job or group.', + }); + basicValidations.groupIds.message = msg; + } + + if (validationResults.contains('model_memory_limit_units_invalid')) { + basicValidations.modelMemoryLimit.valid = false; + const str = `${ALLOWED_DATA_UNITS.slice(0, ALLOWED_DATA_UNITS.length - 1).join(', ')} or ${[ + ...ALLOWED_DATA_UNITS, + ].pop()}`; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.modelMemoryLimitUnitsInvalidErrorMessage', + { + defaultMessage: 'Model memory limit data unit unrecognized. It must be {str}', + values: { str }, + } + ); + basicValidations.modelMemoryLimit.message = msg; + } + + if (validationResults.contains('model_memory_limit_invalid')) { + basicValidations.modelMemoryLimit.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.modelMemoryLimitRangeInvalidErrorMessage', + { + defaultMessage: + 'Model memory limit cannot be higher than the maximum value of {maxModelMemoryLimit}', + values: { maxModelMemoryLimit: limits.max_model_memory_limit.toUpperCase() }, + } + ); + basicValidations.modelMemoryLimit.message = msg; + } + + if (validationResults.contains('detectors_duplicates')) { + basicValidations.duplicateDetectors.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage', + { + defaultMessage: 'Duplicate detectors were found.', + } + ); + basicValidations.duplicateDetectors.message = msg; + } + + if (validationResults.contains('bucket_span_empty')) { + basicValidations.bucketSpan.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage', + { + defaultMessage: 'Bucket span must be set', + } + ); + + basicValidations.bucketSpan.message = msg; + } else if (validationResults.contains('bucket_span_invalid')) { + basicValidations.bucketSpan.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.bucketSpanInvalidTimeIntervalFormatErrorMessage', + { + defaultMessage: + '{bucketSpan} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.', + values: { + bucketSpan: jobConfig.analysis_config.bucket_span, + tenMinutes: '10m', + oneHour: '1h', + }, + } + ); + + basicValidations.bucketSpan.message = msg; + } +} + +export function checkForExistingJobAndGroupIds( + jobId: string, + groupIds: string[], + existingJobsAndGroups: ExistingJobsAndGroups +): ValidationResults { + const messages: ValidationMessage[] = []; + + // check that job id does not already exist as a job or group or a newly created group + if ( + existingJobsAndGroups.jobIds.includes(jobId) || + existingJobsAndGroups.groupIds.includes(jobId) || + groupIds.includes(jobId) + ) { + messages.push({ id: 'job_id_already_exists' }); + } + + // check that groups that have been newly added in this job do not already exist as job ids + const newGroups = groupIds.filter(g => !existingJobsAndGroups.groupIds.includes(g)); + if (existingJobsAndGroups.jobIds.some(g => newGroups.includes(g))) { + messages.push({ id: 'job_group_id_already_exists' }); + } + + return { + messages, + valid: messages.length === 0, + contains: (id: string) => messages.some(m => id === m.id), + find: (id: string) => messages.find(m => id === m.id), + }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts new file mode 100644 index 0000000000000..ef0b05f73fa31 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts new file mode 100644 index 0000000000000..ef237d3c46b39 --- /dev/null +++ b/x-pack/legacy/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 _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 && + (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) { + return 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/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts new file mode 100644 index 0000000000000..bb47af0c4b27a --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts new file mode 100644 index 0000000000000..d3feaf087524c --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx new file mode 100644 index 0000000000000..ae0cb502d56fd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx @@ -0,0 +1,66 @@ +/* + * 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; + if (data.length === 0) { + return null; + } + + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx new file mode 100644 index 0000000000000..d068609760da8 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts new file mode 100644 index 0000000000000..6543466bfd254 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts @@ -0,0 +1,56 @@ +/* + * 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 chrome from 'ui/chrome'; +import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; + +const IS_DARK_THEME = chrome.getUiSettingsClient().get('theme:darkMode'); +const themeName = IS_DARK_THEME ? darkTheme : lightTheme; + +export const LINE_COLOR = themeName.euiColorPrimary; +export const MODEL_COLOR = themeName.euiColorPrimary; +export const EVENT_RATE_COLOR = themeName.euiColorPrimary; + +export interface ChartSettings { + width: string; + height: string; + cols: 1 | 2 | 3; + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx new file mode 100644 index 0000000000000..011df2bc550a7 --- /dev/null +++ b/x-pack/legacy/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'; +import { EVENT_RATE_COLOR } from '../common/settings'; + +interface Props { + eventRateChartData: LineChartPoint[]; + height: string; + width: string; + showAxis?: boolean; +} + +const SPEC_ID = 'event_rate'; + +export const EventRateChart: FC = ({ eventRateChartData, height, width, showAxis }) => { + return ( +
+ + {showAxis === true && } + + + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts new file mode 100644 index 0000000000000..8e242adc6fee7 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts @@ -0,0 +1,46 @@ +/* + * 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 } 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'; +import { JobValidator } from '../../common/job_validator'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +export interface JobCreatorContextValue { + jobCreatorUpdated: number; + jobCreatorUpdate: () => void; + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + jobValidator: JobValidator; + jobValidatorUpdated: number; + 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, + jobValidator: {} as JobValidator, + jobValidatorUpdated: 0, + fields: [], + aggs: [], + existingJobsAndGroups: {} as ExistingJobsAndGroups, +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.tsx new file mode 100644 index 0000000000000..0aa87925eb61b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.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, { FC, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { CalendarsSelection } from './components/calendars'; + +const ButtonContent = Additional settings; + +interface Props { + additionalExpanded: boolean; + setAdditionalExpanded: (a: boolean) => void; +} + +export const AdditionalSection: FC = ({ additionalExpanded, setAdditionalExpanded }) => { + return null; // disable this section until custom URLs component is ready + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx new file mode 100644 index 0000000000000..f749d78508a2e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx @@ -0,0 +1,53 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; +import { ml } from '../../../../../../../../../services/ml_api_service'; + +export const CalendarsSelection: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [selectedCalendars, setSelectedCalendars] = useState(jobCreator.calendars); + const [selectedOptions, setSelectedOptions] = useState([]); + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + async function loadCalendars() { + setIsLoading(true); + const calendars = await ml.calendars(); + setOptions(calendars.map(c => ({ label: c.calendar_id }))); + setSelectedOptions(selectedCalendars.map(c => ({ label: c }))); + setIsLoading(false); + } + + useEffect(() => { + loadCalendars(); + }, []); + + function onChange(optionsIn: EuiComboBoxOptionProps[]) { + setSelectedOptions(optionsIn); + setSelectedCalendars(optionsIn.map(o => o.label)); + } + + useEffect(() => { + jobCreator.calendars = selectedCalendars; + jobCreatorUpdate(); + }, [selectedCalendars.join()]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.tsx new file mode 100644 index 0000000000000..339a4c14530e8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Calendars'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/index.ts new file mode 100644 index 0000000000000..54fd45c6f40e4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/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 { CalendarsSelection } from './calendars_selection'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/index.ts new file mode 100644 index 0000000000000..ba62f0cdc6983 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/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 { AdditionalSection } from './additional_section'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.tsx new file mode 100644 index 0000000000000..2bc7a612e1f20 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.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, { FC, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { ModelPlotSwitch } from './components/model_plot'; +import { DedicatedIndexSwitch } from './components/dedicated_index'; +import { ModelMemoryLimitInput } from './components/model_memory_limit'; + +const ButtonContent = Advanced; + +interface Props { + advancedExpanded: boolean; + setAdvancedExpanded: (a: boolean) => void; +} + +export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpanded }) => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx new file mode 100644 index 0000000000000..7be4c5563355d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx @@ -0,0 +1,30 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const DedicatedIndexSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [useDedicatedIndex, setUseDedicatedIndex] = useState(jobCreator.useDedicatedIndex); + + useEffect(() => { + jobCreator.useDedicatedIndex = useDedicatedIndex; + jobCreatorUpdate(); + }, [useDedicatedIndex]); + + function toggleModelPlot() { + setUseDedicatedIndex(!useDedicatedIndex); + } + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx new file mode 100644 index 0000000000000..8807bfbb810d5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Use dedicated index'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/index.ts new file mode 100644 index 0000000000000..97135b6e67c42 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/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 { DedicatedIndexSwitch } from './dedicated_index_switch'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx new file mode 100644 index 0000000000000..244f37cd33f83 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx @@ -0,0 +1,39 @@ +/* + * 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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Model memory limit'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/index.ts new file mode 100644 index 0000000000000..97f708a0bf124 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/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 { ModelMemoryLimitInput } from './model_memory_limit_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx new file mode 100644 index 0000000000000..cb690cfcfbc53 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx @@ -0,0 +1,39 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const ModelMemoryLimitInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [validation, setValidation] = useState(jobValidator.modelMemoryLimit); + const [modelMemoryLimit, setModelMemoryLimit] = useState( + jobCreator.modelMemoryLimit === null ? '' : jobCreator.modelMemoryLimit + ); + + useEffect(() => { + jobCreator.modelMemoryLimit = modelMemoryLimit === '' ? null : modelMemoryLimit; + jobCreatorUpdate(); + }, [modelMemoryLimit]); + + useEffect(() => { + setValidation(jobValidator.modelMemoryLimit); + }, [jobValidatorUpdated]); + + return ( + + setModelMemoryLimit(e.target.value)} + isInvalid={validation.valid === false} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.tsx new file mode 100644 index 0000000000000..17a0a0d7f1a62 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Enable model plot'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/index.ts new file mode 100644 index 0000000000000..f7526ff93df9a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/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 { ModelPlotSwitch } from './model_plot_switch'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx new file mode 100644 index 0000000000000..0ae8016b7dce9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -0,0 +1,30 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const ModelPlotSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [modelPlotEnabled, setModelPlotEnabled] = useState(jobCreator.modelPlot); + + useEffect(() => { + jobCreator.modelPlot = modelPlotEnabled; + jobCreatorUpdate(); + }, [modelPlotEnabled]); + + function toggleModelPlot() { + setModelPlotEnabled(!modelPlotEnabled); + } + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/index.ts new file mode 100644 index 0000000000000..abec13ebdf70c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/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 { AdvancedSection } from './advanced_section'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx new file mode 100644 index 0000000000000..027bbe25a8cf8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx @@ -0,0 +1,39 @@ +/* + * 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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Groups'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx new file mode 100644 index 0000000000000..d1eb1a45cf09b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx @@ -0,0 +1,83 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { tabColor } from '../../../../../../../../common/util/group_color_utils'; +import { Description } from './description'; + +export const GroupsInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const { existingJobsAndGroups } = useContext(JobCreatorContext); + const [selectedGroups, setSelectedGroups] = useState(jobCreator.groups); + const [validation, setValidation] = useState(jobValidator.groupIds); + + useEffect(() => { + jobCreator.groups = selectedGroups; + jobCreatorUpdate(); + }, [selectedGroups.join()]); + + const options: EuiComboBoxOptionProps[] = existingJobsAndGroups.groupIds.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + const selectedOptions: EuiComboBoxOptionProps[] = selectedGroups.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + function onChange(optionsIn: EuiComboBoxOptionProps[]) { + setSelectedGroups(optionsIn.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 + ) { + options.push(newGroup); + } + + setSelectedGroups([...selectedOptions, newGroup].map(g => g.label)); + } + + useEffect(() => { + setValidation(jobValidator.groupIds); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/index.ts new file mode 100644 index 0000000000000..bdf4215883632 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/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 { GroupsInput } from './groups_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.tsx new file mode 100644 index 0000000000000..768be30dcc35d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Job description'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/index.ts new file mode 100644 index 0000000000000..c8a2ed92fc63a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/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 { JobDescriptionInput } from './job_description_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx new file mode 100644 index 0000000000000..f1e894f359082 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx @@ -0,0 +1,30 @@ +/* + * 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, useState, useContext, useEffect } from 'react'; +import { EuiTextArea } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Description } from './description'; + +export const JobDescriptionInput: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [jobDescription, setJobDescription] = useState(jobCreator.description); + + useEffect(() => { + jobCreator.description = jobDescription; + jobCreatorUpdate(); + }, [jobDescription]); + + return ( + + setJobDescription(e.target.value)} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx new file mode 100644 index 0000000000000..358f473074bae --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx @@ -0,0 +1,39 @@ +/* + * 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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Job ID'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/index.ts new file mode 100644 index 0000000000000..fd4c1581ca340 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/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 { JobIdInput } from './job_id_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.tsx new file mode 100644 index 0000000000000..14a8b29c0cca4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.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, useState, useContext, useEffect } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Description } from './description'; + +export const JobIdInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [jobId, setJobId] = useState(jobCreator.jobId); + const [validation, setValidation] = useState(jobValidator.jobId); + + useEffect(() => { + jobCreator.jobId = jobId; + jobCreatorUpdate(); + }, [jobId]); + + useEffect(() => { + const isEmptyId = jobId === ''; + setValidation({ + valid: isEmptyId === true || jobValidator.jobId.valid, + message: isEmptyId === false ? jobValidator.jobId.message : '', + }); + }, [jobValidatorUpdated]); + + return ( + + setJobId(e.target.value)} + isInvalid={validation.valid === false} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx new file mode 100644 index 0000000000000..244608f400f55 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx @@ -0,0 +1,78 @@ +/* + * 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, EuiSpacer } from '@elastic/eui'; +import { WizardNav } from '../wizard_nav'; +import { JobIdInput } from './components/job_id'; +import { JobDescriptionInput } from './components/job_description'; +import { GroupsInput } from './components/groups'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { AdvancedSection } from './components/advanced_section'; +import { AdditionalSection } from './components/additional_section'; + +interface Props extends StepProps { + advancedExpanded: boolean; + setAdvancedExpanded: (a: boolean) => void; + additionalExpanded: boolean; + setAdditionalExpanded: (a: boolean) => void; +} + +export const JobDetailsStep: FC = ({ + setCurrentStep, + isCurrentStep, + advancedExpanded, + setAdvancedExpanded, + additionalExpanded, + setAdditionalExpanded, +}) => { + const { jobValidator, jobValidatorUpdated } = useContext(JobCreatorContext); + const [nextActive, setNextActive] = useState(false); + + useEffect(() => { + const active = + jobValidator.jobId.valid && + jobValidator.modelMemoryLimit.valid && + jobValidator.groupIds.valid; + setNextActive(active); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + + + + + + + + + + + + + + + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} + next={() => setCurrentStep(WIZARD_STEPS.VALIDATION)} + nextActive={nextActive} + /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx new file mode 100644 index 0000000000000..938489feef7ee --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx @@ -0,0 +1,82 @@ +/* + * 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, useState, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +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 }) => { + const { jobValidator, jobValidatorUpdated } = useContext(JobCreatorContext); + const [validation, setValidation] = useState(jobValidator.duplicateDetectors); + // create list of labels based on already selected detectors + // so they can be removed from the dropdown list + const removeLabels = removeOptions.map(createLabel); + + 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; + }); + + useEffect(() => { + setValidation(jobValidator.duplicateDetectors); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; + +export function createLabel(pair: AggFieldPair | null): string { + return pair === null ? '' : `${pair.agg.title}(${pair.field.name})`; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts new file mode 100644 index 0000000000000..174bcc799a923 --- /dev/null +++ b/x-pack/legacy/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, createLabel } from './agg_select'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx new file mode 100644 index 0000000000000..eace905b2c6de --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx @@ -0,0 +1,46 @@ +/* + * 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'; +import { Description } from './description'; + +export const BucketSpan: FC = () => { + const { + jobCreator, + jobCreatorUpdate, + jobCreatorUpdated, + jobValidator, + jobValidatorUpdated, + } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + const [validation, setValidation] = useState(jobValidator.bucketSpan); + + useEffect(() => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + }, [bucketSpan]); + + useEffect(() => { + setBucketSpan(jobCreator.bucketSpan); + }, [jobCreatorUpdated]); + + useEffect(() => { + setValidation(jobValidator.bucketSpan); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx b/x-pack/legacy/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..c277b3b84e54a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx @@ -0,0 +1,25 @@ +/* + * 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 { EuiFieldText } from '@elastic/eui'; + +interface Props { + bucketSpan: string; + setBucketSpan: (bs: string) => void; + isInvalid: boolean; +} + +export const BucketSpanInput: FC = ({ bucketSpan, setBucketSpan, isInvalid }) => { + return ( + setBucketSpan(e.target.value)} + isInvalid={isInvalid} + /> + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx new file mode 100644 index 0000000000000..ce5ac51814255 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx @@ -0,0 +1,39 @@ +/* + * 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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Bucket span'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.tsx new file mode 100644 index 0000000000000..a98237de38a06 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Influencers'; + return ( + {title}} + 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} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx new file mode 100644 index 0000000000000..01fc9888d31ed --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx @@ -0,0 +1,58 @@ +/* + * 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 { 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'; +import { Description } from './description'; + +export const Influencers: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return null; + } + + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx new file mode 100644 index 0000000000000..c7495054bdb27 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.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, { FC } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; + +import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../../common/types/fields'; + +interface Props { + fields: Field[]; + changeHandler(i: string[]): void; + selectedInfluencers: string[]; +} + +export const InfluencersSelect: FC = ({ fields, changeHandler, selectedInfluencers }) => { + const options: EuiComboBoxOptionProps[] = fields + .filter(f => f.id !== EVENT_RATE_FIELD_ID) + .map(f => ({ + label: f.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selection: EuiComboBoxOptionProps[] = selectedInfluencers.map(i => ({ label: i })); + + function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + changeHandler(selectedOptions.map(o => o.label)); + } + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx new file mode 100644 index 0000000000000..691527513b804 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; + +import { AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { ChartSettings } from '../../../charts/common/settings'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; +import { SplitCards, useAnimateSplit } from '../split_cards'; +import { DetectorTitle } from '../detector_title'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + fieldValues: string[]; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; + animate?: boolean; +} + +export const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + fieldValues, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, +}) => { + const animateSplit = useAnimateSplit(); + + return ( + + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + )} + + ))} + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/legacy/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..da477193e35ab --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -0,0 +1,193 @@ +/* + * 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 { 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 } from '../../../../../../../../common/types/fields'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { ChartGrid } from './chart_grid'; + +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 null; + } + const jobCreator = jc as MultiMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState( + jobCreator.aggFieldPairs + ); + 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 [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); + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + // watch for changes in detector list length + useEffect(() => { + jobCreator.removeAllDetectors(); + aggFieldPairList.forEach(pair => { + jobCreator.addDetector(pair.agg, pair.field); + }); + jobCreator.calculateModelMemoryLimit(); + 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([]); + } + jobCreator.calculateModelMemoryLimit(); + }, [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 && ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx b/x-pack/legacy/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..e82f7aa9a9b30 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx @@ -0,0 +1,43 @@ +/* + * 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[]; +} + +const MAX_WIDTH = 560; + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx b/x-pack/legacy/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..3dc775f9d00d4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx @@ -0,0 +1,39 @@ +/* + * 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(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx new file mode 100644 index 0000000000000..03cd9b2dee232 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx @@ -0,0 +1,56 @@ +/* + * 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 } 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx new file mode 100644 index 0000000000000..fb801d72fef7e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx @@ -0,0 +1,89 @@ +/* + * 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 { EuiFlexGrid, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { ChartSettings } from '../../../charts/common/settings'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; +import { SplitCards, useAnimateSplit } from '../split_cards'; +import { DetectorTitle } from '../detector_title'; +import { ByFieldSelector } from '../split_field'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; + +type DetectorFieldValues = Record; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; + fieldValuesPerDetector: DetectorFieldValues; +} + +export const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, + fieldValuesPerDetector, +}) => { + const animateSplit = useAnimateSplit(); + + return ( + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + + + {deleteDetector !== undefined && } + + + + + + + )} + + ))} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx new file mode 100644 index 0000000000000..d64b72eb251e4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -0,0 +1,264 @@ +/* + * 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 { 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 } from '../../../../../../../../common/types/fields'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { SplitFieldSelector } from '../split_field'; +import { MlTimeBuckets } from '../../../../../../../util/ml_time_buckets'; +import { ChartGrid } from './chart_grid'; + +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 null; + } + const jobCreator = jc as PopulationJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState( + jobCreator.aggFieldPairs + ); + 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 [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); + const updateByFields = () => setByFieldsUpdated(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]); + updateByFields(); + } + + function setResultsWrapper(results: Results) { + setModelData(results.model); + setAnomalyData(results.anomalies); + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + // 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); + updateByFields(); + } + }, [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 && ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx new file mode 100644 index 0000000000000..e82f7aa9a9b30 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx @@ -0,0 +1,43 @@ +/* + * 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[]; +} + +const MAX_WIDTH = 560; + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx new file mode 100644 index 0000000000000..ab226f12fdf5d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx @@ -0,0 +1,39 @@ +/* + * 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(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx new file mode 100644 index 0000000000000..ee010f89c94a2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem } 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/legacy/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..4bd7602798bd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -0,0 +1,141 @@ +/* + * 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, createLabel } 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'; + +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 null; + } + const jobCreator = jc as SingleMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([ + { label: createLabel(jobCreator.aggFieldPair) }, + ]); + const [aggFieldPair, setAggFieldPair] = useState(jobCreator.aggFieldPair); + const [lineChartsData, setLineChartData] = useState([]); + const [modelData, setModelData] = useState([]); + const [anomalyData, setAnomalyData] = useState([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + + 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); + } + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + 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 && ( + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx new file mode 100644 index 0000000000000..145d492018503 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -0,0 +1,31 @@ +/* + * 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx b/x-pack/legacy/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..35274fe120a0b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx @@ -0,0 +1,39 @@ +/* + * 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(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts new file mode 100644 index 0000000000000..cef01cdabce43 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts @@ -0,0 +1,25 @@ +/* + * 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 { useEffect, useState } from 'react'; + +export const ANIMATION_SWITCH_DELAY_MS = 1000; + +// custom hook to enable the card split animation of the cards 1 second after the component has been rendered +// then switching to a step which contains the cards, the animation shouldn't play, instead +// the cards should be initially rendered in the split state. +// all subsequent changes to the split should be animated. + +export function useAnimateSplit() { + const [animateSplit, setAnimateSplit] = useState(false); + useEffect(() => { + setTimeout(() => { + setAnimateSplit(true); + }, ANIMATION_SWITCH_DELAY_MS); + }, []); + + return animateSplit; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts new file mode 100644 index 0000000000000..f243577d9ae96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/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. + */ + +export { SplitCards } from './split_cards'; +export { useAnimateSplit } from './animate_split_hook'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx new file mode 100644 index 0000000000000..62441c37fd9aa --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -0,0 +1,105 @@ +/* + * 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, 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: JSX.Element; + jobType: JOB_TYPE; + animate?: boolean; +} + +interface Panel { + panel: HTMLDivElement; + marginBottom: number; +} + +export const SplitCards: FC = memo( + ({ fieldValues, splitField, children, numberOfDetectors, jobType, animate = false }) => { + const panels: Panel[] = []; + + function storePanels(panel: HTMLDivElement | null, marginBottom: number) { + if (panel !== null) { + if (animate === false) { + panel.style.marginBottom = `${marginBottom}px`; + } + 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(); + + if (animate === true) { + 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`, + ...(animate ? { 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx new file mode 100644 index 0000000000000..6e16b946d8ac3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -0,0 +1,78 @@ +/* + * 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 { SplitFieldSelect } from './split_field_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Field } from '../../../../../../../../common/types/fields'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + MultiMetricJobCreator, + 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 null; + } + const jobCreator = jc as PopulationJobCreator; + + const { categoryFields: allCategoryFields } = newJobCapsService; + + const [byField, setByField] = useState(jobCreator.getByField(detectorIndex)); + const categoryFields = useFilteredCategoryFields( + allCategoryFields, + jobCreator, + jobCreatorUpdated + ); + + useEffect(() => { + jobCreator.setByField(byField, detectorIndex); + jobCreatorUpdate(); + }, [byField]); + + useEffect(() => { + const bf = jobCreator.getByField(detectorIndex); + setByField(bf); + }, [jobCreatorUpdated]); + + return ( + + ); +}; + +// remove the split (over) field from the by field options +function useFilteredCategoryFields( + allCategoryFields: Field[], + jobCreator: MultiMetricJobCreator | PopulationJobCreator, + jobCreatorUpdated: number +) { + const [fields, setFields] = useState(allCategoryFields); + + useEffect(() => { + const sf = jobCreator.splitField; + if (sf !== null) { + setFields(allCategoryFields.filter(f => f.name !== sf.name)); + } else { + setFields(allCategoryFields); + } + }, [jobCreatorUpdated]); + + return fields; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx new file mode 100644 index 0000000000000..1227f15dd5a5a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx @@ -0,0 +1,57 @@ +/* + * 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, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + children: JSX.Element; + jobType: JOB_TYPE; +} + +export const Description: FC = memo(({ children, jobType }) => { + if (jobType === JOB_TYPE.MULTI_METRIC) { + const title = 'Split field'; + return ( + {title}} + 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) { + const title = 'Population field'; + return ( + {title}} + 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 null; + } +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx new file mode 100644 index 0000000000000..de0546996ef96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.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, 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 null; + } + const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; + const canClearSelection = isMultiMetricJobCreator(jc); + + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx b/x-pack/legacy/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..a30212aeb37bf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_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, { 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; + isClearable: boolean; +} + +export const SplitFieldSelect: FC = ({ + fields, + changeHandler, + selectedField, + isClearable, +}) => { + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx new file mode 100644 index 0000000000000..712d49159b542 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx @@ -0,0 +1,59 @@ +/* + * 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 '../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, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [nextActive, setNextActive] = useState(false); + const [jobType, setJobType] = useState(jobCreator.type); + + useEffect(() => { + // this shouldn't really change, but just in case we need to... + setJobType(jobCreator.type); + }, [jobCreatorUpdated]); + + useEffect(() => { + const active = + jobCreator.detectors.length > 0 && + jobValidator.bucketSpan.valid && + jobValidator.duplicateDetectors.valid; + setNextActive(active); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + {jobType === JOB_TYPE.SINGLE_METRIC && ( + + )} + {jobType === JOB_TYPE.MULTI_METRIC && ( + + )} + {jobType === JOB_TYPE.POPULATION && ( + + )} + setCurrentStep(WIZARD_STEPS.TIME_RANGE)} + next={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} + nextActive={nextActive} + /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts new file mode 100644 index 0000000000000..9497f985efc3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts @@ -0,0 +1,18 @@ +/* + * 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, + VALIDATION, + SUMMARY, +} + +export interface StepProps { + isCurrentStep: boolean; + setCurrentStep: React.Dispatch>; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/index.ts new file mode 100644 index 0000000000000..648e1da5ba1f1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/job_progress.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/job_progress.tsx new file mode 100644 index 0000000000000..d270f37ab48f1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx new file mode 100644 index 0000000000000..93517b1fc6645 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx @@ -0,0 +1,24 @@ +/* + * 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 { JobCreatorContext } from '../job_creator_context'; +import { JOB_TYPE } from '../../../common/job_creator/util/constants'; +import { SingleMetricView } from '../pick_fields_step/components/single_metric_view'; +import { MultiMetricView } from '../pick_fields_step/components/multi_metric_view'; +import { PopulationView } from '../pick_fields_step/components/population_view'; + +export const DetectorChart: FC = () => { + const { jobCreator } = useContext(JobCreatorContext); + + return ( + + {jobCreator.type === JOB_TYPE.SINGLE_METRIC && } + {jobCreator.type === JOB_TYPE.MULTI_METRIC && } + {jobCreator.type === JOB_TYPE.POPULATION && } + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts new file mode 100644 index 0000000000000..f0f441d48afcb --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_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 { SummaryStep } from './summary'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx new file mode 100644 index 0000000000000..149a5ffc4f161 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx @@ -0,0 +1,116 @@ +/* + * 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 } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui'; +import { JobCreatorContext } from '../job_creator_context'; +import { isMultiMetricJobCreator, isPopulationJobCreator } from '../../../common/job_creator'; + +export const JobDetails: FC = () => { + const { jobCreator } = useContext(JobCreatorContext); + + interface ListItems { + title: string; + description: string | JSX.Element; + } + + const jobDetails: ListItems[] = [ + { + title: 'Job ID', + description: jobCreator.jobId, + }, + { + title: 'Job description', + description: + jobCreator.description.length > 0 ? ( + jobCreator.description + ) : ( + No description provided + ), + }, + { + title: 'Groups', + description: + jobCreator.groups.length > 0 ? ( + jobCreator.groups.join(', ') + ) : ( + No groups selected + ), + }, + ]; + + const detectorDetails: ListItems[] = [ + { + title: 'Bucket span', + description: jobCreator.bucketSpan, + }, + ]; + + if (isMultiMetricJobCreator(jobCreator)) { + detectorDetails.push({ + title: 'Split field', + description: + isMultiMetricJobCreator(jobCreator) && jobCreator.splitField !== null ? ( + jobCreator.splitField.name + ) : ( + No split field selected + ), + }); + } + + if (isPopulationJobCreator(jobCreator)) { + detectorDetails.push({ + title: 'Population field', + description: + isPopulationJobCreator(jobCreator) && jobCreator.splitField !== null ? ( + jobCreator.splitField.name + ) : ( + + No population field selected + + ), + }); + } + + detectorDetails.push({ + title: 'Influencers', + description: + jobCreator.influencers.length > 0 ? ( + jobCreator.influencers.join(', ') + ) : ( + No split field selected + ), + }); + + const advancedDetails: ListItems[] = [ + { + title: 'Enable model plot', + description: jobCreator.modelPlot ? 'True' : 'False', + }, + { + title: 'Use dedicated index', + description: jobCreator.useDedicatedIndex ? 'True' : 'False', + }, + { + title: 'Model memory limit', + description: jobCreator.modelMemoryLimit !== null ? jobCreator.modelMemoryLimit : '', + }, + ]; + + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx new file mode 100644 index 0000000000000..e2bb85bfdb318 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx @@ -0,0 +1,58 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; +import { JobCreator } from '../../../common/job_creator'; +import { MLJobEditor } from '../../../../jobs_list/components/ml_job_editor'; + +interface Props { + jobCreator: JobCreator; + closeFlyout: () => void; +} +export const JsonFlyout: FC = ({ jobCreator, closeFlyout }) => { + return ( + + + + + + + + + + + + Close + + + + + + ); +}; + +const Contents: FC<{ title: string; value: string }> = ({ title, value }) => { + const EDITOR_HEIGHT = '800px'; + return ( + + +
{title}
+
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx new file mode 100644 index 0000000000000..a6f4361ee5475 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.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. + */ + +import React, { Fragment, FC, useContext, useState, useEffect } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { mlJobService } from '../../../../../services/job_service'; +import { JsonFlyout } from './json_flyout'; +import { isSingleMetricJobCreator } from '../../../common/job_creator'; +import { JobDetails } from './job_details'; +import { DetectorChart } from './detector_chart'; +import { JobProgress } from './components/job_progress'; + +export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobValidator, jobValidatorUpdated, resultsLoader } = useContext( + JobCreatorContext + ); + const [progress, setProgress] = useState(resultsLoader.progress); + const [showJsonFlyout, setShowJsonFlyout] = useState(false); + const [isValid, setIsValid] = useState(jobValidator.validationSummary.basic); + + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + async function start() { + setShowJsonFlyout(false); + try { + await jobCreator.createAndStartJob(); + } catch (error) { + // catch and display all job creation errors + toastNotifications.addDanger({ + title: i18n.translate('xpack.ml.newJob.wizard.createJobError', { + defaultMessage: `Job creation error`, + }), + text: error.message, + }); + } + } + + function viewResults() { + const url = mlJobService.createResultsUrl( + [jobCreator.jobId], + jobCreator.start, + jobCreator.end, + isSingleMetricJobCreator(jobCreator) === true ? 'timeseriesexplorer' : 'explorer' + ); + window.open(url, '_blank'); + } + + function toggleJsonFlyout() { + setShowJsonFlyout(!showJsonFlyout); + } + + useEffect(() => { + setIsValid(jobValidator.validationSummary.basic); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + + + + + + + {progress === 0 && setCurrentStep(WIZARD_STEPS.VALIDATION)} />} + + {progress < 100 && ( + + 0} disabled={isValid === false}> + Create job + +   + 0}> + Preview job JSON + + {showJsonFlyout && ( + setShowJsonFlyout(false)} jobCreator={jobCreator} /> + )} +   + + )} + {progress > 0 && ( + + View results + + )} + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts b/x-pack/legacy/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/legacy/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/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx new file mode 100644 index 0000000000000..28f8a0460246f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx @@ -0,0 +1,106 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +import { timefilter } from 'ui/timefilter'; +import moment from 'moment'; +import { WizardNav } from '../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 { FullTimeRangeSelector } from '../../../../../components/full_time_range_selector'; +import { EventRateChart } from '../charts/event_rate_chart'; +import { LineChartPoint } from '../../../common/chart_loader'; +import { TimeRangePicker } from './time_range_picker'; +import { GetTimeFieldRangeResponse } from '../../../../../services/ml_api_service'; + +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), + }); + // update the timefilter, to keep the URL in sync + timefilter.setTime({ + from: moment(start).toISOString(), + to: moment(end).toISOString(), + }); + + jobCreatorUpdate(); + loadChart(); + }, [start, end]); + + useEffect(() => { + setStart(jobCreator.start); + setEnd(jobCreator.end); + }, [jobCreatorUpdated]); + + function fullTimeRangeCallback(range: GetTimeFieldRangeResponse) { + setStart(range.start.epoch); + setEnd(range.end.epoch); + } + + return ( + + {isCurrentStep && ( + + + + + + + + + + + + + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} nextActive={true} /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx new file mode 100644 index 0000000000000..1c8964a0dc588 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx @@ -0,0 +1,89 @@ +/* + * 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'; +import React, { Fragment, FC, useContext, useState, useEffect } from 'react'; +import { EuiDatePickerRange, EuiDatePicker } from '@elastic/eui'; + +import { KibanaContext, isKibanaContext } from '../../../../../data_frame/common/kibana_context'; + +const WIDTH = '512px'; + +interface Props { + setStart: (s: number) => void; + setEnd: (e: number) => void; + start: number; + end: number; +} + +type Moment = moment.Moment; + +export const TimeRangePicker: FC = ({ setStart, setEnd, start, end }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + const dateFormat = kibanaContext.kibanaConfig.get('dateFormat'); + + const [startMoment, setStartMoment] = useState(moment(start)); + const [endMoment, setEndMoment] = useState(moment(end)); + + function handleChangeStart(date: Moment | null) { + setStartMoment(date || undefined); + } + + function handleChangeEnd(date: Moment | null) { + setEndMoment(date || undefined); + } + + // update the parent start and end if the timepicker changes + useEffect(() => { + if (startMoment !== undefined && endMoment !== undefined) { + setStart(startMoment.valueOf()); + setEnd(endMoment.valueOf()); + } + }, [startMoment, endMoment]); + + // update our local start and end moment objects if + // the parent start and end updates. + // this happens if the use full data button is pressed. + useEffect(() => { + setStartMoment(moment(start)); + setEndMoment(moment(end)); + }, [start, end]); + + return ( + +
+ + } + endDateControl={ + + } + /> +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/index.ts new file mode 100644 index 0000000000000..c789e9a2fdc5a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_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 { ValidationStep } from './validation'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.tsx new file mode 100644 index 0000000000000..994f909140f3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.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, useContext, useState } from 'react'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { mlJobService } from '../../../../../services/job_service'; +import { ValidateJob } from '../../../../../components/validate_job/validate_job_view'; + +const idFilterList = [ + 'job_id_valid', + 'job_group_id_valid', + 'detectors_function_not_empty', + 'success_bucket_span', +]; + +export const ValidationStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobValidator } = useContext(JobCreatorContext); + const [nextActive, setNextActive] = useState(false); + + function getJobConfig() { + return { + ...jobCreator.jobConfig, + datafeed_config: jobCreator.datafeedConfig, + }; + } + + function getDuration() { + return { + start: jobCreator.start, + end: jobCreator.end, + }; + } + + // keep a record of the advanced validation in the jobValidator + // and disable the next button if any advanced checks have failed. + // note, it is not currently possible to get to a state where any of the + // advanced validation checks return an error because they are all + // caught in previous basic checks + function setIsValid(valid: boolean) { + jobValidator.advancedValid = valid; + setNextActive(valid); + } + + return ( + + {isCurrentStep && ( + + + setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} + next={() => setCurrentStep(WIZARD_STEPS.SUMMARY)} + nextActive={nextActive} + /> + + )} + {isCurrentStep === false && } + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/index.ts new file mode 100644 index 0000000000000..5d9db25730fce --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/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 { WizardNav } from './wizard_nav'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx new file mode 100644 index 0000000000000..d1629e03f36d9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx @@ -0,0 +1,47 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface StepsNavProps { + previousActive?: boolean; + nextActive?: boolean; + previous?(): void; + next?(): void; +} + +export const WizardNav: FC = ({ + previous, + previousActive = true, + next, + nextActive = true, +}) => ( + + + {previous && ( + + + {i18n.translate('xpack.ml.newJob.wizard.previousStepButton', { + defaultMessage: 'Previous', + })} + + + )} + {next && ( + + + {i18n.translate('xpack.ml.newJob.wizard.nextStepButton', { + defaultMessage: 'Next', + })} + + + )} + +); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx new file mode 100644 index 0000000000000..cd80a7f6ca034 --- /dev/null +++ b/x-pack/legacy/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.disableTimeRangeSelector(); + 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/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx new file mode 100644 index 0000000000000..6a06d29c5c690 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx @@ -0,0 +1,109 @@ +/* + * 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 } from '@elastic/eui'; +import { Wizard } from './wizard'; +import { + jobCreatorFactory, + isSingleMetricJobCreator, + isPopulationJobCreator, +} from '../../common/job_creator'; +import { + JOB_TYPE, + DEFAULT_MODEL_MEMORY_LIMIT, + DEFAULT_BUCKET_SPAN, +} from '../../common/job_creator/util/constants'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { JobValidator } from '../../common/job_validator'; +import { KibanaContext, isKibanaContext } from '../../../../data_frame/common/kibana_context'; +import { getTimeFilterRange } from '../../../../components/full_time_range_selector'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { newJobDefaults } from '../../../new_job/utils/new_job_defaults'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +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: ExistingJobsAndGroups; + jobType: JOB_TYPE; +} + +export const Page: FC = ({ existingJobsAndGroups, jobType }) => { + const kibanaContext = useContext(KibanaContext); + if (!isKibanaContext(kibanaContext)) { + return null; + } + + const jobDefaults = newJobDefaults(); + + const jobCreator = jobCreatorFactory(jobType)( + kibanaContext.currentIndexPattern, + kibanaContext.currentSavedSearch, + kibanaContext.combinedQuery + ); + + const { from, to } = getTimeFilterRange(); + jobCreator.setTimeRange(from, to); + + jobCreator.bucketSpan = DEFAULT_BUCKET_SPAN; + + if (isPopulationJobCreator(jobCreator) === true) { + // for population jobs use the default mml (1GB) + jobCreator.modelMemoryLimit = jobDefaults.anomaly_detectors.model_memory_limit; + } else { + // for all other jobs, use 10MB + jobCreator.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } + + if (isSingleMetricJobCreator(jobCreator) === true) { + 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 jobValidator = new JobValidator(jobCreator, existingJobsAndGroups); + + const resultsLoader = new ResultsLoader(jobCreator, chartInterval, chartLoader); + + useEffect(() => { + return () => { + jobCreator.forceStopRefreshPolls(); + }; + }); + + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts new file mode 100644 index 0000000000000..e494e14f65d84 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts @@ -0,0 +1,63 @@ +/* + * 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'; +import { checkGetJobsPrivilege } from '../../../../privilege/check_privilege'; +// @ts-ignore +import { loadCurrentIndexPattern, loadCurrentSavedSearch } from '../../../../util/index_utils'; + +import { + getCreateSingleMetricJobBreadcrumbs, + getCreateMultiMetricJobBreadcrumbs, + getCreatePopulationJobBreadcrumbs, + // @ts-ignore +} from '../../../breadcrumbs'; + +import { Route } from '../../../../../common/types/kibana'; + +import { loadNewJobCapabilities } from '../../../../services/new_job_capabilities_service'; + +import { loadNewJobDefaults } from '../../../new_job/utils/new_job_defaults'; + +import { mlJobService } from '../../../../services/job_service'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; + +const template = ``; + +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, + loadNewJobDefaults, + existingJobsAndGroups: mlJobService.getJobAndGroupIds, + jobType: () => route.id, + }, + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx new file mode 100644 index 0000000000000..6c0ba54793007 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx @@ -0,0 +1,230 @@ +/* + * 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, useReducer, useState, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiStepsHorizontal, EuiSpacer, EuiTitle } 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 { ValidationStep } from '../components/validation_step'; +import { SummaryStep } from '../components/summary_step'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; + +import { JobCreatorContext, JobCreatorContextValue } from '../components/job_creator_context'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + PopulationJobCreator, +} from '../../common/job_creator'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { JobValidator } from '../../common/job_validator'; +import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; + +interface Props { + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + jobValidator: JobValidator; + existingJobsAndGroups: ExistingJobsAndGroups; +} + +export const Wizard: FC = ({ + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + jobValidator, + existingJobsAndGroups, +}) => { + const [jobCreatorUpdated, setJobCreatorUpdate] = useReducer<(s: number) => number>(s => s + 1, 0); + const jobCreatorUpdate = () => setJobCreatorUpdate(jobCreatorUpdated); + + const [jobValidatorUpdated, setJobValidatorUpdate] = useReducer<(s: number) => number>( + s => s + 1, + 0 + ); + + const jobCreatorContext: JobCreatorContextValue = { + jobCreatorUpdated, + jobCreatorUpdate, + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + jobValidator, + jobValidatorUpdated, + fields: newJobCapsService.fields, + aggs: newJobCapsService.aggs, + existingJobsAndGroups, + }; + + // store whether the advanced and additional sections have been expanded. + // has to be stored at this level to ensure it's remembered on wizard step change + const [advancedExpanded, setAdvancedExpanded] = useState(false); + const [additionalExpanded, setAdditionalExpanded] = useState(false); + + const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.TIME_RANGE); + const [highestStep, setHighestStep] = useState(WIZARD_STEPS.TIME_RANGE); + const [disableSteps, setDisableSteps] = useState(false); + const [progress, setProgress] = useState(resultsLoader.progress); + + useEffect(() => { + // IIFE to run the validation. the useEffect callback can't be async + (async () => { + await jobValidator.validate(); + setJobValidatorUpdate(jobValidatorUpdated); + })(); + // if the job config has changed, reset the highestStep + setHighestStep(currentStep); + }, [jobCreatorUpdated]); + + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + // disable the step links if the job is running + useEffect(() => { + setDisableSteps(progress > 0); + }, [progress]); + + // keep a record of the highest step reached in the wizard + useEffect(() => { + if (currentStep >= highestStep) { + setHighestStep(currentStep); + } + }, [currentStep]); + + function jumpToStep(step: WIZARD_STEPS) { + if (step <= highestStep) { + setCurrentStep(step); + } + } + + const stepsConfig = [ + { + title: i18n.translate('xpack.ml.newJob.wizard.step.timeRangeTitle', { + defaultMessage: 'Time range', + }), + onClick: () => jumpToStep(WIZARD_STEPS.TIME_RANGE), + isSelected: currentStep === WIZARD_STEPS.TIME_RANGE, + isComplete: currentStep > WIZARD_STEPS.TIME_RANGE, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.pickFieldsTitle', { + defaultMessage: 'Pick fields', + }), + onClick: () => jumpToStep(WIZARD_STEPS.PICK_FIELDS), + isSelected: currentStep === WIZARD_STEPS.PICK_FIELDS, + isComplete: currentStep > WIZARD_STEPS.PICK_FIELDS, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.jobDetailsTitle', { + defaultMessage: 'Job details', + }), + onClick: () => jumpToStep(WIZARD_STEPS.JOB_DETAILS), + isSelected: currentStep === WIZARD_STEPS.JOB_DETAILS, + isComplete: currentStep > WIZARD_STEPS.JOB_DETAILS, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.validationTitle', { + defaultMessage: 'Validation', + }), + onClick: () => jumpToStep(WIZARD_STEPS.VALIDATION), + isSelected: currentStep === WIZARD_STEPS.VALIDATION, + isComplete: currentStep > WIZARD_STEPS.VALIDATION, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.summaryTitle', { + defaultMessage: 'Summary', + }), + onClick: () => jumpToStep(WIZARD_STEPS.SUMMARY), + isSelected: currentStep === WIZARD_STEPS.SUMMARY, + isComplete: currentStep > WIZARD_STEPS.SUMMARY, + disabled: disableSteps, + }, + ]; + + return ( + + + + {currentStep === WIZARD_STEPS.TIME_RANGE && ( + + Time range + + + )} + {currentStep === WIZARD_STEPS.PICK_FIELDS && ( + + Pick fields + + + )} + {currentStep === WIZARD_STEPS.JOB_DETAILS && ( + + Job details + + + )} + {currentStep === WIZARD_STEPS.VALIDATION && ( + + Validation + + + )} + {currentStep === WIZARD_STEPS.SUMMARY && ( + + Summary + + + )} + + ); +}; + +const Title: FC = ({ children }) => { + return ( + + +

{children}

+
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json b/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json new file mode 100644 index 0000000000000..b05462348a300 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json @@ -0,0 +1,206 @@ +{ + "farequote-*": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "median", + "title": "Median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_median", + "title": "High median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_median", + "title": "Low median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "distinct_count", + "title": "Distinct count", + "kibanaName": "cardinality", + "dslName": "cardinality", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "airline", + "responsetime" + ] + } + ], + "fields": [ + { + "id": "responsetime", + "name": "responsetime", + "type": "float", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "median", + "high_median", + "low_median", + "min", + "max", + "distinct_count" + ] + }, + { + "id": "airline", + "name": "airline", + "type": "keyword", + "aggregatable": true, + "aggIds": [ + "distinct_count" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/public/services/job_service.d.ts b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts new file mode 100644 index 0000000000000..2dc3f791aa8bf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts @@ -0,0 +1,21 @@ +/* + * 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 interface ExistingJobsAndGroups { + jobIds: string[]; + groupIds: string[]; +} + +declare interface JobService { + saveNewJob(job: any): Promise; + 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(): ExistingJobsAndGroups; +} + +export const mlJobService: JobService; diff --git a/x-pack/legacy/plugins/ml/public/services/job_service.js b/x-pack/legacy/plugins/ml/public/services/job_service.js index bb24e77f509e0..25cb1d0ffdacb 100644 --- a/x-pack/legacy/plugins/ml/public/services/job_service.js +++ b/x-pack/legacy/plugins/ml/public/services/job_service.js @@ -740,6 +740,16 @@ class JobService { } + async getJobAndGroupIds() { + try { + return await ml.jobs.getAllJobAndGroupIds(); + } catch (error) { + return { + jobIds: [], + groupIds: [], + }; + } + } } // private function used to check the job saving response diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts index f160b686d845e..133794725d856 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts @@ -5,6 +5,8 @@ */ import { Annotation } from '../../../common/types/annotations'; +import { DslName, AggFieldNamePair } from '../../../common/types/fields'; +import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../common/types/privileges'; // TODO This is not a complete representation of all methods of `ml.*`. @@ -15,6 +17,12 @@ interface EsIndex { name: string; } +export interface GetTimeFieldRangeResponse { + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; +} + declare interface Ml { annotations: { deleteAnnotation(id: string | undefined): Promise; @@ -37,11 +45,64 @@ declare interface Ml { }; hasPrivileges(obj: object): Promise; + checkMlPrivileges(): Promise; - esSearch: any; + getJobStats(obj: object): Promise; + getDatafeedStats(obj: object): Promise; + esSearch(obj: object): any; getIndices(): Promise; - getTimeFieldRange(obj: object): Promise; + getTimeFieldRange(obj: object): Promise; + calculateModelMemoryLimit(obj: object): Promise<{ modelMemoryLimit: string }>; + calendars(): Promise< + Array<{ + calendar_id: string; + description: string; + events: any[]; + job_ids: string[]; + }> + >; + + jobs: { + jobsSummary(jobIds: string[]): Promise; + jobs(jobIds: string[]): Promise; + groups(): Promise; + updateGroups(updatedJobs: string[]): Promise; + forceStartDatafeeds(datafeedIds: string[], start: string, end: string): Promise; + stopDatafeeds(datafeedIds: string[]): Promise; + deleteJobs(jobIds: string[]): Promise; + closeJobs(jobIds: string[]): Promise; + 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 }>; + }; } declare const ml: Ml; diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js index 25962620b9af2..39b646998b426 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js @@ -127,4 +127,84 @@ export const jobs = { }); }, + newJobCaps(indexPatternTitle, isRollup = false) { + const isRollupString = (isRollup === true) ? `?rollup=true` : ''; + return http({ + url: `${basePath}/jobs/new_job_caps/${indexPatternTitle}${isRollupString}`, + 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/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts new file mode 100644 index 0000000000000..ca66c79588a73 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { newJobCapsService } from './new_job_capabilities_service'; +import { IndexPattern } from 'ui/index_patterns'; + +// there is magic happening here. starting the include name with `mock..` +// ensures it can be lazily loaded by the jest.mock function below. +import mockFarequoteResponse from './__mocks__/farequote_job_caps_response.json'; + +jest.mock('./ml_api_service', () => ({ + ml: { + jobs: { + newJobCaps: jest.fn(() => Promise.resolve(mockFarequoteResponse)), + }, + }, +})); + +const indexPattern = ({ + id: 'farequote-*', + title: 'farequote-*', +} as unknown) as IndexPattern; + +describe('new_job_capabilities_service', () => { + describe('farequote newJobCaps()', () => { + it('can construct job caps objects from endpoint json', async done => { + await newJobCapsService.initializeFromIndexPattern(indexPattern); + const { fields, aggs } = await newJobCapsService.newJobCaps; + + const responseTimeField = fields.find(f => f.id === 'responsetime') || { aggs: [] }; + const airlineField = fields.find(f => f.id === 'airline') || { aggs: [] }; + const meanAgg = aggs.find(a => a.id === 'mean') || { fields: [] }; + const distinctCountAgg = aggs.find(a => a.id === 'distinct_count') || { fields: [] }; + + expect(fields).toHaveLength(3); + expect(aggs).toHaveLength(15); + + expect(responseTimeField.aggs).toHaveLength(12); + expect(airlineField.aggs).toHaveLength(1); + + expect(meanAgg.fields).toHaveLength(1); + expect(distinctCountAgg.fields).toHaveLength(2); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.ts new file mode 100644 index 0000000000000..51e8b49ef0982 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.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 { IndexPattern } from 'ui/index_patterns'; +import { + Field, + Aggregation, + AggId, + FieldId, + NewJobCaps, + EVENT_RATE_FIELD_ID, +} from '../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../common/constants/field_types'; +import { ML_JOB_AGGREGATION } from '../../common/constants/aggregation_types'; +import { ml } from './ml_api_service'; + +// called in the angular routing resolve block to initialize the +// newJobCapsService with the currently selected index pattern +export function loadNewJobCapabilities(indexPatterns: any, $route: Record) { + return new Promise(resolve => { + indexPatterns + .get($route.current.params.index) + .then(async (indexPattern: IndexPattern) => { + await newJobCapsService.initializeFromIndexPattern(indexPattern); + resolve(newJobCapsService.newJobCaps); + }) + .catch((error: any) => { + resolve(error); + }); + }); +} + +const categoryFieldTypes = [ES_FIELD_TYPES.TEXT, ES_FIELD_TYPES.KEYWORD, ES_FIELD_TYPES.IP]; + +class NewJobCapsService { + private _fields: Field[]; + private _aggs: Aggregation[]; + private _includeCountAgg: boolean; + + constructor(includeCountAgg = true) { + this._fields = []; + this._aggs = []; + this._includeCountAgg = includeCountAgg; + } + + public get fields(): Field[] { + return this._fields; + } + + public get aggs(): Aggregation[] { + return this._aggs; + } + + public get newJobCaps(): NewJobCaps { + return { + fields: this._fields, + aggs: this._aggs, + }; + } + + public get categoryFields(): Field[] { + return this._fields.filter(f => categoryFieldTypes.includes(f.type)); + } + + public async initializeFromIndexPattern(indexPattern: IndexPattern) { + try { + const resp = await ml.jobs.newJobCaps(indexPattern.title, indexPattern.type === 'rollup'); + const { fields, aggs } = createObjects(resp, indexPattern.title); + + if (this._includeCountAgg === true) { + const { countField, countAggs } = createCountFieldAndAggs(); + + fields.push(countField); + aggs.push(...countAggs); + } + + this._fields = fields; + this._aggs = aggs; + } catch (error) { + console.error('Unable to load new job capabilities', error); // eslint-disable-line no-console + } + } +} + +// using the response from the endpoint, create the field and aggs objects +// when transported over the endpoint, the fields and aggs contain lists of ids of the +// fields and aggs they are related to. +// this function creates lists of real Fields and Aggregations and cross references them. +// the list if ids are then deleted. +function createObjects(resp: any, indexPatternTitle: string) { + const results = resp[indexPatternTitle]; + + const fields: Field[] = []; + const aggs: Aggregation[] = []; + // for speed, a map of aggregations, keyed on their id + + // create a AggMap type to allow an enum (AggId) to be used as a Record key and then initialized with {} + type AggMap = Record; + const aggMap: AggMap = {} as AggMap; + // for speed, a map of aggregation id lists from a field, keyed on the field id + const aggIdMap: Record = {}; + + if (results !== undefined) { + results.aggs.forEach((a: Aggregation) => { + // copy the agg and add a Fields list + const agg: Aggregation = { + ...a, + fields: [], + }; + aggMap[agg.id] = agg; + aggs.push(agg); + }); + + results.fields.forEach((f: Field) => { + // copy the field and add an Aggregations list + const field: Field = { + ...f, + aggs: [], + }; + if (field.aggIds !== undefined) { + aggIdMap[field.id] = field.aggIds; + } + fields.push(field); + }); + + // loop through the fields and populate their aggs lists. + // for each agg added to a field, also add that field to the agg's field list + fields.forEach((field: Field) => { + aggIdMap[field.id].forEach((aggId: AggId) => { + mix(field, aggMap[aggId]); + }); + }); + } + + // the aggIds and fieldIds lists are no longer needed as we've created + // lists of real fields and aggs + fields.forEach(f => delete f.aggIds); + aggs.forEach(a => delete a.fieldIds); + + return { + fields, + aggs, + }; +} + +function mix(field: Field, agg: Aggregation) { + if (agg.fields === undefined) { + agg.fields = []; + } + if (field.aggs === undefined) { + field.aggs = []; + } + agg.fields.push(field); + field.aggs.push(agg); +} + +function createCountFieldAndAggs() { + const countField: Field = { + id: EVENT_RATE_FIELD_ID, + name: 'Event rate', + type: ES_FIELD_TYPES.INTEGER, + aggregatable: true, + aggs: [], + }; + + const countAggs: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + ]; + + if (countField.aggs !== undefined) { + countField.aggs.push(...countAggs); + } + + return { + countField, + countAggs, + }; +} + +export const newJobCapsService = new NewJobCapsService(); diff --git a/x-pack/legacy/plugins/ml/public/services/results_service.d.ts b/x-pack/legacy/plugins/ml/public/services/results_service.d.ts new file mode 100644 index 0000000000000..b30a13ad175cf --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/util/field_types_utils.d.ts b/x-pack/legacy/plugins/ml/public/util/field_types_utils.d.ts new file mode 100644 index 0000000000000..23ca2d1237df5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/util/field_types_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 kbnTypeToMLJobType(field: any): any; diff --git a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.d.ts b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.d.ts new file mode 100644 index 0000000000000..81c150efa2d24 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/public/util/string_utils.d.ts b/x-pack/legacy/plugins/ml/public/util/string_utils.d.ts new file mode 100644 index 0000000000000..10b0cbbe9aeb0 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index 748ce81a748b0..f7f44fb9b3225 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -737,4 +737,18 @@ export const elasticsearchJsPlugin = (Client, config, components) => { method: 'POST' }); + ml.rollupIndexCapabilities = ca({ + urls: [ + { + fmt: '/<%=indexPattern%>/_rollup/data', + req: { + indexPattern: { + type: 'string' + } + } + } + ], + method: 'GET' + }); + }; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index ca896fe302e2f..c180a205cb850 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -8,11 +8,15 @@ 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) { +export function jobServiceProvider(callWithRequest, request) { return { ...datafeedsProvider(callWithRequest), ...jobsProvider(callWithRequest), ...groupsProvider(callWithRequest), + ...newJobCapsProvider(callWithRequest, request), + ...newJobChartsProvider(callWithRequest, request), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js index 18e883f318301..321a8b4a3048e 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js @@ -13,6 +13,7 @@ import { resultsServiceProvider } from '../results_service'; import { CalendarManager } from '../calendar'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; import { getLatestDataOrBucketTimestamp, isTimeSeriesViewJob } from '../../../common/util/job_utils'; +import { groupsProvider } from './groups'; import { uniq } from 'lodash'; export function jobsProvider(callWithRequest) { @@ -342,6 +343,50 @@ export function jobsProvider(callWithRequest) { return results; } + async function getAllJobAndGroupIds() { + const { getAllGroups } = groupsProvider(callWithRequest); + const jobs = await callWithRequest('ml.jobs'); + const jobIds = jobs.jobs.map(job => job.job_id); + const groups = await getAllGroups(); + const groupIds = groups.map(group => group.id); + + return { + jobIds, + groupIds, + }; + } + + 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, @@ -351,5 +396,7 @@ export function jobsProvider(callWithRequest) { createFullJobsList, deletingJobTasks, jobsExist, + getAllJobAndGroupIds, + getLookBackProgress, }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts new file mode 100644 index 0000000000000..a8b6ba494a070 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/server/models/job_service/new_job/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts new file mode 100644 index 0000000000000..758b834ed7b3a --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts new file mode 100644 index 0000000000000..59a33db1da2e9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -0,0 +1,165 @@ +/* + * 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 { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +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'], []); + + 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_FIELD_ID) { + 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]; + } + + tempResults[i].push({ + time, + value, + }); + }); + }); + + 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 && field !== EVENT_RATE_FIELD_ID) { + aggs[i] = { + [agg]: { field }, + }; + + if (agg === 'percentiles') { + aggs[i][agg].percents = [ML_MEDIAN_PERCENTS]; + } + } + }); + + json.body.aggs.times.aggs = aggs; + + return json; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts new file mode 100644 index 0000000000000..69a0472800bf6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -0,0 +1,234 @@ +/* + * 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 { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +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'], []); + + const tempResults: Record = {}; + fields.forEach((f, i) => (tempResults[i] = [])); + + aggregationsByTime.forEach((dataForTime: any) => { + const time: TimeStamp = +dataForTime.key; + + fields.forEach((field, i) => { + const populationBuckets = get(dataForTime, ['population', 'buckets'], []); + const values: Thing[] = []; + if (field === EVENT_RATE_FIELD_ID) { + populationBuckets.forEach((b: any) => { + // 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 }); + }); + } + + tempResults[i].push({ + time, + values, + }); + }); + }); + + 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: any = {}; + + aggFieldNamePairs.forEach(({ agg, field, by }, i) => { + if (field === EVENT_RATE_FIELD_ID) { + if (by !== undefined && by.field !== null && by.value !== null) { + aggs[i] = { + filter: { + term: { + [by.field]: by.value, + }, + }, + }; + } + } 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/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json new file mode 100644 index 0000000000000..9a41c9649b848 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json @@ -0,0 +1,175 @@ +{ + "indices": [ + "cloudwatch-2018.11.03", + "cloudwatch-2018.11.02", + "cloudwatch-2018.11.01", + "cloudwatch-2018.11.11", + "cloudwatch-2018.10.28", + "cloudwatch-2018.11.07", + "cloudwatch-2018.11.06", + "cloudwatch-2018.11.05", + "cloudwatch-2018.11.04", + "cloudwatch-2018.11.10", + "cloudwatch-2018.10.31", + "cloudwatch-2018.10.30", + "cloudwatch-2018.11.09", + "cloudwatch-2018.11.08", + "cloudwatch-2018.10.29" + ], + "fields": { + "_routing": { + "_routing": { + "type": "_routing", + "searchable": true, + "aggregatable": false + } + }, + "instance": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_index": { + "_index": { + "type": "_index", + "searchable": true, + "aggregatable": true + } + }, + "_feature": { + "_feature": { + "type": "_feature", + "searchable": true, + "aggregatable": false + } + }, + "DiskReadOps": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_type": { + "_type": { + "type": "_type", + "searchable": true, + "aggregatable": true + } + }, + "DiskReadBytes": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_ignored": { + "_ignored": { + "type": "_ignored", + "searchable": true, + "aggregatable": false + } + }, + "DiskWriteOps": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "NetworkOut": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "CPUUtilization": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_seq_no": { + "_seq_no": { + "type": "_seq_no", + "searchable": true, + "aggregatable": true + } + }, + "@timestamp": { + "date": { + "type": "date", + "searchable": true, + "aggregatable": true + } + }, + "DiskWriteBytes": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_field_names": { + "_field_names": { + "type": "_field_names", + "searchable": true, + "aggregatable": false + } + }, + "NetworkIn": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "sourcetype.keyword": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_source": { + "_source": { + "type": "_source", + "searchable": false, + "aggregatable": false + } + }, + "sourcetype": { + "text": { + "type": "text", + "searchable": true, + "aggregatable": false + } + }, + "_id": { + "_id": { + "type": "_id", + "searchable": true, + "aggregatable": true + } + }, + "region": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_version": { + "_version": { + "type": "_version", + "searchable": false, + "aggregatable": false + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json new file mode 100644 index 0000000000000..3bf00d6018700 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json @@ -0,0 +1,98 @@ +{ + "indices": [ + "farequote-2019" + ], + "fields": { + "_routing": { + "_routing": { + "type": "_routing", + "searchable": true, + "aggregatable": false + } + }, + "_index": { + "_index": { + "type": "_index", + "searchable": true, + "aggregatable": true + } + }, + "_feature": { + "_feature": { + "type": "_feature", + "searchable": true, + "aggregatable": false + } + }, + "_type": { + "_type": { + "type": "_type", + "searchable": true, + "aggregatable": true + } + }, + "_ignored": { + "_ignored": { + "type": "_ignored", + "searchable": true, + "aggregatable": false + } + }, + "_seq_no": { + "_seq_no": { + "type": "_seq_no", + "searchable": true, + "aggregatable": true + } + }, + "@timestamp": { + "date": { + "type": "date", + "searchable": true, + "aggregatable": true + } + }, + "_field_names": { + "_field_names": { + "type": "_field_names", + "searchable": true, + "aggregatable": false + } + }, + "responsetime": { + "float": { + "type": "float", + "searchable": true, + "aggregatable": true + } + }, + "_source": { + "_source": { + "type": "_source", + "searchable": false, + "aggregatable": false + } + }, + "_id": { + "_id": { + "type": "_id", + "searchable": true, + "aggregatable": true + } + }, + "airline": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_version": { + "_version": { + "type": "_version", + "searchable": false, + "aggregatable": false + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json new file mode 100644 index 0000000000000..ca356b2bede22 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json @@ -0,0 +1,35 @@ +{ + "page": 1, + "per_page": 1000, + "total": 4, + "saved_objects": [ + { + "type": "index-pattern", + "id": "be0eebe0-65ac-11e9-aa86-0793be5f3670", + "attributes": { + "title": "farequote-*" + }, + "references": [], + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2019-04-23T09:47:02.203Z", + "version": "WzcsMV0=" + }, + { + "type": "index-pattern", + "id": "be14ceb0-66b1-11e9-91c9-ffa52374d341", + "attributes": { + "typeMeta": "{\"params\":{\"rollup_index\":\"cloud_roll_index\"},\"aggs\":{\"histogram\":{\"NetworkOut\":{\"agg\":\"histogram\",\"interval\":5},\"CPUUtilization\":{\"agg\":\"histogram\",\"interval\":5},\"NetworkIn\":{\"agg\":\"histogram\",\"interval\":5}},\"avg\":{\"NetworkOut\":{\"agg\":\"avg\"},\"CPUUtilization\":{\"agg\":\"avg\"},\"NetworkIn\":{\"agg\":\"avg\"},\"DiskReadBytes\":{\"agg\":\"avg\"}},\"min\":{\"NetworkOut\":{\"agg\":\"min\"},\"NetworkIn\":{\"agg\":\"min\"}},\"value_count\":{\"NetworkOut\":{\"agg\":\"value_count\"},\"DiskReadBytes\":{\"agg\":\"value_count\"},\"CPUUtilization\":{\"agg\":\"value_count\"},\"NetworkIn\":{\"agg\":\"value_count\"}},\"max\":{\"CPUUtilization\":{\"agg\":\"max\"},\"DiskReadBytes\":{\"agg\":\"max\"}},\"date_histogram\":{\"@timestamp\":{\"agg\":\"date_histogram\",\"delay\":\"1d\",\"interval\":\"5m\",\"time_zone\":\"UTC\"}},\"terms\":{\"instance\":{\"agg\":\"terms\"},\"sourcetype.keyword\":{\"agg\":\"terms\"},\"region\":{\"agg\":\"terms\"}},\"sum\":{\"DiskReadBytes\":{\"agg\":\"sum\"},\"NetworkOut\":{\"agg\":\"sum\"}}}}", + "title": "cloud_roll_index", + "type": "rollup" + }, + "references": [], + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2019-04-24T16:55:20.550Z", + "version": "Wzc0LDJd" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json new file mode 100644 index 0000000000000..2b2f8576d6769 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json @@ -0,0 +1,185 @@ +{ + "cloud_roll_index": { + "rollup_jobs": [ + { + "job_id": "cloud_roll", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatch-*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + }, + { + "agg": "value_count" + } + ], + "CPUUtilization": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "max" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ], + "instance": [ + { + "agg": "terms" + } + ], + "NetworkIn": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + } + ], + "sourcetype.keyword": [ + { + "agg": "terms" + } + ], + "region": [ + { + "agg": "terms" + } + ], + "DiskReadBytes": [ + { + "agg": "avg" + }, + { + "agg": "max" + }, + { + "agg": "sum" + }, + { + "agg": "value_count" + } + ] + } + }, + { + "job_id": "cloud_roll2", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatch*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "sum" + }, + { + "agg": "value_count" + } + ], + "CPUUtilization": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "max" + }, + { + "agg": "value_count" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ], + "instance": [ + { + "agg": "terms" + } + ], + "NetworkIn": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + }, + { + "agg": "value_count" + } + ], + "region": [ + { + "agg": "terms" + } + ] + } + }, + { + "job_id": "cloud_roll3", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatc*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 15 + } + ], + "DiskWriteOps": [ + { + "agg": "sum" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ] + } + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json new file mode 100644 index 0000000000000..90c44b6b6b8b8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json @@ -0,0 +1,196 @@ +{ + "cloud_roll_index": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "NetworkOut", + "NetworkIn" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "DiskReadBytes", + "CPUUtilization" + ] + } + ], + "fields": [ + { + "id": "DiskReadBytes", + "name": "DiskReadBytes", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "max" + ] + }, + { + "id": "DiskWriteOps", + "name": "DiskWriteOps", + "type": "double", + "aggregatable": true, + "aggIds": [ + "sum", + "high_sum", + "low_sum" + ] + }, + { + "id": "NetworkOut", + "name": "NetworkOut", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "min" + ] + }, + { + "id": "CPUUtilization", + "name": "CPUUtilization", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "max" + ] + }, + { + "id": "NetworkIn", + "name": "NetworkIn", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "min" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json new file mode 100644 index 0000000000000..b05462348a300 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json @@ -0,0 +1,206 @@ +{ + "farequote-*": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "median", + "title": "Median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_median", + "title": "High median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_median", + "title": "Low median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "distinct_count", + "title": "Distinct count", + "kibanaName": "cardinality", + "dslName": "cardinality", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "airline", + "responsetime" + ] + } + ], + "fields": [ + { + "id": "responsetime", + "name": "responsetime", + "type": "float", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "median", + "high_median", + "low_median", + "min", + "max", + "distinct_count" + ] + }, + { + "id": "airline", + "name": "airline", + "type": "keyword", + "aggregatable": true, + "aggIds": [ + "distinct_count" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json new file mode 100644 index 0000000000000..3d4a97bb6cd53 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json @@ -0,0 +1,6 @@ +{ + "farequote-*": { + "aggs": [], + "fields": [] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts new file mode 100644 index 0000000000000..06a81167fa5a4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts @@ -0,0 +1,195 @@ +/* + * 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 { Aggregation } from '../../../../common/types/fields'; +import { + ML_JOB_AGGREGATION, + KIBANA_AGGREGATION, + ES_AGGREGATION, +} from '../../../../common/constants/aggregation_types'; + +export const aggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEAN, + title: 'Mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEAN, + title: 'High mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEAN, + title: 'Low mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.SUM, + title: 'Sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_SUM, + title: 'High sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_SUM, + title: 'Low sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEDIAN, + title: 'Median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEDIAN, + title: 'High median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEDIAN, + title: 'Low median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MIN, + title: 'Min', + kibanaName: KIBANA_AGGREGATION.MIN, + dslName: ES_AGGREGATION.MIN, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MIN, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MAX, + title: 'Max', + kibanaName: KIBANA_AGGREGATION.MAX, + dslName: ES_AGGREGATION.MAX, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MAX, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.DISTINCT_COUNT, + title: 'Distinct count', + kibanaName: KIBANA_AGGREGATION.CARDINALITY, + dslName: ES_AGGREGATION.CARDINALITY, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, +]; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts new file mode 100644 index 0000000000000..6bba4e90ad2e5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -0,0 +1,222 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { Request } from 'src/legacy/server/kbn_server'; +import { Field, Aggregation, FieldId, NewJobCaps } from '../../../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../../../common/constants/field_types'; +import { rollupServiceProvider, RollupJob, RollupFields } from './rollup'; +import { aggregations } from './aggregations'; + +const METRIC_AGG_TYPE: string = 'metrics'; + +const supportedTypes: string[] = [ + ES_FIELD_TYPES.DATE, + ES_FIELD_TYPES.KEYWORD, + ES_FIELD_TYPES.TEXT, + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.INTEGER, + ES_FIELD_TYPES.FLOAT, + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.BYTE, + ES_FIELD_TYPES.HALF_FLOAT, + ES_FIELD_TYPES.SCALED_FLOAT, + ES_FIELD_TYPES.SHORT, +]; + +export function fieldServiceProvider( + indexPattern: string, + isRollup: boolean, + callWithRequest: any, + request: Request +) { + return new FieldsService(indexPattern, isRollup, callWithRequest, request); +} + +class FieldsService { + private _indexPattern: string; + private _isRollup: boolean; + private _callWithRequest: any; + private _request: Request; + + constructor(indexPattern: string, isRollup: boolean, callWithRequest: any, request: Request) { + this._indexPattern = indexPattern; + this._isRollup = isRollup; + this._callWithRequest = callWithRequest; + this._request = request; + } + + private async loadFieldCaps(): Promise { + return this._callWithRequest('fieldCaps', { + index: this._indexPattern, + fields: '*', + }); + } + + // create field object from the results from _field_caps + private async createFields(): Promise { + const fieldCaps = await this.loadFieldCaps(); + const fields: Field[] = []; + if (fieldCaps && fieldCaps.fields) { + Object.keys(fieldCaps.fields).forEach((k: FieldId) => { + const fc = fieldCaps.fields[k]; + const firstKey = Object.keys(fc)[0]; + if (firstKey !== undefined) { + const field = fc[firstKey]; + // add to the list of fields if the field type can be used by ML + if (supportedTypes.includes(field.type) === true) { + fields.push({ + id: k, + name: k, + type: field.type, + aggregatable: field.aggregatable, + aggs: [], + }); + } + } + }); + } + return fields; + } + + // public function to load fields from _field_caps and create a list + // of aggregations and fields that can be used for an ML job + // if the index is a rollup, the fields and aggs will be filtered + // based on what is available in the rollup job + // the _indexPattern will be replaced with a comma separated list + // of index patterns from all of the rollup jobs + public async getData(): Promise { + let rollupFields: RollupFields = {}; + + if (this._isRollup) { + const rollupService = await rollupServiceProvider( + this._indexPattern, + this._callWithRequest, + this._request + ); + const rollupConfigs: RollupJob[] | null = await rollupService.getRollupJobs(); + + // if a rollup index has been specified, yet there are no + // rollup configs, return with no results + if (rollupConfigs === null) { + return { + aggs: [], + fields: [], + }; + } else { + rollupFields = combineAllRollupFields(rollupConfigs); + this._indexPattern = rollupService.getIndexPattern(); + } + } + + const aggs = cloneDeep(aggregations); + const fields: Field[] = await this.createFields(); + + return await combineFieldsAndAggs(fields, aggs, rollupFields); + } +} + +// cross reference fields and aggs. +// fields contain a list of aggs that are compatible, and vice versa. +async function combineFieldsAndAggs( + fields: Field[], + aggs: Aggregation[], + rollupFields: RollupFields +): Promise { + const textAndKeywordFields = getTextAndKeywordFields(fields); + const numericalFields = getNumericalFields(fields); + + const mix = mixFactory(rollupFields); + + aggs.forEach(a => { + if (a.type === METRIC_AGG_TYPE) { + switch (a.kibanaName) { + case 'cardinality': + textAndKeywordFields.forEach(f => { + mix(f, a); + }); + numericalFields.forEach(f => { + mix(f, a); + }); + break; + case 'count': + break; + default: + numericalFields.forEach(f => { + mix(f, a); + }); + break; + } + } + }); + + return { + aggs: filterAggs(aggs), + fields: filterFields(fields), + }; +} + +// remove fields that have no aggs associated to them +function filterFields(fields: Field[]): Field[] { + return fields.filter(f => f.aggs && f.aggs.length); +} + +// remove aggs that have no fields associated to them +function filterAggs(aggs: Aggregation[]): Aggregation[] { + return aggs.filter(a => a.fields && a.fields.length); +} + +// returns a mix function that is used to cross-reference aggs and fields. +// wrapped in a provider to allow filtering based on rollup job capabilities +function mixFactory(rollupFields: RollupFields) { + const isRollup = Object.keys(rollupFields).length > 0; + + return function mix(field: Field, agg: Aggregation): void { + if ( + isRollup === false || + (rollupFields[field.id] && rollupFields[field.id].find(f => f.agg === agg.kibanaName)) + ) { + if (field.aggs !== undefined) { + field.aggs.push(agg); + } + if (agg.fields !== undefined) { + agg.fields.push(field); + } + } + }; +} + +function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { + const rollupFields: RollupFields = {}; + rollupConfigs.forEach(conf => { + Object.keys(conf.fields).forEach(fieldName => { + if (rollupFields[fieldName] === undefined) { + rollupFields[fieldName] = conf.fields[fieldName]; + } else { + const aggs = conf.fields[fieldName]; + aggs.forEach(agg => { + if (rollupFields[fieldName].find(f => f.agg === agg.agg) === null) { + rollupFields[fieldName].push(agg); + } + }); + } + }); + }); + return rollupFields; +} + +function getTextAndKeywordFields(fields: Field[]): Field[] { + return fields.filter(f => f.type === ES_FIELD_TYPES.KEYWORD || f.type === ES_FIELD_TYPES.TEXT); +} + +function getNumericalFields(fields: Field[]): Field[] { + return fields.filter( + f => + f.type === ES_FIELD_TYPES.DOUBLE || + f.type === ES_FIELD_TYPES.FLOAT || + f.type === ES_FIELD_TYPES.LONG + ); +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/index.ts new file mode 100644 index 0000000000000..9cf764bbb5a42 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/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 { newJobCapsProvider } from './new_job_caps'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts new file mode 100644 index 0000000000000..87653a1e3f47d --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { newJobCapsProvider } from './index'; + +import farequoteFieldCaps from './__mocks__/responses/farequote_field_caps.json'; +import cloudwatchFieldCaps from './__mocks__/responses/cloudwatch_field_caps.json'; +import rollupCaps from './__mocks__/responses/rollup_caps.json'; +import kibanaSavedObjects from './__mocks__/responses/kibana_saved_objects.json'; + +import farequoteJobCaps from './__mocks__/results/farequote_job_caps.json'; +import farequoteJobCapsEmpty from './__mocks__/results/farequote_job_caps_empty.json'; +import cloudwatchJobCaps from './__mocks__/results/cloudwatch_rollup_job_caps.json'; + +describe('job_service - job_caps', () => { + let callWithRequestNonRollupMock: jest.Mock; + let callWithRequestRollupMock: jest.Mock; + let requestMock: any; + + beforeEach(() => { + callWithRequestNonRollupMock = jest.fn((action: string) => { + switch (action) { + case 'fieldCaps': + return farequoteFieldCaps; + } + }); + + callWithRequestRollupMock = jest.fn((action: string) => { + switch (action) { + case 'fieldCaps': + return cloudwatchFieldCaps; + case 'ml.rollupIndexCapabilities': + return Promise.resolve(rollupCaps); + } + }); + + requestMock = { + getSavedObjectsClient: jest.fn(() => { + return { + async find() { + return Promise.resolve(kibanaSavedObjects); + }, + }; + }), + }; + }); + + describe('farequote newJobCaps()', () => { + it('can get job caps for index pattern', async done => { + const indexPattern = 'farequote-*'; + const isRollup = false; + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(farequoteJobCaps); + done(); + }); + + it('can get rollup job caps for non rollup index pattern', async done => { + const indexPattern = 'farequote-*'; + const isRollup = true; + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(farequoteJobCapsEmpty); + done(); + }); + + it('can get rollup job caps for rollup index pattern', async done => { + const indexPattern = 'cloud_roll_index'; + const isRollup = true; + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(cloudwatchJobCaps); + done(); + }); + + it('can get non rollup job caps for rollup index pattern', async done => { + const indexPattern = 'cloud_roll_index'; + const isRollup = false; + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).not.toEqual(cloudwatchJobCaps); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts new file mode 100644 index 0000000000000..798790e1d37c3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.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. + */ + +import { Request } from 'src/legacy/server/kbn_server'; +import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; +import { fieldServiceProvider } from './field_service'; + +interface NewJobCapsResponse { + [indexPattern: string]: NewJobCaps; +} + +export function newJobCapsProvider(callWithRequest: any, request: Request) { + async function newJobCaps( + indexPattern: string, + isRollup: boolean = false + ): Promise { + const fieldService = fieldServiceProvider(indexPattern, isRollup, callWithRequest, request); + const { aggs, fields } = await fieldService.getData(); + convertForStringify(aggs, fields); + + return { + [indexPattern]: { + aggs, + fields, + }, + }; + } + return { + newJobCaps, + }; +} + +// replace the recursive field and agg references with a +// map of ids to allow it to be stringified for transportation +// over the network. +function convertForStringify(aggs: Aggregation[], fields: Field[]): void { + fields.forEach(f => { + f.aggIds = f.aggs ? f.aggs.map(a => a.id) : []; + delete f.aggs; + }); + aggs.forEach(a => { + a.fieldIds = a.fields ? a.fields.map(f => f.id) : []; + delete a.fields; + }); +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts new file mode 100644 index 0000000000000..91e07e96739c6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -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 { Request } from 'src/legacy/server/kbn_server'; +import { SavedObject } from 'src/core/server'; +import { FieldId, AggId } from '../../../../common/types/fields'; + +export type RollupFields = Record]>; + +export interface RollupJob { + job_id: string; + rollup_index: string; + index_pattern: string; + fields: RollupFields; +} + +export async function rollupServiceProvider( + indexPattern: string, + callWithRequest: any, + request: Request +) { + const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, request); + let jobIndexPatterns: string[] = [indexPattern]; + + async function getRollupJobs(): Promise { + if (rollupIndexPatternObject !== null) { + const parsedTypeMetaData = JSON.parse(rollupIndexPatternObject.attributes.typeMeta); + const rollUpIndex: string = parsedTypeMetaData.params.rollup_index; + const rollupCaps = await callWithRequest('ml.rollupIndexCapabilities', { + indexPattern: rollUpIndex, + }); + + const indexRollupCaps = rollupCaps[rollUpIndex]; + if (indexRollupCaps && indexRollupCaps.rollup_jobs) { + jobIndexPatterns = indexRollupCaps.rollup_jobs.map((j: RollupJob) => j.index_pattern); + + return indexRollupCaps.rollup_jobs; + } + } + + return null; + } + + function getIndexPattern() { + return jobIndexPatterns.join(','); + } + + return { + getRollupJobs, + getIndexPattern, + }; +} + +async function loadRollupIndexPattern( + indexPattern: string, + request: Request +): Promise { + const savedObjectsClient = request.getSavedObjectsClient(); + const resp = await savedObjectsClient.find({ + type: 'index-pattern', + fields: ['title', 'type', 'typeMeta'], + perPage: 1000, + }); + + const obj = resp.saved_objects.find( + r => + r.attributes && + r.attributes.type === 'rollup' && + r.attributes.title === indexPattern && + r.attributes.typeMeta !== undefined + ); + + return obj || null; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js b/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js index 92d2679243c15..72b26d2163045 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js +++ b/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js @@ -255,7 +255,7 @@ export const getMessages = () => { job_id_valid: { status: 'SUCCESS', heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdValidHeading', { - defaultMessage: 'Job id format is valid.', + defaultMessage: 'Job id format is valid', }), text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdValidMessage', { defaultMessage: 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, ' + @@ -274,7 +274,7 @@ export const getMessages = () => { job_group_id_valid: { status: 'SUCCESS', heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdValidHeading', { - defaultMessage: 'Job group id formats are valid.', + defaultMessage: 'Job group id formats are valid', }), text: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdValidMessage', { defaultMessage: 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, ' + diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.js b/x-pack/legacy/plugins/ml/server/routes/job_service.js index dc43f3b3160c8..6311d341618be 100644 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.js +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.js @@ -180,4 +180,115 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route } }); + route({ + method: 'GET', + path: '/api/ml/jobs/new_job_caps/{indexPattern}', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { indexPattern } = request.params; + const isRollup = (request.query.rollup === 'true'); + const { newJobCaps } = jobServiceProvider(callWithRequest, request); + return newJobCaps(indexPattern, isRollup) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + 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 + } + }); + }