{/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */}
@@ -1228,21 +455,23 @@ export const Explorer = injectI18n(injectObservablesAsProps(
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
-
+ {showOverallSwimlane && (
+
+ )}
{viewBySwimlaneOptions.length > 0 && (
-
+ <>
@@ -1286,7 +515,7 @@ export const Explorer = injectI18n(injectObservablesAsProps(
/>
)}
{filterActive === true &&
- swimlaneViewByFieldName === 'job ID' && (
+ viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
{showViewBySwimlane && (
-
+ <>
-
+ >
)}
{viewBySwimlaneDataLoading && (
)}
- {!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && (
+ {!showViewBySwimlane && !viewBySwimlaneDataLoading && viewBySwimlaneFieldName !== null && (
)}
-
+ >
)}
{annotationsData.length > 0 && (
-
+ <>
-
+ >
)}
@@ -1394,7 +623,7 @@ export const Explorer = injectI18n(injectObservablesAsProps(
- {this.props.showCharts && }
+ {showCharts && }
{
// the directive just ends up being empty.
expect(wrapper.isEmptyRender()).toBeTruthy();
expect(wrapper.find('.content-wrapper')).toHaveLength(0);
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(0);
});
test('Loading status active, no chart', () => {
@@ -69,7 +69,7 @@ describe('ExplorerChart', () => {
);
// test if the loading indicator is shown
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(1);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1);
});
// For the following tests the directive needs to be rendered in the actual DOM,
@@ -97,7 +97,7 @@ describe('ExplorerChart', () => {
const wrapper = init(mockChartData);
// the loading indicator should not be shown
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(0);
// test if all expected elements are present
// need to use getDOMNode() because the chart is not rendered via react itself
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
index 83d4fda0858a2..e3d7dcca490d6 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js
@@ -56,7 +56,7 @@ describe('ExplorerChart', () => {
// the directive just ends up being empty.
expect(wrapper.isEmptyRender()).toBeTruthy();
expect(wrapper.find('.content-wrapper')).toHaveLength(0);
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(0);
});
test('Loading status active, no chart', () => {
@@ -69,7 +69,7 @@ describe('ExplorerChart', () => {
);
// test if the loading indicator is shown
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(1);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1);
});
// For the following tests the directive needs to be rendered in the actual DOM,
@@ -97,7 +97,7 @@ describe('ExplorerChart', () => {
const wrapper = init(mockChartData);
// the loading indicator should not be shown
- expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
+ expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(0);
// test if all expected elements are present
// need to use getDOMNode() because the chart is not rendered via react itself
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts
new file mode 100644
index 0000000000000..ccd52a26f2abc
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export declare interface ExplorerChartsData {
+ chartsPerRow: number;
+ seriesToPlot: any[];
+ tooManyBuckets: boolean;
+ timeFieldName: string;
+}
+
+export declare const getDefaultChartsData: () => ExplorerChartsData;
+
+export declare const explorerChartsContainerServiceFactory: (
+ callback: (data: ExplorerChartsData) => void
+) => (anomalyRecords: any[], earliestMs: number, latestMs: number) => void;
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js
index 01afd9ffb602f..4b8c5030634f0 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js
@@ -48,10 +48,7 @@ export function explorerChartsContainerServiceFactory(callback) {
callback(getDefaultChartsData());
- let requestCount = 0;
const anomalyDataChange = function (anomalyRecords, earliestMs, latestMs) {
- const newRequestCount = ++requestCount;
- requestCount = newRequestCount;
const data = getDefaultChartsData();
@@ -380,11 +377,6 @@ export function explorerChartsContainerServiceFactory(callback) {
Promise.all(seriesPromises)
.then(response => {
- // TODO: Add test to prevent this regression.
- // Ignore this response if it's returned by an out of date promise
- if (newRequestCount < requestCount) {
- return;
- }
// calculate an overall min/max for all series
const processedData = response.map(processChartData);
const allDataPoints = _.reduce(processedData, (datapoints, series) => {
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
similarity index 54%
rename from x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.js
rename to x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
index 27073ca250d11..66cd98f7ebe29 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
@@ -13,16 +13,32 @@ import { i18n } from '@kbn/i18n';
export const DRAG_SELECT_ACTION = {
NEW_SELECTION: 'newSelection',
ELEMENT_SELECT: 'elementSelect',
- DRAG_START: 'dragStart'
+ DRAG_START: 'dragStart',
};
export const EXPLORER_ACTION = {
- IDLE: 'idle',
+ APP_STATE_SET: 'appStateSet',
+ APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: 'appStateClearInfluencerFilterSettings',
+ APP_STATE_CLEAR_SELECTION: 'appStateClearSelection',
+ APP_STATE_SAVE_SELECTION: 'appStateSaveSelection',
+ APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: 'appStateSaveViewBySwimlaneFieldName',
+ APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: 'appStateSaveInfluencerFilterSettings',
+ CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings',
+ CLEAR_JOBS: 'clearJobs',
+ CLEAR_SELECTION: 'clearSelection',
INITIALIZE: 'initialize',
JOB_SELECTION_CHANGE: 'jobSelectionChange',
LOAD_JOBS: 'loadJobs',
- REDRAW: 'redraw',
- RELOAD: 'reload',
+ RESET: 'reset',
+ SET_BOUNDS: 'setBounds',
+ SET_CHARTS: 'setCharts',
+ SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings',
+ SET_SELECTED_CELLS: 'setSelectedCells',
+ SET_STATE: 'setState',
+ SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth',
+ SET_SWIMLANE_LIMIT: 'setSwimlaneLimit',
+ SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName',
+ SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading',
};
export const FILTER_ACTION = {
@@ -30,17 +46,9 @@ export const FILTER_ACTION = {
REMOVE: '-',
};
-export const APP_STATE_ACTION = {
- CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings',
- CLEAR_SELECTION: 'clearSelection',
- SAVE_SELECTION: 'saveSelection',
- SAVE_SWIMLANE_VIEW_BY_FIELD_NAME: 'saveSwimlaneViewByFieldName',
- SAVE_INFLUENCER_FILTER_SETTINGS: 'saveInfluencerFilterSettings'
-};
-
export const SWIMLANE_TYPE = {
OVERALL: 'overall',
- VIEW_BY: 'viewBy'
+ VIEW_BY: 'viewBy',
};
export const CHART_TYPE = {
@@ -53,4 +61,6 @@ export const MAX_CATEGORY_EXAMPLES = 10;
export const MAX_INFLUENCER_FIELD_VALUES = 10;
export const MAX_INFLUENCER_FIELD_NAMES = 50;
-export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID' });
+export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', {
+ defaultMessage: 'job ID',
+});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_controller.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_controller.js
deleted file mode 100644
index c33b86bacf942..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_controller.js
+++ /dev/null
@@ -1,289 +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 Explorer dashboard. The controller makes
- * multiple queries to Elasticsearch to obtain the data to populate all the components
- * in the view.
- */
-
-import $ from 'jquery';
-import { Subscription } from 'rxjs';
-
-import '../components/controls';
-
-import uiRoutes from 'ui/routes';
-import {
- createJobs,
-} from './explorer_utils';
-import { getAnomalyExplorerBreadcrumbs } from './breadcrumbs';
-import { checkFullLicense } from '../license/check_license';
-import { checkGetJobsPrivilege } from '../privilege/check_privilege';
-import { loadIndexPatterns } from '../util/index_utils';
-import { TimeBuckets } from '../util/time_buckets';
-import { explorer$ } from './explorer_dashboard_service';
-import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
-import { mlFieldFormatService } from '../services/field_format_service';
-import { mlJobService } from '../services/job_service';
-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: {
- CheckLicense: checkFullLicense,
- privileges: checkGetJobsPrivilege,
- indexPatterns: loadIndexPatterns,
- jobs: mlJobService.loadJobsWrapper
- },
- });
-
-import { uiModules } from 'ui/modules';
-
-const module = uiModules.get('apps/ml');
-
-module.controller('MlExplorerController', function (
- $scope,
- $timeout,
- $rootScope,
- AppState,
- globalState,
-) {
- const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState);
- const subscriptions = new Subscription();
-
- // $scope should only contain what's actually still necessary for the angular part.
- // For the moment that's the job selector and the (hidden) filter bar.
- $scope.jobs = [];
- timefilter.enableTimeRangeSelector();
- timefilter.enableAutoRefreshSelector();
-
- $scope.TimeBuckets = TimeBuckets;
-
- let resizeTimeout = null;
-
- function jobSelectionUpdate(action, {
- fullJobs,
- filterData,
- selectedCells,
- selectedJobIds,
- swimlaneViewByFieldName
- }) {
- const jobs = createJobs(fullJobs).map((job) => {
- job.selected = selectedJobIds.some((id) => job.id === id);
- return job;
- });
-
- const selectedJobs = jobs.filter(job => job.selected);
-
- function fieldFormatServiceCallback() {
- $scope.jobs = jobs;
- $scope.$applyAsync();
-
- const noJobsFound = ($scope.jobs.length === 0);
-
- explorer$.next({
- action,
- payload: {
- loading: false,
- noJobsFound,
- selectedCells,
- selectedJobs,
- swimlaneViewByFieldName,
- filterData
- }
- });
- $scope.jobSelectionUpdateInProgress = false;
- $scope.$applyAsync();
- }
-
- // Populate the map of jobs / detectors / field formatters for the selected IDs.
- mlFieldFormatService.populateFormats(selectedJobIds)
- .catch((err) => {
- console.log('Error populating field formats:', err);
- })
- .then(() => {
- fieldFormatServiceCallback();
- });
- }
-
- // Initialize the AppState in which to store swimlane settings.
- // AppState is used to store state in the URL.
- $scope.appState = new AppState({
- mlExplorerSwimlane: {},
- mlExplorerFilter: {}
- });
-
- // Load the job info needed by the dashboard, then do the first load.
- // Calling loadJobs() ensures the full datafeed config is available for building the charts.
- // Using this listener ensures the jobs will only be loaded and passed on after
- // and have been initialized.
- function loadJobsListener({ action }) {
- if (action === EXPLORER_ACTION.LOAD_JOBS) {
- // Jobs load via route resolver
- if (mlJobService.jobs.length > 0) {
- // Select any jobs set in the global state (i.e. passed in the URL).
- const { jobIds: selectedJobIds } = getSelectedJobIds(globalState);
- let selectedCells;
- let filterData = {};
-
- // keep swimlane selection, restore selectedCells from AppState
- if ($scope.appState.mlExplorerSwimlane.selectedType !== undefined) {
- selectedCells = {
- type: $scope.appState.mlExplorerSwimlane.selectedType,
- lanes: $scope.appState.mlExplorerSwimlane.selectedLanes,
- times: $scope.appState.mlExplorerSwimlane.selectedTimes,
- showTopFieldValues: $scope.appState.mlExplorerSwimlane.showTopFieldValues,
- viewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
- };
- }
-
- // keep influencers filter selection, restore from AppState
- if ($scope.appState.mlExplorerFilter.influencersFilterQuery !== undefined) {
- filterData = {
- influencersFilterQuery: $scope.appState.mlExplorerFilter.influencersFilterQuery,
- filterActive: $scope.appState.mlExplorerFilter.filterActive,
- filteredFields: $scope.appState.mlExplorerFilter.filteredFields,
- queryString: $scope.appState.mlExplorerFilter.queryString,
- };
- }
-
- jobSelectionUpdate(EXPLORER_ACTION.INITIALIZE, {
- filterData,
- fullJobs: mlJobService.jobs,
- selectedCells,
- selectedJobIds,
- swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
- });
-
- subscriptions.add(jobSelectService.subscribe(({ selection }) => {
- if (selection !== undefined) {
- $scope.jobSelectionUpdateInProgress = true;
- jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds: selection });
- }
- }));
-
- } else {
- explorer$.next({
- action: EXPLORER_ACTION.RELOAD,
- payload: {
- loading: false,
- noJobsFound: true,
- }
- });
- }
- }
- }
-
- subscriptions.add(explorer$.subscribe(loadJobsListener));
-
- // Listen for changes to job selection.
- $scope.jobSelectionUpdateInProgress = false;
-
- subscriptions.add(mlTimefilterRefresh$.subscribe(() => {
- if ($scope.jobSelectionUpdateInProgress === false) {
- explorer$.next({ action: EXPLORER_ACTION.REDRAW });
- }
- }));
-
- // Refresh all the data when the time range is altered.
- subscriptions.add(timefilter.getFetch$().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() {
- if (resizeTimeout !== null) {
- $timeout.cancel(resizeTimeout);
- }
- // Only redraw 100ms after last resize event.
- resizeTimeout = $timeout(redrawOnResize, 100);
- }
-
- $(window).resize(jqueryRedrawOnResize);
-
- const navListener = $scope.$on('globalNav:update', () => {
- // Run in timeout so that content pane has resized after global nav has updated.
- $timeout(() => {
- redrawOnResize();
- }, 300);
- });
-
- function redrawOnResize() {
- if ($scope.jobSelectionUpdateInProgress === false) {
- explorer$.next({ action: EXPLORER_ACTION.REDRAW });
- }
- }
-
- $scope.appStateHandler = ((action, payload) => {
- $scope.appState.fetch();
-
- if (action === APP_STATE_ACTION.CLEAR_SELECTION) {
- delete $scope.appState.mlExplorerSwimlane.selectedType;
- delete $scope.appState.mlExplorerSwimlane.selectedLanes;
- delete $scope.appState.mlExplorerSwimlane.selectedTimes;
- delete $scope.appState.mlExplorerSwimlane.showTopFieldValues;
- }
-
- if (action === APP_STATE_ACTION.SAVE_SELECTION) {
- const swimlaneSelectedCells = payload.swimlaneSelectedCells;
- $scope.appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
- $scope.appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
- $scope.appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
- $scope.appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
- $scope.appState.mlExplorerSwimlane.viewByFieldName = swimlaneSelectedCells.viewByFieldName;
-
- }
-
- if (action === APP_STATE_ACTION.SAVE_SWIMLANE_VIEW_BY_FIELD_NAME) {
- $scope.appState.mlExplorerSwimlane.viewByFieldName = payload.swimlaneViewByFieldName;
- }
-
- if (action === APP_STATE_ACTION.SAVE_INFLUENCER_FILTER_SETTINGS) {
- $scope.appState.mlExplorerFilter.influencersFilterQuery = payload.influencersFilterQuery;
- $scope.appState.mlExplorerFilter.filterActive = payload.filterActive;
- $scope.appState.mlExplorerFilter.filteredFields = payload.filteredFields;
- $scope.appState.mlExplorerFilter.queryString = payload.queryString;
- }
-
- if (action === APP_STATE_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS) {
- delete $scope.appState.mlExplorerFilter.influencersFilterQuery;
- delete $scope.appState.mlExplorerFilter.filterActive;
- delete $scope.appState.mlExplorerFilter.filteredFields;
- delete $scope.appState.mlExplorerFilter.queryString;
- }
-
- $scope.appState.save();
- $scope.$applyAsync();
- });
-
- $scope.$on('$destroy', () => {
- subscriptions.unsubscribe();
- $(window).off('resize', jqueryRedrawOnResize);
- // Cancel listening for updates to the global nav state.
- navListener();
- unsubscribeFromGlobalState();
- });
-});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.js
deleted file mode 100644
index a017784292337..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.js
+++ /dev/null
@@ -1,19 +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.
- */
-
-
-
-/*
- * Service for firing and registering for events across the different
- * components in the Explorer dashboard.
- */
-
-import { Subject } from 'rxjs';
-
-export const ALLOW_CELL_RANGE_SELECTION = true;
-
-export const dragSelect$ = new Subject();
-export const explorer$ = new Subject();
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts
new file mode 100644
index 0000000000000..713857835b3b9
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts
@@ -0,0 +1,179 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * Service for firing and registering for events across the different
+ * components in the Explorer dashboard.
+ */
+
+import { isEqual, pick } from 'lodash';
+
+import { from, isObservable, BehaviorSubject, Observable, Subject } from 'rxjs';
+import { distinctUntilChanged, flatMap, map, pairwise, scan } from 'rxjs/operators';
+
+import { DeepPartial } from '../../../common/types/common';
+
+import { jobSelectionActionCreator, loadExplorerData } from './actions';
+import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service';
+import { EXPLORER_ACTION } from './explorer_constants';
+import { RestoredAppState, SelectedCells, TimeRangeBounds } from './explorer_utils';
+import {
+ explorerReducer,
+ getExplorerDefaultState,
+ ExplorerAppState,
+ ExplorerState,
+} from './reducers';
+
+export const ALLOW_CELL_RANGE_SELECTION = true;
+
+export const dragSelect$ = new Subject();
+
+type ExplorerAction = Action | Observable;
+const explorerAction$ = new BehaviorSubject({ type: EXPLORER_ACTION.RESET });
+
+export type ActionPayload = any;
+
+export interface Action {
+ type: string;
+ payload?: ActionPayload;
+}
+
+const explorerFilteredAction$ = explorerAction$.pipe(
+ // consider observables as side-effects
+ flatMap((action: ExplorerAction) =>
+ isObservable(action) ? action : (from([action]) as Observable)
+ ),
+ distinctUntilChanged(isEqual)
+);
+
+// applies action and returns state
+const explorerState$: Observable = explorerFilteredAction$.pipe(
+ scan(explorerReducer, getExplorerDefaultState()),
+ pairwise(),
+ map(([prev, curr]) => {
+ if (
+ curr.selectedJobs !== null &&
+ curr.bounds !== undefined &&
+ !isEqual(getCompareState(prev), getCompareState(curr))
+ ) {
+ explorerAction$.next(loadExplorerData(curr).pipe(map(d => setStateActionCreator(d))));
+ }
+ return curr;
+ })
+);
+
+const explorerAppState$: Observable = explorerState$.pipe(
+ map((state: ExplorerState) => state.appState),
+ distinctUntilChanged(isEqual)
+);
+
+function getCompareState(state: ExplorerState) {
+ return pick(state, [
+ 'bounds',
+ 'filterActive',
+ 'filteredFields',
+ 'influencersFilterQuery',
+ 'isAndOperator',
+ 'noInfluencersConfigured',
+ 'selectedCells',
+ 'selectedJobs',
+ 'swimlaneContainerWidth',
+ 'swimlaneLimit',
+ 'tableInterval',
+ 'tableSeverity',
+ 'viewBySwimlaneFieldName',
+ ]);
+}
+
+export const setStateActionCreator = (payload: DeepPartial) => ({
+ type: EXPLORER_ACTION.SET_STATE,
+ payload,
+});
+
+interface AppStateSelection {
+ type: string;
+ lanes: string[];
+ times: number[];
+ showTopFieldValues: boolean;
+ viewByFieldName: string;
+}
+
+// Export observable state and action dispatchers as service
+export const explorerService = {
+ appState$: explorerAppState$,
+ state$: explorerState$,
+ appStateClearSelection: () => {
+ explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION });
+ },
+ appStateSaveSelection: (payload: AppStateSelection) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION, payload });
+ },
+ clearInfluencerFilterSettings: () => {
+ explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS });
+ },
+ clearJobs: () => {
+ explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_JOBS });
+ },
+ clearSelection: () => {
+ explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_SELECTION });
+ },
+ updateJobSelection: (selectedJobIds: string[], restoredAppState: RestoredAppState) => {
+ explorerAction$.next(
+ jobSelectionActionCreator(
+ EXPLORER_ACTION.JOB_SELECTION_CHANGE,
+ selectedJobIds,
+ restoredAppState
+ )
+ );
+ },
+ initialize: (selectedJobIds: string[], restoredAppState: RestoredAppState) => {
+ explorerAction$.next(
+ jobSelectionActionCreator(EXPLORER_ACTION.INITIALIZE, selectedJobIds, restoredAppState)
+ );
+ },
+ reset: () => {
+ explorerAction$.next({ type: EXPLORER_ACTION.RESET });
+ },
+ setAppState: (payload: DeepPartial) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SET, payload });
+ },
+ setBounds: (payload: TimeRangeBounds) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.SET_BOUNDS, payload });
+ },
+ setCharts: (payload: ExplorerChartsData) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS, payload });
+ },
+ setInfluencerFilterSettings: (payload: any) => {
+ explorerAction$.next({
+ type: EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS,
+ payload,
+ });
+ },
+ setSelectedCells: (payload: SelectedCells) => {
+ explorerAction$.next({
+ type: EXPLORER_ACTION.SET_SELECTED_CELLS,
+ payload,
+ });
+ },
+ setState: (payload: DeepPartial) => {
+ explorerAction$.next(setStateActionCreator(payload));
+ },
+ setSwimlaneContainerWidth: (payload: number) => {
+ explorerAction$.next({
+ type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH,
+ payload,
+ });
+ },
+ setSwimlaneLimit: (payload: number) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_LIMIT, payload });
+ },
+ setViewBySwimlaneFieldName: (payload: string) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload });
+ },
+ setViewBySwimlaneLoading: (payload: any) => {
+ explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload });
+ },
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_directive.tsx b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_directive.tsx
new file mode 100644
index 0000000000000..b5d65fbf937e4
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_directive.tsx
@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 Anomaly Explorer's React component.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { Subscription } from 'rxjs';
+
+import { IRootElementService, IRootScopeService, IScope } from 'angular';
+
+// @ts-ignore
+import { uiModules } from 'ui/modules';
+const module = uiModules.get('apps/ml');
+
+import { I18nContext } from 'ui/i18n';
+import { State } from 'ui/state_management/state';
+import { AppState as IAppState, AppStateClass } from 'ui/state_management/app_state';
+
+import { jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils';
+
+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 { Explorer } from './explorer';
+import { explorerService } from './explorer_dashboard_service';
+import { getExplorerDefaultAppState, ExplorerAppState } from './reducers';
+
+interface ExplorerScope extends IScope {
+ appState: IAppState;
+}
+
+module.directive('mlAnomalyExplorer', function(
+ globalState: State,
+ $rootScope: IRootScopeService,
+ AppState: AppStateClass
+) {
+ function link($scope: ExplorerScope, element: IRootElementService) {
+ const subscriptions = new Subscription();
+
+ const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState);
+
+ ReactDOM.render(
+
+
+ ,
+ element[0]
+ );
+
+ // Initialize the AppState in which to store swimlane and filter settings.
+ // AppState is used to store state in the URL.
+ $scope.appState = new AppState(getExplorerDefaultAppState());
+ const { mlExplorerFilter, mlExplorerSwimlane } = $scope.appState;
+
+ // Pass the current URL AppState on to anomaly explorer's reactive state.
+ // After this hand-off, the appState stored in explorerState$ is the single
+ // source of truth.
+ explorerService.setAppState({ mlExplorerSwimlane, mlExplorerFilter });
+
+ // Now that appState in explorerState$ is the single source of truth,
+ // subscribe to it and update the actual URL appState on changes.
+ subscriptions.add(
+ explorerService.appState$.subscribe((appState: ExplorerAppState) => {
+ $scope.appState.fetch();
+ $scope.appState.mlExplorerFilter = appState.mlExplorerFilter;
+ $scope.appState.mlExplorerSwimlane = appState.mlExplorerSwimlane;
+ $scope.appState.save();
+ $scope.$applyAsync();
+ })
+ );
+
+ subscriptions.add(
+ subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () =>
+ $rootScope.$applyAsync()
+ )
+ );
+ subscriptions.add(
+ subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () =>
+ $rootScope.$applyAsync()
+ )
+ );
+ subscriptions.add(
+ subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () =>
+ $rootScope.$applyAsync()
+ )
+ );
+
+ element.on('$destroy', () => {
+ ReactDOM.unmountComponentAtNode(element[0]);
+ $scope.$destroy();
+ subscriptions.unsubscribe();
+ unsubscribeFromGlobalState();
+ });
+ }
+
+ return { link };
+});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_react_wrapper_directive.js
deleted file mode 100644
index 40213a0649667..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_react_wrapper_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.
- */
-
-/*
- * AngularJS directive wrapper for rendering Anomaly Explorer's React component.
- */
-
-import React from 'react';
-import ReactDOM from 'react-dom';
-
-import moment from 'moment-timezone';
-
-import { uiModules } from 'ui/modules';
-const module = uiModules.get('apps/ml');
-
-import { I18nContext } from 'ui/i18n';
-
-import { jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils';
-
-import { Explorer } from './explorer';
-import { EXPLORER_ACTION } from './explorer_constants';
-import { explorer$ } from './explorer_dashboard_service';
-
-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(
-
-
- ,
- element[0]
- );
-
- explorer$.next({ action: EXPLORER_ACTION.LOAD_JOBS });
-
- element.on('$destroy', () => {
- ReactDOM.unmountComponentAtNode(element[0]);
- scope.$destroy();
- unsubscribeFromGlobalState();
- });
- }
-
- return {
- scope: false,
- link,
- };
-});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_route.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_route.ts
new file mode 100644
index 0000000000000..a061176a5ef5b
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_route.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { checkGetJobsPrivilege } from '../privilege/check_privilege';
+import { mlJobService } from '../services/job_service';
+import { loadIndexPatterns } from '../util/index_utils';
+
+import { getAnomalyExplorerBreadcrumbs } from './breadcrumbs';
+
+uiRoutes.when('/explorer/?', {
+ template: ``,
+ k7Breadcrumbs: getAnomalyExplorerBreadcrumbs,
+ resolve: {
+ CheckLicense: checkFullLicense,
+ privileges: checkGetJobsPrivilege,
+ indexPatterns: loadIndexPatterns,
+ jobs: mlJobService.loadJobsWrapper,
+ },
+});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts
new file mode 100644
index 0000000000000..d7873e6d52d78
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts
@@ -0,0 +1,202 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Moment } from 'moment';
+
+import { CombinedJob } from '../jobs/new_job/common/job_creator/configs';
+
+import { TimeBucketsInterval } from '../util/time_buckets';
+
+interface ClearedSelectedAnomaliesState {
+ anomalyChartRecords: [];
+ selectedCells: null;
+ viewByLoadedForTimeFormatted: null;
+}
+
+export declare const getClearedSelectedAnomaliesState: () => ClearedSelectedAnomaliesState;
+
+export declare interface SwimlaneData {
+ fieldName: string;
+ laneLabels: string[];
+ points: any[];
+ interval: number;
+}
+
+export declare interface OverallSwimlaneData extends SwimlaneData {
+ earliest: number;
+ latest: number;
+}
+
+export declare const getDateFormatTz: () => any;
+
+export declare const getDefaultSwimlaneData: () => SwimlaneData;
+
+export declare const getInfluencers: (selectedJobs: any[]) => string[];
+
+export declare const getSelectionInfluencers: (
+ selectedCells: SelectedCells,
+ fieldName: string
+) => any[];
+
+interface SelectionTimeRange {
+ earliestMs: number;
+ latestMs: number;
+}
+
+export declare const getSelectionTimeRange: (
+ selectedCells: SelectedCells,
+ interval: number,
+ bounds: TimeRangeBounds
+) => SelectionTimeRange;
+
+export declare const getSwimlaneBucketInterval: (
+ selectedJobs: ExplorerJob[],
+ swimlaneContainerWidth: number
+) => any;
+
+interface ViewBySwimlaneOptionsArgs {
+ currentViewBySwimlaneFieldName: string | undefined;
+ filterActive: boolean;
+ filteredFields: any[];
+ isAndOperator: boolean;
+ selectedCells: SelectedCells;
+ selectedJobs: ExplorerJob[];
+}
+
+interface ViewBySwimlaneOptions {
+ viewBySwimlaneFieldName: string;
+ viewBySwimlaneOptions: string[];
+}
+
+export declare const getViewBySwimlaneOptions: (
+ arg: ViewBySwimlaneOptionsArgs
+) => ViewBySwimlaneOptions;
+
+export declare interface ExplorerJob {
+ id: string;
+ selected: boolean;
+ bucketSpanSeconds: number;
+}
+
+export declare const createJobs: (jobs: CombinedJob[]) => ExplorerJob[];
+
+export declare interface TimeRangeBounds {
+ min: Moment | undefined;
+ max: Moment | undefined;
+}
+
+declare interface SwimlaneBounds {
+ earliest: number;
+ latest: number;
+}
+
+export declare const loadAnnotationsTableData: (
+ selectedCells: SelectedCells,
+ selectedJobs: ExplorerJob[],
+ interval: number,
+ bounds: TimeRangeBounds
+) => Promise;
+
+export declare interface AnomaliesTableData {
+ anomalies: any[];
+ interval: number;
+ examplesByJobId: string[];
+ showViewSeriesLink: boolean;
+ jobIds: string[];
+}
+
+export declare const loadAnomaliesTableData: (
+ selectedCells: SelectedCells,
+ selectedJobs: ExplorerJob[],
+ dateFormatTz: any,
+ interval: number,
+ bounds: TimeRangeBounds,
+ fieldName: string,
+ tableInterval: string,
+ tableSeverity: number,
+ influencersFilterQuery: any
+) => Promise;
+
+export declare const loadDataForCharts: (
+ jobIds: string[],
+ earliestMs: number,
+ latestMs: number,
+ influencers: any[],
+ selectedCells: SelectedCells,
+ influencersFilterQuery: any
+) => Promise;
+
+export declare const loadFilteredTopInfluencers: (
+ jobIds: string[],
+ earliestMs: number,
+ latestMs: number,
+ records: any[],
+ influencers: any[],
+ noInfluencersConfigured: boolean,
+ influencersFilterQuery: any
+) => Promise;
+
+export declare const loadTopInfluencers: (
+ selectedJobIds: string[],
+ earliestMs: number,
+ latestMs: number,
+ influencers: any[],
+ noInfluencersConfigured?: boolean,
+ influencersFilterQuery?: any
+) => Promise;
+
+declare interface LoadOverallDataResponse {
+ loading: boolean;
+ overallSwimlaneData: OverallSwimlaneData;
+}
+
+export declare const loadOverallData: (
+ selectedJobs: ExplorerJob[],
+ interval: TimeBucketsInterval,
+ bounds: TimeRangeBounds
+) => Promise;
+
+export declare const loadViewBySwimlane: (
+ fieldValues: string[],
+ bounds: SwimlaneBounds,
+ selectedJobs: ExplorerJob[],
+ viewBySwimlaneFieldName: string,
+ swimlaneLimit: number,
+ influencersFilterQuery: any,
+ noInfluencersConfigured: boolean
+) => Promise;
+
+export declare const loadViewByTopFieldValuesForSelectedTime: (
+ earliestMs: number,
+ latestMs: number,
+ selectedJobs: ExplorerJob[],
+ viewBySwimlaneFieldName: string,
+ swimlaneLimit: number,
+ noInfluencersConfigured: boolean
+) => Promise;
+
+declare interface FilterData {
+ influencersFilterQuery: any;
+ filterActive: boolean;
+ filteredFields: string[];
+ queryString: string;
+}
+
+declare interface SelectedCells {
+ type: string;
+ lanes: string[];
+ times: number[];
+ showTopFieldValues: boolean;
+ viewByFieldName: string;
+}
+
+export declare interface RestoredAppState {
+ selectedCells?: SelectedCells;
+ filterData: {} | FilterData;
+ viewBySwimlaneFieldName: string;
+}
+
+export declare const restoreAppState: (appState: any) => RestoredAppState;
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js
index 5ca8681d16749..38b088eed9b81 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js
@@ -9,13 +9,25 @@
*/
import { chain, each, get, union, uniq } from 'lodash';
+import moment from 'moment-timezone';
+import { i18n } from '@kbn/i18n';
+import chrome from 'ui/chrome';
+
+import { npStart } from 'ui/new_platform';
+import { timefilter } from 'ui/timefilter';
+
+import {
+ ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
+ ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
+} from '../../../common/constants/search';
import { getEntityFieldList } from '../../../common/util/anomaly_utils';
import { isSourceDataChartableForDetector, isModelPlotEnabled } from '../../../common/util/job_utils';
import { parseInterval } from '../../../common/util/parse_interval';
import { ml } from '../services/ml_api_service';
import { mlJobService } from '../services/job_service';
import { mlResultsService } from '../services/results_service';
+import { getBoundsRoundedToInterval, TimeBuckets } from '../util/time_buckets';
import {
MAX_CATEGORY_EXAMPLES,
@@ -23,13 +35,8 @@ import {
SWIMLANE_TYPE,
VIEW_BY_JOB_LABEL,
} from './explorer_constants';
-import {
- ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
- ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
-} from '../../../common/constants/search';
+import { getSwimlaneContainerWidth } from './legacy_utils';
-import { i18n } from '@kbn/i18n';
-import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
// create new job objects based on standard job config objects
@@ -49,7 +56,7 @@ export function getClearedSelectedAnomaliesState() {
};
}
-export function getDefaultViewBySwimlaneData() {
+export function getDefaultSwimlaneData() {
return {
fieldName: '',
laneLabels: [],
@@ -58,7 +65,7 @@ export function getDefaultViewBySwimlaneData() {
};
}
-export async function getFilteredTopInfluencers(
+export async function loadFilteredTopInfluencers(
jobIds,
earliestMs,
latestMs,
@@ -131,6 +138,14 @@ export function getInfluencers(selectedJobs = []) {
return influencers;
}
+export function getDateFormatTz() {
+ const config = npStart.core.uiSettings;
+ // 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();
+ return dateFormatTz;
+}
+
export function getFieldsByJob() {
return mlJobService.jobs.reduce((reducedFieldsByJob, job) => {
// Add the list of distinct by, over, partition and influencer fields for each job.
@@ -193,9 +208,89 @@ export function getSelectionInfluencers(selectedCells, fieldName) {
return [];
}
-// Obtain the list of 'View by' fields per job and swimlaneViewByFieldName
+export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth) {
+ // Bucketing interval should be the maximum of the chart related interval (i.e. time range related)
+ // and the max bucket span for the jobs shown in the chart.
+ const bounds = timefilter.getActiveBounds();
+ const buckets = new TimeBuckets();
+ buckets.setInterval('auto');
+ buckets.setBounds(bounds);
+
+ const intervalSeconds = buckets.getInterval().asSeconds();
+
+ // if the swimlane cell widths are too small they will not be visible
+ // calculate how many buckets will be drawn before the swimlanes are actually rendered
+ // and increase the interval to widen the cells if they're going to be smaller than 8px
+ // this has to be done at this stage so all searches use the same interval
+ const timerangeSeconds = (bounds.max.valueOf() - bounds.min.valueOf()) / 1000;
+ const numBuckets = parseInt(timerangeSeconds / intervalSeconds);
+ const cellWidth = Math.floor(swimlaneContainerWidth / numBuckets * 100) / 100;
+
+ // if the cell width is going to be less than 8px, double the interval
+ if (cellWidth < 8) {
+ buckets.setInterval((intervalSeconds * 2) + 's');
+ }
+
+ const maxBucketSpanSeconds = selectedJobs.reduce((memo, job) => Math.max(memo, job.bucketSpanSeconds), 0);
+ if (maxBucketSpanSeconds > intervalSeconds) {
+ buckets.setInterval(maxBucketSpanSeconds + 's');
+ buckets.setBounds(bounds);
+ }
+
+ return buckets.getInterval();
+}
+
+export function loadViewByTopFieldValuesForSelectedTime(
+ earliestMs,
+ latestMs,
+ selectedJobs,
+ viewBySwimlaneFieldName,
+ swimlaneLimit,
+ noInfluencersConfigured
+) {
+ const selectedJobIds = selectedJobs.map(d => d.id);
+
+ // Find the top field values for the selected time, and then load the 'view by'
+ // swimlane over the full time range for those specific field values.
+ return new Promise((resolve) => {
+ if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
+ mlResultsService.getTopInfluencers(
+ selectedJobIds,
+ earliestMs,
+ latestMs,
+ swimlaneLimit
+ ).then((resp) => {
+ if (resp.influencers[viewBySwimlaneFieldName] === undefined) {
+ resolve([]);
+ }
+
+ const topFieldValues = [];
+ const topInfluencers = resp.influencers[viewBySwimlaneFieldName];
+ topInfluencers.forEach((influencerData) => {
+ if (influencerData.maxAnomalyScore > 0) {
+ topFieldValues.push(influencerData.influencerFieldValue);
+ }
+ });
+ resolve(topFieldValues);
+ });
+ } else {
+ mlResultsService.getScoresByBucket(
+ selectedJobIds,
+ earliestMs,
+ latestMs,
+ getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)).asSeconds() + 's',
+ swimlaneLimit
+ ).then((resp) => {
+ const topFieldValues = Object.keys(resp.results);
+ resolve(topFieldValues);
+ });
+ }
+ });
+}
+
+// Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName
export function getViewBySwimlaneOptions({
- currentSwimlaneViewByFieldName,
+ currentViewBySwimlaneFieldName,
filterActive,
filteredFields,
isAndOperator,
@@ -219,20 +314,20 @@ export function getViewBySwimlaneOptions({
viewByOptions.push(VIEW_BY_JOB_LABEL);
let viewBySwimlaneOptions = viewByOptions;
- let swimlaneViewByFieldName = undefined;
+ let viewBySwimlaneFieldName = undefined;
if (
- viewBySwimlaneOptions.indexOf(currentSwimlaneViewByFieldName) !== -1
+ viewBySwimlaneOptions.indexOf(currentViewBySwimlaneFieldName) !== -1
) {
// Set the swimlane viewBy to that stored in the state (URL) if set.
// This means we reset it to the current state because it was set by the listener
// on initialization.
- swimlaneViewByFieldName = currentSwimlaneViewByFieldName;
+ viewBySwimlaneFieldName = currentViewBySwimlaneFieldName;
} else {
if (selectedJobIds.length > 1) {
// If more than one job selected, default to job ID.
- swimlaneViewByFieldName = VIEW_BY_JOB_LABEL;
- } else {
+ viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL;
+ } else if (mlJobService.jobs.length > 0) {
// For a single job, default to the first partition, over,
// by or influencer field of the first selected job.
const firstSelectedJob = mlJobService.jobs.find((job) => {
@@ -245,7 +340,7 @@ export function getViewBySwimlaneOptions({
detector.partition_field_name !== undefined &&
firstJobInfluencers.indexOf(detector.partition_field_name) !== -1
) {
- swimlaneViewByFieldName = detector.partition_field_name;
+ viewBySwimlaneFieldName = detector.partition_field_name;
return false;
}
@@ -253,7 +348,7 @@ export function getViewBySwimlaneOptions({
detector.over_field_name !== undefined &&
firstJobInfluencers.indexOf(detector.over_field_name) !== -1
) {
- swimlaneViewByFieldName = detector.over_field_name;
+ viewBySwimlaneFieldName = detector.over_field_name;
return false;
}
@@ -265,17 +360,17 @@ export function getViewBySwimlaneOptions({
detector.over_field_name === undefined &&
firstJobInfluencers.indexOf(detector.by_field_name) !== -1
) {
- swimlaneViewByFieldName = detector.by_field_name;
+ viewBySwimlaneFieldName = detector.by_field_name;
return false;
}
});
- if (swimlaneViewByFieldName === undefined) {
+ if (viewBySwimlaneFieldName === undefined) {
if (firstJobInfluencers.length > 0) {
- swimlaneViewByFieldName = firstJobInfluencers[0];
+ viewBySwimlaneFieldName = firstJobInfluencers[0];
} else {
// No influencers for first selected job - set to first available option.
- swimlaneViewByFieldName = viewBySwimlaneOptions.length > 0
+ viewBySwimlaneFieldName = viewBySwimlaneOptions.length > 0
? viewBySwimlaneOptions[0]
: undefined;
}
@@ -301,7 +396,7 @@ export function getViewBySwimlaneOptions({
}
return {
- swimlaneViewByFieldName,
+ viewBySwimlaneFieldName,
viewBySwimlaneOptions,
};
}
@@ -339,8 +434,8 @@ export function processOverallResults(scoresByTime, searchBounds, interval) {
export function processViewByResults(
scoresByInfluencerAndTime,
sortedLaneValues,
- overallSwimlaneData,
- swimlaneViewByFieldName,
+ bounds,
+ viewBySwimlaneFieldName,
interval,
) {
// Processes the scores for the 'view by' swimlane.
@@ -348,14 +443,14 @@ export function processViewByResults(
// values in the order in which they should be displayed,
// or pass an empty array to sort lanes according to max score over all time.
const dataset = {
- fieldName: swimlaneViewByFieldName,
+ fieldName: viewBySwimlaneFieldName,
points: [],
interval
};
// Set the earliest and latest to be the same as the overall swimlane.
- dataset.earliest = overallSwimlaneData.earliest;
- dataset.latest = overallSwimlaneData.latest;
+ dataset.earliest = bounds.earliest;
+ dataset.latest = bounds.latest;
const laneLabels = [];
const maxScoreByLaneLabel = {};
@@ -548,7 +643,7 @@ export async function loadDataForCharts(jobIds, earliestMs, latestMs, influencer
.then((resp) => {
// Ignore this response if it's returned by an out of date promise
if (newRequestCount < requestCount) {
- resolve(undefined);
+ resolve([]);
}
if ((selectedCells !== null && Object.keys(selectedCells).length > 0) ||
@@ -557,11 +652,147 @@ export async function loadDataForCharts(jobIds, earliestMs, latestMs, influencer
resolve(resp.records);
}
- resolve(undefined);
+ resolve([]);
});
});
}
+export function loadOverallData(selectedJobs, interval, bounds) {
+ return new Promise((resolve) => {
+ // Loads the overall data components i.e. the overall swimlane and influencers list.
+ if (selectedJobs === null) {
+ resolve({
+ loading: false,
+ hasResuts: false
+ });
+ return;
+ }
+
+ // Ensure the search bounds align to the bucketing interval used in the swimlane so
+ // that the first and last buckets are complete.
+ const searchBounds = getBoundsRoundedToInterval(
+ bounds,
+ interval,
+ false
+ );
+ const selectedJobIds = selectedJobs.map(d => d.id);
+
+ // Load the overall bucket scores by time.
+ // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
+ // which wouldn't be the case if e.g. '1M' was used.
+ // Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works
+ // to ensure the search is inclusive of end time.
+ const overallBucketsBounds = getBoundsRoundedToInterval(
+ bounds,
+ interval,
+ true
+ );
+ mlResultsService.getOverallBucketScores(
+ selectedJobIds,
+ // Note there is an optimization for when top_n == 1.
+ // If top_n > 1, we should test what happens when the request takes long
+ // and refactor the loading calls, if necessary, to avoid delays in loading other components.
+ 1,
+ overallBucketsBounds.min.valueOf(),
+ overallBucketsBounds.max.valueOf(),
+ interval.asSeconds() + 's'
+ ).then((resp) => {
+ const overallSwimlaneData = processOverallResults(
+ resp.results,
+ searchBounds,
+ interval.asSeconds(),
+ );
+
+ console.log('Explorer overall swimlane data set:', overallSwimlaneData);
+ resolve({
+ loading: false,
+ overallSwimlaneData,
+ });
+ });
+ });
+}
+
+export function loadViewBySwimlane(
+ fieldValues,
+ bounds,
+ selectedJobs,
+ viewBySwimlaneFieldName,
+ swimlaneLimit,
+ influencersFilterQuery,
+ noInfluencersConfigured
+) {
+
+ return new Promise((resolve) => {
+
+ const finish = (resp) => {
+ if (resp !== undefined) {
+ const viewBySwimlaneData = processViewByResults(
+ resp.results,
+ fieldValues,
+ bounds,
+ viewBySwimlaneFieldName,
+ getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)).asSeconds(),
+ );
+ console.log('Explorer view by swimlane data set:', viewBySwimlaneData);
+
+ resolve({
+ viewBySwimlaneData,
+ viewBySwimlaneDataLoading: false
+ });
+ } else {
+ resolve({ viewBySwimlaneDataLoading: false });
+ }
+ };
+
+ if (
+ selectedJobs === undefined ||
+ viewBySwimlaneFieldName === undefined
+ ) {
+ finish();
+ return;
+ } else {
+ // Ensure the search bounds align to the bucketing interval used in the swimlane so
+ // that the first and last buckets are complete.
+ const timefilterBounds = timefilter.getActiveBounds();
+ const searchBounds = getBoundsRoundedToInterval(
+ timefilterBounds,
+ getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)),
+ false,
+ );
+ const selectedJobIds = selectedJobs.map(d => d.id);
+
+ // load scores by influencer/jobId value and time.
+ // Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
+ // which wouldn't be the case if e.g. '1M' was used.
+ const interval = `${getSwimlaneBucketInterval(
+ selectedJobs,
+ getSwimlaneContainerWidth(noInfluencersConfigured)
+ ).asSeconds()}s`;
+ if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
+ mlResultsService.getInfluencerValueMaxScoreByTime(
+ selectedJobIds,
+ viewBySwimlaneFieldName,
+ fieldValues,
+ searchBounds.min.valueOf(),
+ searchBounds.max.valueOf(),
+ interval,
+ swimlaneLimit,
+ influencersFilterQuery
+ ).then(finish);
+ } else {
+ const jobIds = (fieldValues !== undefined && fieldValues.length > 0) ? fieldValues : selectedJobIds;
+ mlResultsService.getScoresByBucket(
+ jobIds,
+ searchBounds.min.valueOf(),
+ searchBounds.max.valueOf(),
+ interval,
+ swimlaneLimit
+ ).then(finish);
+ }
+ }
+ });
+}
+
export async function loadTopInfluencers(
selectedJobIds,
earliestMs,
@@ -589,3 +820,32 @@ export async function loadTopInfluencers(
}
});
}
+
+export function restoreAppState(appState) {
+ // Select any jobs set in the global state (i.e. passed in the URL).
+ let selectedCells;
+ let filterData = {};
+
+ // keep swimlane selection, restore selectedCells from AppState
+ if (appState.mlExplorerSwimlane.selectedType !== undefined) {
+ selectedCells = {
+ type: appState.mlExplorerSwimlane.selectedType,
+ lanes: appState.mlExplorerSwimlane.selectedLanes,
+ times: appState.mlExplorerSwimlane.selectedTimes,
+ showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues,
+ viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName,
+ };
+ }
+
+ // keep influencers filter selection, restore from AppState
+ if (appState.mlExplorerFilter.influencersFilterQuery !== undefined) {
+ filterData = {
+ influencersFilterQuery: appState.mlExplorerFilter.influencersFilterQuery,
+ filterActive: appState.mlExplorerFilter.filterActive,
+ filteredFields: appState.mlExplorerFilter.filteredFields,
+ queryString: appState.mlExplorerFilter.queryString,
+ };
+ }
+
+ return { filterData, selectedCells, viewBySwimlaneFieldName: appState.mlExplorerSwimlane.viewByFieldName };
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/index.js b/x-pack/legacy/plugins/ml/public/application/explorer/index.ts
similarity index 80%
rename from x-pack/legacy/plugins/ml/public/application/explorer/index.js
rename to x-pack/legacy/plugins/ml/public/application/explorer/index.ts
index ebd3eb9c12662..edc25565daa9f 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/index.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/index.ts
@@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-
-
-import '../explorer/explorer_controller';
import '../explorer/explorer_dashboard_service';
-import '../explorer/explorer_react_wrapper_directive';
+import '../explorer/explorer_directive';
+import '../explorer/explorer_route';
import '../explorer/explorer_charts';
import '../explorer/select_limit';
import '../components/job_selector';
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.js b/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.js
deleted file mode 100644
index 62feabdf1e141..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.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.
- */
-
-// This file includes utils which should eventuelly become obsolete once Anomaly Explorer
-// is fully migrated to React. Their purpose is to retain functionality while we migrate step by step.
-
-export function getChartContainerWidth() {
- const chartContainer = document.querySelector('.explorer-charts');
- return Math.floor(chartContainer && chartContainer.clientWidth || 0);
-}
-
-export function getSwimlaneContainerWidth(noInfluencersConfigured = true) {
- const explorerContainer = document.querySelector('.ml-explorer');
- const explorerContainerWidth = explorerContainer && explorerContainer.clientWidth || 0;
- if (noInfluencersConfigured === true) {
- // swimlane is full width, minus 30 for the 'no influencers' info icon,
- // minus 170 for the lane labels, minus 50 padding
- return explorerContainerWidth - 250;
- } else {
- // swimlane width is 5 sixths of the window,
- // minus 170 for the lane labels, minus 50 padding
- return ((explorerContainerWidth / 6) * 5) - 220;
- }
-}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.ts b/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.ts
new file mode 100644
index 0000000000000..3b92ee3fa37f6
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/legacy_utils.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+// This file includes utils which should eventuelly become obsolete once Anomaly Explorer
+// is fully migrated to React. Their purpose is to retain functionality while we migrate step by step.
+
+export function getChartContainerWidth() {
+ const chartContainer = document.querySelector('.explorer-charts');
+ return Math.floor((chartContainer && chartContainer.clientWidth) || 0);
+}
+
+export function getSwimlaneContainerWidth() {
+ const explorerContainer = document.querySelector('.ml-explorer');
+ return (explorerContainer && explorerContainer.clientWidth) || 0;
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts
new file mode 100644
index 0000000000000..66e00a41a3f31
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { cloneDeep } from 'lodash';
+
+import { EXPLORER_ACTION } from '../explorer_constants';
+import { Action } from '../explorer_dashboard_service';
+
+export interface ExplorerAppState {
+ mlExplorerSwimlane: {
+ selectedType?: string;
+ selectedLanes?: string[];
+ selectedTimes?: number[];
+ showTopFieldValues?: boolean;
+ viewByFieldName?: string;
+ };
+ mlExplorerFilter: {
+ influencersFilterQuery?: unknown;
+ filterActive?: boolean;
+ filteredFields?: string[];
+ queryString?: string;
+ };
+}
+
+export function getExplorerDefaultAppState(): ExplorerAppState {
+ return {
+ mlExplorerSwimlane: {},
+ mlExplorerFilter: {},
+ };
+}
+
+export const appStateReducer = (state: ExplorerAppState, nextAction: Action) => {
+ const { type, payload } = nextAction;
+
+ const appState = cloneDeep(state);
+
+ if (appState.mlExplorerSwimlane === undefined) {
+ appState.mlExplorerSwimlane = {};
+ }
+ if (appState.mlExplorerFilter === undefined) {
+ appState.mlExplorerFilter = {};
+ }
+
+ switch (type) {
+ case EXPLORER_ACTION.APP_STATE_SET:
+ return { ...appState, ...payload };
+
+ case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION:
+ delete appState.mlExplorerSwimlane.selectedType;
+ delete appState.mlExplorerSwimlane.selectedLanes;
+ delete appState.mlExplorerSwimlane.selectedTimes;
+ delete appState.mlExplorerSwimlane.showTopFieldValues;
+ break;
+
+ case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION:
+ const swimlaneSelectedCells = payload;
+ appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
+ appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
+ appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
+ appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
+ appState.mlExplorerSwimlane.viewByFieldName = swimlaneSelectedCells.viewByFieldName;
+ break;
+
+ case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME:
+ appState.mlExplorerSwimlane.viewByFieldName = payload.viewBySwimlaneFieldName;
+ break;
+
+ case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS:
+ appState.mlExplorerFilter.influencersFilterQuery = payload.influencersFilterQuery;
+ appState.mlExplorerFilter.filterActive = payload.filterActive;
+ appState.mlExplorerFilter.filteredFields = payload.filteredFields;
+ appState.mlExplorerFilter.queryString = payload.queryString;
+ break;
+
+ case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS:
+ delete appState.mlExplorerFilter.influencersFilterQuery;
+ delete appState.mlExplorerFilter.filterActive;
+ delete appState.mlExplorerFilter.filteredFields;
+ delete appState.mlExplorerFilter.queryString;
+ break;
+
+ default:
+ }
+
+ return appState;
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts
new file mode 100644
index 0000000000000..28f04bf65634a
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { EXPLORER_ACTION, SWIMLANE_TYPE } from '../../explorer_constants';
+import { getClearedSelectedAnomaliesState } from '../../explorer_utils';
+
+import { appStateReducer } from '../app_state_reducer';
+
+import { ExplorerState } from './state';
+
+interface SwimlanePoint {
+ laneLabel: string;
+ time: number;
+}
+
+// do a sanity check against selectedCells. It can happen that a previously
+// selected lane loaded via URL/AppState is not available anymore.
+// If filter is active - selectedCell may not be available due to swimlane view by change to filter fieldName
+// Ok to keep cellSelection in this case
+export const checkSelectedCells = (state: ExplorerState) => {
+ const { filterActive, selectedCells, viewBySwimlaneData, viewBySwimlaneDataLoading } = state;
+
+ if (viewBySwimlaneDataLoading) {
+ return {};
+ }
+
+ let clearSelection = false;
+ if (
+ selectedCells !== null &&
+ selectedCells.type === SWIMLANE_TYPE.VIEW_BY &&
+ viewBySwimlaneData !== undefined &&
+ viewBySwimlaneData.points !== undefined
+ ) {
+ clearSelection =
+ filterActive === false &&
+ !selectedCells.lanes.some((lane: string) => {
+ return viewBySwimlaneData.points.some((point: SwimlanePoint) => {
+ return (
+ point.laneLabel === lane &&
+ point.time >= selectedCells.times[0] &&
+ point.time <= selectedCells.times[1]
+ );
+ });
+ });
+ }
+
+ if (clearSelection === true) {
+ return {
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ ...getClearedSelectedAnomaliesState(),
+ };
+ }
+
+ return {};
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts
new file mode 100644
index 0000000000000..29c077a5cba43
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts
@@ -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.
+ */
+
+import { EXPLORER_ACTION } from '../../explorer_constants';
+import { getClearedSelectedAnomaliesState } from '../../explorer_utils';
+
+import { appStateReducer } from '../app_state_reducer';
+
+import { ExplorerState } from './state';
+
+export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState {
+ const appStateClearInfluencer = appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS,
+ });
+ const appStateClearSelection = appStateReducer(appStateClearInfluencer, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ });
+
+ return {
+ ...state,
+ appState: appStateClearSelection,
+ filterActive: false,
+ filteredFields: [],
+ influencersFilterQuery: undefined,
+ isAndOperator: false,
+ maskAll: false,
+ queryString: '',
+ tableQueryString: '',
+ ...getClearedSelectedAnomaliesState(),
+ };
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts
new file mode 100644
index 0000000000000..9b6c7e4fb99bc
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/get_index_pattern.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns';
+
+import { getInfluencers, ExplorerJob } from '../../explorer_utils';
+
+// Creates index pattern in the format expected by the kuery bar/kuery autocomplete provider
+// Field objects required fields: name, type, aggregatable, searchable
+export function getIndexPattern(selectedJobs: ExplorerJob[]) {
+ return {
+ title: ML_RESULTS_INDEX_PATTERN,
+ fields: getInfluencers(selectedJobs).map(influencer => ({
+ name: influencer,
+ type: 'string',
+ aggregatable: true,
+ searchable: true,
+ })),
+ };
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts
new file mode 100644
index 0000000000000..7f2281454a4ea
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { getIndexPattern } from './get_index_pattern';
+export { explorerReducer } from './reducer';
+export { getExplorerDefaultState, ExplorerState } from './state';
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts
new file mode 100644
index 0000000000000..8536c8f3e542e
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ActionPayload } from '../../explorer_dashboard_service';
+import { getInfluencers } from '../../explorer_utils';
+
+import { getIndexPattern } from './get_index_pattern';
+import { ExplorerState } from './state';
+
+export const initialize = (state: ExplorerState, payload: ActionPayload): ExplorerState => {
+ const { selectedCells, selectedJobs, viewBySwimlaneFieldName, filterData } = payload;
+ let currentSelectedCells = state.selectedCells;
+ let currentviewBySwimlaneFieldName = state.viewBySwimlaneFieldName;
+
+ if (viewBySwimlaneFieldName !== undefined) {
+ currentviewBySwimlaneFieldName = viewBySwimlaneFieldName;
+ }
+
+ if (selectedCells !== undefined && currentSelectedCells === null) {
+ currentSelectedCells = selectedCells;
+ }
+
+ return {
+ ...state,
+ indexPattern: getIndexPattern(selectedJobs),
+ noInfluencersConfigured: getInfluencers(selectedJobs).length === 0,
+ selectedCells: currentSelectedCells,
+ selectedJobs,
+ viewBySwimlaneFieldName: currentviewBySwimlaneFieldName,
+ ...(filterData.influencersFilterQuery !== undefined ? { ...filterData } : {}),
+ };
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts
new file mode 100644
index 0000000000000..9fe8ebbb2c481
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants';
+import { ActionPayload } from '../../explorer_dashboard_service';
+import {
+ getClearedSelectedAnomaliesState,
+ getDefaultSwimlaneData,
+ getInfluencers,
+} from '../../explorer_utils';
+
+import { appStateReducer } from '../app_state_reducer';
+
+import { getIndexPattern } from './get_index_pattern';
+import { getExplorerDefaultState, ExplorerState } from './state';
+
+export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload): ExplorerState => {
+ const { selectedJobs } = payload;
+ const stateUpdate: ExplorerState = {
+ ...state,
+ appState: appStateReducer(getExplorerDefaultState().appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ ...getClearedSelectedAnomaliesState(),
+ noInfluencersConfigured: getInfluencers(selectedJobs).length === 0,
+ overallSwimlaneData: getDefaultSwimlaneData(),
+ selectedJobs,
+ };
+
+ // clear filter if selected jobs have no influencers
+ if (stateUpdate.noInfluencersConfigured === true) {
+ stateUpdate.appState = appStateReducer(stateUpdate.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS,
+ });
+ const noFilterState = {
+ filterActive: false,
+ filteredFields: [],
+ influencersFilterQuery: undefined,
+ maskAll: false,
+ queryString: '',
+ tableQueryString: '',
+ };
+
+ Object.assign(stateUpdate, noFilterState);
+ } else {
+ // indexPattern will not be used if there are no influencers so set up can be skipped
+ // indexPattern is passed to KqlFilterBar which is only shown if (noInfluencersConfigured === false)
+ stateUpdate.indexPattern = getIndexPattern(selectedJobs);
+ }
+
+ if (selectedJobs.length > 1) {
+ stateUpdate.viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL;
+ return stateUpdate;
+ }
+
+ stateUpdate.loading = true;
+ return stateUpdate;
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
new file mode 100644
index 0000000000000..1919ce949683f
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts
@@ -0,0 +1,249 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { formatHumanReadableDateTime } from '../../../util/date_utils';
+
+import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service';
+import { EXPLORER_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../../explorer_constants';
+import { Action } from '../../explorer_dashboard_service';
+import {
+ getClearedSelectedAnomaliesState,
+ getDefaultSwimlaneData,
+ getSelectionTimeRange,
+ getSwimlaneBucketInterval,
+ getViewBySwimlaneOptions,
+} from '../../explorer_utils';
+import { appStateReducer } from '../app_state_reducer';
+
+import { checkSelectedCells } from './check_selected_cells';
+import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings';
+import { initialize } from './initialize';
+import { jobSelectionChange } from './job_selection_change';
+import { getExplorerDefaultState, ExplorerState } from './state';
+import { setInfluencerFilterSettings } from './set_influencer_filter_settings';
+import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder';
+
+export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => {
+ const { type, payload } = nextAction;
+
+ let nextState;
+
+ switch (type) {
+ case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS:
+ nextState = clearInfluencerFilterSettings(state);
+ break;
+
+ case EXPLORER_ACTION.CLEAR_JOBS:
+ nextState = {
+ ...state,
+ ...getClearedSelectedAnomaliesState(),
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ loading: false,
+ selectedJobs: [],
+ };
+ break;
+
+ case EXPLORER_ACTION.CLEAR_SELECTION:
+ nextState = {
+ ...state,
+ ...getClearedSelectedAnomaliesState(),
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ };
+ break;
+
+ case EXPLORER_ACTION.INITIALIZE:
+ nextState = initialize(state, payload);
+ break;
+
+ case EXPLORER_ACTION.JOB_SELECTION_CHANGE:
+ nextState = jobSelectionChange(state, payload);
+ break;
+
+ case EXPLORER_ACTION.APP_STATE_SET:
+ case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION:
+ case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION:
+ case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME:
+ case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS:
+ case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS:
+ nextState = { ...state, appState: appStateReducer(state.appState, nextAction) };
+ break;
+
+ case EXPLORER_ACTION.RESET:
+ nextState = getExplorerDefaultState();
+ break;
+
+ case EXPLORER_ACTION.SET_BOUNDS:
+ nextState = { ...state, bounds: payload };
+ break;
+
+ case EXPLORER_ACTION.SET_CHARTS:
+ nextState = {
+ ...state,
+ chartsData: {
+ ...getDefaultChartsData(),
+ chartsPerRow: payload.chartsPerRow,
+ seriesToPlot: payload.seriesToPlot,
+ // convert truthy/falsy value to Boolean
+ tooManyBuckets: !!payload.tooManyBuckets,
+ },
+ };
+ break;
+
+ case EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS:
+ nextState = setInfluencerFilterSettings(state, payload);
+ break;
+
+ case EXPLORER_ACTION.SET_SELECTED_CELLS:
+ const selectedCells = payload;
+ selectedCells.showTopFieldValues = false;
+
+ const currentSwimlaneType = state.selectedCells?.type;
+ const currentShowTopFieldValues = state.selectedCells?.showTopFieldValues;
+ const newSwimlaneType = selectedCells?.type;
+
+ if (
+ (currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
+ newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
+ newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
+ currentShowTopFieldValues === true
+ ) {
+ selectedCells.showTopFieldValues = true;
+ }
+
+ nextState = {
+ ...state,
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION,
+ payload,
+ }),
+ selectedCells,
+ };
+ break;
+
+ case EXPLORER_ACTION.SET_STATE:
+ if (payload.viewBySwimlaneFieldName) {
+ nextState = {
+ ...state,
+ ...payload,
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME,
+ payload: { viewBySwimlaneFieldName: payload.viewBySwimlaneFieldName },
+ }),
+ };
+ } else {
+ nextState = { ...state, ...payload };
+ }
+ break;
+
+ case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH:
+ if (state.noInfluencersConfigured === true) {
+ // swimlane is full width, minus 30 for the 'no influencers' info icon,
+ // minus 170 for the lane labels, minus 50 padding
+ nextState = { ...state, swimlaneContainerWidth: payload - 250 };
+ } else {
+ // swimlane width is 5 sixths of the window,
+ // minus 170 for the lane labels, minus 50 padding
+ nextState = { ...state, swimlaneContainerWidth: (payload / 6) * 5 - 220 };
+ }
+ break;
+
+ case EXPLORER_ACTION.SET_SWIMLANE_LIMIT:
+ nextState = {
+ ...state,
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ ...getClearedSelectedAnomaliesState(),
+ swimlaneLimit: payload,
+ };
+ break;
+
+ case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME:
+ const { filteredFields, influencersFilterQuery } = state;
+ const viewBySwimlaneFieldName = payload;
+
+ let maskAll = false;
+
+ if (influencersFilterQuery !== undefined) {
+ maskAll =
+ viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ||
+ filteredFields.includes(viewBySwimlaneFieldName) === false;
+ }
+
+ nextState = {
+ ...state,
+ ...getClearedSelectedAnomaliesState(),
+ appState: appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
+ }),
+ maskAll,
+ viewBySwimlaneFieldName,
+ };
+ break;
+
+ case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING:
+ const { annotationsData, overallState, tableData } = payload;
+ nextState = {
+ ...state,
+ annotationsData,
+ ...overallState,
+ tableData,
+ viewBySwimlaneData: {
+ ...getDefaultSwimlaneData(),
+ },
+ viewBySwimlaneDataLoading: true,
+ };
+ break;
+
+ default:
+ nextState = state;
+ }
+
+ if (nextState.selectedJobs === null || nextState.bounds === undefined) {
+ return nextState;
+ }
+
+ const swimlaneBucketInterval = getSwimlaneBucketInterval(
+ nextState.selectedJobs,
+ nextState.swimlaneContainerWidth
+ );
+
+ // Does a sanity check on the selected `viewBySwimlaneFieldName`
+ // and return the available `viewBySwimlaneOptions`.
+ const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({
+ currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName,
+ filterActive: nextState.filterActive,
+ filteredFields: nextState.filteredFields,
+ isAndOperator: nextState.isAndOperator,
+ selectedJobs: nextState.selectedJobs,
+ selectedCells: nextState.selectedCells,
+ });
+
+ const { bounds, selectedCells } = nextState;
+
+ const timerange = getSelectionTimeRange(
+ selectedCells,
+ swimlaneBucketInterval.asSeconds(),
+ bounds
+ );
+
+ return {
+ ...nextState,
+ swimlaneBucketInterval,
+ viewByLoadedForTimeFormatted:
+ selectedCells !== null && selectedCells.showTopFieldValues === true
+ ? formatHumanReadableDateTime(timerange.earliestMs)
+ : null,
+ viewBySwimlaneFieldName,
+ viewBySwimlaneOptions,
+ ...checkSelectedCells(nextState),
+ ...setKqlQueryBarPlaceholder(nextState),
+ };
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts
new file mode 100644
index 0000000000000..76577ae557fe3
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants';
+import { ActionPayload } from '../../explorer_dashboard_service';
+
+import { appStateReducer } from '../app_state_reducer';
+
+import { ExplorerState } from './state';
+
+export function setInfluencerFilterSettings(
+ state: ExplorerState,
+ payload: ActionPayload
+): ExplorerState {
+ const {
+ filterQuery: influencersFilterQuery,
+ isAndOperator,
+ filteredFields,
+ queryString,
+ tableQueryString,
+ } = payload;
+
+ const { selectedCells, viewBySwimlaneOptions } = state;
+ let selectedViewByFieldName = state.viewBySwimlaneFieldName;
+
+ // if it's an AND filter set view by swimlane to job ID as the others will have no results
+ if (isAndOperator && selectedCells === null) {
+ selectedViewByFieldName = VIEW_BY_JOB_LABEL;
+ } else {
+ // Set View by dropdown to first relevant fieldName based on incoming filter if there's no cell selection already
+ // or if selected cell is from overall swimlane as this won't include an additional influencer filter
+ for (let i = 0; i < filteredFields.length; i++) {
+ if (
+ viewBySwimlaneOptions.includes(filteredFields[i]) &&
+ (selectedCells === null || (selectedCells && selectedCells.type === 'overall'))
+ ) {
+ selectedViewByFieldName = filteredFields[i];
+ break;
+ }
+ }
+ }
+
+ const appState = appStateReducer(state.appState, {
+ type: EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS,
+ payload: {
+ influencersFilterQuery,
+ filterActive: true,
+ filteredFields,
+ queryString,
+ tableQueryString,
+ isAndOperator,
+ },
+ });
+
+ return {
+ ...state,
+ appState,
+ filterActive: true,
+ filteredFields,
+ influencersFilterQuery,
+ isAndOperator,
+ queryString,
+ tableQueryString,
+ maskAll:
+ selectedViewByFieldName === VIEW_BY_JOB_LABEL ||
+ filteredFields.includes(selectedViewByFieldName) === false,
+ viewBySwimlaneFieldName: selectedViewByFieldName,
+ };
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts
new file mode 100644
index 0000000000000..f0f3767974fdb
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_kql_query_bar_placeholder.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+import { ExplorerState } from './state';
+
+// Set the KQL query bar placeholder value
+export const setKqlQueryBarPlaceholder = (state: ExplorerState) => {
+ const { influencers, noInfluencersConfigured } = state;
+
+ if (influencers !== undefined && !noInfluencersConfigured) {
+ for (const influencerName in influencers) {
+ if (influencers[influencerName][0] && influencers[influencerName][0].influencerFieldValue) {
+ return {
+ filterPlaceHolder: i18n.translate('xpack.ml.explorer.kueryBar.filterPlaceholder', {
+ defaultMessage: 'Filter by influencer fields… ({queryExample})',
+ values: {
+ queryExample: `${influencerName} : ${influencers[influencerName][0].influencerFieldValue}`,
+ },
+ }),
+ };
+ }
+ }
+ }
+
+ return {};
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts
new file mode 100644
index 0000000000000..ce37605c3a926
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns';
+import { Dictionary } from '../../../../../common/types/common';
+
+import {
+ getDefaultChartsData,
+ ExplorerChartsData,
+} from '../../explorer_charts/explorer_charts_container_service';
+import {
+ getDefaultSwimlaneData,
+ AnomaliesTableData,
+ ExplorerJob,
+ SwimlaneData,
+ TimeRangeBounds,
+} from '../../explorer_utils';
+
+import { getExplorerDefaultAppState, ExplorerAppState } from '../app_state_reducer';
+
+export interface ExplorerState {
+ annotationsData: any[];
+ anomalyChartRecords: any[];
+ appState: ExplorerAppState;
+ bounds: TimeRangeBounds | undefined;
+ chartsData: ExplorerChartsData;
+ fieldFormatsLoading: boolean;
+ filterActive: boolean;
+ filteredFields: any[];
+ filterPlaceHolder: any;
+ indexPattern: { title: string; fields: any[] };
+ influencersFilterQuery: any;
+ influencers: Dictionary;
+ isAndOperator: boolean;
+ loading: boolean;
+ maskAll: boolean;
+ noInfluencersConfigured: boolean;
+ overallSwimlaneData: SwimlaneData;
+ queryString: string;
+ selectedCells: any;
+ selectedJobs: ExplorerJob[] | null;
+ swimlaneBucketInterval: any;
+ swimlaneContainerWidth: number;
+ swimlaneLimit: number;
+ tableData: AnomaliesTableData;
+ tableInterval: string;
+ tableQueryString: string;
+ tableSeverity: number;
+ viewByLoadedForTimeFormatted: string | null;
+ viewBySwimlaneData: SwimlaneData;
+ viewBySwimlaneDataLoading: boolean;
+ viewBySwimlaneFieldName?: string;
+ viewBySwimlaneOptions: string[];
+}
+
+function getDefaultIndexPattern() {
+ return { title: ML_RESULTS_INDEX_PATTERN, fields: [] };
+}
+
+export function getExplorerDefaultState(): ExplorerState {
+ return {
+ annotationsData: [],
+ anomalyChartRecords: [],
+ appState: getExplorerDefaultAppState(),
+ bounds: undefined,
+ chartsData: getDefaultChartsData(),
+ fieldFormatsLoading: false,
+ filterActive: false,
+ filteredFields: [],
+ filterPlaceHolder: undefined,
+ indexPattern: getDefaultIndexPattern(),
+ influencersFilterQuery: undefined,
+ influencers: {},
+ isAndOperator: false,
+ loading: true,
+ maskAll: false,
+ noInfluencersConfigured: true,
+ overallSwimlaneData: getDefaultSwimlaneData(),
+ queryString: '',
+ selectedCells: null,
+ selectedJobs: null,
+ swimlaneBucketInterval: undefined,
+ swimlaneContainerWidth: 0,
+ swimlaneLimit: 10,
+ tableData: {
+ anomalies: [],
+ examplesByJobId: [''],
+ interval: 0,
+ jobIds: [],
+ showViewSeriesLink: false,
+ },
+ tableInterval: 'auto',
+ tableQueryString: '',
+ tableSeverity: 0,
+ viewByLoadedForTimeFormatted: null,
+ viewBySwimlaneData: getDefaultSwimlaneData(),
+ viewBySwimlaneDataLoading: false,
+ viewBySwimlaneFieldName: undefined,
+ viewBySwimlaneOptions: [],
+ };
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts
new file mode 100644
index 0000000000000..98cc07e8f9449
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts
@@ -0,0 +1,13 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { appStateReducer, getExplorerDefaultAppState, ExplorerAppState } from './app_state_reducer';
+export {
+ explorerReducer,
+ getExplorerDefaultState,
+ getIndexPattern,
+ ExplorerState,
+} from './explorer_reducer';
diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx
index eed9e46a47745..d74c3802c2ed2 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx
@@ -32,10 +32,10 @@ describe('annotations_service', () => {
annotationsRefresh$.subscribe(subscriber);
- expect(subscriber.mock.calls).toHaveLength(0);
+ expect(subscriber.mock.calls).toHaveLength(1);
annotationsRefresh$.next(true);
- expect(subscriber.mock.calls).toHaveLength(1);
+ expect(subscriber.mock.calls).toHaveLength(2);
});
});
diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx
index 051c6ab445102..6953232f0cc6c 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { BehaviorSubject, Subject } from 'rxjs';
+import { BehaviorSubject } from 'rxjs';
import { Annotation } from '../../../common/types/annotations';
@@ -74,4 +74,4 @@ export const annotation$ = new BehaviorSubject(null);
Instead of passing around callbacks or deeply nested props, it can be imported for both
angularjs controllers/directives and React components.
*/
-export const annotationsRefresh$ = new Subject();
+export const annotationsRefresh$ = new BehaviorSubject(false);
diff --git a/x-pack/legacy/plugins/ml/public/application/services/field_format_service.ts b/x-pack/legacy/plugins/ml/public/application/services/field_format_service.ts
index ce6bc7896c44c..a9ecf56c58ea7 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/field_format_service.ts
+++ b/x-pack/legacy/plugins/ml/public/application/services/field_format_service.ts
@@ -24,7 +24,7 @@ class FieldFormatService {
// configured in the datafeed of each job.
// Builds a map of Kibana FieldFormats (plugins/data/common/field_formats)
// against detector index by job ID.
- populateFormats(jobIds: string[]) {
+ populateFormats(jobIds: string[]): Promise {
return new Promise((resolve, reject) => {
// Populate a map of index pattern IDs against job ID, by finding the ID of the index
// pattern with a title attribute which matches the index configured in the datafeed.
diff --git a/x-pack/legacy/plugins/ml/public/application/services/job_service.d.ts b/x-pack/legacy/plugins/ml/public/application/services/job_service.d.ts
index 436d13589adcc..b1d3d338e22c4 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/job_service.d.ts
+++ b/x-pack/legacy/plugins/ml/public/application/services/job_service.d.ts
@@ -13,6 +13,7 @@ export interface ExistingJobsAndGroups {
}
declare interface JobService {
+ jobs: CombinedJob[];
createResultsUrlForJobs: (jobs: any[], target: string) => string;
tempJobCloningObjects: {
job: any;
@@ -35,6 +36,7 @@ declare interface JobService {
getJobAndGroupIds(): ExistingJobsAndGroups;
searchPreview(job: CombinedJob): Promise>;
getJob(jobId: string): CombinedJob;
+ loadJobsWrapper(): Promise;
}
export const mlJobService: JobService;
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
index 02e29c1117ffc..a70e1d38784e9 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
@@ -166,8 +166,8 @@ export class TimeSeriesExplorer extends React.Component {
constructor(props) {
super(props);
- const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(props.globalState);
- this.jobSelectService = jobSelectService;
+ const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(props.globalState);
+ this.jobSelectService$ = jobSelectService$;
this.unsubscribeFromGlobalState = unsubscribeFromGlobalState;
}
@@ -864,7 +864,7 @@ export class TimeSeriesExplorer extends React.Component {
}));
// Listen for changes to job selection.
- this.subscriptions.add(this.jobSelectService.subscribe(({ selection: selectedJobIds }) => {
+ this.subscriptions.add(this.jobSelectService$.subscribe(({ selection: selectedJobIds }) => {
const jobs = createTimeSeriesJobData(mlJobService.jobs);
this.contextChartSelectedInitCallDone = false;
@@ -903,7 +903,7 @@ export class TimeSeriesExplorer extends React.Component {
);
setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] });
- this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true });
+ this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true });
} else {
// if a group has been loaded
if (selectedJobIds.length > 0) {
@@ -915,12 +915,12 @@ export class TimeSeriesExplorer extends React.Component {
);
setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] });
- this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true });
+ 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 });
+ this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true });
} else {
// if there are no valid jobs left.
this.setState({ loading: false });
@@ -930,7 +930,7 @@ export class TimeSeriesExplorer extends React.Component {
// 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 });
+ 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) {
@@ -943,7 +943,7 @@ export class TimeSeriesExplorer extends React.Component {
// 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 });
+ this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true });
} else {
// Jobs exist, but no time series jobs.
this.setState({ loading: false });
@@ -1132,7 +1132,7 @@ export class TimeSeriesExplorer extends React.Component {
const jobSelectorProps = {
dateFormatTz,
globalState,
- jobSelectService: this.jobSelectService,
+ jobSelectService$: this.jobSelectService$,
selectedJobIds,
selectedGroups,
singleSelection: true,
diff --git a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts
new file mode 100644
index 0000000000000..454ea55210dcc
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { Observable } from 'rxjs';
+
+export const initializeAppState: (AppState: any, stateName: any, defaultState: any) => any;
+
+export const subscribeAppStateToObservable: (
+ AppState: any,
+ appStateName: string,
+ o$: Observable,
+ callback: (payload: any) => void
+) => any;
diff --git a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx
index 7f1fc366bc5bb..4b8027260ab9a 100644
--- a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx
@@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import { isEqual } from 'lodash';
import React, { Component, ComponentType } from 'react';
-
import { BehaviorSubject, Subscription } from 'rxjs';
+import { distinctUntilChanged } from 'rxjs/operators';
import { Dictionary } from '../../../common/types/common';
// Sets up a ObservableComponent which subscribes to given observable updates and
@@ -30,7 +31,9 @@ export function injectObservablesAsProps(
public componentDidMount() {
observableKeys.forEach(k => {
- this.subscriptions[k] = observables[k].subscribe(v => this.setState({ [k]: v }));
+ this.subscriptions[k] = observables[k]
+ .pipe(distinctUntilChanged(isEqual))
+ .subscribe(v => this.setState({ [k]: v }));
});
}
@@ -41,6 +44,17 @@ export function injectObservablesAsProps(
}
public render() {
+ // All injected observables are expected to provide initial state.
+ // If an observable has undefined as its current value, rendering
+ // the wrapped component will be skipped.
+ if (
+ Object.keys(this.state)
+ .map(k => this.state[k])
+ .some(v => v === undefined)
+ ) {
+ return null;
+ }
+
return (
{this.props.children}
diff --git a/x-pack/legacy/plugins/ml/public/application/util/time_buckets.d.ts b/x-pack/legacy/plugins/ml/public/application/util/time_buckets.d.ts
index 17773b66e7456..96a4653d0026a 100644
--- a/x-pack/legacy/plugins/ml/public/application/util/time_buckets.d.ts
+++ b/x-pack/legacy/plugins/ml/public/application/util/time_buckets.d.ts
@@ -6,19 +6,22 @@
import { Moment } from 'moment';
-declare interface TimeFilterBounds {
- min: Moment;
- max: Moment;
+declare interface TimeRangeBounds {
+ min: Moment | undefined;
+ max: Moment | undefined;
+}
+
+export declare interface TimeBucketsInterval {
+ asMilliseconds: () => number;
+ asSeconds: () => number;
+ expression: string;
}
export class TimeBuckets {
setBarTarget: (barTarget: number) => void;
setMaxBars: (maxBars: number) => void;
setInterval: (interval: string) => void;
- setBounds: (bounds: TimeFilterBounds) => void;
+ setBounds: (bounds: TimeRangeBounds) => void;
getBounds: () => { min: any; max: any };
- getInterval: () => {
- asMilliseconds: () => number;
- expression: string;
- };
+ getInterval: () => TimeBucketsInterval;
}
diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts b/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts
index b465392a50ae1..b40645799fb4b 100644
--- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts
+++ b/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { SavedObjectsClientContract, UiSettingsClientContract } from 'src/core/public';
+import { SavedObjectsClientContract, IUiSettingsClient } from 'src/core/public';
import {
IndexPattern as IndexPatternType,
IndexPatterns as IndexPatternsType,
@@ -73,7 +73,7 @@ export function loadCurrentSavedSearch(savedSearches: any, savedSearchId: SavedS
export function createSearchItems(
indexPattern: IndexPatternType | undefined,
savedSearch: any,
- config: UiSettingsClientContract
+ config: IUiSettingsClient
) {
// query is only used by the data visualizer as it needs
// a lucene query_string.
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 23ebdf0ad6aad..ad3fc5544bc3b 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -6931,8 +6931,8 @@
"xpack.ml.explorer.limitLabel": "制限",
"xpack.ml.explorer.loadingLabel": "読み込み中",
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。",
- "xpack.ml.explorer.noInfluencersFoundTitle": "{swimlaneViewByFieldName}影響因子が見つかりません",
- "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "指定されたフィルターの {swimlaneViewByFieldName} 影響因子が見つかりません",
+ "xpack.ml.explorer.noInfluencersFoundTitle": "{viewBySwimlaneFieldName}影響因子が見つかりません",
+ "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "指定されたフィルターの {viewBySwimlaneFieldName} 影響因子が見つかりません",
"xpack.ml.explorer.noJobsFoundLabel": "ジョブが見つかりません",
"xpack.ml.explorer.noResultsFoundLabel": "結果が見つかりませんでした",
"xpack.ml.explorer.overallLabel": "全体",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 28f6c2857d51d..14daaa7f78fad 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -6933,8 +6933,8 @@
"xpack.ml.explorer.limitLabel": "限制",
"xpack.ml.explorer.loadingLabel": "正在加载",
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "“顶级影响因素”列表被隐藏,因为没有为所选作业配置影响因素。",
- "xpack.ml.explorer.noInfluencersFoundTitle": "找不到 {swimlaneViewByFieldName} 影响因素",
- "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "找不到指定筛选的 {swimlaneViewByFieldName} 影响因素",
+ "xpack.ml.explorer.noInfluencersFoundTitle": "找不到 {viewBySwimlaneFieldName} 影响因素",
+ "xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "找不到指定筛选的 {viewBySwimlaneFieldName} 影响因素",
"xpack.ml.explorer.noJobsFoundLabel": "找不到作业",
"xpack.ml.explorer.noResultsFoundLabel": "找不到结果",
"xpack.ml.explorer.overallLabel": "总体",
diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts
index ef0f0451ee058..db08fc24a474a 100644
--- a/x-pack/test/api_integration/apis/features/features/features.ts
+++ b/x-pack/test/api_integration/apis/features/features/features.ts
@@ -115,6 +115,8 @@ export default function({ getService }: FtrProviderContext) {
'maps',
'uptime',
'siem',
+ 'alerting',
+ 'actions',
].sort()
);
});
diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts
index d4c8a3e68c50e..d6ad1608f3688 100644
--- a/x-pack/test/api_integration/apis/security/privileges.ts
+++ b/x-pack/test/api_integration/apis/security/privileges.ts
@@ -37,6 +37,8 @@ export default function({ getService }: FtrProviderContext) {
uptime: ['all', 'read'],
apm: ['all', 'read'],
siem: ['all', 'read'],
+ actions: ['all', 'read'],
+ alerting: ['all', 'read'],
},
global: ['all', 'read'],
space: ['all', 'read'],