From 82ac64dbe3fcb77aaa2a00d03b094677dd500575 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 29 Jul 2019 20:09:16 +0200 Subject: [PATCH] [ML] Migrates single metric viewer to React. (#41739) (#42166) Migrates the overall page of Single Metric Viewer to React. --- .../annotation_flyout_directive.js | 45 - .../__tests__/annotations_table_directive.js | 55 - .../annotations_table_directive.js | 81 -- .../annotations/annotations_table/index.js | 2 - .../anomalies_table_directive.js | 31 - .../components/anomalies_table/index.js | 2 +- .../checkbox_showcharts_service.js | 15 - .../controls/checkbox_showcharts/index.js | 3 +- .../ml/public/components/controls/index.js | 8 +- .../__tests__/select_interval_directive.js | 53 - .../controls/select_interval/index.js | 2 +- .../select_interval_directive.js | 27 - .../controls/select_severity/index.js | 2 +- .../select_severity_directive.js | 29 - .../components/influencers_list/index.js | 2 +- .../custom_selection_table.js | 4 +- .../public/components/job_selector/index.js | 5 +- .../job_selector/job_select_service_utils.js | 29 +- .../components/job_selector/job_selector.js | 14 +- .../job_selector_react_wrapper_directive.js | 67 - .../job_selector_table/job_selector_table.js | 6 +- .../job_selector_table.test.js | 7 +- .../explorer_no_influencers_found/index.js | 2 +- .../explorer_no_jobs_found/index.js | 2 +- .../explorer_no_results_found/index.js | 2 +- .../ml/public/explorer/components/index.js | 6 +- .../plugins/ml/public/explorer/explorer.html | 8 - .../plugins/ml/public/explorer/explorer.js | 448 +++--- .../components/explorer_chart_label/index.js | 2 +- .../ml/public/explorer/explorer_controller.js | 48 +- .../explorer_react_wrapper_directive.js | 33 +- .../ml/public/explorer/explorer_utils.js | 9 - .../components/utils/index.js | 8 +- .../simple/components/utils/search_service.js | 8 +- .../simple/components/watcher/watch.js | 2 +- .../ml/public/services/forecast_service.js | 7 +- .../__tests__/timeseries_chart_directive.js | 62 - .../timeseriesexplorer_controller.js | 29 - .../__tests__/timeseriesexplorer_directive.js | 40 + .../_timeseriesexplorer.scss | 16 - .../components/context_chart_mask/index.js | 2 +- .../entity_control/entity_control.js | 97 ++ .../components/entity_control/index.js | 7 + .../forecasting_modal/forecasting_modal.js | 10 +- .../components/forecasting_modal/index.js | 5 +- .../timeseries_chart/timeseries_chart.js | 89 +- .../timeseries_chart/timeseries_chart.test.js | 1 + .../timeseries_chart_directive.js | 146 -- .../timeseriesexplorer_no_chart_data/index.js | 7 + .../timeseriesexplorer_no_chart_data.js | 48 + .../timeseriesexplorer_no_jobs_found/index.js | 7 + .../timeseriesexplorer_no_jobs_found.js | 34 + .../ml/public/timeseriesexplorer/index.js | 9 +- .../timeseries_search_service.js | 8 +- .../timeseriesexplorer.html | 263 ---- .../timeseriesexplorer/timeseriesexplorer.js | 1221 +++++++++++++++++ .../timeseriesexplorer_constants.js | 30 + .../timeseriesexplorer_controller.js | 1061 -------------- .../timeseriesexplorer_directive.js | 116 ++ .../timeseriesexplorer_route.js | 30 + .../timeseriesexplorer_utils.js | 304 +++- .../plugins/ml/public/util/app_state_utils.js | 9 +- .../plugins/ml/public/util/ml_time_buckets.js | 5 +- .../public/util/refresh_interval_watcher.js | 49 - .../ml/server/lib/ml_telemetry/index.ts | 10 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- 67 files changed, 2377 insertions(+), 2424 deletions(-) delete mode 100644 x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/explorer/explorer.html delete mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js delete mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js delete mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js delete mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js create mode 100644 x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js delete mode 100644 x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js deleted file mode 100644 index 5f766a31b3ae37..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * angularjs wrapper directive for the AnnotationsTable React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { AnnotationFlyout } from './index'; - -import 'angular'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { I18nProvider } from '@kbn/i18n/react'; - -module.directive('mlAnnotationFlyout', function () { - - function link(scope, element) { - ReactDOM.render( - - {React.createElement(AnnotationFlyout)} - , - element[0] - ); - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - } - - return { - scope: false, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js deleted file mode 100644 index d53271aba55115..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import jobConfig from '../../../../../common/types/__mocks__/job_config_farequote'; - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { ml } from '../../../../services/ml_api_service'; - -describe('ML - ', () => { - let $scope; - let $compile; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Plain initialization doesn\'t throw an error', () => { - expect(() => { - $compile('')($scope); - }).to.not.throwError(); - }); - - it('Initialization with empty annotations array doesn\'t throw an error', () => { - expect(() => { - $compile('')($scope); - }).to.not.throwError(); - }); - - it('Initialization with job config doesn\'t throw an error', () => { - const getAnnotationsStub = sinon.stub(ml.annotations, 'getAnnotations').resolves({ annotations: [] }); - - expect(() => { - $scope.jobs = [jobConfig]; - $compile('')($scope); - }).to.not.throwError(); - - getAnnotationsStub.restore(); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js deleted file mode 100644 index 5e8cece1629977..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * angularjs wrapper directive for the AnnotationsTable React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { AnnotationsTable } from './annotations_table'; - -import 'angular'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import chrome from 'ui/chrome'; -const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - -import { I18nContext } from 'ui/i18n'; - -module.directive('mlAnnotationTable', function () { - - function link(scope, element) { - function renderReactComponent() { - if (typeof scope.jobs === 'undefined' && typeof scope.annotations === 'undefined') { - return; - } - - const props = { - annotations: scope.annotations, - jobs: scope.jobs, - isSingleMetricViewerLinkVisible: scope.drillDown, - isNumberBadgeVisible: scope.numberBadge - }; - - ReactDOM.render( - - {React.createElement(AnnotationsTable, props)} - , - element[0] - ); - } - - renderReactComponent(); - - scope.$on('render', () => { - renderReactComponent(); - }); - - function renderFocusChart() { - renderReactComponent(); - } - - if (mlAnnotationsEnabled) { - scope.$watchCollection('annotations', renderFocusChart); - } - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - - } - - return { - scope: { - annotations: '=', - drillDown: '=', - jobs: '=', - numberBadge: '=' - }, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js index 6364275ce2e55b..965f201f5e8f15 100644 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js +++ b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js @@ -5,5 +5,3 @@ */ export { AnnotationsTable } from './annotations_table'; - -import './annotations_table_directive'; diff --git a/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js b/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js deleted file mode 100644 index 5772bc98959e47..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from 'ui/modules'; -import { timefilter } from 'ui/timefilter'; -const module = uiModules.get('apps/ml', ['react']); - -import { AnomaliesTable } from './anomalies_table'; - -module.directive('mlAnomaliesTable', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - wrapInI18nContext(AnomaliesTable), - [ - ['filter', { watchDepth: 'reference' }], - ['tableData', { watchDepth: 'reference' }] - ], - { restrict: 'E' }, - { - timefilter - } - ); -}); diff --git a/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js b/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js index d16092d1f61896..1999654055c717 100644 --- a/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js +++ b/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js @@ -5,4 +5,4 @@ */ -import './anomalies_table_directive'; +export { AnomaliesTable } from './anomalies_table'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js deleted file mode 100644 index c4f12776ad9659..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { showCharts$ } from './checkbox_showcharts'; - -module.service('mlCheckboxShowChartsService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync()); -}); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js index 7c11d47bae2df6..b7957b807591c9 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - -import './checkbox_showcharts_service'; +export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/index.js b/x-pack/legacy/plugins/ml/public/components/controls/index.js index 275c6e6a6790d7..26cb89d672632d 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/index.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ - - -import './checkbox_showcharts'; -import './select_interval'; -import './select_severity'; +export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; +export { interval$, SelectInterval } from './select_interval'; +export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js deleted file mode 100644 index 67b39d9bf85fcf..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; - -import { interval$ } from '../select_interval'; - -describe('ML - mlSelectIntervalService', () => { - let appState; - - beforeEach(ngMock.module('kibana', (stateManagementConfigProvider) => { - stateManagementConfigProvider.enable(); - })); - beforeEach(ngMock.module(($provide) => { - appState = { - fetch: () => {}, - save: () => {} - }; - - $provide.factory('AppState', () => () => appState); - })); - - it('initializes AppState with correct default value', (done) => { - ngMock.inject(($injector) => { - $injector.get('mlSelectIntervalService'); - const defaultValue = { display: 'Auto', val: 'auto' }; - - expect(appState.mlSelectInterval).to.eql(defaultValue); - expect(interval$.getValue()).to.eql(defaultValue); - - done(); - }); - }); - - it('restores AppState to interval$ observable', (done) => { - ngMock.inject(($injector) => { - const restoreValue = { display: '1 day', val: 'day' }; - appState.mlSelectInterval = restoreValue; - - $injector.get('mlSelectIntervalService'); - - expect(appState.mlSelectInterval).to.eql(restoreValue); - expect(interval$.getValue()).to.eql(restoreValue); - - done(); - }); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js index 8fe80d63bb99c6..a38c71d89d07b4 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js @@ -5,4 +5,4 @@ */ -import './select_interval_directive'; +export { interval$, SelectInterval } from './select_interval'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js deleted file mode 100644 index 4391d33951932d..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { SelectInterval, interval$ } from './select_interval'; - -module.service('mlSelectIntervalService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync()); -}) - .directive('mlSelectInterval', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - SelectInterval, - undefined, - { restrict: 'E' } - ); - }); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js index 618edf599e5097..7c841156009f3e 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js @@ -5,4 +5,4 @@ */ -import './select_severity_directive'; +export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js deleted file mode 100644 index 6ab94225cea486..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { wrapInI18nContext } from 'ui/i18n'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { SelectSeverity, severity$ } from './select_severity'; - -module.service('mlSelectSeverityService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync()); -}) - .directive('mlSelectSeverity', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - wrapInI18nContext(SelectSeverity), - undefined, - { restrict: 'E' }, - ); - }); diff --git a/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js b/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js index 07d95019813e54..0b64ce2367a0db 100644 --- a/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js +++ b/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './influencers_list'; +export { InfluencersList } from './influencers_list'; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js b/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js index d906b49beef7ff..bbb02917c7061b 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js @@ -387,7 +387,7 @@ CustomSelectionTable.propTypes = { items: PropTypes.array.isRequired, onTableChange: PropTypes.func.isRequired, selectedId: PropTypes.array, - singleSelection: PropTypes.string, + singleSelection: PropTypes.bool, sortableProperties: PropTypes.object, - timeseriesOnly: PropTypes.string + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/index.js b/x-pack/legacy/plugins/ml/public/components/job_selector/index.js index 31427150f9261a..c5e5e17b978040 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/index.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - - - -import './job_selector_react_wrapper_directive'; +export { JobSelector } from './job_selector'; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js index b0061204bd3fa5..5f1d85869b3bcc 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference } from 'lodash'; +import { difference, isEqual } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; import { toastNotifications } from 'ui/notify'; -import { mlJobService } from '../../services/job_service'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import d3 from 'd3'; +import { mlJobService } from '../../services/job_service'; function warnAboutInvalidJobIds(invalidIds) { if (invalidIds.length > 0) { @@ -34,6 +35,30 @@ function getInvalidJobIds(ids) { }); } +export const jobSelectServiceFactory = (globalState) => { + const { jobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false }); + + // Subscribe to changes to globalState and trigger + // a jobSelectService update if the job selection changed. + const listener = () => { + const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); + const oldSelectedJobIds = jobSelectService.getValue().selection; + + if (newJobIds && !(isEqual(oldSelectedJobIds, newJobIds))) { + jobSelectService.next({ selection: newJobIds, groups: newSelectedGroups }); + } + }; + + globalState.on('save_with_changes', listener); + + const unsubscribeFromGlobalState = () => { + globalState.off('save_with_changes', listener); + }; + + return { jobSelectService, unsubscribeFromGlobalState }; +}; + function loadJobIdsFromGlobalState(globalState) { // jobIds, groups // fetch to get the latest state globalState.fetch(); diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js index 0d0f7b0c0d5bda..0d00516921e00d 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js @@ -72,13 +72,13 @@ const BADGE_LIMIT = 10; const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels export function JobSelector({ - config, + dateFormatTz, globalState, jobSelectService, selectedJobIds, selectedGroups, singleSelection, - timeseriesOnly + timeseriesOnly, }) { const [jobs, setJobs] = useState([]); const [groups, setGroups] = useState([]); @@ -114,8 +114,6 @@ export function JobSelector({ // Not wrapping it would cause this dependency to change on every render const handleResize = useCallback(() => { if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); // get all cols in flyout table const tableHeaderCols = flyoutEl.current.flyout.querySelectorAll('table thead th'); // get the width of the last col @@ -126,7 +124,7 @@ export function JobSelector({ setGroups(updatedGroups); setGanttBarWidth(derivedWidth); } - }, [config, jobs]); + }, [dateFormatTz, jobs]); useEffect(() => { // Ensure ganttBar width gets calculated on resize @@ -151,8 +149,6 @@ export function JobSelector({ function handleJobSelectionClick() { showFlyout(); - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); ml.jobs.jobsWithTimerange(dateFormatTz) .then((resp) => { @@ -381,6 +377,6 @@ JobSelector.propTypes = { globalState: PropTypes.object, jobSelectService: PropTypes.object, selectedJobIds: PropTypes.array, - singleSelection: PropTypes.string, - timeseriesOnly: PropTypes.string + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js deleted file mode 100644 index 49c5140acd15ae..00000000000000 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * AngularJS directive wrapper for rendering Job Selector React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import _ from 'lodash'; - -import { JobSelector } from './job_selector'; -import { getSelectedJobIds } from './job_select_service_utils'; -import { BehaviorSubject } from 'rxjs'; -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module - .directive('mlJobSelectorReactWrapper', function (globalState, config, mlJobSelectService) { - function link(scope, element, attrs) { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - - const props = { - config, - globalState, - jobSelectService: mlJobSelectService, - selectedJobIds: jobIds, - selectedGroups, - timeseriesOnly: attrs.timeseriesonly, - singleSelection: attrs.singleselection - }; - - ReactDOM.render(React.createElement(JobSelector, props), - element[0] - ); - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - } - - return { - scope: false, - link, - }; - }) - .service('mlJobSelectService', function (globalState) { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - const mlJobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false }); - - // Subscribe to changes to globalState and trigger - // a mlJobSelectService update if the job selection changed. - globalState.on('save_with_changes', () => { - const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); - const oldSelectedJobIds = mlJobSelectService.getValue().selection; - - if (newJobIds && !(_.isEqual(oldSelectedJobIds, newJobIds))) { - mlJobSelectService.next({ selection: newJobIds, groups: newSelectedGroups }); - } - }); - - return mlJobSelectService; - }); diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js index ab8c024bae7f3d..a754fbfab5ca6c 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js @@ -232,7 +232,7 @@ export function JobSelectorTable({ return ( {jobs.length === 0 && } - {jobs.length !== 0 && singleSelection === 'true' && renderJobsTable()} + {jobs.length !== 0 && singleSelection === true && renderJobsTable()} {jobs.length !== 0 && singleSelection === undefined && renderTabs()} ); @@ -244,6 +244,6 @@ JobSelectorTable.propTypes = { jobs: PropTypes.array, onSelection: PropTypes.func.isRequired, selectedIds: PropTypes.array.isRequired, - singleSelection: PropTypes.string, - timeseriesOnly: PropTypes.string + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js index 044fa3bd4c4fea..af300e51eef999 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js @@ -109,21 +109,21 @@ describe('JobSelectorTable', () => { describe('Single Selection', () => { test('Does not render tabs', () => { - const singleSelectionProps = { ...props, singleSelection: 'true' }; + const singleSelectionProps = { ...props, singleSelection: true }; const { queryByRole } = render(); const tabs = queryByRole('tab'); expect(tabs).toBeNull(); }); test('incoming selectedId is selected in the table', () => { - const singleSelectionProps = { ...props, singleSelection: 'true' }; + const singleSelectionProps = { ...props, singleSelection: true }; const { getByTestId } = render(); const radioButton = getByTestId('price-by-day-radio-button'); expect(radioButton.firstChild.checked).toEqual(true); }); test('job cannot be selected if it is not a single metric viewer job', () => { - const timeseriesOnlyProps = { ...props, singleSelection: 'true', timeseriesOnly: 'true' }; + const timeseriesOnlyProps = { ...props, singleSelection: true, timeseriesOnly: true }; const { getByTestId } = render(); const radioButton = getByTestId('non-timeseries-job-radio-button'); expect(radioButton.firstChild.disabled).toEqual(true); @@ -179,4 +179,3 @@ describe('JobSelectorTable', () => { }); }); - diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js index b78623cdbf1066..241623b796bfa0 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_influencers_found'; +export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js index b5ee3e5c94dbe5..143cd82d7a8297 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_jobs_found'; +export { ExplorerNoJobsFound } from './explorer_no_jobs_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js index b5a49dd4c7f83a..d6f71dc131db79 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_results_found'; +export { ExplorerNoResultsFound } from './explorer_no_results_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/index.js index fa4bead02c6999..d4138f3ec6c660 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/index.js @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_influencers_found'; -export * from './explorer_no_jobs_found'; -export * from './explorer_no_results_found'; +export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found'; +export { ExplorerNoJobsFound } from './explorer_no_jobs_found'; +export { ExplorerNoResultsFound } from './explorer_no_results_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer.html b/x-pack/legacy/plugins/ml/public/explorer/explorer.html deleted file mode 100644 index db5b12b7eeb9f8..00000000000000 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
- - - - -
diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer.js b/x-pack/legacy/plugins/ml/public/explorer/explorer.js index 005efb9cc5f7c1..7c7108daa11521 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer.js @@ -10,7 +10,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Fragment } from 'react'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import DragSelect from 'dragselect/dist/ds.min.js'; import { map } from 'rxjs/operators'; @@ -37,11 +37,14 @@ import { ExplorerSwimlane } from './explorer_swimlane'; import { KqlFilterBar } from '../components/kql_filter_bar'; import { formatHumanReadableDateTime } from '../util/date_utils'; import { getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets'; +import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$, explorer$ } from './explorer_dashboard_service'; import { mlResultsService } from 'plugins/ml/services/results_service'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; -import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts/checkbox_showcharts'; +import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; +import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts'; +import { JobSelector } from '../components/job_selector'; import { SelectInterval, interval$ } from '../components/controls/select_interval/select_interval'; import { SelectLimit, limit$ } from './select_limit/select_limit'; import { SelectSeverity, severity$ } from '../components/controls/select_severity/select_severity'; @@ -132,6 +135,14 @@ function mapSwimlaneOptionsToEuiOptions(options) { })); } +const ExplorerPage = ({ children, jobSelectorProps }) => ( + + + + {children} + +); + export const Explorer = injectI18n(injectObservablesAsProps( { annotationsRefresh: annotationsRefresh$, @@ -144,7 +155,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( class Explorer extends React.Component { static propTypes = { appStateHandler: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, dateFormatTz: PropTypes.string.isRequired, + globalState: PropTypes.object.isRequired, + jobSelectService: PropTypes.object.isRequired, MlTimeBuckets: PropTypes.func.isRequired, }; @@ -1046,7 +1060,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( render() { const { + dateFormatTz, + globalState, intl, + jobSelectService, MlTimeBuckets, } = this.props; @@ -1077,23 +1094,34 @@ export const Explorer = injectI18n(injectObservablesAsProps( const swimlaneWidth = getSwimlaneContainerWidth(noInfluencersConfigured); + const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectorProps = { + dateFormatTz, + globalState, + jobSelectService, + selectedJobIds, + selectedGroups, + }; + if (loading === true) { return ( - + + + ); } if (noJobsFound) { - return ; + return ; } if (noJobsFound && hasResults === false) { - return ; + return ; } const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; @@ -1106,9 +1134,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( ); return ( -
+ +
- {noInfluencersConfigured === false && + {noInfluencersConfigured === false && influencers !== undefined &&
} - {noInfluencersConfigured && ( -
- +
- )} + })} + position="right" + type="iInCircle" + /> +
+ )} - {noInfluencersConfigured === false && ( -
- + {noInfluencersConfigured === false && ( +
+ + + + +
+ )} + +
+ - -
- )} -
- - - - -
- -
- - {viewBySwimlaneOptions.length > 0 && ( - - - - + +
+ + {viewBySwimlaneOptions.length > 0 && ( + + + + + + + + + + + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && + swimlaneViewByFieldName === 'job ID' && ( + + )} +
+
+
+
+ + {showViewBySwimlane && ( +
- - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && - swimlaneViewByFieldName === 'job ID' && ( - - )} -
-
-
- - - {showViewBySwimlane && ( -
- -
- )} +
+ )} - {viewBySwimlaneDataLoading && ( - - )} + {viewBySwimlaneDataLoading && ( + + )} - {!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && ( - - )} -
- )} + {!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && ( + + )} + + )} - {annotationsData.length > 0 && ( - - - 0 && ( + + + + + - - - - - - )} + + + + )} - - - - - - - - - - - - - - - - {(anomalyChartRecords.length > 0 && selectedCells !== null) && ( - - - + + + + + + + + - )} - + + + + + + {(anomalyChartRecords.length > 0 && selectedCells !== null) && ( + + + + + + )} + - + -
- {this.props.showCharts && } -
+
+ {this.props.showCharts && } +
- + +
- + ); } } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js index 0cfc2b8463ef84..f8bcec79c96ced 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_chart_label'; +export { ExplorerChartLabel } from './explorer_chart_label'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js index da93a2bf54d1c1..0b83e4d313f131 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js @@ -12,15 +12,10 @@ */ import $ from 'jquery'; -import moment from 'moment-timezone'; import { Subscription } from 'rxjs'; -import '../components/annotations/annotations_table'; -import '../components/anomalies_table'; import '../components/controls'; -import template from './explorer.html'; - import uiRoutes from 'ui/routes'; import { createJobs, @@ -34,14 +29,21 @@ import { explorer$ } from './explorer_dashboard_service'; import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; import { mlJobService } from '../services/job_service'; -import { refreshIntervalWatcher } from '../util/refresh_interval_watcher'; -import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; +import { getSelectedJobIds, jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils'; import { timefilter } from 'ui/timefilter'; +import { interval$ } from '../components/controls/select_interval'; +import { severity$ } from '../components/controls/select_severity'; +import { showCharts$ } from '../components/controls/checkbox_showcharts'; +import { subscribeAppStateToObservable } from '../util/app_state_utils'; + import { APP_STATE_ACTION, EXPLORER_ACTION } from './explorer_constants'; +const template = ``; + uiRoutes .when('/explorer/?', { + controller: 'MlExplorerController', template, k7Breadcrumbs: getAnomalyExplorerBreadcrumbs, resolve: { @@ -57,24 +59,13 @@ import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); module.controller('MlExplorerController', function ( - $injector, $scope, $timeout, + $rootScope, AppState, - Private, - config, globalState, ) { - - // Even if they are not used directly anymore in this controller but via imports - // in React components, because of the use of AppState and its dependency on angularjs - // these services still need to be required here to properly initialize. - $injector.get('mlCheckboxShowChartsService'); - $injector.get('mlSelectIntervalService'); - $injector.get('mlSelectLimitService'); - $injector.get('mlSelectSeverityService'); - - const mlJobSelectService = $injector.get('mlJobSelectService'); + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); const subscriptions = new Subscription(); // $scope should only contain what's actually still necessary for the angular part. @@ -83,10 +74,6 @@ module.controller('MlExplorerController', function ( timefilter.enableTimeRangeSelector(); timefilter.enableAutoRefreshSelector(); - // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. - const tzConfig = config.get('dateFormat:tz'); - $scope.dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); - $scope.MlTimeBuckets = MlTimeBuckets; let resizeTimeout = null; @@ -185,7 +172,7 @@ module.controller('MlExplorerController', function ( swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName, }); - subscriptions.add(mlJobSelectService.subscribe(({ selection }) => { + subscriptions.add(jobSelectService.subscribe(({ selection }) => { if (selection !== undefined) { $scope.jobSelectionUpdateInProgress = true; jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds: selection }); @@ -223,12 +210,15 @@ module.controller('MlExplorerController', function ( }); // Add a watcher for auto-refresh of the time filter to refresh all the data. - const refreshWatcher = Private(refreshIntervalWatcher); - refreshWatcher.init(async () => { + subscriptions.add(mlTimefilterRefresh$.subscribe(() => { if ($scope.jobSelectionUpdateInProgress === false) { explorer$.next({ action: EXPLORER_ACTION.RELOAD }); } - }); + })); + + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync())); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync())); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync())); // Redraw the swimlane when the window resizes or the global nav is toggled. function jqueryRedrawOnResize() { @@ -298,9 +288,9 @@ module.controller('MlExplorerController', function ( $scope.$on('$destroy', () => { subscriptions.unsubscribe(); - refreshWatcher.cancel(); $(window).off('resize', jqueryRedrawOnResize); // Cancel listening for updates to the global nav state. navListener(); + unsubscribeFromGlobalState(); }); }); diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js index 4cfaed04a12a7b..fd6e537532553c 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js @@ -11,30 +11,53 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Explorer } from './explorer'; +import moment from 'moment-timezone'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); import { I18nContext } from 'ui/i18n'; -import { mapScopeToProps } from './explorer_utils'; +import chrome from 'ui/chrome'; +import { timefilter } from 'ui/timefilter'; +import { timeHistory } from 'ui/timefilter/time_history'; + +import { jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils'; +import { NavigationMenuContext } from '../util/context_utils'; +import { Explorer } from './explorer'; import { EXPLORER_ACTION } from './explorer_constants'; import { explorer$ } from './explorer_dashboard_service'; -module.directive('mlExplorerReactWrapper', function () { +module.directive('mlExplorerReactWrapper', function (config, globalState) { function link(scope, element) { + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); + // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. + const tzConfig = config.get('dateFormat:tz'); + const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); + ReactDOM.render( - {React.createElement(Explorer, mapScopeToProps(scope))}, + + + + + , element[0] ); explorer$.next({ action: EXPLORER_ACTION.LOAD_JOBS }); - element.on('$destroy', () => { ReactDOM.unmountComponentAtNode(element[0]); scope.$destroy(); + unsubscribeFromGlobalState(); }); } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js index 39f294a5675289..eee0f349dcd333 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js @@ -58,15 +58,6 @@ export function getDefaultViewBySwimlaneData() { }; } -export function mapScopeToProps(scope) { - return { - appStateHandler: scope.appStateHandler, - dateFormatTz: scope.dateFormatTz, - mlJobSelectService: scope.mlJobSelectService, - MlTimeBuckets: scope.MlTimeBuckets, - }; -} - export async function getFilteredTopInfluencers( jobIds, earliestMs, diff --git a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js index 53d6de0f8b5ea5..5db5228aa44251 100644 --- a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js +++ b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js @@ -5,4 +5,10 @@ */ -export * from './utils'; +export { + createUrlOverrides, + hasImportPermission, + processResults, + readFile, + reduceData, +} from './utils'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js index b6f9723ca82a1d..4a4b9e95e3dfef 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js @@ -8,9 +8,9 @@ import _ from 'lodash'; -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; -import { escapeForElasticsearchQuery } from 'plugins/ml/util/string_utils'; -import { ml } from 'plugins/ml/services/ml_api_service'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; +import { escapeForElasticsearchQuery } from '../../../../../util/string_utils'; +import { ml } from '../../../../../services/ml_api_service'; // detector swimlane search function getScoresByRecord(jobId, earliestMs, latestMs, interval, firstSplitField) { @@ -163,5 +163,3 @@ export const mlSimpleJobSearchService = { getScoresByRecord, getCategoryFields }; - - diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js index 536b0b3b750458..447869ff8fdb43 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; export const watch = { trigger: { diff --git a/x-pack/legacy/plugins/ml/public/services/forecast_service.js b/x-pack/legacy/plugins/ml/public/services/forecast_service.js index 17b29e65cfde7f..4ec8a9dd1fec4c 100644 --- a/x-pack/legacy/plugins/ml/public/services/forecast_service.js +++ b/x-pack/legacy/plugins/ml/public/services/forecast_service.js @@ -10,10 +10,8 @@ // data on forecasts that have been performed. import _ from 'lodash'; -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; -import { ml } from 'plugins/ml/services/ml_api_service'; - - +import { ML_RESULTS_INDEX_PATTERN } from '../../common/constants/index_patterns'; +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. @@ -382,4 +380,3 @@ export const mlForecastService = { runForecast, getForecastRequestStats }; - diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js deleted file mode 100644 index 9ee71c676c6c6e..00000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { TimeseriesChart } from '../components/timeseries_chart/timeseries_chart'; - -describe('ML - ', () => { - let $scope; - let $compile; - let $element; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Plain initialization doesn\'t throw an error', () => { - // this creates a dummy DOM element with class 'ml-timeseries-chart' as a direct child of - // the tag so the directive can find it in the DOM to create the resizeChecker. - const mockClassedElement = document.createElement('div'); - mockClassedElement.classList.add('ml-timeseries-chart'); - document.getElementsByTagName('body')[0].append(mockClassedElement); - - // spy the TimeseriesChart component's unmount method to be able to test if it was called - const componentWillUnmountSpy = sinon.spy(TimeseriesChart.prototype, 'componentWillUnmount'); - - $element = $compile('')($scope); - const scope = $element.isolateScope(); - - // sanity test to check if directive picked up the attribute for its scope - expect(scope.showForecast).to.equal(true); - - // componentWillUnmount() should not have been called so far - expect(componentWillUnmountSpy.callCount).to.equal(0); - - // remove $element to trigger $destroy() callback - $element.remove(); - - // componentWillUnmount() should now have been called once - expect(componentWillUnmountSpy.callCount).to.equal(1); - - componentWillUnmountSpy.restore(); - - // clean up the dummy DOM element - mockClassedElement.parentNode.removeChild(mockClassedElement); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js deleted file mode 100644 index 42510c5a30fd14..00000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; - -describe('ML - Time Series Explorer Controller', () => { - beforeEach(() => { - ngMock.module('kibana'); - }); - - it('Initialize Time Series Explorer Controller', (done) => { - ngMock.inject(function ($rootScope, $controller) { - const scope = $rootScope.$new(); - - expect(() => { - $controller('MlTimeSeriesExplorerController', { $scope: scope }); - }).to.not.throwError(); - - expect(scope.timeFieldName).to.eql('timestamp'); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js new file mode 100644 index 00000000000000..b86abd15dce7e2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ngMock from 'ng_mock'; +import expect from '@kbn/expect'; + +describe('ML - Time Series Explorer Directive', () => { + let $scope; + let $compile; + let $element; + + beforeEach(ngMock.module('kibana')); + beforeEach(() => { + ngMock.inject(function ($injector) { + $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + $scope = $rootScope.$new(); + }); + }); + + afterEach(() => { + $scope.$destroy(); + }); + + it('Initialize Time Series Explorer Directive', (done) => { + ngMock.inject(function () { + expect(() => { + $element = $compile('')($scope); + }).to.not.throwError(); + + // directive has scope: false + const scope = $element.isolateScope(); + expect(scope).to.eql(undefined); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss index eec3db7fac8891..f4366ec13fda3f 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss @@ -60,22 +60,6 @@ } } - .show-model-controls { - float: right; - position: relative; - top: 18px; - - div { - display: inline; - padding-left: $euiSize; - } - - .kuiCheckBoxLabel { - display: inline-block; - font-size: $euiFontSizeXS; - } - } - .forecast-controls { float: right; } diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js index 95dfd9325f5a6d..e6ed8674ca0c54 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js @@ -5,4 +5,4 @@ */ -export * from './context_chart_mask'; +export { ContextChartMask } from './context_chart_mask'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js new file mode 100644 index 00000000000000..0e82002904d519 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import { injectI18n } from '@kbn/i18n/react'; + +import { + EuiComboBox, + EuiFlexItem, + EuiFormRow, +} from '@elastic/eui'; + +function getEntityControlOptions(entity) { + if (!Array.isArray(entity.fieldValues)) { + return []; + } + + return entity.fieldValues.map((value) => { + return { label: value }; + }); +} + +export const EntityControl = injectI18n( + class EntityControl extends React.Component { + static propTypes = { + entity: PropTypes.object.isRequired, + entityFieldValueChanged: PropTypes.func.isRequired, + }; + + state = { + selectedOptions: undefined + } + + constructor(props) { + super(props); + } + + componentDidUpdate() { + const { entity } = this.props; + const { selectedOptions } = this.state; + + const fieldValue = entity.fieldValue; + + if ( + (selectedOptions === undefined && fieldValue.length > 0) || + (Array.isArray(selectedOptions) && fieldValue.length > 0 && selectedOptions[0].label !== fieldValue) + ) { + this.setState({ + selectedOptions: [{ label: fieldValue }] + }); + } else if (Array.isArray(selectedOptions) && fieldValue.length === 0) { + this.setState({ + selectedOptions: undefined + }); + } + } + + onChange = (selectedOptions) => { + const options = (selectedOptions.length > 0) ? selectedOptions : undefined; + this.setState({ + selectedOptions: options, + }); + + const fieldValue = (Array.isArray(options) && options[0].label.length > 0) ? options[0].label : ''; + this.props.entityFieldValueChanged(this.props.entity, fieldValue); + }; + + render() { + const { entity, intl } = this.props; + const { selectedOptions } = this.state; + const options = getEntityControlOptions(entity); + + return ( + + + + + + ); + } + } +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js new file mode 100644 index 00000000000000..63ceb2b4490b5b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EntityControl } from './entity_control'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index 1b807638e57d30..00812d56ade4ac 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ - - /* * React modal dialog which allows the user to run and view time series forecasts. */ @@ -22,6 +20,8 @@ import { EuiToolTip } from '@elastic/eui'; +import { timefilter } from 'ui/timefilter'; + // don't use something like plugins/ml/../common // because it won't work with the jest tests import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../common/constants/states'; @@ -44,7 +44,6 @@ const WARN_NUM_PARTITIONS = 100; // Warn about running a forecast with this n const FORECAST_STATS_POLL_FREQUENCY = 250; // Frequency in ms at which to poll for forecast request stats. const WARN_NO_PROGRESS_MS = 120000; // If no progress in forecast request, abort check and warn. - function getDefaultState() { return { isModalVisible: false, @@ -60,7 +59,6 @@ function getDefaultState() { }; } - export const ForecastingModal = injectI18n(class ForecastingModal extends Component { static propTypes = { isDisabled: PropTypes.bool, @@ -68,7 +66,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon detectorIndex: PropTypes.number, entities: PropTypes.array, loadForForecastId: PropTypes.func, - timefilter: PropTypes.object, }; constructor(props) { @@ -348,7 +345,7 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon 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 bounds = this.props.timefilter.getActiveBounds(); + const bounds = timefilter.getActiveBounds(); const statusFinishedQuery = { term: { forecast_status: FORECAST_REQUEST_STATE.FINISHED @@ -458,7 +455,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon const forecastButton = ( 0) { + if (mlAnnotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) { const levels = getAnnotationLevels(focusAnnotationData); const maxLevel = d3.max(Object.keys(levels).map(key => levels[key])); // TODO needs revisiting to be a more robust normalization @@ -643,7 +635,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .classed('hidden', !showModelBounds); } - if (annotationsEnabled) { + if (mlAnnotationsEnabled) { renderAnnotations( focusChart, focusAnnotationData, @@ -853,12 +845,9 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo const data = contextChartData; - const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this); - const drawContextBrush = this.drawContextBrush.bind(this); - const drawSwimlane = this.drawSwimlane.bind(this); this.contextXScale = d3.time.scale().range([0, cxtWidth]) - .domain(calculateContextXAxisDomain()); + .domain(this.calculateContextXAxisDomain()); const combinedData = contextForecastData === undefined ? data : data.concat(contextForecastData); const valuesRange = { min: Number.MAX_VALUE, max: Number.MIN_VALUE }; @@ -969,7 +958,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('class', 'swimlane') .attr('transform', 'translate(0,' + cxtChartHeight + ')'); - drawSwimlane(swimlane, cxtWidth, swlHeight); + this.drawSwimlane(swimlane, cxtWidth, swlHeight); // Draw a mask over the sections of the context chart and swimlane // which fall outside of the zoom brush selection area. @@ -988,17 +977,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo filterAxisLabels(cxtGroup.selectAll('.x.context-chart-axis'), cxtWidth); - drawContextBrush(cxtGroup); + this.drawContextBrush(cxtGroup); } - drawContextBrush(contextGroup) { + drawContextBrush = (contextGroup) => { const { contextChartSelected } = this.props; const brush = this.brush; const contextXScale = this.contextXScale; - const setBrushVisibility = this.setBrushVisibility.bind(this); const mask = this.mask; // Create the brush for zooming in to the focus area of interest. @@ -1023,6 +1011,8 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('x', 0) .attr('width', 10); + const handleBrushExtent = brush.extent(); + const topBorder = contextGroup.append('rect') .attr('class', 'top-border') .attr('y', -2) @@ -1034,16 +1024,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('width', 10) .attr('height', 90) .attr('class', 'brush-handle') + .attr('x', contextXScale(handleBrushExtent[0]) - 10) .html('
'); const rightHandle = contextGroup.append('foreignObject') .attr('width', 10) .attr('height', 90) .attr('class', 'brush-handle') + .attr('x', contextXScale(handleBrushExtent[1]) + 0) .html('
'); - setBrushVisibility(!brush.empty()); - - function showBrush(show) { + const showBrush = (show) => { if (show === true) { const brushExtent = brush.extent(); mask.reveal(brushExtent); @@ -1054,8 +1044,10 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo topBorder.attr('width', contextXScale(brushExtent[1]) - contextXScale(brushExtent[0]) - 2); } - setBrushVisibility(show); - } + this.setBrushVisibility(show); + }; + + showBrush(!brush.empty()); function brushing() { const isEmpty = brush.empty(); @@ -1087,7 +1079,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } } - setBrushVisibility(show) { + setBrushVisibility = (show) => { const mask = this.mask; if (mask !== undefined) { @@ -1107,14 +1099,12 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } } - drawSwimlane(swlGroup, swlWidth, swlHeight) { + drawSwimlane = (swlGroup, swlWidth, swlHeight) => { const { contextAggregationInterval, swimlaneData } = this.props; - const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this); - const data = swimlaneData; if (typeof data === 'undefined') { @@ -1126,7 +1116,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo // x-axis min to the start of the aggregation interval. // Need to use the min(earliest) and max(earliest) of the context chart // aggregation to align the axes of the chart and swimlane elements. - const xAxisDomain = calculateContextXAxisDomain(); + const xAxisDomain = this.calculateContextXAxisDomain(); const x = d3.time.scale().range([0, swlWidth]) .domain(xAxisDomain); @@ -1182,7 +1172,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } - calculateContextXAxisDomain() { + calculateContextXAxisDomain = () => { const { contextAggregationInterval, swimlaneData, @@ -1211,9 +1201,19 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo // Sets the extent of the brush on the context chart to the // supplied from and to Date objects. - setContextBrushExtent(from, to, fireEvent) { + setContextBrushExtent = (from, to, fireEvent) => { const brush = this.brush; - brush.extent([from, to]); + const brushExtent = brush.extent(); + + const newExtent = [from, to]; + if ( + newExtent[0].getTime() === brushExtent[0].getTime() && + newExtent[1].getTime() === brushExtent[1].getTime() + ) { + fireEvent = false; + } + + brush.extent(newExtent); brush(d3.select('.brush')); if (fireEvent) { brush.event(d3.select('.brush')); @@ -1226,8 +1226,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo zoomTo } = this.props; - const setContextBrushExtent = this.setContextBrushExtent.bind(this); - const bounds = timefilter.getActiveBounds(); const minBoundsMs = bounds.min.valueOf(); const maxBoundsMs = bounds.max.valueOf(); @@ -1242,12 +1240,11 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo to = Math.min(minBoundsMs + millis, maxBoundsMs); } - setContextBrushExtent(new Date(from), new Date(to), true); + this.setContextBrushExtent(new Date(from), new Date(to), true); } showFocusChartTooltip(marker, circle) { const { - annotationsEnabled, modelPlotEnabled, intl } = this.props; @@ -1388,7 +1385,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo }); } - if (annotationsEnabled && _.has(marker, 'annotation')) { + if (mlAnnotationsEnabled && _.has(marker, 'annotation')) { contents = mlEscape(marker.annotation); contents += `
${moment(marker.timestamp).format('MMMM Do YYYY, HH:mm')}`; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index 9dd71a0ba65f9b..6374e752951b22 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -16,6 +16,7 @@ import { TimeseriesChart } from './timeseries_chart'; // mocking the following files because they import some core kibana // code which the jest setup isn't happy with. jest.mock('ui/chrome', () => ({ + addBasePath: path => path, getBasePath: path => path, // returns false for mlAnnotationsEnabled getInjected: () => false, diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js deleted file mode 100644 index 9ce4506a91e73d..00000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * Chart plotting data from a single time series, with or without model plot enabled, - * annotated with anomalies. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { TimeseriesChart } from './timeseries_chart'; - -import angular from 'angular'; -import { timefilter } from 'ui/timefilter'; - -import { ResizeChecker } from 'ui/resize_checker'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { I18nContext } from 'ui/i18n'; - -module.directive('mlTimeseriesChart', function ($timeout) { - - function link(scope, element) { - // Key dimensions for the viz and constituent charts. - let svgWidth = angular.element('.results-container').width(); - - function contextChartSelected(selection) { - scope.$root.$broadcast('contextChartSelected', selection); - } - - function renderReactComponent(renderFocusChartOnly = false) { - // Set the size of the components according to the width of the parent container at render time. - svgWidth = Math.max(angular.element('.results-container').width(), 0); - - const props = { - annotationsEnabled: scope.annotationsEnabled, - autoZoomDuration: scope.autoZoomDuration, - contextAggregationInterval: scope.contextAggregationInterval, - contextChartData: scope.contextChartData, - contextForecastData: scope.contextForecastData, - contextChartSelected: contextChartSelected, - detectorIndex: scope.detectorIndex, - focusAnnotationData: scope.focusAnnotationData, - focusChartData: scope.focusChartData, - focusForecastData: scope.focusForecastData, - focusAggregationInterval: scope.focusAggregationInterval, - modelPlotEnabled: scope.modelPlotEnabled, - refresh: scope.refresh, - renderFocusChartOnly, - selectedJob: scope.selectedJob, - showAnnotations: scope.showAnnotations, - showForecast: scope.showForecast, - showModelBounds: scope.showModelBounds, - svgWidth, - swimlaneData: scope.swimlaneData, - timefilter, - zoomFrom: scope.zoomFrom, - zoomTo: scope.zoomTo - }; - - ReactDOM.render( - - - , - element[0] - ); - } - - renderReactComponent(); - - scope.$on('render', () => { - $timeout(() => { - renderReactComponent(); - }); - }); - - function renderFocusChart() { - renderReactComponent(true); - } - - scope.$watchCollection('focusForecastData', renderFocusChart); - scope.$watchCollection('focusChartData', renderFocusChart); - scope.$watchGroup(['showModelBounds', 'showForecast'], renderFocusChart); - scope.$watch('annotationsEnabled', renderReactComponent); - if (scope.annotationsEnabled) { - scope.$watchCollection('focusAnnotationData', renderFocusChart); - scope.$watch('showAnnotations', renderFocusChart); - } - - // Redraw the charts when the container is resize. - const resizeChecker = new ResizeChecker(angular.element('.ml-timeseries-chart')); - resizeChecker.on('resize', () => { - scope.$evalAsync(() => { - renderReactComponent(); - - // Add a re-render of the focus chart to set renderFocusChartOnly back to true. - // Not efficient, but ensures adding annotations doesn't cause the whole chart - // to be re-rendered. - renderReactComponent(true); - }); - }); - - element.on('$destroy', () => { - resizeChecker.destroy(); - // unmountComponentAtNode() needs to be called so mlTableService listeners within - // the TimeseriesChart component get unwatched properly. - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - - } - - return { - scope: { - annotationsEnabled: '=', - selectedJob: '=', - detectorIndex: '=', - modelPlotEnabled: '=', - contextChartData: '=', - contextForecastData: '=', - contextChartAnomalyData: '=', - focusChartData: '=', - swimlaneData: '=', - focusAnnotationData: '=', - focusForecastData: '=', - contextAggregationInterval: '=', - focusAggregationInterval: '=', - zoomFrom: '=', - zoomTo: '=', - autoZoomDuration: '=', - refresh: '=', - showAnnotations: '=', - showModelBounds: '=', - showForecast: '=' - }, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js new file mode 100644 index 00000000000000..73c577e5134cce --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimeseriesexplorerNoChartData } from './timeseriesexplorer_no_chart_data'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js new file mode 100644 index 00000000000000..726ef2cffe8e8f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering EuiEmptyPrompt when no results were found. + */ + +import React from 'react'; + +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +export const TimeseriesexplorerNoChartData = ({ dataNotChartable, entities }) => ( + + {i18n.translate('xpack.ml.timeSeriesExplorer.noResultsFoundLabel', { + defaultMessage: 'No results found' + })} + + } + body={dataNotChartable + ? ( +

+ {i18n.translate('xpack.ml.timeSeriesExplorer.dataNotChartableDescription', { + defaultMessage: `Model plot is not collected for the selected {entityCount, plural, one {entity} other {entities}} +and the source data cannot be plotted for this detector.`, + values: { + entityCount: entities.length + } + })} +

+ ) + : ( +

+ {i18n.translate('xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription', { + defaultMessage: 'Try widening the time selection or moving further back in time.' + })} +

+ ) + } + /> +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js new file mode 100644 index 00000000000000..843e2490ac4b62 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimeseriesexplorerNoJobsFound } from './timeseriesexplorer_no_jobs_found'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js new file mode 100644 index 00000000000000..52d0326d7ca647 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering EuiEmptyPrompt when no jobs were found. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +export const TimeseriesexplorerNoJobsFound = () => ( + + + + } + actions={ + + + + } + /> +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js index 157a60713cd7df..946312d08e9ce5 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import './components/forecasting_modal'; -import './components/timeseries_chart/timeseries_chart_directive'; -import './timeseriesexplorer_controller.js'; +import './timeseriesexplorer_directive.js'; +import './timeseriesexplorer_route.js'; import './timeseries_search_service.js'; -import 'plugins/ml/components/job_selector'; -import 'plugins/ml/components/chart_tooltip'; +import '../components/job_selector'; +import '../components/chart_tooltip'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js index d92a0143b7672d..520cce3c732601 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js @@ -8,10 +8,10 @@ import _ from 'lodash'; -import { ml } from 'plugins/ml/services/ml_api_service'; -import { isModelPlotEnabled } from 'plugins/ml/../common/util/job_utils'; -import { buildConfigFromDetector } from 'plugins/ml/util/chart_config_builder'; -import { mlResultsService } from 'plugins/ml/services/results_service'; +import { ml } from '../services/ml_api_service'; +import { isModelPlotEnabled } from '../../common/util/job_utils'; +import { buildConfigFromDetector } from '../util/chart_config_builder'; +import { mlResultsService } from '../services/results_service'; function getMetricData(job, detectorIndex, entityFields, earliestMs, latestMs, interval) { if (isModelPlotEnabled(job, detectorIndex, entityFields)) { diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html deleted file mode 100644 index 300832d05c7413..00000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html +++ /dev/null @@ -1,263 +0,0 @@ - - -
- - - -
-
-
-
-
-
-
-
- -
- - - -
- - - - - - -
- - - - - -
- - - -
-
-
-
-
-
- -
-
-
-
-
-
- -
-
- - - - - {{$first ? '(' : ''}}{{entity.fieldName}}: {{entity.fieldValue}}{{$last ? ')' : ', '}} - - - - - - - -
-
- - -
- -
- - -
- -
- - -
-
- -
- - - -
- -
- - - - -
-
- - - - - -
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- - - -
-
- -
diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js new file mode 100644 index 00000000000000..9a5437199f5b21 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js @@ -0,0 +1,1221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering Single Metric Viewer. + */ + +import { chain, difference, each, find, filter, first, get, has, isEqual, without } from 'lodash'; +import moment from 'moment-timezone'; +import { Subscription } from 'rxjs'; + +import PropTypes from 'prop-types'; +import React, { createRef, Fragment } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiButton, + EuiSelect, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import chrome from 'ui/chrome'; +import { parseInterval } from 'ui/utils/parse_interval'; +import { toastNotifications } from 'ui/notify'; +import { ResizeChecker } from 'ui/resize_checker'; + +import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../common/constants/search'; +import { + isModelPlotEnabled, + isSourceDataChartableForDetector, + isTimeSeriesViewJob, + isTimeSeriesViewDetector, + mlFunctionToESAggregation, +} from '../../common/util/job_utils'; + +import { jobSelectServiceFactory, setGlobalState, getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; +import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; +import { AnnotationsTable } from '../components/annotations/annotations_table'; +import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +import { EntityControl } from './components/entity_control'; +import { ForecastingModal } from './components/forecasting_modal/forecasting_modal'; +import { JobSelector } from '../components/job_selector'; +import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; +import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; +import { severity$, SelectSeverity } from '../components/controls/select_severity/select_severity'; +import { interval$, SelectInterval } from '../components/controls/select_interval/select_interval'; +import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart'; +import { TimeseriesexplorerNoJobsFound } from './components/timeseriesexplorer_no_jobs_found'; +import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data'; + +import { annotationsRefresh$ } from '../services/annotations_service'; +import { ml } from '../services/ml_api_service'; +import { mlFieldFormatService } from '../services/field_format_service'; +import { mlForecastService } from '../services/forecast_service'; +import { mlJobService } from '../services/job_service'; +import { mlResultsService } from '../services/results_service'; +import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; + +import { getIndexPatterns } from '../util/index_utils'; +import { getBoundsRoundedToInterval } from '../util/ml_time_buckets'; + +import { APP_STATE_ACTION, CHARTS_POINT_TARGET, TIME_FIELD_NAME } from './timeseriesexplorer_constants'; +import { mlTimeSeriesSearchService } from './timeseries_search_service'; +import { + calculateAggregationInterval, + calculateDefaultFocusRange, + calculateInitialFocusRange, + createTimeSeriesJobData, + getAutoZoomDuration, + getFocusData, + processForecastResults, + processMetricPlotResults, + processRecordScoreResults, +} from './timeseriesexplorer_utils'; + +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); + +// Used to indicate the chart is being plotted across +// all partition field values, where the cardinality of the field cannot be +// obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values' +const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', { + defaultMessage: 'all', +}); + +function getTimeseriesexplorerDefaultState() { + return { + chartDetails: undefined, + contextChartData: undefined, + contextForecastData: undefined, + // Not chartable if e.g. model plot with terms for a varp detector + dataNotChartable: false, + detectorId: undefined, + detectors: [], + entities: [], + focusAnnotationData: [], + focusChartData: undefined, + focusForecastData: undefined, + hasResults: false, + jobs: [], + // Counter to keep track of what data sets have been loaded. + loadCounter: 0, + loading: false, + modelPlotEnabled: false, + selectedJob: undefined, + // Toggles display of annotations in the focus chart + showAnnotations: mlAnnotationsEnabled, + showAnnotationsCheckbox: mlAnnotationsEnabled, + // Toggles display of forecast data in the focus chart + showForecast: true, + showForecastCheckbox: false, + showModelBoundsCheckbox: false, + svgWidth: 0, + tableData: undefined, + zoomFrom: undefined, + zoomTo: undefined, + + // Toggles display of model bounds in the focus chart + showModelBounds: true, + }; +} + +const TimeSeriesExplorerPage = ({ children, jobSelectorProps, resizeRef }) => ( + + + +
+ {children} +
+
+); + +const containerPadding = 24; + +export class TimeSeriesExplorer extends React.Component { + static propTypes = { + appStateHandler: PropTypes.func.isRequired, + dateFormatTz: PropTypes.string.isRequired, + globalState: PropTypes.object.isRequired, + timefilter: PropTypes.object.isRequired, + }; + + state = getTimeseriesexplorerDefaultState(); + + subscriptions = new Subscription(); + + constructor(props) { + super(props); + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(props.globalState); + this.jobSelectService = jobSelectService; + this.unsubscribeFromGlobalState = unsubscribeFromGlobalState; + } + + resizeRef = createRef(); + resizeChecker = undefined; + resizeHandler = () => { + this.setState({ + svgWidth: (this.resizeRef.current !== null) ? this.resizeRef.current.offsetWidth - containerPadding : 0, + }); + } + + detectorIndexChangeHandler = (e) => { + const id = e.target.value; + if (id !== undefined) { + this.setState({ detectorId: id }); + } + this.updateControlsForDetector(); + this.loadEntityValues(); + }; + + toggleShowAnnotationsHandler = () => { + if (mlAnnotationsEnabled) { + this.setState(prevState => ({ + showAnnotations: !prevState.showAnnotations + })); + } + } + + toggleShowForecastHandler = () => { + this.setState(prevState => ({ + showForecast: !prevState.showForecast + })); + }; + + toggleShowModelBoundsHandler = () => { + this.setState({ + showModelBounds: !this.state.showModelBounds, + }); + } + + previousChartProps = {}; + previousShowAnnotations = undefined; + previousShowForecast = undefined; + previousShowModelBounds = undefined; + + tableFilter = (field, value, operator) => { + const { entities } = this.state; + + const entity = find(entities, { fieldName: field }); + if (entity !== undefined) { + if (operator === '+' && entity.fieldValue !== value) { + entity.fieldValue = value; + this.saveSeriesPropertiesAndRefresh(); + } else if (operator === '-' && entity.fieldValue === value) { + entity.fieldValue = ''; + this.saveSeriesPropertiesAndRefresh(); + } + } + } + + contextChartSelectedInitCallDone = false; + contextChartSelected = (selection) => { + const { appStateHandler } = this.props; + + const { + autoZoomDuration, + contextAggregationInterval, + contextChartData, + contextForecastData, + focusChartData, + jobs, + selectedJob, + zoomFrom, + zoomTo, + } = this.state; + + + if ((contextChartData === undefined || contextChartData.length === 0) && + (contextForecastData === undefined || contextForecastData.length === 0)) { + return; + } + + const stateUpdate = {}; + + const defaultRange = calculateDefaultFocusRange( + autoZoomDuration, + contextAggregationInterval, + contextChartData, + contextForecastData, + ); + + if ((selection.from.getTime() !== defaultRange[0].getTime() || selection.to.getTime() !== defaultRange[1].getTime()) && + (isNaN(Date.parse(selection.from)) === false && isNaN(Date.parse(selection.to)) === false)) { + const zoomState = { from: selection.from.toISOString(), to: selection.to.toISOString() }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + } else { + appStateHandler(APP_STATE_ACTION.UNSET_ZOOM); + } + + if ( + (this.contextChartSelectedInitCallDone === false && focusChartData === undefined) || + (zoomFrom.getTime() !== selection.from.getTime()) || + (zoomTo.getTime() !== selection.to.getTime()) + ) { + this.contextChartSelectedInitCallDone = true; + + // Calculate the aggregation interval for the focus chart. + const bounds = { min: moment(selection.from), max: moment(selection.to) }; + const focusAggregationInterval = calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + jobs, + selectedJob, + ); + stateUpdate.focusAggregationInterval = focusAggregationInterval; + + // 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 + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); + + const { + criteriaFields, + detectorId, + entities, + modelPlotEnabled, + } = this.state; + + getFocusData( + criteriaFields, + +detectorId, + focusAggregationInterval, + appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID), + modelPlotEnabled, + filter(entities, entity => entity.fieldValue.length > 0), + searchBounds, + selectedJob, + TIME_FIELD_NAME, + ).then((refreshFocusData) => { + // All the data is ready now for a state update. + this.setState({ + ...stateUpdate, + ...refreshFocusData, + loading: false, + showModelBoundsCheckbox: (modelPlotEnabled === true) && (refreshFocusData.focusChartData.length > 0), + }); + }); + + // Load the data for the anomalies table. + this.loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf()); + + this.setState({ + zoomFrom: selection.from, + zoomTo: selection.to, + }); + } + } + + entityFieldValueChanged = (entity, fieldValue) => { + this.setState(prevState => ({ + entities: prevState.entities.map(stateEntity => { + if (stateEntity.fieldName === entity.fieldName) { + stateEntity.fieldValue = fieldValue; + } + return stateEntity; + }) + })); + }; + + loadAnomaliesTableData = (earliestMs, latestMs) => { + const { dateFormatTz } = this.props; + const { criteriaFields, selectedJob } = this.state; + + ml.results.getAnomaliesTableData( + [selectedJob.job_id], + criteriaFields, + [], + interval$.getValue().val, + severity$.getValue().val, + earliestMs, + latestMs, + dateFormatTz, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ).then((resp) => { + const anomalies = resp.anomalies; + const detectorsByJob = mlJobService.detectorsByJob; + anomalies.forEach((anomaly) => { + // Add a detector property to each anomaly. + // Default to functionDescription if no description available. + // TODO - when job_service is moved server_side, move this to server endpoint. + const jobId = anomaly.jobId; + const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]); + anomaly.detector = get(detector, + ['detector_description'], + anomaly.source.function_description); + + // For detectors with rules, add a property with the rule count. + const customRules = detector.custom_rules; + if (customRules !== undefined) { + anomaly.rulesLength = customRules.length; + } + + // Add properties used for building the links menu. + // TODO - when job_service is moved server_side, move this to server endpoint. + if (has(mlJobService.customUrlsByJob, jobId)) { + anomaly.customUrls = mlJobService.customUrlsByJob[jobId]; + } + }); + + this.setState({ + tableData: { + anomalies, + interval: resp.interval, + examplesByJobId: resp.examplesByJobId, + showViewSeriesLink: false + } + }); + }).catch((resp) => { + console.log('Time series explorer - error loading data for anomalies table:', resp); + }); + } + + loadEntityValues = () => { + const { timefilter } = this.props; + const { detectorId, entities, selectedJob } = this.state; + + // Populate the entity input datalists with the values from the top records by score + // for the selected detector across the full time range. No need to pass through finish(). + const bounds = timefilter.getActiveBounds(); + const detectorIndex = +detectorId; + + mlResultsService.getRecordsForCriteria( + [selectedJob.job_id], + [{ 'fieldName': 'detector_index', 'fieldValue': detectorIndex }], + 0, + bounds.min.valueOf(), + bounds.max.valueOf(), + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE) + .then((resp) => { + if (resp.records && resp.records.length > 0) { + const firstRec = resp.records[0]; + + this.setState({ + entities: entities.map((entity) => { + if (firstRec.partition_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('partition_field_value').uniq().value(); + } + if (firstRec.over_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('over_field_value').uniq().value(); + } + if (firstRec.by_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('by_field_value').uniq().value(); + } + return entity; + }) + }); + } + }); + } + + loadForForecastId = (forecastId) => { + const { appStateHandler, timefilter } = this.props; + const { autoZoomDuration, contextChartData, selectedJob } = this.state; + + mlForecastService.getForecastDateRange( + selectedJob, + forecastId + ).then((resp) => { + const bounds = timefilter.getActiveBounds(); + const earliest = moment(resp.earliest || timefilter.getTime().from); + const latest = moment(resp.latest || timefilter.getTime().to); + + // Store forecast ID in the appState. + appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); + + // Set the zoom to centre on the start of the forecast range, depending + // on the time range of the forecast and data. + const earliestDataDate = first(contextChartData).date; + const zoomLatestMs = Math.min(earliest + (autoZoomDuration / 2), latest.valueOf()); + const zoomEarliestMs = Math.max(zoomLatestMs - autoZoomDuration, earliestDataDate.getTime()); + + const zoomState = { + from: moment(zoomEarliestMs).toISOString(), + to: moment(zoomLatestMs).toISOString() + }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + + // Ensure the forecast data will be shown if hidden previously. + this.setState({ showForecast: true }); + + if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { + const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); + const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); + + timefilter.setTime({ + from: moment(earliestMs).toISOString(), + to: moment(latestMs).toISOString() + }); + } else { + // Refresh to show the requested forecast data. + this.refresh(); + } + }).catch((resp) => { + console.log('Time series explorer - error loading time range of forecast from elasticsearch:', resp); + }); + } + + refresh = () => { + const { appStateHandler, timefilter } = this.props; + const { + detectorId: currentDetectorId, + entities: currentEntities, + loadCounter: currentLoadCounter, + selectedJob: currentSelectedJob, + } = this.state; + + if (currentSelectedJob === undefined) { + return; + } + + this.contextChartSelectedInitCallDone = false; + + this.setState({ + chartDetails: undefined, + contextChartData: undefined, + contextForecastData: undefined, + focusChartData: undefined, + focusForecastData: undefined, + loadCounter: currentLoadCounter + 1, + loading: true, + modelPlotEnabled: isModelPlotEnabled(currentSelectedJob, +currentDetectorId, currentEntities), + hasResults: false, + dataNotChartable: false + }, () => { + const { detectorId, entities, loadCounter, jobs, modelPlotEnabled, selectedJob } = this.state; + const detectorIndex = +detectorId; + + let awaitingCount = 3; + + const stateUpdate = {}; + + // finish() function, called after each data set has been loaded and processed. + // The last one to call it will trigger the page render. + const finish = (counterVar) => { + awaitingCount--; + if (awaitingCount === 0 && (counterVar === loadCounter)) { + stateUpdate.hasResults = ( + (Array.isArray(stateUpdate.contextChartData) && stateUpdate.contextChartData.length > 0) || + (Array.isArray(stateUpdate.contextForecastData) && stateUpdate.contextForecastData.length > 0) + ); + stateUpdate.loading = false; + // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically + // selecting the specified range in the context chart, and so loading that date range in the focus chart. + if (stateUpdate.contextChartData.length) { + // Calculate the 'auto' zoom duration which shows data at bucket span granularity. + stateUpdate.autoZoomDuration = getAutoZoomDuration(jobs, selectedJob); + + // Check for a zoom parameter in the appState (URL). + let focusRange = calculateInitialFocusRange( + appStateHandler(APP_STATE_ACTION.GET_ZOOM), + stateUpdate.contextAggregationInterval, + timefilter + ); + + if (focusRange === undefined) { + focusRange = calculateDefaultFocusRange( + stateUpdate.autoZoomDuration, + stateUpdate.contextAggregationInterval, + stateUpdate.contextChartData, + stateUpdate.contextForecastData, + ); + } + + stateUpdate.zoomFrom = focusRange[0]; + stateUpdate.zoomTo = focusRange[1]; + } + + this.setState(stateUpdate); + } + }; + + // Only filter on the entity if the field has a value. + const nonBlankEntities = filter(currentEntities, (entity) => { return entity.fieldValue.length > 0; }); + stateUpdate.criteriaFields = [{ + 'fieldName': 'detector_index', + 'fieldValue': +currentDetectorId } + ].concat(nonBlankEntities); + + if (modelPlotEnabled === false && + isSourceDataChartableForDetector(selectedJob, detectorIndex) === false && + nonBlankEntities.length > 0) { + // For detectors where model plot has been enabled with a terms filter and the + // selected entity(s) are not in the terms list, indicate that data cannot be viewed. + stateUpdate.hasResults = false; + stateUpdate.loading = false; + stateUpdate.dataNotChartable = true; + this.setState(stateUpdate); + return; + } + + const bounds = timefilter.getActiveBounds(); + + // 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, + ); + + // 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 + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = getBoundsRoundedToInterval(bounds, stateUpdate.contextAggregationInterval, false); + + // Query 1 - load metric data at low granularity across full time range. + // Pass a counter flag into the finish() function to make sure we only process the results + // 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.getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression + ).then((resp) => { + const fullRangeChartData = processMetricPlotResults(resp.results, modelPlotEnabled); + stateUpdate.contextChartData = fullRangeChartData; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting metric data from elasticsearch:', resp); + }); + + // Query 2 - load max record score at same granularity as context chart + // across full time range for use in the swimlane. + mlResultsService.getRecordMaxScoreByTime( + selectedJob.job_id, + stateUpdate.criteriaFields, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression + ).then((resp) => { + const fullRangeRecordScoreData = processRecordScoreResults(resp.results); + stateUpdate.swimlaneData = fullRangeRecordScoreData; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting bucket anomaly scores from elasticsearch:', resp); + }); + + // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). + mlTimeSeriesSearchService.getChartDetails( + selectedJob, + detectorIndex, + entities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf() + ).then((resp) => { + stateUpdate.chartDetails = resp.results; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting entity counts from elasticsearch:', resp); + }); + + // Plus query for forecast data if there is a forecastId stored in the appState. + const forecastId = appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID); + if (forecastId !== undefined) { + awaitingCount++; + let aggType = undefined; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + const esAgg = mlFunctionToESAggregation(detector.function); + if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { + aggType = { avg: 'sum', max: 'sum', min: 'sum' }; + } + mlForecastService.getForecastData( + selectedJob, + detectorIndex, + forecastId, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression, + aggType) + .then((resp) => { + stateUpdate.contextForecastData = processForecastResults(resp.results); + finish(counter); + }).catch((resp) => { + console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); + }); + } + + this.loadEntityValues(); + }); + } + + updateControlsForDetector = () => { + const { appStateHandler } = this.props; + const { detectorId, selectedJob } = this.state; + // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. + const detectorIndex = +detectorId; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + + const entities = []; + const entitiesState = appStateHandler(APP_STATE_ACTION.GET_ENTITIES); + const partitionFieldName = get(detector, 'partition_field_name'); + const overFieldName = get(detector, 'over_field_name'); + const byFieldName = get(detector, 'by_field_name'); + if (partitionFieldName !== undefined) { + const partitionFieldValue = get(entitiesState, partitionFieldName, ''); + entities.push({ fieldName: partitionFieldName, fieldValue: partitionFieldValue }); + } + if (overFieldName !== undefined) { + const overFieldValue = get(entitiesState, overFieldName, ''); + entities.push({ fieldName: overFieldName, fieldValue: overFieldValue }); + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + // TODO - metric data can be filtered by this field, so should only exclude + // from filter for the anomaly records. + if (byFieldName !== undefined && overFieldName === undefined) { + const byFieldValue = get(entitiesState, byFieldName, ''); + entities.push({ fieldName: byFieldName, fieldValue: byFieldValue }); + } + + this.setState({ entities }); + } + + loadForJobId(jobId, jobs) { + const { appStateHandler } = this.props; + + // Validation that the ID is for a time series job must already have been performed. + // Check if the job was created since the page was first loaded. + let jobPickerSelectedJob = find(jobs, { 'id': jobId }); + if (jobPickerSelectedJob === undefined) { + const newJobs = []; + each(mlJobService.jobs, (job) => { + if (isTimeSeriesViewJob(job) === true) { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + newJobs.push({ id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }); + } + }); + this.setState({ jobs: newJobs }); + jobPickerSelectedJob = find(newJobs, { 'id': jobId }); + } + + const selectedJob = mlJobService.getJob(jobId); + + // Read the detector index and entities out of the AppState. + const jobDetectors = selectedJob.analysis_config.detectors; + const viewableDetectors = []; + each(jobDetectors, (dtr, index) => { + if (isTimeSeriesViewDetector(selectedJob, index)) { + viewableDetectors.push({ index: '' + index, detector_description: dtr.detector_description }); + } + }); + const detectors = viewableDetectors; + + // Check the supplied index is valid. + const appStateDtrIdx = appStateHandler(APP_STATE_ACTION.GET_DETECTOR_INDEX); + let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +(viewableDetectors[0].index); + if (find(viewableDetectors, { 'index': '' + detectorIndex }) === undefined) { + const warningText = i18n.translate('xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { + defaultMessage: 'Requested detector index {detectorIndex} is not valid for job {jobId}', + values: { + detectorIndex, + jobId: selectedJob.job_id + } + }); + toastNotifications.addWarning(warningText); + detectorIndex = +(viewableDetectors[0].index); + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorIndex); + } + + // Store the detector index as a string so it can be used as ng-model in a select control. + const detectorId = '' + detectorIndex; + + this.setState( + { detectorId, detectors, selectedJob }, + () => { + this.updateControlsForDetector(); + + // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. + mlFieldFormatService.populateFormats([jobId], getIndexPatterns()) + .catch((err) => { console.log('Error populating field formats:', err); }) + // Load the data - if the FieldFormats failed to populate + // the default formatting will be used for metric values. + .then(() => { + this.refresh(); + }); + } + ); + } + + saveSeriesPropertiesAndRefresh = () => { + const { appStateHandler } = this.props; + const { detectorId, entities } = this.state; + + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +detectorId); + appStateHandler(APP_STATE_ACTION.SET_ENTITIES, entities.reduce((appStateEntities, entity) => { + appStateEntities[entity.fieldName] = entity.fieldValue; + return appStateEntities; + }, {})); + + this.refresh(); + } + + componentDidMount() { + const { appStateHandler, globalState, timefilter } = this.props; + + this.setState({ jobs: [] }); + + // Get the job info needed by the visualization, then do the first load. + if (mlJobService.jobs.length > 0) { + const jobs = createTimeSeriesJobData(mlJobService.jobs); + this.setState({ jobs }); + } else { + this.setState({ loading: false }); + } + + // Reload the anomalies table if the Interval or Threshold controls are changed. + const tableControlsListener = () => { + const { zoomFrom, zoomTo } = this.state; + if (zoomFrom !== undefined && zoomTo !== undefined) { + this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()); + } + }; + + this.subscriptions.add(annotationsRefresh$.subscribe(this.refresh)); + this.subscriptions.add(interval$.subscribe(tableControlsListener)); + this.subscriptions.add(severity$.subscribe(tableControlsListener)); + this.subscriptions.add(mlTimefilterRefresh$.subscribe(this.refresh)); + + // Listen for changes to job selection. + this.subscriptions.add(this.jobSelectService.subscribe(({ selection: selectedJobIds }) => { + const jobs = createTimeSeriesJobData(mlJobService.jobs); + + this.contextChartSelectedInitCallDone = false; + this.setState({ showForecastCheckbox: false }); + + const timeSeriesJobIds = jobs.map(j => j.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). + const invalidIds = difference(selectedJobIds, timeSeriesJobIds); + selectedJobIds = without(selectedJobIds, ...invalidIds); + if (invalidIds.length > 0) { + let warningText = i18n.translate('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', { + defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, + values: { + invalidIdsCount: invalidIds.length, + invalidIds + } + }); + if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { + warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { + defaultMessage: ', auto selecting first job' + }); + } + toastNotifications.addWarning(warningText); + } + + if (selectedJobIds.length > 1) { + // if more than one job or a group has been loaded from the URL + if (selectedJobIds.length > 1) { + // if more than one job, select the first job from the selection. + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard' + }) + ); + + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else { + // if a group has been loaded + if (selectedJobIds.length > 0) { + // if the group contains valid jobs, select the first + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard' + }) + ); + + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else if (jobs.length > 0) { + // if there are no valid jobs in the group but there are valid jobs + // in the list of all jobs, select the first + setGlobalState(globalState, { selectedIds: [jobs[0].id] }); + this.jobSelectService.next({ selection: [jobs[0].id], resetSelection: true }); + } else { + // if there are no valid jobs left. + this.setState({ loading: false }); + } + } + } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { + // if some ids have been filtered out because they were invalid. + // refresh the URL with the first valid id + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else if (selectedJobIds.length > 0) { + // normal behavior. a job ID has been loaded from the URL + if (this.state.selectedJob !== undefined && selectedJobIds[0] !== this.state.selectedJob.job_id) { + // Clear the detectorIndex, entities and forecast info. + appStateHandler(APP_STATE_ACTION.CLEAR); + } + this.loadForJobId(selectedJobIds[0], jobs); + } else { + if (selectedJobIds.length === 0 && jobs.length > 0) { + // no jobs were loaded from the URL, so add the first job + // from the full jobs list. + setGlobalState(globalState, { selectedIds: [jobs[0].id] }); + this.jobSelectService.next({ selection: [jobs[0].id], resetSelection: true }); + } else { + // Jobs exist, but no time series jobs. + this.setState({ loading: false }); + } + } + })); + + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + timefilter.on('timeUpdate', this.refresh); + + // Required to redraw the time series chart when the container is resized. + this.resizeChecker = new ResizeChecker(this.resizeRef.current); + this.resizeChecker.on('resize', () => { + this.resizeHandler(); + }); + this.resizeHandler(); + } + + componentWillUnmount() { + this.subscriptions.unsubscribe(); + this.props.timefilter.off('timeUpdate', this.refresh); + this.resizeChecker.destroy(); + this.unsubscribeFromGlobalState(); + } + + render() { + const { + dateFormatTz, + globalState, + timefilter, + } = this.props; + + const { + autoZoomDuration, + chartDetails, + contextAggregationInterval, + contextChartData, + contextForecastData, + dataNotChartable, + detectors, + detectorId, + entities, + focusAggregationInterval, + focusAnnotationData, + focusChartData, + focusForecastData, + hasResults, + jobs, + loading, + modelPlotEnabled, + selectedJob, + showAnnotations, + showAnnotationsCheckbox, + showForecast, + showForecastCheckbox, + showModelBounds, + showModelBoundsCheckbox, + svgWidth, + swimlaneData, + tableData, + zoomFrom, + zoomTo, + } = this.state; + + const chartProps = { + modelPlotEnabled, + contextChartData, + contextChartSelected: this.contextChartSelected, + contextForecastData, + contextAggregationInterval, + swimlaneData, + focusAnnotationData, + focusChartData, + focusForecastData, + focusAggregationInterval, + svgWidth, + zoomFrom, + zoomTo, + autoZoomDuration, + }; + + const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectorProps = { + dateFormatTz, + globalState, + jobSelectService: this.jobSelectService, + selectedJobIds, + selectedGroups, + singleSelection: true, + timeseriesOnly: true, + }; + + if (jobs.length === 0) { + return ( + + + + ); + } + + const detectorSelectOptions = detectors.map(d => ({ + value: d.index, + text: d.detector_description + })); + + let renderFocusChartOnly = true; + + if ( + isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) && + isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) && + isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && + this.previousShowAnnotations === showAnnotations && + this.previousShowForecast === showForecast && + this.previousShowModelBounds === showModelBounds + ) { + renderFocusChartOnly = false; + } + + this.previousChartProps = chartProps; + this.previousShowAnnotations = showAnnotations; + this.previousShowForecast = showForecast; + this.previousShowModelBounds = showModelBounds; + + return ( + +
+ + + + + + + {entities.map((entity) => { + const entityKey = `${entity.fieldName}`; + return ( + + ); + })} + + + + {i18n.translate('xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel', { + defaultMessage: 'Refresh' + })} + + + + + + + + + +
+ + {(loading === true) && ( + + )} + + {(jobs.length > 0 && loading === false && hasResults === false) && ( + + )} + + {(jobs.length > 0 && loading === false && hasResults === true) && ( + + + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel } + })} +   + + {chartDetails.entityData.count === 1 && ( + + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities.map((entity) => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }).join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate('xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: (i === 0) ? '(' : '', + closeBrace: (i === (chartDetails.entityData.entities.length - 1)) ? ')' : '', + cardinalityValue: countData.cardinality === 0 ? allValuesLabel : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName + } + })} + {(i !== (chartDetails.entityData.entities.length - 1)) ? ', ' : ''} + + ); + })} + + )} + + + {showModelBoundsCheckbox && ( + + + + )} + + {showAnnotationsCheckbox && ( + + + + )} + + {showForecastCheckbox && ( + + + + )} + + +
+ +
+ + {showAnnotations && focusAnnotationData.length > 0 && ( +
+ + {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { + defaultMessage: 'Annotations' + })} + + + +
+ )} + + + + + {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { + defaultMessage: 'Anomalies' + })} + + + + + + + + + + + + + + + + + + + +
+ )} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js new file mode 100644 index 00000000000000..52590bb6824c1f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Contains values for ML time series explorer. + */ + + +export const APP_STATE_ACTION = { + CLEAR: 'CLEAR', + GET_DETECTOR_INDEX: 'GET_DETECTOR_INDEX', + SET_DETECTOR_INDEX: 'SET_DETECTOR_INDEX', + GET_ENTITIES: 'GET_ENTITIES', + SET_ENTITIES: 'SET_ENTITIES', + GET_FORECAST_ID: 'GET_FORECAST_ID', + SET_FORECAST_ID: 'SET_FORECAST_ID', + GET_ZOOM: 'GET_ZOOM', + SET_ZOOM: 'SET_ZOOM', + UNSET_ZOOM: 'UNSET_ZOOM', +}; + +export const CHARTS_POINT_TARGET = 500; + +// Max number of scheduled events displayed per bucket. +export const MAX_SCHEDULED_EVENTS = 10; + +export const TIME_FIELD_NAME = 'timestamp'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js deleted file mode 100644 index 836ef7dafbee05..00000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js +++ /dev/null @@ -1,1061 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * Angular controller for the Machine Learning Single Metric Viewer dashboard, which - * allows the user to explore a single time series. The controller makes multiple queries - * to Elasticsearch to obtain the data to populate all the components in the view. - */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import moment from 'moment-timezone'; - -import 'plugins/ml/components/annotations/annotation_flyout/annotation_flyout_directive'; -import 'plugins/ml/components/annotations/annotations_table'; -import 'plugins/ml/components/anomalies_table'; -import 'plugins/ml/components/controls'; - -import { toastNotifications } from 'ui/notify'; -import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { parseInterval } from 'ui/utils/parse_interval'; -import { checkFullLicense } from 'plugins/ml/license/check_license'; -import { checkGetJobsPrivilege, checkPermission } from 'plugins/ml/privilege/check_privilege'; -import { - isTimeSeriesViewJob, - isTimeSeriesViewDetector, - isModelPlotEnabled, - isSourceDataChartableForDetector, - mlFunctionToESAggregation } from 'plugins/ml/../common/util/job_utils'; -import { loadIndexPatterns, getIndexPatterns } from 'plugins/ml/util/index_utils'; -import { getSingleMetricViewerBreadcrumbs } from './breadcrumbs'; -import { - createTimeSeriesJobData, - processForecastResults, - processDataForFocusAnomalies, - processMetricPlotResults, - processRecordScoreResults, - processScheduledEventsForChart } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils'; -import { refreshIntervalWatcher } from 'plugins/ml/util/refresh_interval_watcher'; -import { MlTimeBuckets, getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets'; -import { mlResultsService } from 'plugins/ml/services/results_service'; -import template from './timeseriesexplorer.html'; -import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; -import { ml } from 'plugins/ml/services/ml_api_service'; -import { mlJobService } from 'plugins/ml/services/job_service'; -import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; -import { mlForecastService } from 'plugins/ml/services/forecast_service'; -import { mlTimeSeriesSearchService } from 'plugins/ml/timeseriesexplorer/timeseries_search_service'; -import { - ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE -} from '../../common/constants/search'; -import { annotationsRefresh$ } from '../services/annotations_service'; -import { interval$ } from '../components/controls/select_interval/select_interval'; -import { severity$ } from '../components/controls/select_severity/select_severity'; -import { setGlobalState, getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; - - -import chrome from 'ui/chrome'; -let mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - -uiRoutes - .when('/timeseriesexplorer/?', { - template, - k7Breadcrumbs: getSingleMetricViewerBreadcrumbs, - resolve: { - CheckLicense: checkFullLicense, - privileges: checkGetJobsPrivilege, - indexPatterns: loadIndexPatterns, - mlNodeCount: getMlNodeCount, - jobs: mlJobService.loadJobsWrapper - } - }); - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module.controller('MlTimeSeriesExplorerController', function ( - $injector, - $scope, - $timeout, - Private, - AppState, - config, - globalState) { - - $injector.get('mlSelectIntervalService'); - $injector.get('mlSelectSeverityService'); - const mlJobSelectService = $injector.get('mlJobSelectService'); - - $scope.timeFieldName = 'timestamp'; - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - const CHARTS_POINT_TARGET = 500; - const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. - - $scope.jobPickerSelections = []; - $scope.selectedJob; - $scope.detectors = []; - $scope.loading = true; - $scope.loadCounter = 0; - $scope.hasResults = false; - $scope.dataNotChartable = false; // e.g. model plot with terms for a varp detector - $scope.anomalyRecords = []; - - $scope.modelPlotEnabled = false; - $scope.showModelBounds = true; // Toggles display of model bounds in the focus chart - $scope.showModelBoundsCheckbox = false; - $scope.showAnnotations = mlAnnotationsEnabled;// Toggles display of annotations in the focus chart - $scope.showAnnotationsCheckbox = mlAnnotationsEnabled; - $scope.showForecast = true; // Toggles display of forecast data in the focus chart - $scope.showForecastCheckbox = false; - - $scope.focusAnnotationData = []; - - // Used in the template to indicate the chart is being plotted across - // all partition field values, where the cardinality of the field cannot be - // obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values' - $scope.allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', { - defaultMessage: 'all', - }); - - // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); - - $scope.permissions = { - canForecastJob: checkPermission('canForecastJob') - }; - - $scope.initializeVis = function () { - // Initialize the AppState in which to store the zoom range. - const stateDefaults = { - mlTimeSeriesExplorer: {} - }; - $scope.appState = new AppState(stateDefaults); - - $scope.jobs = []; - - // Get the job info needed by the visualization, then do the first load. - if (mlJobService.jobs.length > 0) { - $scope.jobs = createTimeSeriesJobData(mlJobService.jobs); - const timeSeriesJobIds = $scope.jobs.map(j => j.id); - - // Select any jobs set in the global state (i.e. passed in the URL). - let { jobIds: selectedJobIds } = getSelectedJobIds(globalState); - - // 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). - const invalidIds = _.difference(selectedJobIds, timeSeriesJobIds); - selectedJobIds = _.without(selectedJobIds, ...invalidIds); - if (invalidIds.length > 0) { - let warningText = i18n.translate('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', { - defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, - values: { - invalidIdsCount: invalidIds.length, - invalidIds - } - }); - if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { - warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { - defaultMessage: ', auto selecting first job' - }); - } - toastNotifications.addWarning(warningText); - } - - if (selectedJobIds.length > 1) { - // if more than one job or a group has been loaded from the URL - if (selectedJobIds.length > 1) { - // if more than one job, select the first job from the selection. - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard' - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else { - // if a group has been loaded - if (selectedJobIds.length > 0) { - // if the group contains valid jobs, select the first - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard' - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if ($scope.jobs.length > 0) { - // if there are no valid jobs in the group but there are valid jobs - // in the list of all jobs, select the first - setGlobalState(globalState, { selectedIds: [$scope.jobs[0].id] }); - mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true }); - } else { - // if there are no valid jobs left. - $scope.loading = false; - } - } - } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { - // if some ids have been filtered out because they were invalid. - // refresh the URL with the first valid id - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (selectedJobIds.length > 0) { - // normal behavior. a job ID has been loaded from the URL - loadForJobId(selectedJobIds[0]); - } else { - if (selectedJobIds.length === 0 && $scope.jobs.length > 0) { - // no jobs were loaded from the URL, so add the first job - // from the full jobs list. - setGlobalState(globalState, { selectedIds: [$scope.jobs[0].id] }); - mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true }); - } else { - // Jobs exist, but no time series jobs. - $scope.loading = false; - } - } - } else { - $scope.loading = false; - } - - $scope.$applyAsync(); - }; - - $scope.refresh = function () { - - if ($scope.selectedJob === undefined) { - return; - } - - $scope.loading = true; - $scope.hasResults = false; - $scope.dataNotChartable = false; - delete $scope.chartDetails; - delete $scope.contextChartData; - delete $scope.focusChartData; - delete $scope.contextForecastData; - delete $scope.focusForecastData; - - // Counter to keep track of what data sets have been loaded. - $scope.loadCounter++; - let awaitingCount = 3; - - // finish() function, called after each data set has been loaded and processed. - // The last one to call it will trigger the page render. - function finish(counterVar) { - awaitingCount--; - if (awaitingCount === 0 && (counterVar === $scope.loadCounter)) { - - if (($scope.contextChartData && $scope.contextChartData.length) || - ($scope.contextForecastData && $scope.contextForecastData.length)) { - $scope.hasResults = true; - } else { - $scope.hasResults = false; - } - $scope.loading = false; - - // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically - // selecting the specified range in the context chart, and so loading that date range in the focus chart. - if ($scope.contextChartData.length) { - const focusRange = calculateInitialFocusRange(); - $scope.zoomFrom = focusRange[0]; - $scope.zoomTo = focusRange[1]; - } - - // Tell the results container directives to render. - // Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data. - if (($scope.contextChartData && $scope.contextChartData.length) || - ($scope.contextForecastData && $scope.contextForecastData.length)) { - $timeout(() => { - $scope.$broadcast('render'); - }, 0); - } else { - // Call $applyAsync() if for any reason the upper condition doesn't trigger the $timeout. - // We still want to trigger a scope update about the changes above the condition. - $scope.$applyAsync(); - } - - } - } - - const bounds = timefilter.getActiveBounds(); - - const detectorIndex = +$scope.detectorId; - $scope.modelPlotEnabled = isModelPlotEnabled($scope.selectedJob, detectorIndex, $scope.entities); - - - // Only filter on the entity if the field has a value. - const nonBlankEntities = _.filter($scope.entities, (entity) => { return entity.fieldValue.length > 0; }); - $scope.criteriaFields = [{ - 'fieldName': 'detector_index', - 'fieldValue': detectorIndex } - ].concat(nonBlankEntities); - - if ($scope.modelPlotEnabled === false && - isSourceDataChartableForDetector($scope.selectedJob, detectorIndex) === false && - nonBlankEntities.length > 0) { - // For detectors where model plot has been enabled with a terms filter and the - // selected entity(s) are not in the terms list, indicate that data cannot be viewed. - $scope.hasResults = false; - $scope.loading = false; - $scope.dataNotChartable = true; - $scope.$applyAsync(); - return; - } - - // Calculate the aggregation interval for the context chart. - // Context chart swimlane will display bucket anomaly score at the same interval. - $scope.contextAggregationInterval = calculateAggregationInterval(bounds, CHARTS_POINT_TARGET, CHARTS_POINT_TARGET); - console.log('aggregationInterval for context data (s):', $scope.contextAggregationInterval.asSeconds()); - - // 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 - // to some extent with all detector functions if not searching complete buckets. - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.contextAggregationInterval, false); - - // Query 1 - load metric data at low granularity across full time range. - // Pass a counter flag into the finish() function to make sure we only process the results - // 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 = $scope.loadCounter; - mlTimeSeriesSearchService.getMetricData( - $scope.selectedJob, - detectorIndex, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression - ).then((resp) => { - const fullRangeChartData = processMetricPlotResults(resp.results, $scope.modelPlotEnabled); - $scope.contextChartData = fullRangeChartData; - console.log('Time series explorer context chart data set:', $scope.contextChartData); - - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting metric data from elasticsearch:', resp); - }); - - // Query 2 - load max record score at same granularity as context chart - // across full time range for use in the swimlane. - mlResultsService.getRecordMaxScoreByTime( - $scope.selectedJob.job_id, - $scope.criteriaFields, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression - ).then((resp) => { - const fullRangeRecordScoreData = processRecordScoreResults(resp.results); - $scope.swimlaneData = fullRangeRecordScoreData; - console.log('Time series explorer swimlane anomalies data set:', $scope.swimlaneData); - - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting bucket anomaly scores from elasticsearch:', resp); - }); - - // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). - mlTimeSeriesSearchService.getChartDetails( - $scope.selectedJob, - detectorIndex, - $scope.entities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf() - ).then((resp) => { - $scope.chartDetails = resp.results; - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting entity counts from elasticsearch:', resp); - }); - - // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = _.get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); - if (forecastId !== undefined) { - awaitingCount++; - let aggType = undefined; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - const esAgg = mlFunctionToESAggregation(detector.function); - if ($scope.modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { - aggType = { avg: 'sum', max: 'sum', min: 'sum' }; - } - mlForecastService.getForecastData( - $scope.selectedJob, - detectorIndex, - forecastId, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression, - aggType) - .then((resp) => { - $scope.contextForecastData = processForecastResults(resp.results); - finish(counter); - }).catch((resp) => { - console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); - }); - } - - loadEntityValues(); - }; - - $scope.refreshFocusData = function (fromDate, toDate) { - - // Counter to keep track of the queries to populate the chart. - let awaitingCount = 4; - - // This object is used to store the results of individual remote requests - // before we transform it into the final data and apply it to $scope. Otherwise - // we might trigger multiple $digest cycles and depending on how deep $watches - // listen for changes we could miss updates. - const refreshFocusData = {}; - - // finish() function, called after each data set has been loaded and processed. - // The last one to call it will trigger the page render. - function finish() { - awaitingCount--; - if (awaitingCount === 0) { - // Tell the results container directives to render the focus chart. - refreshFocusData.focusChartData = processDataForFocusAnomalies( - refreshFocusData.focusChartData, - refreshFocusData.anomalyRecords, - $scope.timeFieldName, - $scope.focusAggregationInterval, - $scope.modelPlotEnabled); - - refreshFocusData.focusChartData = processScheduledEventsForChart( - refreshFocusData.focusChartData, - refreshFocusData.scheduledEvents); - - // All the data is ready now for a scope update. - // Use $evalAsync to ensure the update happens after the child scope is updated with the new data. - $scope.$evalAsync(() => { - $scope = Object.assign($scope, refreshFocusData); - console.log('Time series explorer focus chart data set:', $scope.focusChartData); - - $scope.loading = false; - - // If the annotations failed to load and the feature flag is set to `false`, - // make sure the checkbox toggle gets hidden. - if (mlAnnotationsEnabled === false) { - $scope.showAnnotationsCheckbox = false; - } - }); - } - } - - const detectorIndex = +$scope.detectorId; - const nonBlankEntities = _.filter($scope.entities, entity => entity.fieldValue.length > 0); - - // Calculate the aggregation interval for the focus chart. - const bounds = { min: moment(fromDate), max: moment(toDate) }; - $scope.focusAggregationInterval = calculateAggregationInterval(bounds, CHARTS_POINT_TARGET, CHARTS_POINT_TARGET); - - // 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 - // to some extent with all detector functions if not searching complete buckets. - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.focusAggregationInterval, false); - - // Query 1 - load metric data across selected time range. - mlTimeSeriesSearchService.getMetricData( - $scope.selectedJob, - detectorIndex, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression - ).then((resp) => { - refreshFocusData.focusChartData = processMetricPlotResults(resp.results, $scope.modelPlotEnabled); - $scope.showModelBoundsCheckbox = ($scope.modelPlotEnabled === true) && - (refreshFocusData.focusChartData.length > 0); - finish(); - }).catch((resp) => { - console.log('Time series explorer - error getting metric data from elasticsearch:', resp); - }); - - // Query 2 - load all the records across selected time range for the chart anomaly markers. - mlResultsService.getRecordsForCriteria( - [$scope.selectedJob.job_id], - $scope.criteriaFields, - 0, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE - ).then((resp) => { - // Sort in descending time order before storing in scope. - refreshFocusData.anomalyRecords = _.chain(resp.records) - .sortBy(record => record[$scope.timeFieldName]) - .reverse() - .value(); - console.log('Time series explorer anomalies:', refreshFocusData.anomalyRecords); - finish(); - }); - - // Query 3 - load any scheduled events for the selected job. - mlResultsService.getScheduledEventsByBucket( - [$scope.selectedJob.job_id], - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression, - 1, - MAX_SCHEDULED_EVENTS - ).then((resp) => { - refreshFocusData.scheduledEvents = resp.events[$scope.selectedJob.job_id]; - finish(); - }).catch((resp) => { - console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp); - }); - - // Query 4 - load any annotations for the selected job. - if (mlAnnotationsEnabled) { - ml.annotations.getAnnotations({ - jobIds: [$scope.selectedJob.job_id], - earliestMs: searchBounds.min.valueOf(), - latestMs: searchBounds.max.valueOf(), - maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE - }).then((resp) => { - refreshFocusData.focusAnnotationData = []; - - if (Array.isArray(resp.annotations[$scope.selectedJob.job_id])) { - refreshFocusData.focusAnnotationData = resp.annotations[$scope.selectedJob.job_id] - .sort((a, b) => { - return a.timestamp - b.timestamp; - }) - .map((d, i) => { - d.key = String.fromCharCode(65 + i); - return d; - }); - } - - finish(); - }).catch(() => { - // silently fail and disable annotations feature if loading annotations fails. - refreshFocusData.focusAnnotationData = []; - mlAnnotationsEnabled = false; - finish(); - }); - } else { - finish(); - } - - // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = _.get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); - if (forecastId !== undefined) { - awaitingCount++; - let aggType = undefined; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - const esAgg = mlFunctionToESAggregation(detector.function); - if ($scope.modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { - aggType = { avg: 'sum', max: 'sum', min: 'sum' }; - } - - mlForecastService.getForecastData( - $scope.selectedJob, - detectorIndex, - forecastId, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression, - aggType) - .then((resp) => { - refreshFocusData.focusForecastData = processForecastResults(resp.results); - refreshFocusData.showForecastCheckbox = (refreshFocusData.focusForecastData.length > 0); - finish(); - }).catch((resp) => { - console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); - }); - } - - // Load the data for the anomalies table. - loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf()); - - }; - - $scope.saveSeriesPropertiesAndRefresh = function () { - $scope.appState.mlTimeSeriesExplorer.detectorIndex = +$scope.detectorId; - $scope.appState.mlTimeSeriesExplorer.entities = {}; - _.each($scope.entities, (entity) => { - $scope.appState.mlTimeSeriesExplorer.entities[entity.fieldName] = entity.fieldValue; - }); - $scope.appState.save(); - - $scope.refresh(); - }; - - $scope.filter = function (field, value, operator) { - const entity = _.find($scope.entities, { fieldName: field }); - if (entity !== undefined) { - if (operator === '+' && entity.fieldValue !== value) { - entity.fieldValue = value; - $scope.saveSeriesPropertiesAndRefresh(); - } else if (operator === '-' && entity.fieldValue === value) { - entity.fieldValue = ''; - $scope.saveSeriesPropertiesAndRefresh(); - } - } - }; - - $scope.loadForForecastId = function (forecastId) { - mlForecastService.getForecastDateRange( - $scope.selectedJob, - forecastId - ).then((resp) => { - const bounds = timefilter.getActiveBounds(); - const earliest = moment(resp.earliest || timefilter.getTime().from); - const latest = moment(resp.latest || timefilter.getTime().to); - - // Store forecast ID in the appState. - $scope.appState.mlTimeSeriesExplorer.forecastId = forecastId; - - // Set the zoom to centre on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestDataDate = _.first($scope.contextChartData).date; - const zoomLatestMs = Math.min(earliest + ($scope.autoZoomDuration / 2), latest.valueOf()); - const zoomEarliestMs = Math.max(zoomLatestMs - $scope.autoZoomDuration, earliestDataDate.getTime()); - - const zoomState = { - from: moment(zoomEarliestMs).toISOString(), - to: moment(zoomLatestMs).toISOString() - }; - $scope.appState.mlTimeSeriesExplorer.zoom = zoomState; - - $scope.appState.save(); - - // Ensure the forecast data will be shown if hidden previously. - $scope.showForecast = true; - - - if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { - const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); - const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); - - timefilter.setTime({ - from: moment(earliestMs).toISOString(), - to: moment(latestMs).toISOString() - }); - } else { - // Refresh to show the requested forecast data. - $scope.refresh(); - } - - }).catch((resp) => { - console.log('Time series explorer - error loading time range of forecast from elasticsearch:', resp); - }); - }; - - $scope.detectorIndexChanged = function () { - updateControlsForDetector(); - loadEntityValues(); - }; - - $scope.toggleShowModelBounds = function () { - $timeout(() => { - $scope.showModelBounds = !$scope.showModelBounds; - }, 0); - }; - - if (mlAnnotationsEnabled) { - $scope.toggleShowAnnotations = function () { - $timeout(() => { - $scope.showAnnotations = !$scope.showAnnotations; - }, 0); - }; - } - - $scope.toggleShowForecast = function () { - $timeout(() => { - $scope.showForecast = !$scope.showForecast; - }, 0); - }; - - // Refresh the data when the time range is altered. - $scope.$listenAndDigestAsync(timefilter, 'fetch', $scope.refresh); - - // Add a watcher for auto-refresh of the time filter to refresh all the data. - const refreshWatcher = Private(refreshIntervalWatcher); - refreshWatcher.init(() => { - $scope.refresh(); - }); - - // Reload the anomalies table if the Interval or Threshold controls are changed. - const tableControlsListener = function () { - if ($scope.zoomFrom !== undefined && $scope.zoomTo !== undefined) { - loadAnomaliesTableData($scope.zoomFrom.getTime(), $scope.zoomTo.getTime()); - } - }; - - const intervalSub = interval$.subscribe(tableControlsListener); - const severitySub = severity$.subscribe(tableControlsListener); - const annotationsRefreshSub = annotationsRefresh$.subscribe($scope.refresh); - // Listen for changes to job selection. - const jobSelectServiceSub = mlJobSelectService.subscribe(({ selection }) => { - // Clear the detectorIndex, entities and forecast info. - if (selection.length > 0 && $scope.appState !== undefined) { - delete $scope.appState.mlTimeSeriesExplorer.detectorIndex; - delete $scope.appState.mlTimeSeriesExplorer.entities; - delete $scope.appState.mlTimeSeriesExplorer.forecastId; - $scope.appState.save(); - - $scope.showForecastCheckbox = false; - loadForJobId(selection[0]); - } - }); - - const timefilterRefreshServiceSub = mlTimefilterRefresh$.subscribe($scope.refresh); - - $scope.$on('$destroy', () => { - refreshWatcher.cancel(); - intervalSub.unsubscribe(); - severitySub.unsubscribe(); - annotationsRefreshSub.unsubscribe(); - jobSelectServiceSub.unsubscribe(); - timefilterRefreshServiceSub.unsubscribe(); - }); - - $scope.$on('contextChartSelected', function (event, selection) { // eslint-disable-line no-unused-vars - // Save state of zoom (adds to URL) if it is different to the default. - if (($scope.contextChartData === undefined || $scope.contextChartData.length === 0) && - ($scope.contextForecastData === undefined || $scope.contextForecastData.length === 0)) { - return; - } - - const defaultRange = calculateDefaultFocusRange(); - - if ((selection.from.getTime() !== defaultRange[0].getTime() || selection.to.getTime() !== defaultRange[1].getTime()) && - (isNaN(Date.parse(selection.from)) === false && isNaN(Date.parse(selection.to)) === false)) { - const zoomState = { from: selection.from.toISOString(), to: selection.to.toISOString() }; - $scope.appState.mlTimeSeriesExplorer.zoom = zoomState; - } else { - delete $scope.appState.mlTimeSeriesExplorer.zoom; - } - $scope.appState.save(); - - if ($scope.focusChartData === undefined || - ($scope.zoomFrom.getTime() !== selection.from.getTime()) || - ($scope.zoomTo.getTime() !== selection.to.getTime())) { - $scope.refreshFocusData(selection.from, selection.to); - } - - $scope.zoomFrom = selection.from; - $scope.zoomTo = selection.to; - - }); - - function loadForJobId(jobId) { - // Validation that the ID is for a time series job must already have been performed. - // Check if the job was created since the page was first loaded. - let jobPickerSelectedJob = _.find($scope.jobs, { 'id': jobId }); - if (jobPickerSelectedJob === undefined) { - const newJobs = []; - _.each(mlJobService.jobs, (job) => { - if (isTimeSeriesViewJob(job) === true) { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - newJobs.push({ id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }); - } - }); - $scope.jobs = newJobs; - jobPickerSelectedJob = _.find(newJobs, { 'id': jobId }); - } - - $scope.selectedJob = mlJobService.getJob(jobId); - $scope.jobPickerSelections = [jobPickerSelectedJob]; - - // Read the detector index and entities out of the AppState. - const jobDetectors = $scope.selectedJob.analysis_config.detectors; - const viewableDetectors = []; - _.each(jobDetectors, (dtr, index) => { - if (isTimeSeriesViewDetector($scope.selectedJob, index)) { - viewableDetectors.push({ index: '' + index, detector_description: dtr.detector_description }); - } - }); - $scope.detectors = viewableDetectors; - - // Check the supplied index is valid. - const appStateDtrIdx = $scope.appState.mlTimeSeriesExplorer.detectorIndex; - let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +(viewableDetectors[0].index); - if (_.find(viewableDetectors, { 'index': '' + detectorIndex }) === undefined) { - const warningText = i18n.translate('xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { - defaultMessage: 'Requested detector index {detectorIndex} is not valid for job {jobId}', - values: { - detectorIndex, - jobId: $scope.selectedJob.job_id - } - }); - toastNotifications.addWarning(warningText); - detectorIndex = +(viewableDetectors[0].index); - $scope.appState.mlTimeSeriesExplorer.detectorIndex = detectorIndex; - $scope.appState.save(); - } - - // Store the detector index as a string so it can be used as ng-model in a select control. - $scope.detectorId = '' + detectorIndex; - - updateControlsForDetector(); - - // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. - mlFieldFormatService.populateFormats([jobId], getIndexPatterns()) - .catch((err) => { console.log('Error populating field formats:', err); }) - // Load the data - if the FieldFormats failed to populate - // the default formatting will be used for metric values. - .then(() => { - $scope.refresh(); - }); - } - - function loadAnomaliesTableData(earliestMs, latestMs) { - - ml.results.getAnomaliesTableData( - [$scope.selectedJob.job_id], - $scope.criteriaFields, - [], - interval$.getValue().val, - severity$.getValue().val, - earliestMs, - latestMs, - dateFormatTz, - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE - ).then((resp) => { - const anomalies = resp.anomalies; - const detectorsByJob = mlJobService.detectorsByJob; - anomalies.forEach((anomaly) => { - // Add a detector property to each anomaly. - // Default to functionDescription if no description available. - // TODO - when job_service is moved server_side, move this to server endpoint. - const jobId = anomaly.jobId; - const detector = _.get(detectorsByJob, [jobId, anomaly.detectorIndex]); - anomaly.detector = _.get(detector, - ['detector_description'], - anomaly.source.function_description); - - // For detectors with rules, add a property with the rule count. - const customRules = detector.custom_rules; - if (customRules !== undefined) { - anomaly.rulesLength = customRules.length; - } - - // Add properties used for building the links menu. - // TODO - when job_service is moved server_side, move this to server endpoint. - if (_.has(mlJobService.customUrlsByJob, jobId)) { - anomaly.customUrls = mlJobService.customUrlsByJob[jobId]; - } - }); - - $scope.$evalAsync(() => { - $scope.tableData = { - anomalies, - interval: resp.interval, - examplesByJobId: resp.examplesByJobId, - showViewSeriesLink: false - }; - }); - - }).catch((resp) => { - console.log('Time series explorer - error loading data for anomalies table:', resp); - }); - } - - function updateControlsForDetector() { - // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. - const detectorIndex = +$scope.detectorId; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - - const entities = []; - const entitiesState = $scope.appState.mlTimeSeriesExplorer.entities || {}; - const partitionFieldName = _.get(detector, 'partition_field_name'); - const overFieldName = _.get(detector, 'over_field_name'); - const byFieldName = _.get(detector, 'by_field_name'); - if (partitionFieldName !== undefined) { - const partitionFieldValue = _.get(entitiesState, partitionFieldName, ''); - entities.push({ fieldName: partitionFieldName, fieldValue: partitionFieldValue }); - } - if (overFieldName !== undefined) { - const overFieldValue = _.get(entitiesState, overFieldName, ''); - entities.push({ fieldName: overFieldName, fieldValue: overFieldValue }); - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - // TODO - metric data can be filtered by this field, so should only exclude - // from filter for the anomaly records. - if (byFieldName !== undefined && overFieldName === undefined) { - const byFieldValue = _.get(entitiesState, byFieldName, ''); - entities.push({ fieldName: byFieldName, fieldValue: byFieldValue }); - } - - $scope.entities = entities; - } - - function loadEntityValues() { - // Populate the entity input datalists with the values from the top records by score - // for the selected detector across the full time range. No need to pass through finish(). - const bounds = timefilter.getActiveBounds(); - const detectorIndex = +$scope.detectorId; - - mlResultsService.getRecordsForCriteria( - [$scope.selectedJob.job_id], - [{ 'fieldName': 'detector_index', 'fieldValue': detectorIndex }], - 0, - bounds.min.valueOf(), - bounds.max.valueOf(), - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE) - .then((resp) => { - if (resp.records && resp.records.length > 0) { - const firstRec = resp.records[0]; - - _.each($scope.entities, (entity) => { - if (firstRec.partition_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('partition_field_value').uniq().value(); - } - if (firstRec.over_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('over_field_value').uniq().value(); - } - if (firstRec.by_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('by_field_value').uniq().value(); - } - }); - $scope.$applyAsync(); - } - - }); - } - - function calculateInitialFocusRange() { - // Check for a zoom parameter in the appState (URL). - const zoomState = $scope.appState.mlTimeSeriesExplorer.zoom; - if (zoomState !== undefined) { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - $scope.autoZoomDuration = getAutoZoomDuration(); - - // 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 bounds = timefilter.getActiveBounds(); - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.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 calculateDefaultFocusRange(); - } - - function calculateDefaultFocusRange() { - - $scope.autoZoomDuration = getAutoZoomDuration(); - const isForecastData = $scope.contextForecastData !== undefined && $scope.contextForecastData.length > 0; - - const combinedData = (isForecastData === false) ? - $scope.contextChartData : $scope.contextChartData.concat($scope.contextForecastData); - const earliestDataDate = _.first(combinedData).date; - const latestDataDate = _.last(combinedData).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 = _.first($scope.contextForecastData).date; - const latestForecastDataDate = _.last($scope.contextForecastData).date; - - rangeLatestMs = Math.min(earliestForecastDataDate.getTime() + ($scope.autoZoomDuration / 2), latestForecastDataDate.getTime()); - rangeEarliestMs = Math.max(rangeLatestMs - $scope.autoZoomDuration, earliestDataDate.getTime()); - } else { - // Returns the range that shows the most recent data at bucket span granularity. - rangeLatestMs = latestDataDate.getTime() + $scope.contextAggregationInterval.asMilliseconds(); - rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - $scope.autoZoomDuration); - } - - return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; - - } - - function calculateAggregationInterval(bounds, bucketsTarget) { - // 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 = new MlTimeBuckets(); - 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($scope.jobs, { 'id': $scope.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(); - } - - console.log('calculateAggregationInterval() barTarget,maxBars,returning:', bucketsTarget, maxBars, - (bounds.max.diff(bounds.min)) / aggInterval.asMilliseconds()); - - return aggInterval; - } - - function getAutoZoomDuration() { - // 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($scope.jobs, { 'id': $scope.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 = new MlTimeBuckets(); - 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; - - } - - $scope.initializeVis(); -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js new file mode 100644 index 00000000000000..d7b902709a6c7b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import moment from 'moment-timezone'; +import { Subscription } from 'rxjs'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml'); + +import chrome from 'ui/chrome'; +import { timefilter } from 'ui/timefilter'; +import { timeHistory } from 'ui/timefilter/time_history'; +import { I18nContext } from 'ui/i18n'; + +import '../components/controls'; + +import { severity$ } from '../components/controls/select_severity/select_severity'; +import { interval$ } from '../components/controls/select_interval/select_interval'; +import { subscribeAppStateToObservable } from '../util/app_state_utils'; +import { NavigationMenuContext } from '../util/context_utils'; + +import { TimeSeriesExplorer } from './timeseriesexplorer'; +import { APP_STATE_ACTION } from './timeseriesexplorer_constants'; + +module.directive('mlTimeSeriesExplorer', function ($injector) { + function link($scope, $element) { + const globalState = $injector.get('globalState'); + const AppState = $injector.get('AppState'); + const config = $injector.get('config'); + + const subscriptions = new Subscription(); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$)); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$)); + + $scope.appState = new AppState({ mlTimeSeriesExplorer: {} }); + + const appStateHandler = (action, payload) => { + $scope.appState.fetch(); + switch (action) { + case APP_STATE_ACTION.CLEAR: + delete $scope.appState.mlTimeSeriesExplorer.detectorIndex; + delete $scope.appState.mlTimeSeriesExplorer.entities; + delete $scope.appState.mlTimeSeriesExplorer.forecastId; + break; + + case APP_STATE_ACTION.GET_DETECTOR_INDEX: + return get($scope, 'appState.mlTimeSeriesExplorer.detectorIndex'); + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + $scope.appState.mlTimeSeriesExplorer.detectorIndex = payload; + break; + + case APP_STATE_ACTION.GET_ENTITIES: + return get($scope, 'appState.mlTimeSeriesExplorer.entities', {}); + case APP_STATE_ACTION.SET_ENTITIES: + $scope.appState.mlTimeSeriesExplorer.entities = payload; + break; + + case APP_STATE_ACTION.GET_FORECAST_ID: + return get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); + case APP_STATE_ACTION.SET_FORECAST_ID: + $scope.appState.mlTimeSeriesExplorer.forecastId = payload; + break; + + case APP_STATE_ACTION.GET_ZOOM: + return get($scope, 'appState.mlTimeSeriesExplorer.zoom'); + case APP_STATE_ACTION.SET_ZOOM: + $scope.appState.mlTimeSeriesExplorer.zoom = payload; + break; + case APP_STATE_ACTION.UNSET_ZOOM: + delete $scope.appState.mlTimeSeriesExplorer.zoom; + break; + } + $scope.appState.save(); + $scope.$applyAsync(); + }; + + function updateComponent() { + // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. + const tzConfig = config.get('dateFormat:tz'); + const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); + + ReactDOM.render( + + + + + , + $element[0] + ); + } + + $element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode($element[0]); + subscriptions.unsubscribe(); + }); + + updateComponent(); + } + + return { + link, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js new file mode 100644 index 00000000000000..c07dfad29f85d4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uiRoutes from 'ui/routes'; + +import '../components/controls'; + +import { checkFullLicense } from '../license/check_license'; +import { getMlNodeCount } from '../ml_nodes_check/check_ml_nodes'; +import { checkGetJobsPrivilege } from '../privilege/check_privilege'; +import { mlJobService } from '../services/job_service'; +import { loadIndexPatterns } from '../util/index_utils'; + +import { getSingleMetricViewerBreadcrumbs } from './breadcrumbs'; + +uiRoutes + .when('/timeseriesexplorer/?', { + template: '', + k7Breadcrumbs: getSingleMetricViewerBreadcrumbs, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + indexPatterns: loadIndexPatterns, + mlNodeCount: getMlNodeCount, + jobs: mlJobService.loadJobsWrapper + } + }); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js index 33048cb0f9d099..7a50b52c191a38 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js @@ -13,9 +13,34 @@ */ import _ from 'lodash'; +import moment from 'moment-timezone'; import { parseInterval } from 'ui/utils/parse_interval'; -import { isTimeSeriesViewJob } from '../../common/util/job_utils'; + +import { + ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE +} from '../../common/constants/search'; +import { + isTimeSeriesViewJob, + mlFunctionToESAggregation, +} from '../../common/util/job_utils'; + +import { ml } from '../services/ml_api_service'; +import { mlForecastService } from '../services/forecast_service'; +import { mlResultsService } from '../services/results_service'; +import { MlTimeBuckets, getBoundsRoundedToInterval } from '../util/ml_time_buckets'; + +import { mlTimeSeriesSearchService } from './timeseries_search_service'; + +import { + CHARTS_POINT_TARGET, + MAX_SCHEDULED_EVENTS, + TIME_FIELD_NAME, +} from './timeseriesexplorer_constants'; + +import chrome from 'ui/chrome'; +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); // 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. @@ -97,7 +122,6 @@ export function processRecordScoreResults(scoreData) { export function processDataForFocusAnomalies( chartData, anomalyRecords, - timeFieldName, aggregationInterval, modelPlotEnabled) { @@ -110,7 +134,7 @@ export function processDataForFocusAnomalies( lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); } anomalyRecords.forEach((record) => { - const recordTime = record[timeFieldName]; + const recordTime = record[TIME_FIELD_NAME]; const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); if (chartPoint === undefined) { const timeToAdd = (Math.floor(recordTime / intervalMs)) * intervalMs; @@ -141,7 +165,7 @@ export function processDataForFocusAnomalies( // Look for a chart point with the same time as the record. // If none found, find closest time in chartData set. - const recordTime = record[timeFieldName]; + const recordTime = record[TIME_FIELD_NAME]; const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); if (chartPoint !== undefined) { // If chart aggregation interval > bucket span, there may be more than @@ -277,3 +301,275 @@ export function findChartPointForAnomalyTime(chartData, anomalyTime, aggregation return chartPoint; } + +export const getFocusData = function ( + criteriaFields, + detectorIndex, + focusAggregationInterval, + forecastId, + modelPlotEnabled, + nonBlankEntities, + searchBounds, + selectedJob, +) { + return new Promise((resolve, reject) => { + // Counter to keep track of the queries to populate the chart. + let awaitingCount = 4; + + // This object is used to store the results of individual remote requests + // before we transform it into the final data and apply it to $scope. Otherwise + // we might trigger multiple $digest cycles and depending on how deep $watches + // listen for changes we could miss updates. + const refreshFocusData = {}; + + // finish() function, called after each data set has been loaded and processed. + // The last one to call it will trigger the page render. + function finish() { + awaitingCount--; + if (awaitingCount === 0) { + // Tell the results container directives to render the focus chart. + refreshFocusData.focusChartData = processDataForFocusAnomalies( + refreshFocusData.focusChartData, + refreshFocusData.anomalyRecords, + focusAggregationInterval, + modelPlotEnabled, + ); + + refreshFocusData.focusChartData = processScheduledEventsForChart( + refreshFocusData.focusChartData, + refreshFocusData.scheduledEvents); + + resolve(refreshFocusData); + } + } + + // Query 1 - load metric data across selected time range. + mlTimeSeriesSearchService.getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression + ).then((resp) => { + refreshFocusData.focusChartData = processMetricPlotResults(resp.results, modelPlotEnabled); + finish(); + }).catch((resp) => { + console.log('Time series explorer - error getting metric data from elasticsearch:', resp); + reject(); + }); + + // Query 2 - load all the records across selected time range for the chart anomaly markers. + mlResultsService.getRecordsForCriteria( + [selectedJob.job_id], + criteriaFields, + 0, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ).then((resp) => { + // Sort in descending time order before storing in scope. + refreshFocusData.anomalyRecords = _.chain(resp.records) + .sortBy(record => record[TIME_FIELD_NAME]) + .reverse() + .value(); + finish(); + }); + + // Query 3 - load any scheduled events for the selected job. + mlResultsService.getScheduledEventsByBucket( + [selectedJob.job_id], + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression, + 1, + MAX_SCHEDULED_EVENTS + ).then((resp) => { + refreshFocusData.scheduledEvents = resp.events[selectedJob.job_id]; + finish(); + }).catch((resp) => { + console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp); + reject(); + }); + + // Query 4 - load any annotations for the selected job. + if (mlAnnotationsEnabled) { + ml.annotations.getAnnotations({ + jobIds: [selectedJob.job_id], + earliestMs: searchBounds.min.valueOf(), + latestMs: searchBounds.max.valueOf(), + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE + }).then((resp) => { + refreshFocusData.focusAnnotationData = resp.annotations[selectedJob.job_id] + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = String.fromCharCode(65 + i); + return d; + }); + + finish(); + }).catch(() => { + // silent fail + refreshFocusData.focusAnnotationData = []; + finish(); + }); + } else { + finish(); + } + + // Plus query for forecast data if there is a forecastId stored in the appState. + if (forecastId !== undefined) { + awaitingCount++; + let aggType = undefined; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + const esAgg = mlFunctionToESAggregation(detector.function); + if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { + aggType = { avg: 'sum', max: 'sum', min: 'sum' }; + } + + mlForecastService.getForecastData( + selectedJob, + detectorIndex, + forecastId, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression, + aggType) + .then((resp) => { + refreshFocusData.focusForecastData = processForecastResults(resp.results); + refreshFocusData.showForecastCheckbox = (refreshFocusData.focusForecastData.length > 0); + finish(); + }).catch((resp) => { + console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); + reject(); + }); + } + }); +}; + +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 = new MlTimeBuckets(); + 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 = _.first(combinedData).date; + const latestDataDate = _.last(combinedData).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 = _.first(contextForecastData).date; + const latestForecastDataDate = _.last(contextForecastData).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, timefilter) { + 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 bounds = timefilter.getActiveBounds(); + 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 = new MlTimeBuckets(); + 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/legacy/plugins/ml/public/util/app_state_utils.js b/x-pack/legacy/plugins/ml/public/util/app_state_utils.js index b60370de67da79..f3dc7086f7c2a5 100644 --- a/x-pack/legacy/plugins/ml/public/util/app_state_utils.js +++ b/x-pack/legacy/plugins/ml/public/util/app_state_utils.js @@ -50,12 +50,17 @@ export function initializeAppState(AppState, stateName, defaultState) { return appState; } +// Some components like the show-chart-checkbox or severity/interval-dropdowns +// emit their state change to an observable. This utility function can be used +// to persist these state changes to AppState and save the state to the url. +// distinctUntilChanged() makes sure the callback is only triggered upon changes +// of the state and filters consecutive triggers of the same value. export function subscribeAppStateToObservable(AppState, appStateName, o$, callback) { const appState = initializeAppState(AppState, appStateName, o$.getValue()); o$.next(appState[appStateName]); - o$.pipe(distinctUntilChanged()).subscribe(payload => { + const subscription = o$.pipe(distinctUntilChanged()).subscribe(payload => { appState.fetch(); appState[appStateName] = payload; appState.save(); @@ -63,4 +68,6 @@ export function subscribeAppStateToObservable(AppState, appStateName, o$, callba callback(payload); } }); + + return subscription; } diff --git a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js index c79a28d2b449e2..3af986e3ca5da7 100644 --- a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js +++ b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js @@ -16,8 +16,8 @@ import moment from 'moment'; import dateMath from '@elastic/datemath'; import chrome from 'ui/chrome'; -import { timeBucketsCalcAutoIntervalProvider } from 'plugins/ml/util/ml_calc_auto_interval'; -import { inherits } from 'plugins/ml/util/inherits'; +import { timeBucketsCalcAutoIntervalProvider } from './ml_calc_auto_interval'; +import { inherits } from './inherits'; const unitsDesc = dateMath.unitsDesc; const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer converted to days for ES intervals. @@ -183,4 +183,3 @@ export function calcEsInterval(duration) { expression: ms + 'ms' }; } - diff --git a/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js b/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js deleted file mode 100644 index 2ddcb15bf933b7..00000000000000 --- a/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { timefilter } from 'ui/timefilter'; - -/* - * Watches for changes to the refresh interval of the page time filter, - * so that listeners can be notified when the auto-refresh interval has elapsed. - */ - -export function refreshIntervalWatcher($timeout) { - - let refresher; - let listener; - - const onRefreshIntervalChange = () => { - if (refresher) { - $timeout.cancel(refresher); - } - const interval = timefilter.getRefreshInterval(); - if (interval.value > 0 && !interval.pause) { - function startRefresh() { - refresher = $timeout(() => { - startRefresh(); - listener(); - }, interval.value); - } - startRefresh(); - } - }; - - function init(listenerCallback) { - listener = listenerCallback; - timefilter.on('refreshIntervalUpdate', onRefreshIntervalChange); - } - - function cancel() { - $timeout.cancel(refresher); - timefilter.off('refreshIntervalUpdate', onRefreshIntervalChange); - } - - return { - init, - cancel - }; -} diff --git a/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts b/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts index 8ec092a803cee7..5da4f6b62bcec9 100644 --- a/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts +++ b/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts @@ -4,5 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './ml_telemetry'; +export { + createMlTelemetry, + getSavedObjectsClient, + incrementFileDataVisualizerIndexCreationCount, + storeMlTelemetry, + MlTelemetry, + MlTelemetrySavedObject, + ML_TELEMETRY_DOC_ID, +} from './ml_telemetry'; export { makeMlUsageCollector } from './make_ml_usage_collector'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a02b11d254051c..c25144dd118865 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7380,9 +7380,9 @@ "xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "{createdDate} に作成された予測を表示", "xpack.ml.timeSeriesExplorer.intervalLabel": "間隔", "xpack.ml.timeSeriesExplorer.loadingLabel": "読み込み中", - "xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "{icon} 結果が見つかりませんでした", - "xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "{icon} シングルメトリックジョブが見つかりませんでした", - "xpack.ml.timeSeriesExplorer.refreshButtonAriLabel": "更新", + "xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "結果が見つかりませんでした", + "xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "シングルメトリックジョブが見つかりませんでした", + "xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel": "更新", "xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage": "リクエストされた検知器インデックス {detectorIndex} はジョブ {jobId} に有効ではありません", "xpack.ml.timeSeriesExplorer.runControls.durationLabel": "期間", "xpack.ml.timeSeriesExplorer.runControls.forecastMaximumLengthHelpText": "予想の長さで、最長 {maximumForecastDurationDays} 日です。秒には s、分には m、時間には h、日には d、週には w を使います。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 13d48183c1f441..bacd5650dabdab 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7380,9 +7380,9 @@ "xpack.ml.timeSeriesExplorer.forecastsList.viewForecastAriaLabel": "查看在 {createdDate} 创建的预测", "xpack.ml.timeSeriesExplorer.intervalLabel": "时间间隔", "xpack.ml.timeSeriesExplorer.loadingLabel": "正在加载", - "xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "{icon} 找不到结果", - "xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "{icon} 未找到单指标作业", - "xpack.ml.timeSeriesExplorer.refreshButtonAriLabel": "刷新", + "xpack.ml.timeSeriesExplorer.noResultsFoundLabel": "找不到结果", + "xpack.ml.timeSeriesExplorer.noSingleMetricJobsFoundLabel": "未找到单指标作业", + "xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel": "刷新", "xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage": "请求的检测工具索引 {detectorIndex} 对于作业 {jobId} 无效", "xpack.ml.timeSeriesExplorer.runControls.durationLabel": "持续时间", "xpack.ml.timeSeriesExplorer.runControls.forecastMaximumLengthHelpText": "预测时长,最多 {maximumForecastDurationDays} 天。使用 s 表示秒,m 表示分钟,h 表示小时,d 表示天,w 表示周。",