diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js index 9bdc8c4c40abb..38df3e1830f41 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js @@ -21,12 +21,12 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { withKibana } from '@kbn/kibana-react-plugin/public'; +import { context } from '@kbn/kibana-react-plugin/public'; import { timeFormatter } from '@kbn/ml-date-utils'; import { FORECAST_REQUEST_STATE } from '../../../../../../../common/constants/states'; import { addItemToRecentlyAccessed } from '../../../../../util/recently_accessed'; -import { mlForecastService } from '../../../../../services/forecast_service'; +import { forecastServiceFactory } from '../../../../../services/forecast_service'; import { getLatestDataOrBucketTimestamp, isTimeSeriesViewJob, @@ -38,20 +38,27 @@ const MAX_FORECASTS = 500; /** * Table component for rendering the lists of forecasts run on an ML job. */ -export class ForecastsTableUI extends Component { +export class ForecastsTable extends Component { constructor(props) { super(props); this.state = { isLoading: props.job.data_counts.processed_record_count !== 0, forecasts: [], }; + this.mlForecastService; } + /** + * Access ML services in react context. + */ + static contextType = context; + componentDidMount() { + this.mlForecastService = forecastServiceFactory(this.context.services.mlServices.mlApiServices); const dataCounts = this.props.job.data_counts; if (dataCounts.processed_record_count > 0) { // Get the list of all the forecasts with results at or later than the specified 'from' time. - mlForecastService + this.mlForecastService .getForecastsSummary( this.props.job, null, @@ -86,7 +93,7 @@ export class ForecastsTableUI extends Component { application: { navigateToUrl }, share, }, - } = this.props.kibana; + } = this.context; // Creates the link to the Single Metric Viewer. // Set the total time range from the start of the job data to the end of the forecast, @@ -337,8 +344,6 @@ export class ForecastsTableUI extends Component { ); } } -ForecastsTableUI.propTypes = { +ForecastsTable.propTypes = { job: PropTypes.object.isRequired, }; - -export const ForecastsTable = withKibana(ForecastsTableUI); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx index c190d2a45a13c..4f26dd2596516 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx @@ -112,6 +112,7 @@ jest.mock('../../contexts/kibana/kibana_context', () => { timefilter: getMockedTimefilter(), }, }, + mlServices: { mlApiServices: {} }, notifications: { toasts: { addDanger: () => {}, diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 48ad9232ebb33..ca7a1db23b055 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -24,16 +24,14 @@ import { useUiSettings, } from '../../contexts/kibana'; import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { isTimeSeriesViewJob } from '../../../../common/util/job_utils'; import { TimeSeriesExplorer } from '../../timeseriesexplorer'; import { getDateFormatTz } from '../../explorer/explorer_utils'; import { mlJobService } from '../../services/job_service'; -import { mlForecastService } from '../../services/forecast_service'; +import { useForecastService } from '../../services/forecast_service'; +import { useTimeSeriesExplorerService } from '../../util/time_series_explorer_service'; import { APP_STATE_ACTION } from '../../timeseriesexplorer/timeseriesexplorer_constants'; -import { - createTimeSeriesJobData, - getAutoZoomDuration, - validateJobSelection, -} from '../../timeseriesexplorer/timeseriesexplorer_utils'; +import { validateJobSelection } from '../../timeseriesexplorer/timeseriesexplorer_utils'; import { TimeSeriesExplorerPage } from '../../timeseriesexplorer/timeseriesexplorer_page'; import { TimeseriesexplorerNoJobsFound } from '../../timeseriesexplorer/components/timeseriesexplorer_no_jobs_found'; import { useTableInterval } from '../../components/controls/select_interval'; @@ -117,6 +115,7 @@ export const TimeSeriesExplorerUrlStateManager: FC Observable; - - getForecastDateRange: (job: Job, forecastId: string) => Promise; -}; - -export type MlForecastService = typeof mlForecastService; diff --git a/x-pack/plugins/ml/public/application/services/forecast_service.js b/x-pack/plugins/ml/public/application/services/forecast_service.js deleted file mode 100644 index 0cc0e40f8fdca..0000000000000 --- a/x-pack/plugins/ml/public/application/services/forecast_service.js +++ /dev/null @@ -1,385 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// Service for carrying out requests to run ML forecasts and to obtain -// data on forecasts that have been performed. -import { get, find, each } from 'lodash'; -import { map } from 'rxjs/operators'; - -import { ml } from './ml_api_service'; - -// Gets a basic summary of the most recently run forecasts for the specified -// job, with results at or later than the supplied timestamp. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a forecasts property, which is an array of objects -// containing id, earliest and latest keys. -function getForecastsSummary(job, query, earliestMs, maxResults) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - forecasts: [], - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, result type and earliest time, plus - // the additional query if supplied. - const filterCriteria = [ - { - term: { result_type: 'model_forecast_request_stats' }, - }, - { - term: { job_id: job.job_id }, - }, - { - range: { - timestamp: { - gte: earliestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - filterCriteria.push(query); - } - - ml.results - .anomalySearch( - { - size: maxResults, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - sort: [{ forecast_create_timestamp: { order: 'desc' } }], - }, - }, - [job.job_id] - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - obj.forecasts = resp.hits.hits.map((hit) => hit._source); - } - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the earliest and latest timestamps for the forecast data from -// the forecast with the specified ID. -// Returned response contains earliest and latest properties which are the -// timestamps of the first and last model_forecast results. -function getForecastDateRange(job, forecastId) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - earliest: null, - latest: null, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, forecast ID, result type and time range. - const filterCriteria = [ - { - query_string: { - query: 'result_type:model_forecast', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - ]; - - // TODO - add in criteria for detector index and entity fields (by, over, partition) - // once forecasting with these parameters is supported. - - ml.results - .anomalySearch( - { - size: 0, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - aggs: { - earliest: { - min: { - field: 'timestamp', - }, - }, - latest: { - max: { - field: 'timestamp', - }, - }, - }, - }, - }, - [job.job_id] - ) - .then((resp) => { - obj.earliest = get(resp, 'aggregations.earliest.value', null); - obj.latest = get(resp, 'aggregations.latest.value', null); - if (obj.earliest === null || obj.latest === null) { - reject(resp); - } else { - resolve(obj); - } - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Obtains the requested forecast model data for the forecast with the specified ID. -function getForecastData( - job, - detectorIndex, - forecastId, - entityFields, - earliestMs, - latestMs, - intervalMs, - aggType -) { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity = find(entityFields, { fieldName: detector.partition_field_name }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } - } - - if (detector.over_field_name !== undefined) { - const overEntity = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } - } - - if (detector.by_field_name !== undefined) { - const byEntity = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } - } - - const obj = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, forecast ID, detector index, result type and time range. - const filterCriteria = [ - { - query_string: { - query: 'result_type:model_forecast', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - { - term: { detector_index: detectorIndex }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - // Add in term queries for each of the specified criteria. - each(criteriaFields, (criteria) => { - filterCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - // If an aggType object has been passed in, use it. - // Otherwise default to avg, min and max aggs for the - // forecast prediction, upper and lower - const forecastAggs = - aggType === undefined - ? { avg: 'avg', max: 'max', min: 'min' } - : { - avg: aggType.avg, - max: aggType.max, - min: aggType.min, - }; - - return ml.results - .anomalySearch$( - { - size: 0, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - fixed_interval: `${intervalMs}ms`, - min_doc_count: 1, - }, - aggs: { - prediction: { - [forecastAggs.avg]: { - field: 'forecast_prediction', - }, - }, - forecastUpper: { - [forecastAggs.max]: { - field: 'forecast_upper', - }, - }, - forecastLower: { - [forecastAggs.min]: { - field: 'forecast_lower', - }, - }, - }, - }, - }, - }, - }, - [job.job_id] - ) - .pipe( - map((resp) => { - const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); - each(aggregationsByTime, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = { - prediction: get(dataForTime, ['prediction', 'value']), - forecastUpper: get(dataForTime, ['forecastUpper', 'value']), - forecastLower: get(dataForTime, ['forecastLower', 'value']), - }; - }); - - return obj; - }) - ); -} - -// Runs a forecast -function runForecast(jobId, duration) { - console.log('ML forecast service run forecast with duration:', duration); - return new Promise((resolve, reject) => { - ml.forecast({ - jobId, - duration, - }) - .then((resp) => { - resolve(resp); - }) - .catch((err) => { - reject(err); - }); - }); -} - -// Gets stats for a forecast that has been run on the specified job. -// Returned response contains a stats property, including -// forecast_progress (a value from 0 to 1), -// and forecast_status ('finished' when complete) properties. -function getForecastRequestStats(job, forecastId) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - stats: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, result type and earliest time. - const filterCriteria = [ - { - query_string: { - query: 'result_type:model_forecast_request_stats', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - ]; - - ml.results - .anomalySearch( - { - size: 1, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - }, - }, - [job.job_id] - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - obj.stats = resp.hits.hits[0]._source; - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -export const mlForecastService = { - getForecastsSummary, - getForecastDateRange, - getForecastData, - runForecast, - getForecastRequestStats, -}; diff --git a/x-pack/plugins/ml/public/application/services/forecast_service.ts b/x-pack/plugins/ml/public/application/services/forecast_service.ts new file mode 100644 index 0000000000000..d156421902291 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/forecast_service.ts @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Service for carrying out requests to run ML forecasts and to obtain +// data on forecasts that have been performed. +import { useMemo } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { get, find, each } from 'lodash'; +import { map } from 'rxjs/operators'; +import type { MlApiServices } from './ml_api_service'; +import type { Job } from '../../../common/types/anomaly_detection_jobs'; +import { useMlKibana } from '../contexts/kibana'; + +export interface AggType { + avg: string; + max: string; + min: string; +} + +export function forecastServiceFactory(mlApiServices: MlApiServices) { + // Gets a basic summary of the most recently run forecasts for the specified + // job, with results at or later than the supplied timestamp. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a forecasts property, which is an array of objects + // containing id, earliest and latest keys. + function getForecastsSummary(job: Job, query: any, earliestMs: number, maxResults: any) { + return new Promise((resolve, reject) => { + const obj: { success: boolean; forecasts: Record } = { + success: true, + forecasts: [], + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, result type and earliest time, plus + // the additional query if supplied. + const filterCriteria = [ + { + term: { result_type: 'model_forecast_request_stats' }, + }, + { + term: { job_id: job.job_id }, + }, + { + range: { + timestamp: { + gte: earliestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + filterCriteria.push(query); + } + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: maxResults, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + sort: [{ forecast_create_timestamp: { order: 'desc' } }], + }, + }, + [job.job_id] + ) + .then((resp) => { + if (resp.hits.total.value > 0) { + obj.forecasts = resp.hits.hits.map((hit) => hit._source); + } + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + } + // Obtains the earliest and latest timestamps for the forecast data from + // the forecast with the specified ID. + // Returned response contains earliest and latest properties which are the + // timestamps of the first and last model_forecast results. + function getForecastDateRange( + job: Job, + forecastId: string + ): Promise<{ success: boolean; earliest: number | null; latest: number | null }> { + return new Promise((resolve, reject) => { + const obj = { + success: true, + earliest: null, + latest: null, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, forecast ID, result type and time range. + const filterCriteria = [ + { + query_string: { + query: 'result_type:model_forecast', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + ]; + + // TODO - add in criteria for detector index and entity fields (by, over, partition) + // once forecasting with these parameters is supported. + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 0, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + aggs: { + earliest: { + min: { + field: 'timestamp', + }, + }, + latest: { + max: { + field: 'timestamp', + }, + }, + }, + }, + }, + [job.job_id] + ) + .then((resp) => { + obj.earliest = get(resp, 'aggregations.earliest.value', null); + obj.latest = get(resp, 'aggregations.latest.value', null); + if (obj.earliest === null || obj.latest === null) { + reject(resp); + } else { + resolve(obj); + } + }) + .catch((resp) => { + reject(resp); + }); + }); + } + // Obtains the requested forecast model data for the forecast with the specified ID. + function getForecastData( + job: Job, + detectorIndex: number, + forecastId: string, + entityFields: any, + earliestMs: number, + latestMs: number, + intervalMs: number, + aggType?: AggType + ) { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity = find(entityFields, { fieldName: detector.partition_field_name }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); + } + } + + if (detector.over_field_name !== undefined) { + const overEntity = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); + } + } + + if (detector.by_field_name !== undefined) { + const byEntity = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); + } + } + + const obj: { success: boolean; results: Record } = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, forecast ID, detector index, result type and time range. + const filterCriteria: estypes.QueryDslQueryContainer[] = [ + { + query_string: { + query: 'result_type:model_forecast', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + { + term: { detector_index: detectorIndex }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + // Add in term queries for each of the specified criteria. + each(criteriaFields, (criteria) => { + filterCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + // If an aggType object has been passed in, use it. + // Otherwise default to avg, min and max aggs for the + // forecast prediction, upper and lower + const forecastAggs = + aggType === undefined + ? { avg: 'avg', max: 'max', min: 'min' } + : { + avg: aggType.avg, + max: aggType.max, + min: aggType.min, + }; + + return mlApiServices.results + .anomalySearch$( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 0, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + fixed_interval: `${intervalMs}ms`, + min_doc_count: 1, + }, + aggs: { + prediction: { + [forecastAggs.avg]: { + field: 'forecast_prediction', + }, + }, + forecastUpper: { + [forecastAggs.max]: { + field: 'forecast_upper', + }, + }, + forecastLower: { + [forecastAggs.min]: { + field: 'forecast_lower', + }, + }, + }, + }, + }, + }, + }, + [job.job_id] + ) + .pipe( + map((resp) => { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + each(aggregationsByTime, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = { + prediction: get(dataForTime, ['prediction', 'value']), + forecastUpper: get(dataForTime, ['forecastUpper', 'value']), + forecastLower: get(dataForTime, ['forecastLower', 'value']), + }; + }); + + return obj; + }) + ); + } + // Runs a forecast + function runForecast(jobId: string, duration?: string) { + // eslint-disable-next-line no-console + console.log('ML forecast service run forecast with duration:', duration); + return new Promise((resolve, reject) => { + mlApiServices + .forecast({ + jobId, + duration, + }) + .then((resp) => { + resolve(resp); + }) + .catch((err) => { + reject(err); + }); + }); + } + // Gets stats for a forecast that has been run on the specified job. + // Returned response contains a stats property, including + // forecast_progress (a value from 0 to 1), + // and forecast_status ('finished' when complete) properties. + function getForecastRequestStats(job: Job, forecastId: string) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + stats: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID, result type and earliest time. + const filterCriteria = [ + { + query_string: { + query: 'result_type:model_forecast_request_stats', + analyze_wildcard: true, + }, + }, + { + term: { job_id: job.job_id }, + }, + { + term: { forecast_id: forecastId }, + }, + ]; + + mlApiServices.results + .anomalySearch( + { + // @ts-expect-error SearchRequest type has not been updated to include size + size: 1, + body: { + query: { + bool: { + filter: filterCriteria, + }, + }, + }, + }, + [job.job_id] + ) + .then((resp) => { + if (resp.hits.total.value > 0) { + obj.stats = resp.hits.hits[0]._source; + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + } + + return { + getForecastsSummary, + getForecastDateRange, + getForecastData, + runForecast, + getForecastRequestStats, + }; +} + +export type MlForecastService = ReturnType; + +export function useForecastService(): MlForecastService { + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const mlForecastService = useMemo(() => forecastServiceFactory(mlApiServices), [mlApiServices]); + return mlForecastService; +} diff --git a/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts b/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts deleted file mode 100644 index c776a79a6f475..0000000000000 --- a/x-pack/plugins/ml/public/application/services/forecast_service_provider.ts +++ /dev/null @@ -1,395 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// Service for carrying out requests to run ML forecasts and to obtain -// data on forecasts that have been performed. -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { get, find, each } from 'lodash'; -import { map } from 'rxjs/operators'; -import type { MlApiServices } from './ml_api_service'; -import type { Job } from '../../../common/types/anomaly_detection_jobs'; - -export interface AggType { - avg: string; - max: string; - min: string; -} - -// TODO Consolidate with legacy code in -// `x-pack/plugins/ml/public/application/services/forecast_service.js` and -// `x-pack/plugins/ml/public/application/services/forecast_service.d.ts`. -export function forecastServiceProvider(mlApiServices: MlApiServices) { - return { - // Gets a basic summary of the most recently run forecasts for the specified - // job, with results at or later than the supplied timestamp. - // Extra query object can be supplied, or pass null if no additional query. - // Returned response contains a forecasts property, which is an array of objects - // containing id, earliest and latest keys. - getForecastsSummary(job: Job, query: any, earliestMs: number, maxResults: any) { - return new Promise((resolve, reject) => { - const obj: { success: boolean; forecasts: Record } = { - success: true, - forecasts: [], - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, result type and earliest time, plus - // the additional query if supplied. - const filterCriteria = [ - { - term: { result_type: 'model_forecast_request_stats' }, - }, - { - term: { job_id: job.job_id }, - }, - { - range: { - timestamp: { - gte: earliestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - filterCriteria.push(query); - } - - mlApiServices.results - .anomalySearch( - { - // @ts-expect-error SearchRequest type has not been updated to include size - size: maxResults, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - sort: [{ forecast_create_timestamp: { order: 'desc' } }], - }, - }, - [job.job_id] - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - obj.forecasts = resp.hits.hits.map((hit) => hit._source); - } - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - }, - // Obtains the earliest and latest timestamps for the forecast data from - // the forecast with the specified ID. - // Returned response contains earliest and latest properties which are the - // timestamps of the first and last model_forecast results. - getForecastDateRange(job: Job, forecastId: string) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - earliest: null, - latest: null, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, forecast ID, result type and time range. - const filterCriteria = [ - { - query_string: { - query: 'result_type:model_forecast', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - ]; - - // TODO - add in criteria for detector index and entity fields (by, over, partition) - // once forecasting with these parameters is supported. - - mlApiServices.results - .anomalySearch( - { - // @ts-expect-error SearchRequest type has not been updated to include size - size: 0, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - aggs: { - earliest: { - min: { - field: 'timestamp', - }, - }, - latest: { - max: { - field: 'timestamp', - }, - }, - }, - }, - }, - [job.job_id] - ) - .then((resp) => { - obj.earliest = get(resp, 'aggregations.earliest.value', null); - obj.latest = get(resp, 'aggregations.latest.value', null); - if (obj.earliest === null || obj.latest === null) { - reject(resp); - } else { - resolve(obj); - } - }) - .catch((resp) => { - reject(resp); - }); - }); - }, - // Obtains the requested forecast model data for the forecast with the specified ID. - getForecastData( - job: Job, - detectorIndex: number, - forecastId: string, - entityFields: any, - earliestMs: number, - latestMs: number, - intervalMs: number, - aggType?: AggType - ) { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity = find(entityFields, { fieldName: detector.partition_field_name }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } - } - - if (detector.over_field_name !== undefined) { - const overEntity = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } - } - - if (detector.by_field_name !== undefined) { - const byEntity = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } - } - - const obj: { success: boolean; results: Record } = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, forecast ID, detector index, result type and time range. - const filterCriteria: estypes.QueryDslQueryContainer[] = [ - { - query_string: { - query: 'result_type:model_forecast', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - { - term: { detector_index: detectorIndex }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - // Add in term queries for each of the specified criteria. - each(criteriaFields, (criteria) => { - filterCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - // If an aggType object has been passed in, use it. - // Otherwise default to avg, min and max aggs for the - // forecast prediction, upper and lower - const forecastAggs = - aggType === undefined - ? { avg: 'avg', max: 'max', min: 'min' } - : { - avg: aggType.avg, - max: aggType.max, - min: aggType.min, - }; - - return mlApiServices.results - .anomalySearch$( - { - // @ts-expect-error SearchRequest type has not been updated to include size - size: 0, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - fixed_interval: `${intervalMs}ms`, - min_doc_count: 1, - }, - aggs: { - prediction: { - [forecastAggs.avg]: { - field: 'forecast_prediction', - }, - }, - forecastUpper: { - [forecastAggs.max]: { - field: 'forecast_upper', - }, - }, - forecastLower: { - [forecastAggs.min]: { - field: 'forecast_lower', - }, - }, - }, - }, - }, - }, - }, - [job.job_id] - ) - .pipe( - map((resp) => { - const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); - each(aggregationsByTime, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = { - prediction: get(dataForTime, ['prediction', 'value']), - forecastUpper: get(dataForTime, ['forecastUpper', 'value']), - forecastLower: get(dataForTime, ['forecastLower', 'value']), - }; - }); - - return obj; - }) - ); - }, - // Runs a forecast - runForecast(jobId: string, duration?: string) { - // eslint-disable-next-line no-console - console.log('ML forecast service run forecast with duration:', duration); - return new Promise((resolve, reject) => { - mlApiServices - .forecast({ - jobId, - duration, - }) - .then((resp) => { - resolve(resp); - }) - .catch((err) => { - reject(err); - }); - }); - }, - // Gets stats for a forecast that has been run on the specified job. - // Returned response contains a stats property, including - // forecast_progress (a value from 0 to 1), - // and forecast_status ('finished' when complete) properties. - getForecastRequestStats(job: Job, forecastId: string) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - stats: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID, result type and earliest time. - const filterCriteria = [ - { - query_string: { - query: 'result_type:model_forecast_request_stats', - analyze_wildcard: true, - }, - }, - { - term: { job_id: job.job_id }, - }, - { - term: { forecast_id: forecastId }, - }, - ]; - - mlApiServices.results - .anomalySearch( - { - // @ts-expect-error SearchRequest type has not been updated to include size - size: 1, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - }, - }, - [job.job_id] - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - obj.stats = resp.hits.hits[0]._source; - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - }, - }; -} - -export type MlForecastService = ReturnType; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index faa29e09e788e..3db872fa607b4 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -118,8 +118,6 @@ const proxyHttpStart = new Proxy({} as unknown as HttpStart, { }, }); -export type MlApiServices = ReturnType; - export const ml = mlApiServicesProvider(new HttpService(proxyHttpStart)); export function mlApiServicesProvider(httpService: HttpService) { @@ -820,3 +818,5 @@ export function mlApiServicesProvider(httpService: HttpService) { jsonSchema: jsonSchemaProvider(httpService), }; } + +export type MlApiServices = ReturnType; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index 3342680070e8b..d74d094cf03e9 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -18,7 +18,7 @@ import { EuiButton, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { withKibana } from '@kbn/kibana-react-plugin/public'; +import { context } from '@kbn/kibana-react-plugin/public'; import { extractErrorMessage } from '@kbn/ml-error-utils'; import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../../common/constants/states'; @@ -29,7 +29,7 @@ import { Modal } from './modal'; import { PROGRESS_STATES } from './progress_states'; import { ml } from '../../../services/ml_api_service'; import { mlJobService } from '../../../services/job_service'; -import { mlForecastService } from '../../../services/forecast_service'; +import { forecastServiceFactory } from '../../../services/forecast_service'; export const FORECAST_DURATION_MAX_DAYS = 3650; // Max forecast duration allowed by analytics. @@ -55,7 +55,7 @@ function getDefaultState() { }; } -export class ForecastingModalUI extends Component { +export class ForecastingModal extends Component { static propTypes = { isDisabled: PropTypes.bool, job: PropTypes.object, @@ -70,6 +70,17 @@ export class ForecastingModalUI extends Component { // Used to poll for updates on a running forecast. this.forecastChecker = null; + + this.mlForecastService; + } + + /** + * Access ML services in react context. + */ + static contextType = context; + + componentDidMount() { + this.mlForecastService = forecastServiceFactory(this.context.services.mlServices.mlApiServices); } addMessage = (message, status, clearFirst = false) => { @@ -238,7 +249,7 @@ export class ForecastingModalUI extends Component { // formats accepted by Kibana (w, M, y) are not valid formats in Elasticsearch. const durationInSeconds = parseInterval(this.state.newForecastDuration).asSeconds(); - mlForecastService + this.mlForecastService .runForecast(this.props.job.job_id, `${durationInSeconds}s`) .then((resp) => { // Endpoint will return { acknowledged:true, id: } before forecast is complete. @@ -259,7 +270,7 @@ export class ForecastingModalUI extends Component { let previousProgress = 0; let noProgressMs = 0; this.forecastChecker = setInterval(() => { - mlForecastService + this.mlForecastService .getForecastRequestStats(this.props.job, forecastId) .then((resp) => { // Get the progress (stats value is between 0 and 1). @@ -380,14 +391,14 @@ export class ForecastingModalUI extends Component { if (typeof job === 'object') { // Get the list of all the finished forecasts for this job with results at or later than the dashboard 'from' time. - const { timefilter } = this.props.kibana.services.data.query.timefilter; + const { timefilter } = this.context.services.data.query.timefilter; const bounds = timefilter.getActiveBounds(); const statusFinishedQuery = { term: { forecast_status: FORECAST_REQUEST_STATE.FINISHED, }, }; - mlForecastService + this.mlForecastService .getForecastsSummary(job, statusFinishedQuery, bounds.min.valueOf(), FORECASTS_VIEW_MAX) .then((resp) => { this.setState({ @@ -545,5 +556,3 @@ export class ForecastingModalUI extends Component { ); } } - -export const ForecastingModal = withKibana(ForecastingModalUI); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index b8f90717d29ee..9e8dfe0b1eb98 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -40,7 +40,7 @@ import { import { timeBucketsServiceFactory } from '../../../util/time_buckets_service'; import { mlTableService } from '../../../services/table_service'; import { ContextChartMask } from '../context_chart_mask'; -import { findChartPointForAnomalyTime } from '../../timeseriesexplorer_utils'; +import { timeSeriesExplorerServiceFactory } from '../../../util/time_series_explorer_service'; import { mlEscape } from '../../../util/string_utils'; import { ANNOTATION_MASK_ID, @@ -136,6 +136,7 @@ class TimeseriesChartIntl extends Component { static contextType = context; getTimeBuckets; + mlTimeSeriesExplorer; rowMouseenterSubscriber = null; rowMouseleaveSubscriber = null; @@ -158,6 +159,11 @@ class TimeseriesChartIntl extends Component { } componentDidMount() { + this.mlTimeSeriesExplorer = timeSeriesExplorerServiceFactory( + this.context.services.uiSettings, + this.context.services.mlServices.mlApiServices, + this.context.services.mlServices.mlResultsService + ); this.getTimeBuckets = timeBucketsServiceFactory( this.context.services.uiSettings ).getTimeBuckets; @@ -1878,7 +1884,7 @@ class TimeseriesChartIntl extends Component { // Depending on the way the chart is aggregated, there may not be // a point at exactly the same time as the record being highlighted. const anomalyTime = record.source.timestamp; - const markerToSelect = findChartPointForAnomalyTime( + const markerToSelect = this.mlTimeSeriesExplorer.findChartPointForAnomalyTime( focusChartData, anomalyTime, focusAggregationInterval diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index 8930111e33f76..9dc7666099f91 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -9,15 +9,33 @@ import moment from 'moment-timezone'; import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import { TimeseriesChart } from './timeseries_chart'; -jest.mock('../../../util/time_buckets', () => ({ - TimeBuckets: function () { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); +jest.mock('../../../util/time_buckets_service', () => ({ + timeBucketsServiceFactory: function () { + return { getTimeBuckets: jest.fn() }; + }, +})); + +jest.mock('../../../util/time_series_explorer_service', () => ({ + timeSeriesExplorerServiceFactory: function () { + return { + getAutoZoomDuration: jest.fn(), + calculateAggregationInterval: jest.fn(), + calculateInitialFocusRange: jest.fn(), + calculateDefaultFocusRange: jest.fn(), + processRecordScoreResults: jest.fn(), + processMetricPlotResults: jest.fn(), + processForecastResults: jest.fn(), + findChartPointForAnomalyTime: jest.fn(), + processDataForFocusAnomalies: jest.fn(), + findChartPointForScheduledEvent: jest.fn(), + processScheduledEventsForChart: jest.fn(), + getFocusData: jest.fn(), + }; }, })); @@ -39,6 +57,13 @@ function getTimeseriesChartPropsMock() { }; } +const servicesMock = { + mlServices: { + mlApiServices: {}, + mlResultsService: {}, + }, +}; + describe('TimeseriesChart', () => { const mockedGetBBox = { x: 0, y: -10, width: 40, height: 20 }; const originalGetBBox = SVGElement.prototype.getBBox; @@ -54,7 +79,11 @@ describe('TimeseriesChart', () => { test('Minimal initialization', () => { const props = getTimeseriesChartPropsMock(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); expect(wrapper.html()).toBe('
'); }); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts deleted file mode 100644 index 83f48f798eaa1..0000000000000 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts +++ /dev/null @@ -1,202 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { each, find, get, filter } from 'lodash'; -import type { ES_AGGREGATION } from '@kbn/ml-anomaly-utils'; - -import type { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import type { MlEntityField } from '@kbn/ml-anomaly-utils'; -import { ml } from '../services/ml_api_service'; -import { - isModelPlotChartableForDetector, - isModelPlotEnabled, -} from '../../../common/util/job_utils'; -import { buildConfigFromDetector } from '../util/chart_config_builder'; -import { mlResultsService } from '../services/results_service'; -import type { ModelPlotOutput } from '../services/results_service/result_service_rx'; -import type { Job } from '../../../common/types/anomaly_detection_jobs'; - -function getMetricData( - job: Job, - detectorIndex: number, - entityFields: MlEntityField[], - earliestMs: number, - latestMs: number, - intervalMs: number, - esMetricFunction?: string -): Observable { - if ( - isModelPlotChartableForDetector(job, detectorIndex) && - isModelPlotEnabled(job, detectorIndex, entityFields) - ) { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity: any = find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } - } - - if (detector.over_field_name !== undefined) { - const overEntity: any = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } - } - - if (detector.by_field_name !== undefined) { - const byEntity: any = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } - } - - return mlResultsService.getModelPlotOutput( - job.job_id, - detectorIndex, - criteriaFields, - earliestMs, - latestMs, - intervalMs - ); - } else { - const obj: ModelPlotOutput = { - success: true, - results: {}, - }; - - const chartConfig = buildConfigFromDetector(job, detectorIndex); - - return mlResultsService - .getMetricData( - chartConfig.datafeedConfig.indices.join(','), - entityFields, - chartConfig.datafeedConfig.query, - esMetricFunction ?? chartConfig.metricFunction, - chartConfig.metricFieldName, - chartConfig.summaryCountFieldName, - chartConfig.timeField, - earliestMs, - latestMs, - intervalMs, - chartConfig?.datafeedConfig - ) - .pipe( - map((resp) => { - each(resp.results, (value, time) => { - // @ts-ignore - obj.results[time] = { - actual: value, - }; - }); - return obj; - }) - ); - } -} - -interface TimeSeriesExplorerChartDetails { - success: boolean; - results: { - functionLabel: string | null; - entityData: { count?: number; entities: Array<{ fieldName: string; cardinality?: number }> }; - }; -} - -/** - * Builds chart detail information (charting function description and entity counts) used - * in the title area of the time series chart. - * Queries Elasticsearch if necessary to obtain the distinct count of entities - * for which data is being plotted. - * @param job Job config info - * @param detectorIndex The index of the detector in the job config - * @param entityFields Array of field name - field value pairs - * @param earliestMs Earliest timestamp in milliseconds - * @param latestMs Latest timestamp in milliseconds - * @param metricFunctionDescription The underlying function (min, max, avg) for "metric" detector type - * @returns chart data to plot for Single Metric Viewer/Time series explorer - */ -function getChartDetails( - job: Job, - detectorIndex: number, - entityFields: MlEntityField[], - earliestMs: number, - latestMs: number, - metricFunctionDescription?: ES_AGGREGATION -) { - return new Promise((resolve, reject) => { - const obj: TimeSeriesExplorerChartDetails = { - success: true, - results: { functionLabel: '', entityData: { entities: [] } }, - }; - - const chartConfig = buildConfigFromDetector(job, detectorIndex, metricFunctionDescription); - - let functionLabel: string | null = chartConfig.metricFunction; - if (chartConfig.metricFieldName !== undefined) { - functionLabel += ` ${chartConfig.metricFieldName}`; - } - - obj.results.functionLabel = functionLabel; - - const blankEntityFields = filter(entityFields, (entity) => { - return entity.fieldValue === null; - }); - - // Look to see if any of the entity fields have defined values - // (i.e. blank input), and if so obtain the cardinality. - if (blankEntityFields.length === 0) { - obj.results.entityData.count = 1; - obj.results.entityData.entities = entityFields; - resolve(obj); - } else { - const entityFieldNames: string[] = blankEntityFields.map((f) => f.fieldName); - ml.getCardinalityOfFields({ - index: chartConfig.datafeedConfig.indices.join(','), - fieldNames: entityFieldNames, - query: chartConfig.datafeedConfig.query, - timeFieldName: chartConfig.timeField, - earliestMs, - latestMs, - }) - .then((results: any) => { - each(blankEntityFields, (field) => { - // results will not contain keys for non-aggregatable fields, - // so store as 0 to indicate over all field values. - obj.results.entityData.entities.push({ - fieldName: field.fieldName, - cardinality: get(results, field.fieldName, 0), - }); - }); - - resolve(obj); - }) - .catch((resp: any) => { - reject(resp); - }); - } - }); -} - -export const mlTimeSeriesSearchService = { - getMetricData, - getChartDetails, -}; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 21a2c1488a13b..4795a62937d8b 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -42,6 +42,7 @@ import { isModelPlotEnabled, isModelPlotChartableForDetector, isSourceDataChartableForDetector, + isTimeSeriesViewJob, mlFunctionToESAggregation, } from '../../../common/util/job_utils'; @@ -56,9 +57,10 @@ import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_n import { TimeSeriesExplorerPage } from './timeseriesexplorer_page'; import { ml } from '../services/ml_api_service'; -import { mlForecastService } from '../services/forecast_service'; +import { forecastServiceFactory } from '../services/forecast_service'; +import { timeSeriesExplorerServiceFactory } from '../util/time_series_explorer_service'; import { mlJobService } from '../services/job_service'; -import { mlResultsService } from '../services/results_service'; +import { mlResultsServiceProvider } from '../services/results_service'; import { getBoundsRoundedToInterval } from '../util/time_buckets'; @@ -67,18 +69,8 @@ import { CHARTS_POINT_TARGET, TIME_FIELD_NAME, } from './timeseriesexplorer_constants'; -import { mlTimeSeriesSearchService } from './timeseries_search_service'; -import { - calculateAggregationInterval, - calculateDefaultFocusRange, - calculateInitialFocusRange, - createTimeSeriesJobData, - processForecastResults, - processMetricPlotResults, - processRecordScoreResults, - getFocusData, - getTimeseriesexplorerDefaultState, -} from './timeseriesexplorer_utils'; +import { timeSeriesSearchServiceFactory } from './timeseriesexplorer_utils/time_series_search_service'; +import { getTimeseriesexplorerDefaultState } from './timeseriesexplorer_utils'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; import { getControlsForDetector } from './get_controls_for_detector'; import { SeriesControls } from './components/series_controls'; @@ -89,6 +81,7 @@ import { getViewableDetectors } from './timeseriesexplorer_utils/get_viewable_de import { TimeseriesexplorerChartDataError } from './components/timeseriesexplorer_chart_data_error'; import { ExplorerNoJobsSelected } from '../explorer/components'; import { getDataViewsAndIndicesWithGeoFields } from '../explorer/explorer_utils'; +import { indexServiceFactory } from '../util/index_service'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -142,6 +135,12 @@ export class TimeSeriesExplorer extends React.Component { */ static contextType = context; + mlTimeSeriesExplorer; + mlTimeSeriesSearchService; + mlForecastService; + mlResultsService; + mlIndexUtils; + /** * Returns field names that don't have a selection yet. */ @@ -221,13 +220,16 @@ export class TimeSeriesExplorer extends React.Component { getFocusAggregationInterval(selection) { const { selectedJobId } = this.props; - const jobs = createTimeSeriesJobData(mlJobService.jobs); const selectedJob = mlJobService.getJob(selectedJobId); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; - return calculateAggregationInterval(bounds, CHARTS_POINT_TARGET, jobs, selectedJob); + return this.mlTimeSeriesExplorer.calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + selectedJob + ); } /** @@ -252,7 +254,7 @@ export class TimeSeriesExplorer extends React.Component { // to some extent with all detector functions if not searching complete buckets. const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); - return getFocusData( + return this.mlTimeSeriesExplorer.getFocusData( this.getCriteriaFields(selectedDetectorIndex, entityControls), selectedDetectorIndex, focusAggregationInterval, @@ -418,7 +420,6 @@ export class TimeSeriesExplorer extends React.Component { () => { const { loadCounter, modelPlotEnabled } = this.state; - const jobs = createTimeSeriesJobData(mlJobService.jobs); const selectedJob = mlJobService.getJob(selectedJobId); const detectorIndex = selectedDetectorIndex; @@ -447,7 +448,7 @@ export class TimeSeriesExplorer extends React.Component { this.arePartitioningFieldsProvided() === true ) { // Check for a zoom parameter in the appState (URL). - let focusRange = calculateInitialFocusRange( + let focusRange = this.mlTimeSeriesExplorer.calculateInitialFocusRange( zoom, stateUpdate.contextAggregationInterval, bounds @@ -460,7 +461,7 @@ export class TimeSeriesExplorer extends React.Component { (focusRange === undefined || this.previousSelectedForecastId !== this.props.selectedForecastId) ) { - focusRange = calculateDefaultFocusRange( + focusRange = this.mlTimeSeriesExplorer.calculateDefaultFocusRange( autoZoomDuration, stateUpdate.contextAggregationInterval, stateUpdate.contextChartData, @@ -499,12 +500,12 @@ export class TimeSeriesExplorer extends React.Component { // Calculate the aggregation interval for the context chart. // Context chart swimlane will display bucket anomaly score at the same interval. - stateUpdate.contextAggregationInterval = calculateAggregationInterval( - bounds, - CHARTS_POINT_TARGET, - jobs, - selectedJob - ); + stateUpdate.contextAggregationInterval = + this.mlTimeSeriesExplorer.calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + selectedJob + ); // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected @@ -520,7 +521,7 @@ export class TimeSeriesExplorer extends React.Component { // for the most recent call to the load the data in cases where the job selection and time filter // have been altered in quick succession (such as from the job picker with 'Apply time range'). const counter = loadCounter; - mlTimeSeriesSearchService + this.mlTimeSeriesSearchService .getMetricData( selectedJob, detectorIndex, @@ -532,7 +533,10 @@ export class TimeSeriesExplorer extends React.Component { ) .toPromise() .then((resp) => { - const fullRangeChartData = processMetricPlotResults(resp.results, modelPlotEnabled); + const fullRangeChartData = this.mlTimeSeriesExplorer.processMetricPlotResults( + resp.results, + modelPlotEnabled + ); stateUpdate.contextChartData = fullRangeChartData; finish(counter); }) @@ -545,7 +549,7 @@ export class TimeSeriesExplorer extends React.Component { // Query 2 - load max record score at same granularity as context chart // across full time range for use in the swimlane. - mlResultsService + this.mlResultsService .getRecordMaxScoreByTime( selectedJob.job_id, this.getCriteriaFields(detectorIndex, entityControls), @@ -555,7 +559,9 @@ export class TimeSeriesExplorer extends React.Component { functionToPlotByIfMetric ) .then((resp) => { - const fullRangeRecordScoreData = processRecordScoreResults(resp.results); + const fullRangeRecordScoreData = this.mlTimeSeriesExplorer.processRecordScoreResults( + resp.results + ); stateUpdate.swimlaneData = fullRangeRecordScoreData; finish(counter); }) @@ -571,7 +577,7 @@ export class TimeSeriesExplorer extends React.Component { }); // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). - mlTimeSeriesSearchService + this.mlTimeSeriesSearchService .getChartDetails( selectedJob, detectorIndex, @@ -602,7 +608,7 @@ export class TimeSeriesExplorer extends React.Component { if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { aggType = { avg: 'sum', max: 'sum', min: 'sum' }; } - mlForecastService + this.mlForecastService .getForecastData( selectedJob, detectorIndex, @@ -615,7 +621,9 @@ export class TimeSeriesExplorer extends React.Component { ) .toPromise() .then((resp) => { - stateUpdate.contextForecastData = processForecastResults(resp.results); + stateUpdate.contextForecastData = this.mlTimeSeriesExplorer.processForecastResults( + resp.results + ); finish(counter); }) .catch((err) => { @@ -700,6 +708,19 @@ export class TimeSeriesExplorer extends React.Component { } componentDidMount() { + const { mlApiServices } = this.context.services.mlServices; + this.mlResultsService = mlResultsServiceProvider(mlApiServices); + this.mlTimeSeriesSearchService = timeSeriesSearchServiceFactory( + this.mlResultsService, + mlApiServices + ); + this.mlTimeSeriesExplorer = timeSeriesExplorerServiceFactory( + this.context.services.uiSettings, + mlApiServices, + this.mlResultsService + ); + this.mlIndexUtils = indexServiceFactory(this.context.services.data.dataViews); + this.mlForecastService = forecastServiceFactory(mlApiServices); // if timeRange used in the url is incorrect // perhaps due to user's advanced setting using incorrect date-maths const { invalidTimeRangeError } = this.props; @@ -765,15 +786,13 @@ export class TimeSeriesExplorer extends React.Component { }), switchMap((selection) => { const { selectedJobId } = this.props; - const jobs = createTimeSeriesJobData(mlJobService.jobs); const selectedJob = mlJobService.getJob(selectedJobId); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; - const focusAggregationInterval = calculateAggregationInterval( + const focusAggregationInterval = this.mlTimeSeriesExplorer.calculateAggregationInterval( bounds, CHARTS_POINT_TARGET, - jobs, selectedJob ); @@ -819,7 +838,11 @@ export class TimeSeriesExplorer extends React.Component { if (previousProps === undefined || previousProps.selectedJobId !== this.props.selectedJobId) { const selectedJob = mlJobService.getJob(this.props.selectedJobId); this.contextChartSelectedInitCallDone = false; - getDataViewsAndIndicesWithGeoFields([selectedJob], this.props.dataViewsService) + getDataViewsAndIndicesWithGeoFields( + [selectedJob], + this.props.dataViewsService, + this.mlIndexUtils + ) .then(({ getSourceIndicesWithGeoFieldsResp }) => this.setState( { @@ -959,7 +982,7 @@ export class TimeSeriesExplorer extends React.Component { zoomToFocusLoaded, autoZoomDuration, }; - const jobs = createTimeSeriesJobData(mlJobService.jobs); + const jobs = mlJobService.jobs.filter(isTimeSeriesViewJob); if (selectedDetectorIndex === undefined || mlJobService.getJob(selectedJobId) === undefined) { return ( diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts deleted file mode 100644 index 6828b9609a99b..0000000000000 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/get_focus_data.ts +++ /dev/null @@ -1,193 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Observable } from 'rxjs'; -import { forkJoin, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { extractErrorMessage } from '@kbn/ml-error-utils'; -import { aggregationTypeTransform } from '@kbn/ml-anomaly-utils'; -import type { MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils'; -import { ml } from '../../services/ml_api_service'; -import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE } from '../../../../common/constants/search'; -import { mlTimeSeriesSearchService } from '../timeseries_search_service'; -import type { CriteriaField } from '../../services/results_service'; -import { mlResultsService } from '../../services/results_service'; -import type { Job } from '../../../../common/types/anomaly_detection_jobs'; -import { MAX_SCHEDULED_EVENTS, TIME_FIELD_NAME } from '../timeseriesexplorer_constants'; -import { - processDataForFocusAnomalies, - processForecastResults, - processMetricPlotResults, - processScheduledEventsForChart, -} from './timeseriesexplorer_utils'; -import { mlForecastService } from '../../services/forecast_service'; -import { mlFunctionToESAggregation } from '../../../../common/util/job_utils'; -import type { GetAnnotationsResponse } from '../../../../common/types/annotations'; - -export interface Interval { - asMilliseconds: () => number; - expression: string; -} - -export interface ChartDataPoint { - date: Date; - value: number | null; - upper?: number | null; - lower?: number | null; -} - -export interface FocusData { - focusChartData: ChartDataPoint[]; - anomalyRecords: MlAnomalyRecordDoc[]; - scheduledEvents: any; - showForecastCheckbox?: boolean; - focusAnnotationError?: string; - focusAnnotationData?: any[]; - focusForecastData?: any; -} - -export function getFocusData( - criteriaFields: CriteriaField[], - detectorIndex: number, - focusAggregationInterval: Interval, - forecastId: string, - modelPlotEnabled: boolean, - nonBlankEntities: any[], - searchBounds: any, - selectedJob: Job, - functionDescription?: string | undefined -): Observable { - const esFunctionToPlotIfMetric = - functionDescription !== undefined - ? aggregationTypeTransform.toES(functionDescription) - : functionDescription; - - return forkJoin([ - // Query 1 - load metric data across selected time range. - mlTimeSeriesSearchService.getMetricData( - selectedJob, - detectorIndex, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - focusAggregationInterval.asMilliseconds(), - esFunctionToPlotIfMetric - ), - // Query 2 - load all the records across selected time range for the chart anomaly markers. - ml.results.getAnomalyRecords$( - [selectedJob.job_id], - criteriaFields, - 0, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - focusAggregationInterval.expression, - functionDescription - ), - // Query 3 - load any scheduled events for the selected job. - mlResultsService.getScheduledEventsByBucket( - [selectedJob.job_id], - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - focusAggregationInterval.asMilliseconds(), - 1, - MAX_SCHEDULED_EVENTS - ), - // Query 4 - load any annotations for the selected job. - ml.annotations - .getAnnotations$({ - jobIds: [selectedJob.job_id], - earliestMs: searchBounds.min.valueOf(), - latestMs: searchBounds.max.valueOf(), - maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, - detectorIndex, - entities: nonBlankEntities, - }) - .pipe( - catchError((resp) => - of({ - annotations: {}, - totalCount: 0, - error: extractErrorMessage(resp), - success: false, - } as GetAnnotationsResponse) - ) - ), - // Plus query for forecast data if there is a forecastId stored in the appState. - forecastId !== undefined - ? (() => { - let aggType; - const detector = selectedJob.analysis_config.detectors[detectorIndex]; - const esAgg = mlFunctionToESAggregation(detector.function); - if (!modelPlotEnabled && (esAgg === 'sum' || esAgg === 'count')) { - aggType = { avg: 'sum', max: 'sum', min: 'sum' }; - } - return mlForecastService.getForecastData( - selectedJob, - detectorIndex, - forecastId, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - focusAggregationInterval.asMilliseconds(), - aggType - ); - })() - : of(null), - ]).pipe( - map(([metricData, recordsForCriteria, scheduledEventsByBucket, annotations, forecastData]) => { - // Sort in descending time order before storing in scope. - const anomalyRecords = recordsForCriteria.records - .sort((a, b) => a[TIME_FIELD_NAME] - b[TIME_FIELD_NAME]) - .reverse(); - - const scheduledEvents = scheduledEventsByBucket.events[selectedJob.job_id]; - - let focusChartData = processMetricPlotResults(metricData.results, modelPlotEnabled); - // Tell the results container directives to render the focus chart. - focusChartData = processDataForFocusAnomalies( - focusChartData, - anomalyRecords, - focusAggregationInterval, - modelPlotEnabled, - functionDescription - ); - focusChartData = processScheduledEventsForChart( - focusChartData, - scheduledEvents, - focusAggregationInterval - ); - - const refreshFocusData: FocusData = { - scheduledEvents, - anomalyRecords, - focusChartData, - }; - - if (annotations) { - if (annotations.error !== undefined) { - refreshFocusData.focusAnnotationError = annotations.error; - refreshFocusData.focusAnnotationData = []; - } else { - refreshFocusData.focusAnnotationData = (annotations.annotations[selectedJob.job_id] ?? []) - .sort((a, b) => { - return a.timestamp - b.timestamp; - }) - .map((d, i: number) => { - d.key = (i + 1).toString(); - return d; - }); - } - } - - if (forecastData) { - refreshFocusData.focusForecastData = processForecastResults(forecastData.results); - refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0; - } - return refreshFocusData; - }) - ); -} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/index.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/index.ts index b3270edf21cf1..88818249a48ea 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/index.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/index.ts @@ -5,7 +5,5 @@ * 2.0. */ -export { getFocusData } from './get_focus_data'; -export * from './timeseriesexplorer_utils'; export { validateJobSelection } from './validate_job_selection'; export { getTimeseriesexplorerDefaultState } from './get_timeseriesexplorer_default_state'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service.ts index 33f57697e1239..4c1426266abb2 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service.ts @@ -5,183 +5,221 @@ * 2.0. */ +import { useMemo } from 'react'; import { each, find, get, filter } from 'lodash'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import type { MlEntityField } from '@kbn/ml-anomaly-utils'; +import type { MlEntityField, ES_AGGREGATION } from '@kbn/ml-anomaly-utils'; import type { Job } from '../../../../common/types/anomaly_detection_jobs'; import type { ModelPlotOutput } from '../../services/results_service/result_service_rx'; import type { MlApiServices } from '../../services/ml_api_service'; -import type { MlResultsService } from '../../services/results_service'; +import { type MlResultsService, mlResultsServiceProvider } from '../../services/results_service'; import { buildConfigFromDetector } from '../../util/chart_config_builder'; import { isModelPlotChartableForDetector, isModelPlotEnabled, } from '../../../../common/util/job_utils'; +import { useMlKibana } from '../../contexts/kibana'; + +interface TimeSeriesExplorerChartDetails { + success: boolean; + results: { + functionLabel: string | null; + entityData: { count?: number; entities: Array<{ fieldName: string; cardinality?: number }> }; + }; +} -// TODO Consolidate with legacy code in -// `x-pack/plugins/ml/public/application/timeseriesexplorer/timeseries_search_service.ts` export function timeSeriesSearchServiceFactory( mlResultsService: MlResultsService, mlApiServices: MlApiServices ) { - return { - getMetricData( - job: Job, - detectorIndex: number, - entityFields: MlEntityField[], - earliestMs: number, - latestMs: number, - intervalMs: number, - esMetricFunction?: string - ): Observable { - if ( - isModelPlotChartableForDetector(job, detectorIndex) && - isModelPlotEnabled(job, detectorIndex, entityFields) - ) { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity: any = find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } + function getMetricData( + job: Job, + detectorIndex: number, + entityFields: MlEntityField[], + earliestMs: number, + latestMs: number, + intervalMs: number, + esMetricFunction?: string + ): Observable { + if ( + isModelPlotChartableForDetector(job, detectorIndex) && + isModelPlotEnabled(job, detectorIndex, entityFields) + ) { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity: any = find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); } + } - if (detector.over_field_name !== undefined) { - const overEntity: any = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } + if (detector.over_field_name !== undefined) { + const overEntity: any = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); } + } - if (detector.by_field_name !== undefined) { - const byEntity: any = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } + if (detector.by_field_name !== undefined) { + const byEntity: any = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); } + } + + return mlResultsService.getModelPlotOutput( + job.job_id, + detectorIndex, + criteriaFields, + earliestMs, + latestMs, + intervalMs + ); + } else { + const obj: ModelPlotOutput = { + success: true, + results: {}, + }; - return mlResultsService.getModelPlotOutput( - job.job_id, - detectorIndex, - criteriaFields, + const chartConfig = buildConfigFromDetector(job, detectorIndex); + + return mlResultsService + .getMetricData( + chartConfig.datafeedConfig.indices.join(','), + entityFields, + chartConfig.datafeedConfig.query, + esMetricFunction ?? chartConfig.metricFunction, + chartConfig.metricFieldName, + chartConfig.summaryCountFieldName, + chartConfig.timeField, earliestMs, latestMs, - intervalMs + intervalMs, + chartConfig?.datafeedConfig + ) + .pipe( + map((resp) => { + each(resp.results, (value, time) => { + // @ts-ignore + obj.results[time] = { + actual: value, + }; + }); + return obj; + }) ); + } + } + /** + * Builds chart detail information (charting function description and entity counts) used + * in the title area of the time series chart. + * Queries Elasticsearch if necessary to obtain the distinct count of entities + * for which data is being plotted. + * @param job Job config info + * @param detectorIndex The index of the detector in the job config + * @param entityFields Array of field name - field value pairs + * @param earliestMs Earliest timestamp in milliseconds + * @param latestMs Latest timestamp in milliseconds + * @param metricFunctionDescription The underlying function (min, max, avg) for "metric" detector type + * @returns chart data to plot for Single Metric Viewer/Time series explorer + */ + function getChartDetails( + job: Job, + detectorIndex: number, + entityFields: MlEntityField[], + earliestMs: number, + latestMs: number, + metricFunctionDescription?: ES_AGGREGATION + ) { + return new Promise((resolve, reject) => { + const obj: TimeSeriesExplorerChartDetails = { + success: true, + results: { functionLabel: '', entityData: { entities: [] } }, + }; + + const chartConfig = buildConfigFromDetector(job, detectorIndex, metricFunctionDescription); + + let functionLabel: string | null = chartConfig.metricFunction; + if (chartConfig.metricFieldName !== undefined) { + functionLabel += ` ${chartConfig.metricFieldName}`; + } + + obj.results.functionLabel = functionLabel; + + const blankEntityFields = filter(entityFields, (entity) => { + return entity.fieldValue === null; + }); + + // Look to see if any of the entity fields have defined values + // (i.e. blank input), and if so obtain the cardinality. + if (blankEntityFields.length === 0) { + obj.results.entityData.count = 1; + obj.results.entityData.entities = entityFields; + resolve(obj); } else { - const obj: ModelPlotOutput = { - success: true, - results: {}, - }; - - const chartConfig = buildConfigFromDetector(job, detectorIndex); - - return mlResultsService - .getMetricData( - chartConfig.datafeedConfig.indices.join(','), - entityFields, - chartConfig.datafeedConfig.query, - esMetricFunction ?? chartConfig.metricFunction, - chartConfig.metricFieldName, - chartConfig.summaryCountFieldName, - chartConfig.timeField, + const entityFieldNames: string[] = blankEntityFields.map((f) => f.fieldName); + mlApiServices + .getCardinalityOfFields({ + index: chartConfig.datafeedConfig.indices.join(','), + fieldNames: entityFieldNames, + query: chartConfig.datafeedConfig.query, + timeFieldName: chartConfig.timeField, earliestMs, latestMs, - intervalMs, - chartConfig?.datafeedConfig - ) - .pipe( - map((resp) => { - each(resp.results, (value, time) => { - // @ts-ignore - obj.results[time] = { - actual: value, - }; + }) + .then((results: any) => { + each(blankEntityFields, (field) => { + // results will not contain keys for non-aggregatable fields, + // so store as 0 to indicate over all field values. + obj.results.entityData.entities.push({ + fieldName: field.fieldName, + cardinality: get(results, field.fieldName, 0), }); - return obj; - }) - ); - } - }, - // Builds chart detail information (charting function description and entity counts) used - // in the title area of the time series chart. - // Queries Elasticsearch if necessary to obtain the distinct count of entities - // for which data is being plotted. - getChartDetails( - job: Job, - detectorIndex: number, - entityFields: any[], - earliestMs: number, - latestMs: number - ) { - return new Promise((resolve, reject) => { - const obj: any = { - success: true, - results: { functionLabel: '', entityData: { entities: [] } }, - }; - - const chartConfig = buildConfigFromDetector(job, detectorIndex); - let functionLabel: string | null = chartConfig.metricFunction; - if (chartConfig.metricFieldName !== undefined) { - functionLabel += ' '; - functionLabel += chartConfig.metricFieldName; - } - obj.results.functionLabel = functionLabel; - - const blankEntityFields = filter(entityFields, (entity) => { - return entity.fieldValue === null; - }); + }); - // Look to see if any of the entity fields have defined values - // (i.e. blank input), and if so obtain the cardinality. - if (blankEntityFields.length === 0) { - obj.results.entityData.count = 1; - obj.results.entityData.entities = entityFields; - resolve(obj); - } else { - const entityFieldNames: string[] = blankEntityFields.map((f) => f.fieldName); - mlApiServices - .getCardinalityOfFields({ - index: chartConfig.datafeedConfig.indices.join(','), - fieldNames: entityFieldNames, - query: chartConfig.datafeedConfig.query, - timeFieldName: chartConfig.timeField, - earliestMs, - latestMs, - }) - .then((results: any) => { - each(blankEntityFields, (field) => { - // results will not contain keys for non-aggregatable fields, - // so store as 0 to indicate over all field values. - obj.results.entityData.entities.push({ - fieldName: field.fieldName, - cardinality: get(results, field.fieldName, 0), - }); - }); + resolve(obj); + }) + .catch((resp: any) => { + reject(resp); + }); + } + }); + } - resolve(obj); - }) - .catch((resp: any) => { - reject(resp); - }); - } - }); - }, + return { + getMetricData, + getChartDetails, }; } export type MlTimeSeriesSearchService = ReturnType; + +export function useTimeSeriesSearchService(): MlTimeSeriesSearchService { + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const mlResultsService = mlResultsServiceProvider(mlApiServices); + + const mlForecastService = useMemo( + () => timeSeriesSearchServiceFactory(mlResultsService, mlApiServices), + [mlApiServices, mlResultsService] + ); + return mlForecastService; +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts deleted file mode 100644 index 11da0acb506f6..0000000000000 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts +++ /dev/null @@ -1,58 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export function createTimeSeriesJobData(jobs: any): any; - -export function processMetricPlotResults(metricPlotData: any, modelPlotEnabled: any): any; - -export function processForecastResults(forecastData: any): any; - -export function processRecordScoreResults(scoreData: any): any; - -export function processDataForFocusAnomalies( - chartData: any, - anomalyRecords: any, - aggregationInterval: any, - modelPlotEnabled: any, - functionDescription: any -): any; - -export function processScheduledEventsForChart( - chartData: any, - scheduledEvents: any, - aggregationInterval: any -): any; - -export function findNearestChartPointToTime(chartData: any, time: any): any; - -export function findChartPointForAnomalyTime( - chartData: any, - anomalyTime: any, - aggregationInterval: any -): any; - -export function calculateAggregationInterval( - bounds: any, - bucketsTarget: any, - jobs: any, - selectedJob: any -): any; - -export function calculateDefaultFocusRange( - autoZoomDuration: any, - contextAggregationInterval: any, - contextChartData: any, - contextForecastData: any -): any; - -export function calculateInitialFocusRange( - zoomState: any, - contextAggregationInterval: any, - bounds: any -): any; - -export function getAutoZoomDuration(jobs: any, selectedJob: any): any; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js deleted file mode 100644 index 531de39f6927c..0000000000000 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js +++ /dev/null @@ -1,470 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * Contains a number of utility functions used for processing - * the data for exploring a time series in the Single Metric - * Viewer dashboard. - */ - -import { each, get, find } from 'lodash'; -import moment from 'moment-timezone'; - -import { isMultiBucketAnomaly, ML_JOB_AGGREGATION } from '@kbn/ml-anomaly-utils'; - -import { isTimeSeriesViewJob } from '../../../../common/util/job_utils'; -import { parseInterval } from '../../../../common/util/parse_interval'; - -import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../../util/time_buckets'; -import { CHARTS_POINT_TARGET, TIME_FIELD_NAME } from '../timeseriesexplorer_constants'; - -// create new job objects based on standard job config objects -// new job objects just contain job id, bucket span in seconds and a selected flag. -// only time series view jobs are allowed -export function createTimeSeriesJobData(jobs) { - const singleTimeSeriesJobs = jobs.filter(isTimeSeriesViewJob); - return singleTimeSeriesJobs.map((job) => { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { - id: job.job_id, - selected: false, - bucketSpanSeconds: bucketSpan.asSeconds(), - }; - }); -} - -// Return dataset in format used by the single metric chart. -// i.e. array of Objects with keys date (JavaScript date) and value, -// plus lower and upper keys if model plot is enabled for the series. -export function processMetricPlotResults(metricPlotData, modelPlotEnabled) { - const metricPlotChartData = []; - if (modelPlotEnabled === true) { - each(metricPlotData, (dataForTime, time) => { - metricPlotChartData.push({ - date: new Date(+time), - lower: dataForTime.modelLower, - value: dataForTime.actual, - upper: dataForTime.modelUpper, - }); - }); - } else { - each(metricPlotData, (dataForTime, time) => { - metricPlotChartData.push({ - date: new Date(+time), - value: dataForTime.actual, - }); - }); - } - - return metricPlotChartData; -} - -// Returns forecast dataset in format used by the single metric chart. -// i.e. array of Objects with keys date (JavaScript date), isForecast, -// value, lower and upper keys. -export function processForecastResults(forecastData) { - const forecastPlotChartData = []; - each(forecastData, (dataForTime, time) => { - forecastPlotChartData.push({ - date: new Date(+time), - isForecast: true, - lower: dataForTime.forecastLower, - value: dataForTime.prediction, - upper: dataForTime.forecastUpper, - }); - }); - - return forecastPlotChartData; -} - -// Return dataset in format used by the swimlane. -// i.e. array of Objects with keys date (JavaScript date) and score. -export function processRecordScoreResults(scoreData) { - const bucketScoreData = []; - each(scoreData, (dataForTime, time) => { - bucketScoreData.push({ - date: new Date(+time), - score: dataForTime.score, - }); - }); - - return bucketScoreData; -} - -// Uses data from the list of anomaly records to add anomalyScore, -// function, actual and typical properties, plus causes and multi-bucket -// info if applicable, to the chartData entries for anomalous buckets. -export function processDataForFocusAnomalies( - chartData, - anomalyRecords, - aggregationInterval, - modelPlotEnabled, - functionDescription -) { - const timesToAddPointsFor = []; - - // Iterate through the anomaly records making sure we have chart points for each anomaly. - const intervalMs = aggregationInterval.asMilliseconds(); - let lastChartDataPointTime = undefined; - if (chartData !== undefined && chartData.length > 0) { - lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); - } - anomalyRecords.forEach((record) => { - const recordTime = record[TIME_FIELD_NAME]; - const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); - if (chartPoint === undefined) { - const timeToAdd = Math.floor(recordTime / intervalMs) * intervalMs; - if (timesToAddPointsFor.indexOf(timeToAdd) === -1 && timeToAdd !== lastChartDataPointTime) { - timesToAddPointsFor.push(timeToAdd); - } - } - }); - - timesToAddPointsFor.sort((a, b) => a - b); - - timesToAddPointsFor.forEach((time) => { - const pointToAdd = { - date: new Date(time), - value: null, - }; - - if (modelPlotEnabled === true) { - pointToAdd.upper = null; - pointToAdd.lower = null; - } - chartData.push(pointToAdd); - }); - - // Iterate through the anomaly records adding the - // various properties required for display. - anomalyRecords.forEach((record) => { - // Look for a chart point with the same time as the record. - // If none found, find closest time in chartData set. - const recordTime = record[TIME_FIELD_NAME]; - if ( - record.function === ML_JOB_AGGREGATION.METRIC && - record.function_description !== functionDescription - ) - return; - - const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); - if (chartPoint !== undefined) { - // If chart aggregation interval > bucket span, there may be more than - // one anomaly record in the interval, so use the properties from - // the record with the highest anomalyScore. - const recordScore = record.record_score; - const pointScore = chartPoint.anomalyScore; - if (pointScore === undefined || pointScore < recordScore) { - chartPoint.anomalyScore = recordScore; - chartPoint.function = record.function; - - if (record.actual !== undefined) { - // If cannot match chart point for anomaly time - // substitute the value with the record's actual so it won't plot as null/0 - if (chartPoint.value === null || record.function === ML_JOB_AGGREGATION.METRIC) { - chartPoint.value = Array.isArray(record.actual) ? record.actual[0] : record.actual; - } - - chartPoint.actual = record.actual; - chartPoint.typical = record.typical; - } else { - const causes = get(record, 'causes', []); - if (causes.length > 0) { - chartPoint.byFieldName = record.by_field_name; - chartPoint.numberOfCauses = causes.length; - if (causes.length === 1) { - // If only a single cause, copy actual and typical values to the top level. - const cause = record.causes[0]; - chartPoint.actual = cause.actual; - chartPoint.typical = cause.typical; - // substitute the value with the record's actual so it won't plot as null/0 - if (chartPoint.value === null) { - chartPoint.value = cause.actual; - } - } - } - } - - if ( - record.anomaly_score_explanation !== undefined && - record.anomaly_score_explanation.multi_bucket_impact !== undefined - ) { - chartPoint.multiBucketImpact = record.anomaly_score_explanation.multi_bucket_impact; - } - - chartPoint.isMultiBucketAnomaly = isMultiBucketAnomaly(record); - } - } - }); - - return chartData; -} - -// Adds a scheduledEvents property to any points in the chart data set -// which correspond to times of scheduled events for the job. -export function processScheduledEventsForChart(chartData, scheduledEvents, aggregationInterval) { - if (scheduledEvents !== undefined) { - const timesToAddPointsFor = []; - - // Iterate through the scheduled events making sure we have a chart point for each event. - const intervalMs = aggregationInterval.asMilliseconds(); - let lastChartDataPointTime = undefined; - if (chartData !== undefined && chartData.length > 0) { - lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); - } - - // In case there's no chart data/sparse data during these scheduled events - // ensure we add chart points at every aggregation interval for these scheduled events. - let sortRequired = false; - each(scheduledEvents, (events, time) => { - const exactChartPoint = findChartPointForScheduledEvent(chartData, +time); - - if (exactChartPoint !== undefined) { - exactChartPoint.scheduledEvents = events; - } else { - const timeToAdd = Math.floor(time / intervalMs) * intervalMs; - if (timesToAddPointsFor.indexOf(timeToAdd) === -1 && timeToAdd !== lastChartDataPointTime) { - const pointToAdd = { - date: new Date(timeToAdd), - value: null, - scheduledEvents: events, - }; - - chartData.push(pointToAdd); - sortRequired = true; - } - } - }); - - // Sort chart data by time if extra points were added at the end of the array for scheduled events. - if (sortRequired === true) { - chartData.sort((a, b) => a.date.getTime() - b.date.getTime()); - } - } - - return chartData; -} - -// Finds the chart point which is closest in time to the specified time. -export function findNearestChartPointToTime(chartData, time) { - let chartPoint; - if (chartData === undefined) { - return chartPoint; - } - - for (let i = 0; i < chartData.length; i++) { - if (chartData[i].date.getTime() === time) { - chartPoint = chartData[i]; - break; - } - } - - if (chartPoint === undefined) { - // Find nearest point in time. - // loop through line items until the date is greater than bucketTime - // grab the current and previous items and compare the time differences - let foundItem; - for (let i = 0; i < chartData.length; i++) { - const itemTime = chartData[i]?.date?.getTime(); - if (itemTime > time) { - const item = chartData[i]; - const previousItem = chartData[i - 1]; - - const diff1 = Math.abs(time - previousItem?.date?.getTime()); - const diff2 = Math.abs(time - itemTime); - - // foundItem should be the item with a date closest to bucketTime - if (previousItem === undefined || diff1 > diff2) { - foundItem = item; - } else { - foundItem = previousItem; - } - - break; - } - } - - chartPoint = foundItem; - } - - return chartPoint; -} - -// Finds the chart point which corresponds to an anomaly with the -// specified time. -export function findChartPointForAnomalyTime(chartData, anomalyTime, aggregationInterval) { - let chartPoint; - if (chartData === undefined) { - return chartPoint; - } - - for (let i = 0; i < chartData.length; i++) { - if (chartData[i].date.getTime() === anomalyTime) { - chartPoint = chartData[i]; - break; - } - } - - if (chartPoint === undefined) { - // Find the time of the point which falls immediately before the - // time of the anomaly. This is the start of the chart 'bucket' - // which contains the anomalous bucket. - let foundItem; - const intervalMs = aggregationInterval.asMilliseconds(); - for (let i = 0; i < chartData.length; i++) { - const itemTime = chartData[i].date.getTime(); - if (anomalyTime - itemTime < intervalMs) { - foundItem = chartData[i]; - break; - } - } - - chartPoint = foundItem; - } - - return chartPoint; -} - -export function findChartPointForScheduledEvent(chartData, eventTime) { - let chartPoint; - if (chartData === undefined) { - return chartPoint; - } - - for (let i = 0; i < chartData.length; i++) { - if (chartData[i].date.getTime() === eventTime) { - chartPoint = chartData[i]; - break; - } - } - - return chartPoint; -} - -export function calculateAggregationInterval(bounds, bucketsTarget, jobs, selectedJob) { - // Aggregation interval used in queries should be a function of the time span of the chart - // and the bucket span of the selected job(s). - const barTarget = bucketsTarget !== undefined ? bucketsTarget : 100; - // Use a maxBars of 10% greater than the target. - const maxBars = Math.floor(1.1 * barTarget); - const buckets = getTimeBucketsFromCache(); - buckets.setInterval('auto'); - buckets.setBounds(bounds); - buckets.setBarTarget(Math.floor(barTarget)); - buckets.setMaxBars(maxBars); - - // Ensure the aggregation interval is always a multiple of the bucket span to avoid strange - // behaviour such as adjacent chart buckets holding different numbers of job results. - const bucketSpanSeconds = find(jobs, { id: selectedJob.job_id }).bucketSpanSeconds; - let aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); - - // Set the interval back to the job bucket span if the auto interval is smaller. - const secs = aggInterval.asSeconds(); - if (secs < bucketSpanSeconds) { - buckets.setInterval(bucketSpanSeconds + 's'); - aggInterval = buckets.getInterval(); - } - - return aggInterval; -} - -export function calculateDefaultFocusRange( - autoZoomDuration, - contextAggregationInterval, - contextChartData, - contextForecastData -) { - const isForecastData = contextForecastData !== undefined && contextForecastData.length > 0; - - const combinedData = - isForecastData === false ? contextChartData : contextChartData.concat(contextForecastData); - const earliestDataDate = combinedData[0].date; - const latestDataDate = combinedData[combinedData.length - 1].date; - - let rangeEarliestMs; - let rangeLatestMs; - - if (isForecastData === true) { - // Return a range centred on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestForecastDataDate = contextForecastData[0].date; - const latestForecastDataDate = contextForecastData[contextForecastData.length - 1].date; - - rangeLatestMs = Math.min( - earliestForecastDataDate.getTime() + autoZoomDuration / 2, - latestForecastDataDate.getTime() - ); - rangeEarliestMs = Math.max(rangeLatestMs - autoZoomDuration, earliestDataDate.getTime()); - } else { - // Returns the range that shows the most recent data at bucket span granularity. - rangeLatestMs = latestDataDate.getTime() + contextAggregationInterval.asMilliseconds(); - rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - autoZoomDuration); - } - - return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; -} - -export function calculateInitialFocusRange(zoomState, contextAggregationInterval, bounds) { - if (zoomState !== undefined) { - // Check that the zoom times are valid. - // zoomFrom must be at or after context chart search bounds earliest, - // zoomTo must be at or before context chart search bounds latest. - const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, true); - const earliest = searchBounds.min; - const latest = searchBounds.max; - - if ( - zoomFrom.isValid() && - zoomTo.isValid && - zoomTo.isAfter(zoomFrom) && - zoomFrom.isBetween(earliest, latest, null, '[]') && - zoomTo.isBetween(earliest, latest, null, '[]') - ) { - return [zoomFrom.toDate(), zoomTo.toDate()]; - } - } - - return undefined; -} - -export function getAutoZoomDuration(jobs, selectedJob) { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - // Get the minimum bucket span of selected jobs. - // TODO - only look at jobs for which data has been returned? - const bucketSpanSeconds = find(jobs, { id: selectedJob.job_id }).bucketSpanSeconds; - - // In most cases the duration can be obtained by simply multiplying the points target - // Check that this duration returns the bucket span when run back through the - // TimeBucket interval calculation. - let autoZoomDuration = bucketSpanSeconds * 1000 * (CHARTS_POINT_TARGET - 1); - - // Use a maxBars of 10% greater than the target. - const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET); - const buckets = getTimeBucketsFromCache(); - buckets.setInterval('auto'); - buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET)); - buckets.setMaxBars(maxBars); - - // Set bounds from 'now' for testing the auto zoom duration. - const nowMs = new Date().getTime(); - const max = moment(nowMs); - const min = moment(nowMs - autoZoomDuration); - buckets.setBounds({ min, max }); - - const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); - const calculatedIntervalSecs = calculatedInterval.asSeconds(); - if (calculatedIntervalSecs !== bucketSpanSeconds) { - // If we haven't got the span back, which may occur depending on the 'auto' ranges - // used in TimeBuckets and the bucket span of the job, then multiply by the ratio - // of the bucket span to the calculated interval. - autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs); - } - - return autoZoomDuration; -} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/validate_job_selection.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/validate_job_selection.ts index 16471cabf9b3f..617d3047056ac 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/validate_job_selection.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/validate_job_selection.ts @@ -11,10 +11,10 @@ import { i18n } from '@kbn/i18n'; import type { ToastsStart } from '@kbn/core/public'; import type { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { isTimeSeriesViewJob } from '../../../../common/util/job_utils'; import { mlJobService } from '../../services/job_service'; -import { createTimeSeriesJobData } from './timeseriesexplorer_utils'; import type { GetJobSelection } from '../../contexts/ml/use_job_selection_flyout'; /** @@ -29,8 +29,8 @@ export function validateJobSelection( toastNotifications: ToastsStart, getJobSelection: GetJobSelection ): boolean | string { - const jobs = createTimeSeriesJobData(mlJobService.jobs); - const timeSeriesJobIds: string[] = jobs.map((j: any) => j.id); + const jobs = mlJobService.jobs.filter(isTimeSeriesViewJob); + const timeSeriesJobIds: string[] = jobs.map((j: any) => j.job_id); // Check if any of the jobs set in the URL are not time series jobs // (e.g. if switching to this view straight from the Anomaly Explorer). diff --git a/x-pack/plugins/ml/public/application/util/index_service.ts b/x-pack/plugins/ml/public/application/util/index_service.ts index 62488208642ff..84da4ee567ab4 100644 --- a/x-pack/plugins/ml/public/application/util/index_service.ts +++ b/x-pack/plugins/ml/public/application/util/index_service.ts @@ -13,44 +13,46 @@ import { useMlKibana } from '../contexts/kibana'; // TODO Consolidate with legacy code in `ml/public/application/util/index_utils.ts`. export function indexServiceFactory(dataViewsService: DataViewsContract) { - return { - /** - * Retrieves the data view ID from the given name. - * If a job is passed in, a temporary data view will be created if the requested data view doesn't exist. - * @param name - The name or index pattern of the data view. - * @param job - Optional job object. - * @returns The data view ID or null if it doesn't exist. - */ - async getDataViewIdFromName(name: string, job?: Job): Promise { - const dataViews = await dataViewsService.find(name); - const dataView = dataViews.find((dv) => dv.getIndexPattern() === name); - if (!dataView) { - if (job !== undefined) { - const tempDataView = await dataViewsService.create({ - id: undefined, - name, - title: name, - timeFieldName: job.data_description.time_field!, - }); - return tempDataView.id ?? null; - } - return null; + /** + * Retrieves the data view ID from the given name. + * If a job is passed in, a temporary data view will be created if the requested data view doesn't exist. + * @param name - The name or index pattern of the data view. + * @param job - Optional job object. + * @returns The data view ID or null if it doesn't exist. + */ + async function getDataViewIdFromName(name: string, job?: Job): Promise { + const dataViews = await dataViewsService.find(name); + const dataView = dataViews.find((dv) => dv.getIndexPattern() === name); + if (!dataView) { + if (job !== undefined) { + const tempDataView = await dataViewsService.create({ + id: undefined, + name, + title: name, + timeFieldName: job.data_description.time_field!, + }); + return tempDataView.id ?? null; } - return dataView.id ?? dataView.getIndexPattern(); - }, - getDataViewById(id: string): Promise { - if (id) { - return dataViewsService.get(id); - } else { - return dataViewsService.create({}); - } - }, - async loadDataViewListItems() { - return (await dataViewsService.getIdsWithTitle()).sort((a, b) => - a.title.localeCompare(b.title) - ); - }, - }; + return null; + } + return dataView.id ?? dataView.getIndexPattern(); + } + + function getDataViewById(id: string): Promise { + if (id) { + return dataViewsService.get(id); + } else { + return dataViewsService.create({}); + } + } + + async function loadDataViewListItems() { + return (await dataViewsService.getIdsWithTitle()).sort((a, b) => + a.title.localeCompare(b.title) + ); + } + + return { getDataViewIdFromName, getDataViewById, loadDataViewListItems }; } export type MlIndexUtils = ReturnType; diff --git a/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts b/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts index 23d55c5199a2d..e9c87547292b9 100644 --- a/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts +++ b/x-pack/plugins/ml/public/application/util/time_series_explorer_service.ts @@ -25,11 +25,6 @@ import { timeBucketsServiceFactory } from './time_buckets_service'; import type { TimeRangeBounds } from './time_buckets'; import type { Job } from '../../../common/types/anomaly_detection_jobs'; import type { TimeBucketsInterval } from './time_buckets'; -import type { - ChartDataPoint, - FocusData, - Interval, -} from '../timeseriesexplorer/timeseriesexplorer_utils/get_focus_data'; import type { CriteriaField } from '../services/results_service'; import { MAX_SCHEDULED_EVENTS, @@ -37,10 +32,32 @@ import { } from '../timeseriesexplorer/timeseriesexplorer_constants'; import type { MlApiServices } from '../services/ml_api_service'; import { mlResultsServiceProvider, type MlResultsService } from '../services/results_service'; -import { forecastServiceProvider } from '../services/forecast_service_provider'; +import { forecastServiceFactory } from '../services/forecast_service'; import { timeSeriesSearchServiceFactory } from '../timeseriesexplorer/timeseriesexplorer_utils/time_series_search_service'; import { useMlKibana } from '../contexts/kibana'; +export interface Interval { + asMilliseconds: () => number; + expression: string; +} + +interface ChartDataPoint { + date: Date; + value: number | null; + upper?: number | null; + lower?: number | null; +} + +interface FocusData { + focusChartData: ChartDataPoint[]; + anomalyRecords: MlAnomalyRecordDoc[]; + scheduledEvents: any; + showForecastCheckbox?: boolean; + focusAnnotationError?: string; + focusAnnotationData?: any[]; + focusForecastData?: any; +} + // TODO Consolidate with legacy code in // `ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js`. export function timeSeriesExplorerServiceFactory( @@ -49,7 +66,7 @@ export function timeSeriesExplorerServiceFactory( mlResultsService: MlResultsService ) { const timeBuckets = timeBucketsServiceFactory(uiSettings); - const mlForecastService = forecastServiceProvider(mlApiServices); + const mlForecastService = forecastServiceFactory(mlApiServices); const mlTimeSeriesSearchService = timeSeriesSearchServiceFactory(mlResultsService, mlApiServices); function getAutoZoomDuration(selectedJob: Job) {