- {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */}
-
-
- {noInfluencersConfigured === false && influencers !== undefined && (
-
-
-
- )}
-
- {noInfluencersConfigured && (
-
-
-
- )}
-
- {noInfluencersConfigured === false && (
-
-
-
-
-
-
- )}
+ sanitizedFieldName,
+ sanitizedFieldValue
+ );
+ }
+ }
-
-
-
-
+ try {
+ const queryValues = getKqlQueryValues(`${newQueryString}`, indexPattern);
+ this.applyInfluencersFilterQuery(queryValues);
+ } catch (e) {
+ console.log('Invalid kuery syntax', e); // eslint-disable-line no-console
+
+ toastNotifications.addDanger(
+ i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', {
+ defaultMessage:
+ 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)',
+ })
+ );
+ }
+ };
+
+ applyInfluencersFilterQuery = payload => {
+ const { filterQuery: influencersFilterQuery } = payload;
+
+ if (
+ influencersFilterQuery.match_all &&
+ Object.keys(influencersFilterQuery.match_all).length === 0
+ ) {
+ explorerService.clearInfluencerFilterSettings();
+ } else {
+ explorerService.setInfluencerFilterSettings(payload);
+ }
+ };
+
+ render() {
+ const { showCharts } = this.props;
+
+ const {
+ annotationsData,
+ chartsData,
+ filterActive,
+ filterPlaceHolder,
+ indexPattern,
+ influencers,
+ loading,
+ maskAll,
+ noInfluencersConfigured,
+ overallSwimlaneData,
+ queryString,
+ selectedCells,
+ selectedJobs,
+ severity,
+ swimlaneContainerWidth,
+ tableData,
+ tableQueryString,
+ viewByLoadedForTimeFormatted,
+ viewBySwimlaneData,
+ viewBySwimlaneDataLoading,
+ viewBySwimlaneFieldName,
+ viewBySwimlaneOptions,
+ } = this.props.explorerState;
+
+ const jobSelectorProps = {
+ dateFormatTz: getDateFormatTz(),
+ };
+
+ const noJobsFound = selectedJobs === null || selectedJobs.length === 0;
+ const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0;
+
+ if (loading === true) {
+ return (
+
+
+
+ );
+ }
-
- {showOverallSwimlane && (
-
- )}
-
+ if (noJobsFound) {
+ return (
+
+
+
+ );
+ }
- {viewBySwimlaneOptions.length > 0 && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {viewByLoadedForTimeFormatted && (
-
- )}
- {viewByLoadedForTimeFormatted === undefined && (
-
- )}
- {filterActive === true &&
- viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
-
- )}
-
-
-
-
-
- {showViewBySwimlane && (
- <>
-
-
-
-
- >
- )}
-
- {viewBySwimlaneDataLoading &&
}
-
- {!showViewBySwimlane &&
- !viewBySwimlaneDataLoading &&
- viewBySwimlaneFieldName !== null && (
-
- )}
- >
- )}
+ if (noJobsFound && hasResults === false) {
+ return (
+
+
+
+ );
+ }
- {annotationsData.length > 0 && (
- <>
-
-
-
-
-
-
- >
- )}
+ const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
+ const mainColumnClasses = `column ${mainColumnWidthClassName}`;
+
+ const showOverallSwimlane =
+ overallSwimlaneData !== null &&
+ overallSwimlaneData.laneLabels &&
+ overallSwimlaneData.laneLabels.length > 0;
+ const showViewBySwimlane =
+ viewBySwimlaneData !== null &&
+ viewBySwimlaneData.laneLabels &&
+ viewBySwimlaneData.laneLabels.length > 0;
+
+ const bounds = timefilter.getActiveBounds();
+
+ return (
+
+
+ {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */}
+
+
+ {noInfluencersConfigured === false && influencers !== undefined && (
+
+
+
+ )}
+
+ {noInfluencersConfigured && (
+
+
+
+ )}
+
+ {noInfluencersConfigured === false && (
+
+
+
+
+
+
+ )}
-
-
-
+
+
+
+
+
+
+ {showOverallSwimlane && (
+
+ )}
+
-
-
+ {viewBySwimlaneOptions.length > 0 && (
+ <>
+
+
-
+
-
+
-
+
+
+
+
+
+
+ {viewByLoadedForTimeFormatted && (
+
+ )}
+ {viewByLoadedForTimeFormatted === undefined && (
+
+ )}
+ {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && (
+
+ )}
+
- {anomalyChartRecords.length > 0 && selectedCells !== null && (
-
-
-
-
-
- )}
-
+ {showViewBySwimlane && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {viewBySwimlaneDataLoading && }
-
- {showCharts && }
-
+ {!showViewBySwimlane &&
+ !viewBySwimlaneDataLoading &&
+ viewBySwimlaneFieldName !== null && (
+
+ )}
+ >
+ )}
- 0 && (
+ <>
+
+
+
+
-
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {showCharts && }
-
- );
- }
- }
- )
-);
+
+
+
+
+
+ );
+ }
+}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
index df76b049e9837..1c0124b90ae77 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
@@ -1,15 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = `
-Object {
- "chartsPerRow": 1,
- "seriesToPlot": Array [],
- "timeFieldName": "timestamp",
- "tooManyBuckets": false,
-}
-`;
-
-exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = `
Object {
"chartsPerRow": 1,
"seriesToPlot": Array [
@@ -69,7 +60,7 @@ Object {
}
`;
-exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 3`] = `
+exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = `
Object {
"chartsPerRow": 1,
"seriesToPlot": Array [
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js
index 757fd00192fc8..ce819a8d6dc8c 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js
@@ -32,7 +32,6 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind
import { TimeBuckets } from '../../util/time_buckets';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
-import { severity$ } from '../../components/controls/select_severity/select_severity';
import { CHART_TYPE } from '../explorer_constants';
@@ -51,6 +50,7 @@ export const ExplorerChartDistribution = injectI18n(
class ExplorerChartDistribution extends React.Component {
static propTypes = {
seriesConfig: PropTypes.object,
+ severity: PropTypes.number,
};
componentDidMount() {
@@ -66,6 +66,7 @@ export const ExplorerChartDistribution = injectI18n(
const element = this.rootNode;
const config = this.props.seriesConfig;
+ const severity = this.props.severity;
if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) {
// just return so the empty directive renders without an error later on
@@ -400,13 +401,12 @@ export const ExplorerChartDistribution = injectI18n(
.on('mouseout', () => mlChartTooltipService.hide());
// Update all dots to new positions.
- const threshold = severity$.getValue();
dots
.attr('cx', d => lineChartXScale(d.date))
.attr('cy', d => lineChartYScale(d[CHART_Y_ATTRIBUTE]))
.attr('class', d => {
let markerClass = 'metric-value';
- if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) {
+ if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) {
markerClass += ' anomaly-marker ';
markerClass += getSeverityWithLow(d.anomalyScore).id;
}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js
index 5319692b00a38..583375c87007e 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js
@@ -42,7 +42,6 @@ import { TimeBuckets } from '../../util/time_buckets';
import { mlEscape } from '../../util/string_utils';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
-import { severity$ } from '../../components/controls/select_severity/select_severity';
import { injectI18n } from '@kbn/i18n/react';
@@ -54,6 +53,7 @@ export const ExplorerChartSingleMetric = injectI18n(
static propTypes = {
tooManyBuckets: PropTypes.bool,
seriesConfig: PropTypes.object,
+ severity: PropTypes.number,
};
componentDidMount() {
@@ -69,6 +69,7 @@ export const ExplorerChartSingleMetric = injectI18n(
const element = this.rootNode;
const config = this.props.seriesConfig;
+ const severity = this.props.severity;
if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) {
// just return so the empty directive renders without an error later on
@@ -312,13 +313,12 @@ export const ExplorerChartSingleMetric = injectI18n(
.on('mouseout', () => mlChartTooltipService.hide());
// Update all dots to new positions.
- const threshold = severity$.getValue();
dots
.attr('cx', d => lineChartXScale(d.date))
.attr('cy', d => lineChartYScale(d.value))
.attr('class', d => {
let markerClass = 'metric-value';
- if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) {
+ if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) {
markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore).id}`;
}
return markerClass;
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js
index 140c5a87056e5..99de38c1e0a84 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js
@@ -52,7 +52,7 @@ function getChartId(series) {
}
// Wrapper for a single explorer chart
-function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) {
+function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) {
const { detectorLabel, entityFields } = series;
const chartType = getChartType(series);
@@ -121,10 +121,20 @@ function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) {
chartType === CHART_TYPE.POPULATION_DISTRIBUTION
) {
return (
-
+
);
}
- return
;
+ return (
+
+ );
})()}
);
@@ -146,7 +156,7 @@ export class ExplorerChartsContainer extends React.Component {
}
render() {
- const { chartsPerRow, seriesToPlot, tooManyBuckets } = this.props;
+ const { chartsPerRow, seriesToPlot, severity, tooManyBuckets } = this.props;
//
doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
// If that's the case we trick it doing that with the following settings:
@@ -166,6 +176,7 @@ export class ExplorerChartsContainer extends React.Component {
>
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
index f0b94cb724c57..4b2d307e72c66 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js
@@ -4,6 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+
+import { I18nProvider } from '@kbn/i18n/react';
+
+import { chartLimits } from '../../util/chart_utils';
+
+import { getDefaultChartsData } from './explorer_charts_container_service';
+import { ExplorerChartsContainer } from './explorer_charts_container';
+
import './explorer_chart_single_metric.test.mocks';
import { chartData } from './__mocks__/mock_chart_data';
import seriesConfig from './__mocks__/mock_series_config_filebeat.json';
@@ -38,17 +48,12 @@ jest.mock(
getBasePath: () => {
return '';
},
+ getInjected: () => true,
}),
{ virtual: true }
);
-import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
-import React from 'react';
-
-import { chartLimits } from '../../util/chart_utils';
-import { getDefaultChartsData } from './explorer_charts_container_service';
-
-import { ExplorerChartsContainer } from './explorer_charts_container';
+jest.mock('ui/new_platform');
describe('ExplorerChartsContainer', () => {
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
@@ -58,7 +63,11 @@ describe('ExplorerChartsContainer', () => {
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
test('Minimal Initialization', () => {
- const wrapper = shallowWithIntl( );
+ const wrapper = shallow(
+
+
+
+ );
expect(wrapper.html()).toBe(
'
'
@@ -78,7 +87,11 @@ describe('ExplorerChartsContainer', () => {
chartsPerRow: 1,
tooManyBuckets: false,
};
- const wrapper = mountWithIntl( );
+ const wrapper = mount(
+
+
+
+ );
// We test child components with snapshots separately
// so we just do some high level sanity check here.
@@ -101,7 +114,11 @@ describe('ExplorerChartsContainer', () => {
chartsPerRow: 1,
tooManyBuckets: false,
};
- const wrapper = mountWithIntl( );
+ const wrapper = mount(
+
+
+
+ );
// We test child components with snapshots separately
// so we just do some high level sanity check here.
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
index ccd52a26f2abc..962072b974867 100644
--- 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
@@ -13,6 +13,9 @@ export declare interface ExplorerChartsData {
export declare const getDefaultChartsData: () => ExplorerChartsData;
-export declare const explorerChartsContainerServiceFactory: (
- callback: (data: ExplorerChartsData) => void
-) => (anomalyRecords: any[], earliestMs: number, latestMs: number) => void;
+export declare const anomalyDataChange: (
+ anomalyRecords: any[],
+ earliestMs: number,
+ latestMs: number,
+ severity?: 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 4aad4fba85746..e0fb97a81f587 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
@@ -23,8 +23,8 @@ import {
} from '../../../../common/util/job_utils';
import { mlResultsService } from '../../services/results_service';
import { mlJobService } from '../../services/job_service';
-import { severity$ } from '../../components/controls/select_severity/select_severity';
import { getChartContainerWidth } from '../legacy_utils';
+import { explorerService } from '../explorer_dashboard_service';
import { CHART_TYPE } from '../explorer_constants';
@@ -38,593 +38,581 @@ export function getDefaultChartsData() {
};
}
-export function explorerChartsContainerServiceFactory(callback) {
- const CHART_MAX_POINTS = 500;
- const ANOMALIES_MAX_RESULTS = 500;
- const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
- const ML_TIME_FIELD_NAME = 'timestamp';
- const USE_OVERALL_CHART_LIMITS = false;
- const MAX_CHARTS_PER_ROW = 4;
-
- callback(getDefaultChartsData());
+const CHART_MAX_POINTS = 500;
+const ANOMALIES_MAX_RESULTS = 500;
+const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
+const ML_TIME_FIELD_NAME = 'timestamp';
+const USE_OVERALL_CHART_LIMITS = false;
+const MAX_CHARTS_PER_ROW = 4;
+
+// callback(getDefaultChartsData());
+
+export const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs, severity = 0) {
+ const data = getDefaultChartsData();
+
+ const filteredRecords = anomalyRecords.filter(record => {
+ return Number(record.record_score) >= severity;
+ });
+ const allSeriesRecords = processRecordsForDisplay(filteredRecords);
+ // Calculate the number of charts per row, depending on the width available, to a max of 4.
+ const chartsContainerWidth = getChartContainerWidth();
+ let chartsPerRow = Math.min(
+ Math.max(Math.floor(chartsContainerWidth / 550), 1),
+ MAX_CHARTS_PER_ROW
+ );
+ if (allSeriesRecords.length === 1) {
+ chartsPerRow = 1;
+ }
- const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs) {
- const data = getDefaultChartsData();
+ data.chartsPerRow = chartsPerRow;
- const threshold = severity$.getValue();
+ // Build the data configs of the anomalies to be displayed.
+ // TODO - implement paging?
+ // For now just take first 6 (or 8 if 4 charts per row).
+ const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6);
+ const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot);
+ const seriesConfigs = recordsToPlot.map(buildConfig);
- const filteredRecords = anomalyRecords.filter(record => {
- return Number(record.record_score) >= threshold.val;
- });
- const allSeriesRecords = processRecordsForDisplay(filteredRecords);
- // Calculate the number of charts per row, depending on the width available, to a max of 4.
- const chartsContainerWidth = getChartContainerWidth();
- let chartsPerRow = Math.min(
- Math.max(Math.floor(chartsContainerWidth / 550), 1),
- MAX_CHARTS_PER_ROW
- );
- if (allSeriesRecords.length === 1) {
- chartsPerRow = 1;
- }
+ // Calculate the time range of the charts, which is a function of the chart width and max job bucket span.
+ data.tooManyBuckets = false;
+ const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow);
+ const { chartRange, tooManyBuckets } = calculateChartRange(
+ seriesConfigs,
+ earliestMs,
+ latestMs,
+ chartWidth,
+ recordsToPlot,
+ data.timeFieldName
+ );
+ data.tooManyBuckets = tooManyBuckets;
- data.chartsPerRow = chartsPerRow;
-
- // Build the data configs of the anomalies to be displayed.
- // TODO - implement paging?
- // For now just take first 6 (or 8 if 4 charts per row).
- const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6);
- const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot);
- const seriesConfigs = recordsToPlot.map(buildConfig);
-
- // Calculate the time range of the charts, which is a function of the chart width and max job bucket span.
- data.tooManyBuckets = false;
- const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow);
- const { chartRange, tooManyBuckets } = calculateChartRange(
- seriesConfigs,
- earliestMs,
- latestMs,
- chartWidth,
- recordsToPlot,
- data.timeFieldName
- );
- data.tooManyBuckets = tooManyBuckets;
+ // initialize the charts with loading indicators
+ data.seriesToPlot = seriesConfigs.map(config => ({
+ ...config,
+ loading: true,
+ chartData: null,
+ }));
- // initialize the charts with loading indicators
- data.seriesToPlot = seriesConfigs.map(config => ({
- ...config,
- loading: true,
- chartData: null,
- }));
+ explorerService.setCharts({ ...data });
- callback(data);
+ if (seriesConfigs.length === 0) {
+ return;
+ }
- // Query 1 - load the raw metric data.
- function getMetricData(config, range) {
- const { jobId, detectorIndex, entityFields, interval } = config;
+ // Query 1 - load the raw metric data.
+ function getMetricData(config, range) {
+ const { jobId, detectorIndex, entityFields, interval } = config;
- const job = mlJobService.getJob(jobId);
+ const job = mlJobService.getJob(jobId);
- // If source data can be plotted, use that, otherwise model plot will be available.
- const useSourceData = isSourceDataChartableForDetector(job, detectorIndex);
- if (useSourceData === true) {
- const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
- return mlResultsService
- .getMetricData(
- config.datafeedConfig.indices,
- config.entityFields,
- datafeedQuery,
- config.metricFunction,
- config.metricFieldName,
- config.timeField,
- range.min,
- range.max,
- config.interval
- )
- .toPromise();
- } else {
- // Extract the partition, by, over fields on which to filter.
- const criteriaFields = [];
- const detector = job.analysis_config.detectors[detectorIndex];
- if (_.has(detector, 'partition_field_name')) {
- const partitionEntity = _.find(entityFields, {
- fieldName: detector.partition_field_name,
- });
- if (partitionEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
- { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
- );
- }
+ // If source data can be plotted, use that, otherwise model plot will be available.
+ const useSourceData = isSourceDataChartableForDetector(job, detectorIndex);
+ if (useSourceData === true) {
+ const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
+ return mlResultsService
+ .getMetricData(
+ config.datafeedConfig.indices,
+ config.entityFields,
+ datafeedQuery,
+ config.metricFunction,
+ config.metricFieldName,
+ config.timeField,
+ range.min,
+ range.max,
+ config.interval
+ )
+ .toPromise();
+ } else {
+ // Extract the partition, by, over fields on which to filter.
+ const criteriaFields = [];
+ const detector = job.analysis_config.detectors[detectorIndex];
+ if (_.has(detector, 'partition_field_name')) {
+ const partitionEntity = _.find(entityFields, {
+ fieldName: detector.partition_field_name,
+ });
+ if (partitionEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName },
+ { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue }
+ );
}
+ }
- if (_.has(detector, 'over_field_name')) {
- const overEntity = _.find(entityFields, { fieldName: detector.over_field_name });
- if (overEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
- { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
- );
- }
+ if (_.has(detector, 'over_field_name')) {
+ const overEntity = _.find(entityFields, { fieldName: detector.over_field_name });
+ if (overEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'over_field_name', fieldValue: overEntity.fieldName },
+ { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue }
+ );
}
+ }
- if (_.has(detector, 'by_field_name')) {
- const byEntity = _.find(entityFields, { fieldName: detector.by_field_name });
- if (byEntity !== undefined) {
- criteriaFields.push(
- { fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
- { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
- );
- }
+ if (_.has(detector, 'by_field_name')) {
+ const byEntity = _.find(entityFields, { fieldName: detector.by_field_name });
+ if (byEntity !== undefined) {
+ criteriaFields.push(
+ { fieldName: 'by_field_name', fieldValue: byEntity.fieldName },
+ { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue }
+ );
}
-
- return new Promise((resolve, reject) => {
- const obj = {
- success: true,
- results: {},
- };
-
- return mlResultsService
- .getModelPlotOutput(
- jobId,
- detectorIndex,
- criteriaFields,
- range.min,
- range.max,
- interval
- )
- .toPromise()
- .then(resp => {
- // Return data in format required by the explorer charts.
- const results = resp.results;
- Object.keys(results).forEach(time => {
- obj.results[time] = results[time].actual;
- });
- resolve(obj);
- })
- .catch(resp => {
- reject(resp);
- });
- });
}
- }
- // Query 2 - load the anomalies.
- // Criteria to return the records for this series are the detector_index plus
- // the specific combination of 'entity' fields i.e. the partition / by / over fields.
- function getRecordsForCriteria(config, range) {
- let criteria = [];
- criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex });
- criteria = criteria.concat(config.entityFields);
- return mlResultsService
- .getRecordsForCriteria(
- [config.jobId],
- criteria,
- 0,
- range.min,
- range.max,
- ANOMALIES_MAX_RESULTS
- )
- .toPromise();
- }
+ return new Promise((resolve, reject) => {
+ const obj = {
+ success: true,
+ results: {},
+ };
- // Query 3 - load any scheduled events for the job.
- function getScheduledEvents(config, range) {
- return mlResultsService
- .getScheduledEventsByBucket(
- [config.jobId],
- range.min,
- range.max,
- config.interval,
- 1,
- MAX_SCHEDULED_EVENTS
- )
- .toPromise();
+ return mlResultsService
+ .getModelPlotOutput(jobId, detectorIndex, criteriaFields, range.min, range.max, interval)
+ .toPromise()
+ .then(resp => {
+ // Return data in format required by the explorer charts.
+ const results = resp.results;
+ Object.keys(results).forEach(time => {
+ obj.results[time] = results[time].actual;
+ });
+ resolve(obj);
+ })
+ .catch(resp => {
+ reject(resp);
+ });
+ });
}
+ }
- // Query 4 - load context data distribution
- function getEventDistribution(config, range) {
- const chartType = getChartType(config);
-
- let splitField;
- let filterField = null;
-
- // Define splitField and filterField based on chartType
- if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
- splitField = config.entityFields.find(f => f.fieldType === 'by');
- filterField = config.entityFields.find(f => f.fieldType === 'partition');
- } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
- splitField = config.entityFields.find(f => f.fieldType === 'over');
- filterField = config.entityFields.find(f => f.fieldType === 'partition');
- }
+ // Query 2 - load the anomalies.
+ // Criteria to return the records for this series are the detector_index plus
+ // the specific combination of 'entity' fields i.e. the partition / by / over fields.
+ function getRecordsForCriteria(config, range) {
+ let criteria = [];
+ criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex });
+ criteria = criteria.concat(config.entityFields);
+ return mlResultsService
+ .getRecordsForCriteria(
+ [config.jobId],
+ criteria,
+ 0,
+ range.min,
+ range.max,
+ ANOMALIES_MAX_RESULTS
+ )
+ .toPromise();
+ }
- const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
- return mlResultsService.getEventDistributionData(
- config.datafeedConfig.indices,
- splitField,
- filterField,
- datafeedQuery,
- config.metricFunction,
- config.metricFieldName,
- config.timeField,
+ // Query 3 - load any scheduled events for the job.
+ function getScheduledEvents(config, range) {
+ return mlResultsService
+ .getScheduledEventsByBucket(
+ [config.jobId],
range.min,
range.max,
- config.interval
- );
+ config.interval,
+ 1,
+ MAX_SCHEDULED_EVENTS
+ )
+ .toPromise();
+ }
+
+ // Query 4 - load context data distribution
+ function getEventDistribution(config, range) {
+ const chartType = getChartType(config);
+
+ let splitField;
+ let filterField = null;
+
+ // Define splitField and filterField based on chartType
+ if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
+ splitField = config.entityFields.find(f => f.fieldType === 'by');
+ filterField = config.entityFields.find(f => f.fieldType === 'partition');
+ } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ splitField = config.entityFields.find(f => f.fieldType === 'over');
+ filterField = config.entityFields.find(f => f.fieldType === 'partition');
}
- // first load and wait for required data,
- // only after that trigger data processing and page render.
- // TODO - if query returns no results e.g. source data has been deleted,
- // display a message saying 'No data between earliest/latest'.
- const seriesPromises = seriesConfigs.map(seriesConfig =>
- Promise.all([
- getMetricData(seriesConfig, chartRange),
- getRecordsForCriteria(seriesConfig, chartRange),
- getScheduledEvents(seriesConfig, chartRange),
- getEventDistribution(seriesConfig, chartRange),
- ])
+ const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
+ return mlResultsService.getEventDistributionData(
+ config.datafeedConfig.indices,
+ splitField,
+ filterField,
+ datafeedQuery,
+ config.metricFunction,
+ config.metricFieldName,
+ config.timeField,
+ range.min,
+ range.max,
+ config.interval
);
+ }
- function processChartData(response, seriesIndex) {
- const metricData = response[0].results;
- const records = response[1].records;
- const jobId = seriesConfigs[seriesIndex].jobId;
- const scheduledEvents = response[2].events[jobId];
- const eventDistribution = response[3];
- const chartType = getChartType(seriesConfigs[seriesIndex]);
-
- // Sort records in ascending time order matching up with chart data
- records.sort((recordA, recordB) => {
- return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME];
- });
+ // first load and wait for required data,
+ // only after that trigger data processing and page render.
+ // TODO - if query returns no results e.g. source data has been deleted,
+ // display a message saying 'No data between earliest/latest'.
+ const seriesPromises = seriesConfigs.map(seriesConfig =>
+ Promise.all([
+ getMetricData(seriesConfig, chartRange),
+ getRecordsForCriteria(seriesConfig, chartRange),
+ getScheduledEvents(seriesConfig, chartRange),
+ getEventDistribution(seriesConfig, chartRange),
+ ])
+ );
+
+ function processChartData(response, seriesIndex) {
+ const metricData = response[0].results;
+ const records = response[1].records;
+ const jobId = seriesConfigs[seriesIndex].jobId;
+ const scheduledEvents = response[2].events[jobId];
+ const eventDistribution = response[3];
+ const chartType = getChartType(seriesConfigs[seriesIndex]);
+
+ // Sort records in ascending time order matching up with chart data
+ records.sort((recordA, recordB) => {
+ return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME];
+ });
- // Return dataset in format used by the chart.
- // i.e. array of Objects with keys date (timestamp), value,
- // plus anomalyScore for points with anomaly markers.
- let chartData = [];
- if (metricData !== undefined) {
- if (eventDistribution.length > 0 && records.length > 0) {
- const filterField = records[0].by_field_value || records[0].over_field_value;
- chartData = eventDistribution.filter(d => d.entity !== filterField);
- _.map(metricData, (value, time) => {
- // The filtering for rare/event_distribution charts needs to be handled
- // differently because of how the source data is structured.
- // For rare chart values we are only interested wether a value is either `0` or not,
- // `0` acts like a flag in the chart whether to display the dot/marker.
- // All other charts (single metric, population) are metric based and with
- // those a value of `null` acts as the flag to hide a data point.
- if (
- (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) ||
- (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null)
- ) {
- chartData.push({
- date: +time,
- value: value,
- entity: filterField,
- });
- }
- });
- } else {
- chartData = _.map(metricData, (value, time) => ({
- date: +time,
- value: value,
- }));
- }
+ // Return dataset in format used by the chart.
+ // i.e. array of Objects with keys date (timestamp), value,
+ // plus anomalyScore for points with anomaly markers.
+ let chartData = [];
+ if (metricData !== undefined) {
+ if (eventDistribution.length > 0 && records.length > 0) {
+ const filterField = records[0].by_field_value || records[0].over_field_value;
+ chartData = eventDistribution.filter(d => d.entity !== filterField);
+ _.map(metricData, (value, time) => {
+ // The filtering for rare/event_distribution charts needs to be handled
+ // differently because of how the source data is structured.
+ // For rare chart values we are only interested wether a value is either `0` or not,
+ // `0` acts like a flag in the chart whether to display the dot/marker.
+ // All other charts (single metric, population) are metric based and with
+ // those a value of `null` acts as the flag to hide a data point.
+ if (
+ (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) ||
+ (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null)
+ ) {
+ chartData.push({
+ date: +time,
+ value: value,
+ entity: filterField,
+ });
+ }
+ });
+ } else {
+ chartData = _.map(metricData, (value, time) => ({
+ date: +time,
+ value: value,
+ }));
}
+ }
- // Iterate through the anomaly records, adding anomalyScore properties
- // to the chartData entries for anomalous buckets.
- const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType);
- _.each(records, record => {
- // Look for a chart point with the same time as the record.
- // If none found, insert a point for anomalies due to a gap in the data.
- const recordTime = record[ML_TIME_FIELD_NAME];
- let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime);
- if (chartPoint === undefined) {
- chartPoint = { date: new Date(recordTime), value: null };
- chartData.push(chartPoint);
- }
+ // Iterate through the anomaly records, adding anomalyScore properties
+ // to the chartData entries for anomalous buckets.
+ const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType);
+ _.each(records, record => {
+ // Look for a chart point with the same time as the record.
+ // If none found, insert a point for anomalies due to a gap in the data.
+ const recordTime = record[ML_TIME_FIELD_NAME];
+ let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime);
+ if (chartPoint === undefined) {
+ chartPoint = { date: new Date(recordTime), value: null };
+ chartData.push(chartPoint);
+ }
- chartPoint.anomalyScore = record.record_score;
+ chartPoint.anomalyScore = record.record_score;
- if (record.actual !== undefined) {
- chartPoint.actual = record.actual;
- chartPoint.typical = record.typical;
- } else {
- const causes = _.get(record, 'causes', []);
- if (causes.length > 0) {
- chartPoint.byFieldName = record.by_field_name;
- chartPoint.numberOfCauses = causes.length;
- if (causes.length === 1) {
- // If only a single cause, copy actual and typical values to the top level.
- const cause = _.first(record.causes);
- chartPoint.actual = cause.actual;
- chartPoint.typical = cause.typical;
- }
+ if (record.actual !== undefined) {
+ chartPoint.actual = record.actual;
+ chartPoint.typical = record.typical;
+ } else {
+ const causes = _.get(record, 'causes', []);
+ if (causes.length > 0) {
+ chartPoint.byFieldName = record.by_field_name;
+ chartPoint.numberOfCauses = causes.length;
+ if (causes.length === 1) {
+ // If only a single cause, copy actual and typical values to the top level.
+ const cause = _.first(record.causes);
+ chartPoint.actual = cause.actual;
+ chartPoint.typical = cause.typical;
}
}
+ }
+
+ if (record.multi_bucket_impact !== undefined) {
+ chartPoint.multiBucketImpact = record.multi_bucket_impact;
+ }
+ });
- if (record.multi_bucket_impact !== undefined) {
- chartPoint.multiBucketImpact = record.multi_bucket_impact;
+ // Add a scheduledEvents property to any points in the chart data set
+ // which correspond to times of scheduled events for the job.
+ if (scheduledEvents !== undefined) {
+ _.each(scheduledEvents, (events, time) => {
+ const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time));
+ if (chartPoint !== undefined) {
+ // Note if the scheduled event coincides with an absence of the underlying metric data,
+ // we don't worry about plotting the event.
+ chartPoint.scheduledEvents = events;
}
});
+ }
- // Add a scheduledEvents property to any points in the chart data set
- // which correspond to times of scheduled events for the job.
- if (scheduledEvents !== undefined) {
- _.each(scheduledEvents, (events, time) => {
- const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time));
- if (chartPoint !== undefined) {
- // Note if the scheduled event coincides with an absence of the underlying metric data,
- // we don't worry about plotting the event.
- chartPoint.scheduledEvents = events;
- }
- });
- }
+ return chartData;
+ }
- return chartData;
+ function getChartDataForPointSearch(chartData, record, chartType) {
+ if (
+ chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
+ chartType === CHART_TYPE.POPULATION_DISTRIBUTION
+ ) {
+ return chartData.filter(d => {
+ return d.entity === (record && (record.by_field_value || record.over_field_value));
+ });
}
- function getChartDataForPointSearch(chartData, record, chartType) {
- if (
- chartType === CHART_TYPE.EVENT_DISTRIBUTION ||
- chartType === CHART_TYPE.POPULATION_DISTRIBUTION
- ) {
- return chartData.filter(d => {
- return d.entity === (record && (record.by_field_value || record.over_field_value));
- });
- }
+ return chartData;
+ }
- return chartData;
- }
+ function findChartPointForTime(chartData, time) {
+ return chartData.find(point => point.date === time);
+ }
- function findChartPointForTime(chartData, time) {
- return chartData.find(point => point.date === time);
+ Promise.all(seriesPromises)
+ .then(response => {
+ // calculate an overall min/max for all series
+ const processedData = response.map(processChartData);
+ const allDataPoints = _.reduce(
+ processedData,
+ (datapoints, series) => {
+ _.each(series, d => datapoints.push(d));
+ return datapoints;
+ },
+ []
+ );
+ const overallChartLimits = chartLimits(allDataPoints);
+
+ data.seriesToPlot = response.map((d, i) => ({
+ ...seriesConfigs[i],
+ loading: false,
+ chartData: processedData[i],
+ plotEarliest: chartRange.min,
+ plotLatest: chartRange.max,
+ selectedEarliest: earliestMs,
+ selectedLatest: latestMs,
+ chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]),
+ }));
+ explorerService.setCharts({ ...data });
+ })
+ .catch(error => {
+ console.error(error);
+ });
+};
+
+function processRecordsForDisplay(anomalyRecords) {
+ // Aggregate the anomaly data by detector, and entity (by/over/partition).
+ if (anomalyRecords.length === 0) {
+ return [];
+ }
+
+ // Aggregate by job, detector, and analysis fields (partition, by, over).
+ const aggregatedData = {};
+ _.each(anomalyRecords, record => {
+ // Check if we can plot a chart for this record, depending on whether the source data
+ // is chartable, and if model plot is enabled for the job.
+ const job = mlJobService.getJob(record.job_id);
+ let isChartable = isSourceDataChartableForDetector(job, record.detector_index);
+ if (isChartable === false) {
+ // Check if model plot is enabled for this job.
+ // Need to check the entity fields for the record in case the model plot config has a terms list.
+ const entityFields = getEntityFieldList(record);
+ isChartable = isModelPlotEnabled(job, record.detector_index, entityFields);
}
- Promise.all(seriesPromises)
- .then(response => {
- // calculate an overall min/max for all series
- const processedData = response.map(processChartData);
- const allDataPoints = _.reduce(
- processedData,
- (datapoints, series) => {
- _.each(series, d => datapoints.push(d));
- return datapoints;
- },
- []
- );
- const overallChartLimits = chartLimits(allDataPoints);
-
- data.seriesToPlot = response.map((d, i) => ({
- ...seriesConfigs[i],
- loading: false,
- chartData: processedData[i],
- plotEarliest: chartRange.min,
- plotLatest: chartRange.max,
- selectedEarliest: earliestMs,
- selectedLatest: latestMs,
- chartLimits: USE_OVERALL_CHART_LIMITS
- ? overallChartLimits
- : chartLimits(processedData[i]),
- }));
- callback(data);
- })
- .catch(error => {
- console.error(error);
- });
- };
+ if (isChartable === false) {
+ return;
+ }
+ const jobId = record.job_id;
+ if (aggregatedData[jobId] === undefined) {
+ aggregatedData[jobId] = {};
+ }
+ const detectorsForJob = aggregatedData[jobId];
- function processRecordsForDisplay(anomalyRecords) {
- // Aggregate the anomaly data by detector, and entity (by/over/partition).
- if (anomalyRecords.length === 0) {
- return [];
+ const detectorIndex = record.detector_index;
+ if (detectorsForJob[detectorIndex] === undefined) {
+ detectorsForJob[detectorIndex] = {};
}
- // Aggregate by job, detector, and analysis fields (partition, by, over).
- const aggregatedData = {};
- _.each(anomalyRecords, record => {
- // Check if we can plot a chart for this record, depending on whether the source data
- // is chartable, and if model plot is enabled for the job.
- const job = mlJobService.getJob(record.job_id);
- let isChartable = isSourceDataChartableForDetector(job, record.detector_index);
- if (isChartable === false) {
- // Check if model plot is enabled for this job.
- // Need to check the entity fields for the record in case the model plot config has a terms list.
- const entityFields = getEntityFieldList(record);
- isChartable = isModelPlotEnabled(job, record.detector_index, entityFields);
- }
+ // TODO - work out how best to display results from detectors with just an over field.
+ const firstFieldName =
+ record.partition_field_name || record.by_field_name || record.over_field_name;
+ const firstFieldValue =
+ record.partition_field_value || record.by_field_value || record.over_field_value;
+ if (firstFieldName !== undefined) {
+ const groupsForDetector = detectorsForJob[detectorIndex];
- if (isChartable === false) {
- return;
+ if (groupsForDetector[firstFieldName] === undefined) {
+ groupsForDetector[firstFieldName] = {};
}
- const jobId = record.job_id;
- if (aggregatedData[jobId] === undefined) {
- aggregatedData[jobId] = {};
+ const valuesForGroup = groupsForDetector[firstFieldName];
+ if (valuesForGroup[firstFieldValue] === undefined) {
+ valuesForGroup[firstFieldValue] = {};
}
- const detectorsForJob = aggregatedData[jobId];
- const detectorIndex = record.detector_index;
- if (detectorsForJob[detectorIndex] === undefined) {
- detectorsForJob[detectorIndex] = {};
- }
+ const dataForGroupValue = valuesForGroup[firstFieldValue];
- // TODO - work out how best to display results from detectors with just an over field.
- const firstFieldName =
- record.partition_field_name || record.by_field_name || record.over_field_name;
- const firstFieldValue =
- record.partition_field_value || record.by_field_value || record.over_field_value;
- if (firstFieldName !== undefined) {
- const groupsForDetector = detectorsForJob[detectorIndex];
-
- if (groupsForDetector[firstFieldName] === undefined) {
- groupsForDetector[firstFieldName] = {};
- }
- const valuesForGroup = groupsForDetector[firstFieldName];
- if (valuesForGroup[firstFieldValue] === undefined) {
- valuesForGroup[firstFieldValue] = {};
- }
-
- const dataForGroupValue = valuesForGroup[firstFieldValue];
-
- let isSecondSplit = false;
- if (record.partition_field_name !== undefined) {
- const splitFieldName = record.over_field_name || record.by_field_name;
- if (splitFieldName !== undefined) {
- isSecondSplit = true;
- }
+ let isSecondSplit = false;
+ if (record.partition_field_name !== undefined) {
+ const splitFieldName = record.over_field_name || record.by_field_name;
+ if (splitFieldName !== undefined) {
+ isSecondSplit = true;
}
+ }
- if (isSecondSplit === false) {
- if (dataForGroupValue.maxScoreRecord === undefined) {
+ if (isSecondSplit === false) {
+ if (dataForGroupValue.maxScoreRecord === undefined) {
+ dataForGroupValue.maxScore = record.record_score;
+ dataForGroupValue.maxScoreRecord = record;
+ } else {
+ if (record.record_score > dataForGroupValue.maxScore) {
dataForGroupValue.maxScore = record.record_score;
dataForGroupValue.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForGroupValue.maxScore) {
- dataForGroupValue.maxScore = record.record_score;
- dataForGroupValue.maxScoreRecord = record;
- }
}
- } else {
- // Aggregate another level for the over or by field.
- const secondFieldName = record.over_field_name || record.by_field_name;
- const secondFieldValue = record.over_field_value || record.by_field_value;
+ }
+ } else {
+ // Aggregate another level for the over or by field.
+ const secondFieldName = record.over_field_name || record.by_field_name;
+ const secondFieldValue = record.over_field_value || record.by_field_value;
- if (dataForGroupValue[secondFieldName] === undefined) {
- dataForGroupValue[secondFieldName] = {};
- }
+ if (dataForGroupValue[secondFieldName] === undefined) {
+ dataForGroupValue[secondFieldName] = {};
+ }
- const splitsForGroup = dataForGroupValue[secondFieldName];
- if (splitsForGroup[secondFieldValue] === undefined) {
- splitsForGroup[secondFieldValue] = {};
- }
+ const splitsForGroup = dataForGroupValue[secondFieldName];
+ if (splitsForGroup[secondFieldValue] === undefined) {
+ splitsForGroup[secondFieldValue] = {};
+ }
- const dataForSplitValue = splitsForGroup[secondFieldValue];
- if (dataForSplitValue.maxScoreRecord === undefined) {
+ const dataForSplitValue = splitsForGroup[secondFieldValue];
+ if (dataForSplitValue.maxScoreRecord === undefined) {
+ dataForSplitValue.maxScore = record.record_score;
+ dataForSplitValue.maxScoreRecord = record;
+ } else {
+ if (record.record_score > dataForSplitValue.maxScore) {
dataForSplitValue.maxScore = record.record_score;
dataForSplitValue.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForSplitValue.maxScore) {
- dataForSplitValue.maxScore = record.record_score;
- dataForSplitValue.maxScoreRecord = record;
- }
}
}
+ }
+ } else {
+ // Detector with no partition or by field.
+ const dataForDetector = detectorsForJob[detectorIndex];
+ if (dataForDetector.maxScoreRecord === undefined) {
+ dataForDetector.maxScore = record.record_score;
+ dataForDetector.maxScoreRecord = record;
} else {
- // Detector with no partition or by field.
- const dataForDetector = detectorsForJob[detectorIndex];
- if (dataForDetector.maxScoreRecord === undefined) {
+ if (record.record_score > dataForDetector.maxScore) {
dataForDetector.maxScore = record.record_score;
dataForDetector.maxScoreRecord = record;
- } else {
- if (record.record_score > dataForDetector.maxScore) {
- dataForDetector.maxScore = record.record_score;
- dataForDetector.maxScoreRecord = record;
- }
}
}
- });
-
- console.log('explorer charts aggregatedData is:', aggregatedData);
- let recordsForSeries = [];
- // Convert to an array of the records with the highest record_score per unique series.
- _.each(aggregatedData, detectorsForJob => {
- _.each(detectorsForJob, groupsForDetector => {
- if (groupsForDetector.maxScoreRecord !== undefined) {
- // Detector with no partition / by field.
- recordsForSeries.push(groupsForDetector.maxScoreRecord);
- } else {
- _.each(groupsForDetector, valuesForGroup => {
- _.each(valuesForGroup, dataForGroupValue => {
- if (dataForGroupValue.maxScoreRecord !== undefined) {
- recordsForSeries.push(dataForGroupValue.maxScoreRecord);
- } else {
- // Second level of aggregation for partition and by/over.
- _.each(dataForGroupValue, splitsForGroup => {
- _.each(splitsForGroup, dataForSplitValue => {
- recordsForSeries.push(dataForSplitValue.maxScoreRecord);
- });
+ }
+ });
+
+ console.log('explorer charts aggregatedData is:', aggregatedData);
+ let recordsForSeries = [];
+ // Convert to an array of the records with the highest record_score per unique series.
+ _.each(aggregatedData, detectorsForJob => {
+ _.each(detectorsForJob, groupsForDetector => {
+ if (groupsForDetector.maxScoreRecord !== undefined) {
+ // Detector with no partition / by field.
+ recordsForSeries.push(groupsForDetector.maxScoreRecord);
+ } else {
+ _.each(groupsForDetector, valuesForGroup => {
+ _.each(valuesForGroup, dataForGroupValue => {
+ if (dataForGroupValue.maxScoreRecord !== undefined) {
+ recordsForSeries.push(dataForGroupValue.maxScoreRecord);
+ } else {
+ // Second level of aggregation for partition and by/over.
+ _.each(dataForGroupValue, splitsForGroup => {
+ _.each(splitsForGroup, dataForSplitValue => {
+ recordsForSeries.push(dataForSplitValue.maxScoreRecord);
});
- }
- });
+ });
+ }
});
- }
- });
+ });
+ }
});
- recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse();
+ });
+ recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse();
- return recordsForSeries;
- }
+ return recordsForSeries;
+}
- function calculateChartRange(
- seriesConfigs,
- earliestMs,
- latestMs,
- chartWidth,
- recordsToPlot,
- timeFieldName
- ) {
- let tooManyBuckets = false;
- // Calculate the time range for the charts.
- // Fit in as many points in the available container width plotted at the job bucket span.
- const midpointMs = Math.ceil((earliestMs + latestMs) / 2);
- const maxBucketSpanMs =
- Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000;
-
- const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs);
-
- // Optimally space points 5px apart.
- const optimumPointSpacing = 5;
- const optimumNumPoints = chartWidth / optimumPointSpacing;
-
- // Increase actual number of points if we can't plot the selected range
- // at optimal point spacing.
- const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection);
- const halfPoints = Math.ceil(plotPoints / 2);
- let chartRange = {
- min: midpointMs - halfPoints * maxBucketSpanMs,
- max: midpointMs + halfPoints * maxBucketSpanMs,
- };
-
- if (plotPoints > CHART_MAX_POINTS) {
- tooManyBuckets = true;
- // For each series being plotted, display the record with the highest score if possible.
- const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS;
- let minMs = recordsToPlot[0][timeFieldName];
- let maxMs = recordsToPlot[0][timeFieldName];
-
- _.each(recordsToPlot, record => {
- const diffMs = maxMs - minMs;
- if (diffMs < maxTimeSpan) {
- const recordTime = record[timeFieldName];
- if (recordTime < minMs) {
- if (maxMs - recordTime <= maxTimeSpan) {
- minMs = recordTime;
- }
- }
+function calculateChartRange(
+ seriesConfigs,
+ earliestMs,
+ latestMs,
+ chartWidth,
+ recordsToPlot,
+ timeFieldName
+) {
+ let tooManyBuckets = false;
+ // Calculate the time range for the charts.
+ // Fit in as many points in the available container width plotted at the job bucket span.
+ const midpointMs = Math.ceil((earliestMs + latestMs) / 2);
+ const maxBucketSpanMs = Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000;
+
+ const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs);
+
+ // Optimally space points 5px apart.
+ const optimumPointSpacing = 5;
+ const optimumNumPoints = chartWidth / optimumPointSpacing;
+
+ // Increase actual number of points if we can't plot the selected range
+ // at optimal point spacing.
+ const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection);
+ const halfPoints = Math.ceil(plotPoints / 2);
+ let chartRange = {
+ min: midpointMs - halfPoints * maxBucketSpanMs,
+ max: midpointMs + halfPoints * maxBucketSpanMs,
+ };
- if (recordTime > maxMs) {
- if (recordTime - minMs <= maxTimeSpan) {
- maxMs = recordTime;
- }
+ if (plotPoints > CHART_MAX_POINTS) {
+ tooManyBuckets = true;
+ // For each series being plotted, display the record with the highest score if possible.
+ const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS;
+ let minMs = recordsToPlot[0][timeFieldName];
+ let maxMs = recordsToPlot[0][timeFieldName];
+
+ _.each(recordsToPlot, record => {
+ const diffMs = maxMs - minMs;
+ if (diffMs < maxTimeSpan) {
+ const recordTime = record[timeFieldName];
+ if (recordTime < minMs) {
+ if (maxMs - recordTime <= maxTimeSpan) {
+ minMs = recordTime;
}
}
- });
- if (maxMs - minMs < maxTimeSpan) {
- // Expand out to cover as much as the requested time span as possible.
- minMs = Math.max(earliestMs, minMs - maxTimeSpan);
- maxMs = Math.min(latestMs, maxMs + maxTimeSpan);
+ if (recordTime > maxMs) {
+ if (recordTime - minMs <= maxTimeSpan) {
+ maxMs = recordTime;
+ }
+ }
}
+ });
- chartRange = { min: minMs, max: maxMs };
+ if (maxMs - minMs < maxTimeSpan) {
+ // Expand out to cover as much as the requested time span as possible.
+ minMs = Math.max(earliestMs, minMs - maxTimeSpan);
+ maxMs = Math.min(latestMs, maxMs + maxTimeSpan);
}
- return {
- chartRange,
- tooManyBuckets,
- };
+ chartRange = { min: minMs, max: maxMs };
}
- return anomalyDataChange;
+ return {
+ chartRange,
+ tooManyBuckets,
+ };
}
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js
index 483a359f98e5b..fbbf5eb324095 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js
@@ -102,119 +102,79 @@ jest.mock('ui/chrome', () => ({
}),
}));
-import {
- explorerChartsContainerServiceFactory,
- getDefaultChartsData,
-} from './explorer_charts_container_service';
+jest.mock('../explorer_dashboard_service', () => ({
+ explorerService: {
+ setCharts: jest.fn(),
+ },
+}));
-describe('explorerChartsContainerService', () => {
- test('Initialize factory', done => {
- explorerChartsContainerServiceFactory(callback);
+import { anomalyDataChange, getDefaultChartsData } from './explorer_charts_container_service';
+import { explorerService } from '../explorer_dashboard_service';
- function callback(data) {
- expect(data).toEqual(getDefaultChartsData());
- done();
- }
+describe('explorerChartsContainerService', () => {
+ afterEach(() => {
+ explorerService.setCharts.mockClear();
});
test('call anomalyChangeListener with empty series config', done => {
- // callback will be called multiple times.
- // the callbackData array contains the expected data values for each consecutive call.
- const callbackData = [];
- callbackData.push(getDefaultChartsData());
- callbackData.push({
- ...getDefaultChartsData(),
- chartsPerRow: 2,
+ anomalyDataChange([], 1486656000000, 1486670399999);
+
+ setImmediate(() => {
+ expect(explorerService.setCharts.mock.calls.length).toBe(1);
+ expect(explorerService.setCharts.mock.calls[0][0]).toStrictEqual({
+ ...getDefaultChartsData(),
+ chartsPerRow: 2,
+ });
+ done();
});
-
- const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
-
- anomalyDataChangeListener([], 1486656000000, 1486670399999);
-
- function callback(data) {
- if (callbackData.length > 0) {
- expect(data).toEqual({
- ...callbackData.shift(),
- });
- }
- if (callbackData.length === 0) {
- done();
- }
- }
});
test('call anomalyChangeListener with actual series config', done => {
- let callbackCount = 0;
- const expectedTestCount = 3;
-
- const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
+ anomalyDataChange(mockAnomalyChartRecords, 1486656000000, 1486670399999);
- anomalyDataChangeListener(mockAnomalyChartRecords, 1486656000000, 1486670399999);
-
- function callback(data) {
- callbackCount++;
- expect(data).toMatchSnapshot();
- if (callbackCount === expectedTestCount) {
- done();
- }
- }
+ setImmediate(() => {
+ expect(explorerService.setCharts.mock.calls.length).toBe(2);
+ expect(explorerService.setCharts.mock.calls[0][0]).toMatchSnapshot();
+ expect(explorerService.setCharts.mock.calls[1][0]).toMatchSnapshot();
+ done();
+ });
});
test('filtering should skip values of null', done => {
- let callbackCount = 0;
- const expectedTestCount = 3;
-
- const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
-
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords).map(d => {
d.job_id = 'mock-job-id-distribution';
return d;
});
- anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999);
+ anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999);
- function callback(data) {
- callbackCount++;
+ setImmediate(() => {
+ expect(explorerService.setCharts.mock.calls.length).toBe(2);
+ expect(explorerService.setCharts.mock.calls[0][0].seriesToPlot.length).toBe(1);
+ expect(explorerService.setCharts.mock.calls[1][0].seriesToPlot.length).toBe(1);
- if (callbackCount === 1) {
- expect(data.seriesToPlot).toHaveLength(0);
- }
- if (callbackCount === 3) {
- expect(data.seriesToPlot).toHaveLength(1);
-
- // the mock source dataset has a length of 115. one data point has a value of `null`,
- // and another one `0`. the received dataset should have a length of 114,
- // it should remove the datapoint with `null` and keep the one with `0`.
- const chartData = data.seriesToPlot[0].chartData;
- expect(chartData).toHaveLength(114);
- expect(chartData.filter(d => d.value === 0)).toHaveLength(1);
- expect(chartData.filter(d => d.value === null)).toHaveLength(0);
- }
- if (callbackCount === expectedTestCount) {
- done();
- }
- }
+ // the mock source dataset has a length of 115. one data point has a value of `null`,
+ // and another one `0`. the received dataset should have a length of 114,
+ // it should remove the datapoint with `null` and keep the one with `0`.
+ const chartData = explorerService.setCharts.mock.calls[1][0].seriesToPlot[0].chartData;
+ expect(chartData).toHaveLength(114);
+ expect(chartData.filter(d => d.value === 0)).toHaveLength(1);
+ expect(chartData.filter(d => d.value === null)).toHaveLength(0);
+ done();
+ });
});
test('field value with trailing dot should not throw an error', done => {
- let callbackCount = 0;
- const expectedTestCount = 3;
-
- const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
-
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords);
mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.';
expect(() => {
- anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999);
+ anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999);
}).not.toThrow();
- function callback() {
- callbackCount++;
-
- if (callbackCount === expectedTestCount) {
- done();
- }
- }
+ setImmediate(() => {
+ expect(explorerService.setCharts.mock.calls.length).toBe(2);
+ done();
+ });
});
});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
index 66cd98f7ebe29..b084f503272cc 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts
@@ -17,24 +17,15 @@ export const DRAG_SELECT_ACTION = {
};
export const EXPLORER_ACTION = {
- 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',
- RESET: 'reset',
SET_BOUNDS: 'setBounds',
SET_CHARTS: 'setCharts',
+ SET_EXPLORER_DATA: 'setExplorerData',
+ SET_FILTER_DATA: 'setFilterData',
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',
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
index 713857835b3b9..89e1a908b1ecc 100644
--- 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
@@ -9,30 +9,25 @@
* components in the Explorer dashboard.
*/
-import { isEqual, pick } from 'lodash';
+import { isEqual } from 'lodash';
-import { from, isObservable, BehaviorSubject, Observable, Subject } from 'rxjs';
-import { distinctUntilChanged, flatMap, map, pairwise, scan } from 'rxjs/operators';
+import { from, isObservable, Observable, Subject } from 'rxjs';
+import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators';
import { DeepPartial } from '../../../common/types/common';
-import { jobSelectionActionCreator, loadExplorerData } from './actions';
+import { jobSelectionActionCreator } 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';
+import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils';
+import { explorerReducer, getExplorerDefaultState, 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 const explorerAction$ = new Subject();
export type ActionPayload = any;
@@ -51,94 +46,79 @@ const explorerFilteredAction$ = explorerAction$.pipe(
// 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;
- })
+ scan(explorerReducer, getExplorerDefaultState())
);
+interface ExplorerAppState {
+ mlExplorerSwimlane: {
+ selectedType?: string;
+ selectedLanes?: string[];
+ selectedTimes?: number[];
+ showTopFieldValues?: boolean;
+ viewByFieldName?: string;
+ };
+ mlExplorerFilter: {
+ influencersFilterQuery?: unknown;
+ filterActive?: boolean;
+ filteredFields?: string[];
+ queryString?: string;
+ };
+}
+
const explorerAppState$: Observable = explorerState$.pipe(
- map((state: ExplorerState) => state.appState),
+ map(
+ (state: ExplorerState): ExplorerAppState => {
+ const appState: ExplorerAppState = {
+ mlExplorerFilter: {},
+ mlExplorerSwimlane: {},
+ };
+
+ if (state.selectedCells !== undefined) {
+ const swimlaneSelectedCells = state.selectedCells;
+ appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
+ appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
+ appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
+ appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
+ }
+
+ if (state.viewBySwimlaneFieldName !== undefined) {
+ appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName;
+ }
+
+ if (state.filterActive) {
+ appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery;
+ appState.mlExplorerFilter.filterActive = state.filterActive;
+ appState.mlExplorerFilter.filteredFields = state.filteredFields;
+ appState.mlExplorerFilter.queryString = state.queryString;
+ }
+
+ return 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,
+const setExplorerDataActionCreator = (payload: DeepPartial) => ({
+ type: EXPLORER_ACTION.SET_EXPLORER_DATA,
+ payload,
+});
+const setFilterDataActionCreator = (payload: DeepPartial) => ({
+ type: EXPLORER_ACTION.SET_FILTER_DATA,
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 });
+ updateJobSelection: (selectedJobIds: string[]) => {
+ explorerAction$.next(jobSelectionActionCreator(selectedJobIds));
},
setBounds: (payload: TimeRangeBounds) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_BOUNDS, payload });
@@ -152,14 +132,17 @@ export const explorerService = {
payload,
});
},
- setSelectedCells: (payload: SelectedCells) => {
+ setSelectedCells: (payload: AppStateSelectedCells | undefined) => {
explorerAction$.next({
type: EXPLORER_ACTION.SET_SELECTED_CELLS,
payload,
});
},
- setState: (payload: DeepPartial) => {
- explorerAction$.next(setStateActionCreator(payload));
+ setExplorerData: (payload: DeepPartial) => {
+ explorerAction$.next(setExplorerDataActionCreator(payload));
+ },
+ setFilterData: (payload: DeepPartial) => {
+ explorerAction$.next(setFilterDataActionCreator(payload));
},
setSwimlaneContainerWidth: (payload: number) => {
explorerAction$.next({
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
index d7873e6d52d78..0ab75b1db2972 100644
--- 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
@@ -11,8 +11,7 @@ import { CombinedJob } from '../jobs/new_job/common/job_creator/configs';
import { TimeBucketsInterval } from '../util/time_buckets';
interface ClearedSelectedAnomaliesState {
- anomalyChartRecords: [];
- selectedCells: null;
+ selectedCells: undefined;
viewByLoadedForTimeFormatted: null;
}
@@ -37,7 +36,7 @@ export declare const getDefaultSwimlaneData: () => SwimlaneData;
export declare const getInfluencers: (selectedJobs: any[]) => string[];
export declare const getSelectionInfluencers: (
- selectedCells: SelectedCells,
+ selectedCells: AppStateSelectedCells | undefined,
fieldName: string
) => any[];
@@ -47,7 +46,7 @@ interface SelectionTimeRange {
}
export declare const getSelectionTimeRange: (
- selectedCells: SelectedCells,
+ selectedCells: AppStateSelectedCells | undefined,
interval: number,
bounds: TimeRangeBounds
) => SelectionTimeRange;
@@ -62,7 +61,7 @@ interface ViewBySwimlaneOptionsArgs {
filterActive: boolean;
filteredFields: any[];
isAndOperator: boolean;
- selectedCells: SelectedCells;
+ selectedCells: AppStateSelectedCells;
selectedJobs: ExplorerJob[];
}
@@ -94,7 +93,7 @@ declare interface SwimlaneBounds {
}
export declare const loadAnnotationsTableData: (
- selectedCells: SelectedCells,
+ selectedCells: AppStateSelectedCells | undefined,
selectedJobs: ExplorerJob[],
interval: number,
bounds: TimeRangeBounds
@@ -109,7 +108,7 @@ export declare interface AnomaliesTableData {
}
export declare const loadAnomaliesTableData: (
- selectedCells: SelectedCells,
+ selectedCells: AppStateSelectedCells | undefined,
selectedJobs: ExplorerJob[],
dateFormatTz: any,
interval: number,
@@ -125,7 +124,7 @@ export declare const loadDataForCharts: (
earliestMs: number,
latestMs: number,
influencers: any[],
- selectedCells: SelectedCells,
+ selectedCells: AppStateSelectedCells | undefined,
influencersFilterQuery: any
) => Promise;
@@ -178,25 +177,17 @@ export declare const loadViewByTopFieldValuesForSelectedTime: (
noInfluencersConfigured: boolean
) => Promise;
-declare interface FilterData {
+export declare interface FilterData {
influencersFilterQuery: any;
filterActive: boolean;
filteredFields: string[];
queryString: string;
}
-declare interface SelectedCells {
+export declare interface AppStateSelectedCells {
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 b54b691f3aba6..4fb4e7d4df94f 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
@@ -53,8 +53,7 @@ export function createJobs(jobs) {
export function getClearedSelectedAnomaliesState() {
return {
- anomalyChartRecords: [],
- selectedCells: null,
+ selectedCells: undefined,
viewByLoadedForTimeFormatted: null,
};
}
@@ -195,7 +194,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) {
let earliestMs = bounds.min.valueOf();
let latestMs = bounds.max.valueOf();
- if (selectedCells !== null && selectedCells.times !== undefined) {
+ if (selectedCells !== undefined && selectedCells.times !== undefined) {
// time property of the cell data is an array, with the elements being
// the start times of the first and last cell selected.
earliestMs =
@@ -212,7 +211,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) {
export function getSelectionInfluencers(selectedCells, fieldName) {
if (
- selectedCells !== null &&
+ selectedCells !== undefined &&
selectedCells.type !== SWIMLANE_TYPE.OVERALL &&
selectedCells.viewByFieldName !== undefined &&
selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL
@@ -346,7 +345,7 @@ export function getViewBySwimlaneOptions({
if (selectedJobIds.length > 1) {
// If more than one job selected, default to job ID.
viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL;
- } else if (mlJobService.jobs.length > 0) {
+ } else if (mlJobService.jobs.length > 0 && selectedJobIds.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 => {
@@ -525,7 +524,7 @@ export function processViewByResults(
export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) {
const jobIds =
- selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
+ selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
? selectedCells.lanes
: selectedJobs.map(d => d.id);
const timeRange = getSelectionTimeRange(selectedCells, interval, bounds);
@@ -587,7 +586,7 @@ export async function loadAnomaliesTableData(
influencersFilterQuery
) {
const jobIds =
- selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
+ selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
? selectedCells.lanes
: selectedJobs.map(d => d.id);
const influencers = getSelectionInfluencers(selectedCells, fieldName);
@@ -677,7 +676,7 @@ export async function loadDataForCharts(
// Just skip doing the request when this function
// is called without the minimum required data.
if (
- selectedCells === null &&
+ selectedCells === undefined &&
influencers.length === 0 &&
influencersFilterQuery === undefined
) {
@@ -705,7 +704,7 @@ export async function loadDataForCharts(
}
if (
- (selectedCells !== null && Object.keys(selectedCells).length > 0) ||
+ (selectedCells !== undefined && Object.keys(selectedCells).length > 0) ||
influencersFilterQuery !== undefined
) {
console.log('Explorer anomaly charts data set:', resp.records);
@@ -879,36 +878,3 @@ 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/hooks/use_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts
new file mode 100644
index 0000000000000..2b3e1c7bd656f
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { useUrlState } from '../../util/url_state';
+import { SWIMLANE_TYPE } from '../../explorer/explorer_constants';
+import { AppStateSelectedCells } from '../../explorer/explorer_utils';
+
+export const useSelectedCells = (): [
+ AppStateSelectedCells | undefined,
+ (swimlaneSelectedCells: AppStateSelectedCells) => void
+] => {
+ const [appState, setAppState] = useUrlState('_a');
+
+ let selectedCells: AppStateSelectedCells | undefined;
+
+ // keep swimlane selection, restore selectedCells from AppState
+ if (
+ appState &&
+ appState.mlExplorerSwimlane &&
+ 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,
+ };
+ }
+
+ const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => {
+ const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane };
+ if (swimlaneSelectedCells !== undefined) {
+ swimlaneSelectedCells.showTopFieldValues = false;
+
+ const currentSwimlaneType = selectedCells?.type;
+ const currentShowTopFieldValues = selectedCells?.showTopFieldValues;
+ const newSwimlaneType = selectedCells?.type;
+
+ if (
+ (currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
+ newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
+ newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
+ currentShowTopFieldValues === true
+ ) {
+ swimlaneSelectedCells.showTopFieldValues = true;
+ }
+
+ mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
+ mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
+ mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
+ mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
+ setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
+ } else {
+ delete mlExplorerSwimlane.selectedType;
+ delete mlExplorerSwimlane.selectedLanes;
+ delete mlExplorerSwimlane.selectedTimes;
+ delete mlExplorerSwimlane.showTopFieldValues;
+ setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
+ }
+ };
+
+ return [selectedCells, setSelectedCells];
+};
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
deleted file mode 100644
index 66e00a41a3f31..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { 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
index 28f04bf65634a..daeb9ae54013c 100644
--- 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
@@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EXPLORER_ACTION, SWIMLANE_TYPE } from '../../explorer_constants';
+import { SWIMLANE_TYPE } from '../../explorer_constants';
import { getClearedSelectedAnomaliesState } from '../../explorer_utils';
-import { appStateReducer } from '../app_state_reducer';
-
import { ExplorerState } from './state';
interface SwimlanePoint {
@@ -21,18 +19,26 @@ interface SwimlanePoint {
// 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) {
+ const {
+ filterActive,
+ loading,
+ selectedCells,
+ viewBySwimlaneData,
+ viewBySwimlaneDataLoading,
+ } = state;
+
+ if (loading || viewBySwimlaneDataLoading) {
return {};
}
let clearSelection = false;
if (
+ selectedCells !== undefined &&
selectedCells !== null &&
selectedCells.type === SWIMLANE_TYPE.VIEW_BY &&
viewBySwimlaneData !== undefined &&
- viewBySwimlaneData.points !== undefined
+ viewBySwimlaneData.points !== undefined &&
+ viewBySwimlaneData.points.length > 0
) {
clearSelection =
filterActive === false &&
@@ -49,9 +55,6 @@ export const checkSelectedCells = (state: ExplorerState) => {
if (clearSelection === true) {
return {
- appState: appStateReducer(state.appState, {
- type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
- }),
...getClearedSelectedAnomaliesState(),
};
}
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
index 29c077a5cba43..1614da14e355a 100644
--- 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
@@ -4,24 +4,13 @@
* 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,
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
deleted file mode 100644
index 8536c8f3e542e..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { 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
index 9fe8ebbb2c481..a26c0564c6b16 100644
--- 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
@@ -4,27 +4,16 @@
* 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 { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils';
import { getIndexPattern } from './get_index_pattern';
-import { getExplorerDefaultState, ExplorerState } from './state';
+import { 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,
@@ -32,9 +21,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload)
// 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: [],
@@ -51,11 +37,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload)
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
index 1919ce949683f..c31b26b7adb7b 100644
--- 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
@@ -7,7 +7,7 @@
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 { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants';
import { Action } from '../../explorer_dashboard_service';
import {
getClearedSelectedAnomaliesState,
@@ -16,13 +16,11 @@ import {
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 { ExplorerState } from './state';
import { setInfluencerFilterSettings } from './set_influencer_filter_settings';
import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder';
@@ -40,45 +38,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
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;
@@ -102,44 +70,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
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 };
- }
+ case EXPLORER_ACTION.SET_EXPLORER_DATA:
+ case EXPLORER_ACTION.SET_FILTER_DATA:
+ nextState = { ...state, ...payload };
break;
case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH:
@@ -157,10 +96,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
case EXPLORER_ACTION.SET_SWIMLANE_LIMIT:
nextState = {
...state,
- appState: appStateReducer(state.appState, {
- type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
- }),
- ...getClearedSelectedAnomaliesState(),
swimlaneLimit: payload,
};
break;
@@ -180,9 +115,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
nextState = {
...state,
...getClearedSelectedAnomaliesState(),
- appState: appStateReducer(state.appState, {
- type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION,
- }),
maskAll,
viewBySwimlaneFieldName,
};
@@ -216,7 +148,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
);
// Does a sanity check on the selected `viewBySwimlaneFieldName`
- // and return the available `viewBySwimlaneOptions`.
+ // and returns the available `viewBySwimlaneOptions`.
const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({
currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName,
filterActive: nextState.filterActive,
@@ -238,7 +170,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...nextState,
swimlaneBucketInterval,
viewByLoadedForTimeFormatted:
- selectedCells !== null && selectedCells.showTopFieldValues === true
+ selectedCells !== undefined && selectedCells.showTopFieldValues === true
? formatHumanReadableDateTime(timerange.earliestMs)
: null,
viewBySwimlaneFieldName,
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
index 76577ae557fe3..8d083a396582a 100644
--- 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
@@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants';
+import { 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(
@@ -43,21 +41,8 @@ export function setInfluencerFilterSettings(
}
}
- 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,
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
index ce37605c3a926..0a2dbf5bcff35 100644
--- 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
@@ -15,16 +15,13 @@ import {
getDefaultSwimlaneData,
AnomaliesTableData,
ExplorerJob,
+ AppStateSelectedCells,
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;
@@ -40,15 +37,13 @@ export interface ExplorerState {
noInfluencersConfigured: boolean;
overallSwimlaneData: SwimlaneData;
queryString: string;
- selectedCells: any;
+ selectedCells: AppStateSelectedCells | undefined;
selectedJobs: ExplorerJob[] | null;
swimlaneBucketInterval: any;
swimlaneContainerWidth: number;
swimlaneLimit: number;
tableData: AnomaliesTableData;
- tableInterval: string;
tableQueryString: string;
- tableSeverity: number;
viewByLoadedForTimeFormatted: string | null;
viewBySwimlaneData: SwimlaneData;
viewBySwimlaneDataLoading: boolean;
@@ -63,8 +58,6 @@ function getDefaultIndexPattern() {
export function getExplorerDefaultState(): ExplorerState {
return {
annotationsData: [],
- anomalyChartRecords: [],
- appState: getExplorerDefaultAppState(),
bounds: undefined,
chartsData: getDefaultChartsData(),
fieldFormatsLoading: false,
@@ -80,7 +73,7 @@ export function getExplorerDefaultState(): ExplorerState {
noInfluencersConfigured: true,
overallSwimlaneData: getDefaultSwimlaneData(),
queryString: '',
- selectedCells: null,
+ selectedCells: undefined,
selectedJobs: null,
swimlaneBucketInterval: undefined,
swimlaneContainerWidth: 0,
@@ -92,9 +85,7 @@ export function getExplorerDefaultState(): ExplorerState {
jobIds: [],
showViewSeriesLink: false,
},
- tableInterval: 'auto',
tableQueryString: '',
- tableSeverity: 0,
viewByLoadedForTimeFormatted: null,
viewBySwimlaneData: getDefaultSwimlaneData(),
viewBySwimlaneDataLoading: false,
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
index 98cc07e8f9449..29787365923c8 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { appStateReducer, getExplorerDefaultAppState, ExplorerAppState } from './app_state_reducer';
export {
explorerReducer,
getExplorerDefaultState,
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts
new file mode 100644
index 0000000000000..5b7040e5c3606
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+export { useSwimlaneLimit, SelectLimit } from './select_limit';
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js
deleted file mode 100644
index 5971e7dcc82be..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js
+++ /dev/null
@@ -1,80 +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.
- */
-
-/*
- * React component for rendering a select element with limit options.
- */
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { BehaviorSubject } from 'rxjs';
-
-import { EuiSelect } from '@elastic/eui';
-
-import { injectObservablesAsProps } from '../../util/observable_utils';
-
-const optionsMap = {
- '5': 5,
- '10': 10,
- '25': 25,
- '50': 50,
-};
-
-const LIMIT_OPTIONS = [
- { val: 5, display: '5' },
- { val: 10, display: '10' },
- { val: 25, display: '25' },
- { val: 50, display: '50' },
-];
-
-function optionValueToLimit(value) {
- // Get corresponding limit object with required display and val properties from the specified value.
- let limit = LIMIT_OPTIONS.find(opt => opt.val === value);
-
- // Default to 10 if supplied value doesn't map to one of the options.
- if (limit === undefined) {
- limit = LIMIT_OPTIONS[1];
- }
-
- return limit;
-}
-
-const EUI_OPTIONS = LIMIT_OPTIONS.map(({ display, val }) => ({
- value: display,
- text: val,
-}));
-
-export const limit$ = new BehaviorSubject(LIMIT_OPTIONS[1]);
-
-class SelectLimitUnwrapped extends Component {
- onChange = e => {
- const valueDisplay = e.target.value;
- const limit = optionValueToLimit(optionsMap[valueDisplay]);
- limit$.next(limit);
- };
-
- render() {
- return (
-
- );
- }
-}
-
-SelectLimitUnwrapped.propTypes = {
- limit: PropTypes.object,
-};
-
-SelectLimitUnwrapped.defaultProps = {
- limit: LIMIT_OPTIONS[1],
-};
-
-const SelectLimit = injectObservablesAsProps(
- {
- limit: limit$,
- },
- SelectLimitUnwrapped
-);
-
-export { SelectLimit };
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx
similarity index 59%
rename from x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js
rename to x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx
index 60543cfad2de4..657f1c6c7af2e 100644
--- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx
@@ -5,25 +5,27 @@
*/
import React from 'react';
+import { act } from 'react-dom/test-utils';
import { shallow } from 'enzyme';
import { SelectLimit } from './select_limit';
+jest.useFakeTimers();
+
describe('SelectLimit', () => {
test('creates correct initial selected value', () => {
const wrapper = shallow( );
- const defaultSelectedValue = wrapper.props().limit.display;
- expect(defaultSelectedValue).toBe('10');
+ expect(wrapper.props().value).toEqual(10);
});
test('state for currently selected value is updated correctly on click', () => {
const wrapper = shallow( );
- const select = wrapper.first().shallow();
+ expect(wrapper.props().value).toEqual(10);
- const defaultSelectedValue = wrapper.props().limit.display;
- expect(defaultSelectedValue).toBe('10');
+ act(() => {
+ wrapper.simulate('change', { target: { value: 25 } });
+ });
+ wrapper.update();
- select.simulate('change', { target: { value: '25' } });
- const updatedSelectedValue = wrapper.props().limit.display;
- expect(updatedSelectedValue).toBe('25');
+ expect(wrapper.props().value).toEqual(10);
});
});
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx
new file mode 100644
index 0000000000000..383d07eb7a9f6
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+/*
+ * React component for rendering a select element with limit options.
+ */
+import React from 'react';
+import useObservable from 'react-use/lib/useObservable';
+import { Subject } from 'rxjs';
+
+import { EuiSelect } from '@elastic/eui';
+
+const limitOptions = [5, 10, 25, 50];
+
+const euiOptions = limitOptions.map(limit => ({
+ value: limit,
+ text: `${limit}`,
+}));
+
+export const limit$ = new Subject();
+export const defaultLimit = limitOptions[1];
+
+export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => {
+ const limit = useObservable(limit$, defaultLimit);
+
+ return [limit, (newLimit: number) => limit$.next(newLimit)];
+};
+
+export const SelectLimit = () => {
+ const [limit, setLimit] = useSwimlaneLimit();
+
+ function onChange(e: React.ChangeEvent) {
+ setLimit(parseInt(e.target.value, 10));
+ }
+
+ return ;
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js
deleted file mode 100644
index dc9d90d3c677e..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_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.
- */
-
-/*
- * AngularJS service for storing limit values in AppState.
- */
-
-import { uiModules } from 'ui/modules';
-const module = uiModules.get('apps/ml');
-
-import { subscribeAppStateToObservable } from '../../util/app_state_utils';
-import { limit$ } from './select_limit';
-
-module.service('mlSelectLimitService', function(AppState, $rootScope) {
- subscribeAppStateToObservable(AppState, 'mlSelectLimit', limit$, () => $rootScope.$applyAsync());
-});
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts
index 947c1bcf6c1a4..8a4411bf9025f 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts
@@ -92,12 +92,11 @@ export class MultiMetricJobCreator extends JobCreator {
// not split field, use the default
this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT;
} else {
- const fieldNames = this._detectors.map(d => d.field_name).filter(fn => fn !== undefined);
const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({
indexPattern: this._indexPatternTitle,
splitFieldName: this._splitField.name,
query: this._datafeed_config.query,
- fieldNames,
+ fieldNames: this.fields.map(f => f.id),
influencerNames: this._influencers,
timeFieldName: this._job_config.data_description.time_field,
earliestMs: this._start,
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx
index c55bdeef4dde8..5c5ba38b1c5a1 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx
@@ -25,6 +25,9 @@ export const Influencers: FC = () => {
useEffect(() => {
jobCreator.removeAllInfluencers();
influencers.forEach(i => jobCreator.addInfluencer(i));
+ if (jobCreator instanceof MultiMetricJobCreator) {
+ jobCreator.calculateModelMemoryLimit();
+ }
jobCreatorUpdate();
}, [influencers.join()]);
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/categorization_job_icon.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/categorization_job_icon.tsx
new file mode 100644
index 0000000000000..d69814e3e1347
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/categorization_job_icon.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+
+export const CategorizationIcon = (
+
+
+
+
+);
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx
index 9a44d561c2d94..1b57dd341a046 100644
--- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx
@@ -24,6 +24,7 @@ import { DataRecognizer } from '../../../../components/data_recognizer';
import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed';
import { timeBasedIndexCheck } from '../../../../util/index_utils';
import { CreateJobLinkCard } from '../../../../components/create_job_link_card';
+import { CategorizationIcon } from './categorization_job_icon';
export const Page: FC = () => {
const kibanaContext = useKibanaContext();
@@ -154,7 +155,7 @@ export const Page: FC = () => {
{
href: getUrl('#jobs/new_job/categorization'),
icon: {
- type: 'createAdvancedJob',
+ type: CategorizationIcon,
ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationAriaLabel', {
defaultMessage: 'Categorization job',
}),
diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx
index 1b6b91026d6a5..6aaad5294369b 100644
--- a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx
@@ -4,27 +4,33 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC, useEffect } from 'react';
+import moment from 'moment';
+import React, { FC, useEffect, useState } from 'react';
+import useObservable from 'react-use/lib/useObservable';
+
import { i18n } from '@kbn/i18n';
-import { decode } from 'rison-node';
-import { Subscription } from 'rxjs';
-// @ts-ignore
-import queryString from 'query-string';
import { timefilter } from 'ui/timefilter';
+
+import { MlJobWithTimeRange } from '../../../../common/types/jobs';
+
import { MlRoute, PageLoader, PageProps } from '../router';
+import { useRefresh } from '../use_refresh';
import { useResolver } from '../use_resolver';
import { basicResolvers } from '../resolvers';
import { Explorer } from '../../explorer';
+import { useSelectedCells } from '../../explorer/hooks/use_selected_cells';
import { mlJobService } from '../../services/job_service';
-import { getExplorerDefaultAppState, ExplorerAppState } from '../../explorer/reducers';
+import { ml } from '../../services/ml_api_service';
+import { useExplorerData } from '../../explorer/actions';
import { explorerService } from '../../explorer/explorer_dashboard_service';
-import { jobSelectServiceFactory } from '../../components/job_selector/job_select_service_utils';
-import { subscribeAppStateToObservable } from '../../util/app_state_utils';
-
-import { interval$ } from '../../components/controls/select_interval';
-import { severity$ } from '../../components/controls/select_severity';
-import { showCharts$ } from '../../components/controls/checkbox_showcharts';
+import { getDateFormatTz } from '../../explorer/explorer_utils';
+import { useSwimlaneLimit } from '../../explorer/select_limit';
+import { useJobSelection } from '../../components/job_selector/use_job_selection';
+import { useShowCharts } from '../../components/controls/checkbox_showcharts';
+import { useTableInterval } from '../../components/controls/select_interval';
+import { useTableSeverity } from '../../components/controls/select_severity';
+import { useUrlState } from '../../util/url_state';
import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs';
const breadcrumbs = [
@@ -44,111 +50,140 @@ export const explorerRoute: MlRoute = {
breadcrumbs,
};
-const PageWrapper: FC = ({ location, config, deps }) => {
- const { index } = queryString.parse(location.search);
- const { context } = useResolver(index, undefined, config, {
+const PageWrapper: FC = ({ config, deps }) => {
+ const { context, results } = useResolver(undefined, undefined, config, {
...basicResolvers(deps),
jobs: mlJobService.loadJobsWrapper,
+ jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
});
- const { _a, _g } = queryString.parse(location.search);
- let appState: any = {};
- let globalState: any = {};
- try {
- appState = decode(_a);
- globalState = decode(_g);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('Could not parse global or app state');
- }
-
- if (appState.mlExplorerSwimlane === undefined) {
- appState.mlExplorerSwimlane = {};
- }
-
- if (appState.mlExplorerFilter === undefined) {
- appState.mlExplorerFilter = {};
- }
-
- appState.fetch = () => {};
- appState.on = () => {};
- appState.off = () => {};
- appState.save = () => {};
- globalState.fetch = () => {};
- globalState.on = () => {};
- globalState.off = () => {};
- globalState.save = () => {};
return (
-
+
);
};
-class AppState {
- fetch() {}
- on() {}
- off() {}
- save() {}
+interface ExplorerUrlStateManagerProps {
+ jobsWithTimeRange: MlJobWithTimeRange[];
}
-const ExplorerWrapper: FC<{ globalState: any; appState: any }> = ({ globalState, appState }) => {
- const subscriptions = new Subscription();
-
- const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState);
- appState = getExplorerDefaultAppState();
- const { mlExplorerFilter, mlExplorerSwimlane } = appState;
- window.setTimeout(() => {
- // 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((appStateIn: ExplorerAppState) => {
- // appState.fetch();
- appState.mlExplorerFilter = appStateIn.mlExplorerFilter;
- appState.mlExplorerSwimlane = appStateIn.mlExplorerSwimlane;
- // appState.save();
- })
- );
- });
+const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => {
+ const [appState, setAppState] = useUrlState('_a');
+ const [globalState] = useUrlState('_g');
+ const [lastRefresh, setLastRefresh] = useState(0);
- subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => {}));
- subscriptions.add(
- subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {})
- );
- subscriptions.add(
- subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {})
- );
+ const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz());
- if (globalState.time) {
- timefilter.setTime({
- from: globalState.time.from,
- to: globalState.time.to,
- });
- }
+ const refresh = useRefresh();
+ useEffect(() => {
+ if (refresh !== undefined) {
+ setLastRefresh(refresh?.lastRefresh);
+ const activeBounds = timefilter.getActiveBounds();
+ if (activeBounds !== undefined) {
+ explorerService.setBounds(activeBounds);
+ }
+ }
+ }, [refresh?.lastRefresh]);
useEffect(() => {
- return () => {
- subscriptions.unsubscribe();
- unsubscribeFromGlobalState();
- };
- });
+ timefilter.enableTimeRangeSelector();
+ timefilter.enableAutoRefreshSelector();
+
+ const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName;
+ if (viewByFieldName !== undefined) {
+ explorerService.setViewBySwimlaneFieldName(viewByFieldName);
+ }
+
+ const filterData = appState?.mlExplorerFilter;
+ if (filterData !== undefined) {
+ explorerService.setFilterData(filterData);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (globalState?.time !== undefined) {
+ timefilter.setTime({
+ from: globalState.time.from,
+ to: globalState.time.to,
+ });
+ explorerService.setBounds({
+ min: moment(globalState.time.from),
+ max: moment(globalState.time.to),
+ });
+ }
+ }, [globalState?.time?.from, globalState?.time?.to]);
+
+ useEffect(() => {
+ if (jobIds.length > 0) {
+ explorerService.updateJobSelection(jobIds);
+ } else {
+ explorerService.clearJobs();
+ }
+ }, [JSON.stringify(jobIds)]);
+
+ const [explorerData, loadExplorerData] = useExplorerData();
+ useEffect(() => {
+ if (explorerData !== undefined && Object.keys(explorerData).length > 0) {
+ explorerService.setExplorerData(explorerData);
+ }
+ }, [explorerData]);
+
+ const explorerAppState = useObservable(explorerService.appState$);
+ useEffect(() => {
+ if (
+ explorerAppState !== undefined &&
+ explorerAppState.mlExplorerSwimlane.viewByFieldName !== undefined
+ ) {
+ setAppState(explorerAppState);
+ }
+ }, [explorerAppState]);
+
+ const explorerState = useObservable(explorerService.state$);
+
+ const [showCharts] = useShowCharts();
+ const [tableInterval] = useTableInterval();
+ const [tableSeverity] = useTableSeverity();
+ const [swimlaneLimit] = useSwimlaneLimit();
+ useEffect(() => {
+ explorerService.setSwimlaneLimit(swimlaneLimit);
+ }, [swimlaneLimit]);
+
+ const [selectedCells, setSelectedCells] = useSelectedCells();
+ useEffect(() => {
+ explorerService.setSelectedCells(selectedCells);
+ }, [JSON.stringify(selectedCells)]);
+
+ const loadExplorerDataConfig =
+ (explorerState !== undefined && {
+ bounds: explorerState.bounds,
+ lastRefresh,
+ influencersFilterQuery: explorerState.influencersFilterQuery,
+ noInfluencersConfigured: explorerState.noInfluencersConfigured,
+ selectedCells,
+ selectedJobs: explorerState.selectedJobs,
+ swimlaneBucketInterval: explorerState.swimlaneBucketInterval,
+ swimlaneLimit: explorerState.swimlaneLimit,
+ tableInterval: tableInterval.val,
+ tableSeverity: tableSeverity.val,
+ viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName,
+ }) ||
+ undefined;
+ useEffect(() => {
+ loadExplorerData(loadExplorerDataConfig);
+ }, [JSON.stringify(loadExplorerDataConfig)]);
+
+ if (explorerState === undefined || refresh === undefined || showCharts === undefined) {
+ return null;
+ }
return (
diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx
index a40bbfa214b28..cbf54a70ea74f 100644
--- a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx
@@ -4,24 +4,37 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC, useEffect } from 'react';
-import { i18n } from '@kbn/i18n';
-import { decode } from 'rison-node';
+import { isEqual } from 'lodash';
+import React, { FC, useCallback, useEffect, useState } from 'react';
+import { usePrevious } from 'react-use';
import moment from 'moment';
-import { Subscription } from 'rxjs';
-
// @ts-ignore
import queryString from 'query-string';
+
+import { i18n } from '@kbn/i18n';
+
import { timefilter } from 'ui/timefilter';
-import { MlRoute, PageLoader, PageProps } from '../router';
-import { useResolver } from '../use_resolver';
-import { basicResolvers } from '../resolvers';
+
+import { MlJobWithTimeRange } from '../../../../common/types/jobs';
+
import { TimeSeriesExplorer } from '../../timeseriesexplorer';
+import { getDateFormatTz, TimeRangeBounds } from '../../explorer/explorer_utils';
+import { ml } from '../../services/ml_api_service';
import { mlJobService } from '../../services/job_service';
+import { mlForecastService } from '../../services/forecast_service';
import { APP_STATE_ACTION } from '../../timeseriesexplorer/timeseriesexplorer_constants';
-import { subscribeAppStateToObservable } from '../../util/app_state_utils';
-import { interval$ } from '../../components/controls/select_interval';
-import { severity$ } from '../../components/controls/select_severity';
+import {
+ createTimeSeriesJobData,
+ getAutoZoomDuration,
+} from '../../timeseriesexplorer/timeseriesexplorer_utils';
+import { useUrlState } from '../../util/url_state';
+import { useTableInterval } from '../../components/controls/select_interval';
+import { useTableSeverity } from '../../components/controls/select_severity';
+
+import { MlRoute, PageLoader, PageProps } from '../router';
+import { useRefresh } from '../use_refresh';
+import { useResolver } from '../use_resolver';
+import { basicResolvers } from '../resolvers';
import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs';
export const timeSeriesExplorerRoute: MlRoute = {
@@ -39,105 +52,207 @@ export const timeSeriesExplorerRoute: MlRoute = {
],
};
-const PageWrapper: FC = ({ location, config, deps }) => {
- const { context } = useResolver('', undefined, config, {
+const PageWrapper: FC = ({ config, deps }) => {
+ const { context, results } = useResolver('', undefined, config, {
...basicResolvers(deps),
jobs: mlJobService.loadJobsWrapper,
+ jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
});
- const { _a, _g } = queryString.parse(location.search);
- let appState: any = {};
- let globalState: any = {};
- try {
- appState = decode(_a);
- globalState = decode(_g);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('Could not parse global or app state');
- }
- if (appState.mlTimeSeriesExplorer === undefined) {
- appState.mlTimeSeriesExplorer = {};
- }
- globalState.fetch = () => {};
- globalState.on = () => {};
- globalState.off = () => {};
- globalState.save = () => {};
return (
-
+
);
};
-class AppState {
- fetch() {}
- on() {}
- off() {}
- save() {}
+interface TimeSeriesExplorerUrlStateManager {
+ config: any;
+ jobsWithTimeRange: MlJobWithTimeRange[];
}
-const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: any }> = ({
- globalState,
- appState,
+const TimeSeriesExplorerUrlStateManager: FC = ({
config,
+ jobsWithTimeRange,
}) => {
- if (globalState.time) {
- timefilter.setTime({
- from: globalState.time.from,
- to: globalState.time.to,
- });
- }
+ const [appState, setAppState] = useUrlState('_a');
+ const [globalState, setGlobalState] = useUrlState('_g');
+ const [lastRefresh, setLastRefresh] = useState(0);
- const subscriptions = new Subscription();
- subscriptions.add(
- subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {})
- );
- subscriptions.add(
- subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {})
- );
+ const refresh = useRefresh();
+ useEffect(() => {
+ if (refresh !== undefined) {
+ setLastRefresh(refresh?.lastRefresh);
- const appStateHandler = (action: string, payload: any) => {
- switch (action) {
- case APP_STATE_ACTION.CLEAR:
- delete appState.mlTimeSeriesExplorer.detectorIndex;
- delete appState.mlTimeSeriesExplorer.entities;
- delete appState.mlTimeSeriesExplorer.forecastId;
- break;
-
- case APP_STATE_ACTION.GET_DETECTOR_INDEX:
- return appState.mlTimeSeriesExplorer.detectorIndex;
- case APP_STATE_ACTION.SET_DETECTOR_INDEX:
- appState.mlTimeSeriesExplorer.detectorIndex = payload;
- break;
-
- case APP_STATE_ACTION.GET_ENTITIES:
- return appState.mlTimeSeriesExplorer.entities;
- case APP_STATE_ACTION.SET_ENTITIES:
- appState.mlTimeSeriesExplorer.entities = payload;
- break;
-
- case APP_STATE_ACTION.GET_FORECAST_ID:
- return appState.mlTimeSeriesExplorer.forecastId;
- case APP_STATE_ACTION.SET_FORECAST_ID:
- appState.mlTimeSeriesExplorer.forecastId = payload;
- break;
-
- case APP_STATE_ACTION.GET_ZOOM:
- return appState.mlTimeSeriesExplorer.zoom;
- case APP_STATE_ACTION.SET_ZOOM:
- appState.mlTimeSeriesExplorer.zoom = payload;
- break;
- case APP_STATE_ACTION.UNSET_ZOOM:
- delete appState.mlTimeSeriesExplorer.zoom;
- break;
+ if (refresh.timeRange !== undefined) {
+ const { start, end } = refresh.timeRange;
+ setGlobalState('time', {
+ from: start,
+ to: end,
+ });
+ }
}
- };
+ }, [refresh?.lastRefresh]);
useEffect(() => {
- return () => {
- subscriptions.unsubscribe();
+ timefilter.enableTimeRangeSelector();
+ timefilter.enableAutoRefreshSelector();
+ }, []);
+
+ useEffect(() => {
+ if (globalState?.time !== undefined) {
+ timefilter.setTime({
+ from: globalState.time.from,
+ to: globalState.time.to,
+ });
+ }
+ }, [globalState?.time?.from, globalState?.time?.to]);
+
+ let bounds: TimeRangeBounds | undefined;
+ if (globalState?.time !== undefined) {
+ bounds = {
+ min: moment(globalState.time.from),
+ max: moment(globalState.time.to),
};
- });
+ }
+
+ const selectedJobIds = globalState?.ml?.jobIds;
+ // Sort selectedJobIds so we can be sure comparison works when stringifying.
+ if (Array.isArray(selectedJobIds)) {
+ selectedJobIds.sort();
+ }
+
+ // When changing jobs we'll clear appState (detectorIndex, entities, forecastId).
+ // To retore settings from the URL on initial load we also need to check against
+ // `previousSelectedJobIds` to avoid wiping appState.
+ const previousSelectedJobIds = usePrevious(selectedJobIds);
+ const isJobChange = !isEqual(previousSelectedJobIds, selectedJobIds);
+
+ // Use a side effect to clear appState when changing jobs.
+ useEffect(() => {
+ if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) {
+ setLastRefresh(Date.now());
+ appStateHandler(APP_STATE_ACTION.CLEAR);
+ }
+ }, [JSON.stringify(selectedJobIds)]);
+
+ // Next we get globalState and appState information to pass it on as props later.
+ // If a job change is going on, we fall back to defaults (as if appState was already cleard),
+ // otherwise the page could break.
+ const selectedDetectorIndex = isJobChange
+ ? 0
+ : +appState?.mlTimeSeriesExplorer?.detectorIndex || 0;
+ const selectedEntities = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.entities;
+ const selectedForecastId = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.forecastId;
+ const zoom = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.zoom;
+
+ const selectedJob = selectedJobIds && mlJobService.getJob(selectedJobIds[0]);
+
+ let autoZoomDuration: number | undefined;
+ if (selectedJobIds !== undefined && selectedJobIds.length === 1 && selectedJob !== undefined) {
+ autoZoomDuration = getAutoZoomDuration(
+ createTimeSeriesJobData(mlJobService.jobs),
+ mlJobService.getJob(selectedJobIds[0])
+ );
+ }
+
+ const appStateHandler = useCallback(
+ (action: string, payload?: any) => {
+ const mlTimeSeriesExplorer =
+ appState?.mlTimeSeriesExplorer !== undefined ? { ...appState.mlTimeSeriesExplorer } : {};
+
+ switch (action) {
+ case APP_STATE_ACTION.CLEAR:
+ delete mlTimeSeriesExplorer.detectorIndex;
+ delete mlTimeSeriesExplorer.entities;
+ delete mlTimeSeriesExplorer.forecastId;
+ delete mlTimeSeriesExplorer.zoom;
+ break;
+
+ case APP_STATE_ACTION.SET_DETECTOR_INDEX:
+ mlTimeSeriesExplorer.detectorIndex = payload;
+ break;
+
+ case APP_STATE_ACTION.SET_ENTITIES:
+ mlTimeSeriesExplorer.entities = payload;
+ break;
+
+ case APP_STATE_ACTION.SET_FORECAST_ID:
+ mlTimeSeriesExplorer.forecastId = payload;
+ break;
+
+ case APP_STATE_ACTION.SET_ZOOM:
+ mlTimeSeriesExplorer.zoom = payload;
+ break;
+
+ case APP_STATE_ACTION.UNSET_ZOOM:
+ delete mlTimeSeriesExplorer.zoom;
+ break;
+ }
+
+ setAppState('mlTimeSeriesExplorer', mlTimeSeriesExplorer);
+ },
+ [JSON.stringify([appState, globalState])]
+ );
+
+ const boundsMinMs = bounds?.min?.valueOf();
+ const boundsMaxMs = bounds?.max?.valueOf();
+ useEffect(() => {
+ if (
+ autoZoomDuration !== undefined &&
+ boundsMinMs !== undefined &&
+ boundsMaxMs !== undefined &&
+ selectedJob !== undefined &&
+ selectedForecastId !== undefined
+ ) {
+ mlForecastService
+ .getForecastDateRange(selectedJob, selectedForecastId)
+ .then(resp => {
+ if (autoZoomDuration === undefined) {
+ return;
+ }
+
+ const earliest = moment(resp.earliest || boundsMinMs);
+ const latest = moment(resp.latest || boundsMaxMs);
+
+ // Set the zoom to centre on the start of the forecast range, depending
+ // on the time range of the forecast and data.
+ // const earliestDataDate = first(contextChartData).date;
+ const zoomLatestMs = Math.min(
+ earliest.valueOf() + autoZoomDuration / 2,
+ latest.valueOf()
+ );
+ const zoomEarliestMs = zoomLatestMs - autoZoomDuration;
+ const zoomState = {
+ from: moment(zoomEarliestMs).toISOString(),
+ to: moment(zoomLatestMs).toISOString(),
+ };
+ appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
+
+ if (earliest.isBefore(moment(boundsMinMs)) || latest.isAfter(moment(boundsMaxMs))) {
+ const earliestMs = Math.min(earliest.valueOf(), boundsMinMs);
+ const latestMs = Math.max(latest.valueOf(), boundsMaxMs);
+ setGlobalState('time', {
+ from: moment(earliestMs).toISOString(),
+ to: moment(latestMs).toISOString(),
+ });
+ }
+ })
+ .catch(resp => {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'Time series explorer - error loading time range of forecast from elasticsearch:',
+ resp
+ );
+ });
+ }
+ }, [selectedForecastId]);
+
+ const [tableInterval] = useTableInterval();
+ const [tableSeverity] = useTableSeverity();
const tzConfig = config.get('dateFormat:tz');
const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess();
@@ -146,9 +261,20 @@ const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: a
);
diff --git a/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts
new file mode 100644
index 0000000000000..f9f3bb66f14f3
--- /dev/null
+++ b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useObservable } from 'react-use';
+import { merge, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { annotationsRefresh$ } from '../services/annotations_service';
+import {
+ mlTimefilterRefresh$,
+ mlTimefilterTimeChange$,
+} from '../services/timefilter_refresh_service';
+
+export interface Refresh {
+ lastRefresh: number;
+ timeRange?: { start: string; end: string };
+}
+
+const refresh$: Observable = merge(
+ mlTimefilterRefresh$,
+ mlTimefilterTimeChange$,
+ annotationsRefresh$.pipe(map(d => ({ lastRefresh: d })))
+);
+
+export const useRefresh = () => {
+ return useObservable(refresh$);
+};
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 d74c3802c2ed2..2ba54d243ed1b 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
@@ -7,7 +7,7 @@
import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json';
import { Annotation } from '../../../common/types/annotations';
-import { annotation$, annotationsRefresh$ } from './annotations_service';
+import { annotation$, annotationsRefresh$, annotationsRefreshed } from './annotations_service';
describe('annotations_service', () => {
test('annotation$', () => {
@@ -34,7 +34,7 @@ describe('annotations_service', () => {
expect(subscriber.mock.calls).toHaveLength(1);
- annotationsRefresh$.next(true);
+ annotationsRefreshed();
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 6953232f0cc6c..6493770156cb8 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
@@ -48,8 +48,8 @@ export type AnnotationState = Annotation | null;
- To add it to a given components state, just use
`annotation$.subscribe(annotation => this.setState({ annotation }));` in `componentDidMount()`.
- 2. injectObservablesAsProps() from public/utils/observable_utils.tsx, as the name implies, offers
- a way to wrap observables into another component which passes on updated values as props.
+ 2. useObservable() from 'react-use', offers a way to wrap observables
+ into another component which passes on updated values as props.
- To subscribe to updates this way, wrap your component like:
@@ -62,10 +62,13 @@ export type AnnotationState = Annotation | null;
return {annotation.annotation} ;
}
- export const MyObservableComponent = injectObservablesAsProps(
- { annotation: annotaton$ },
- MyOriginalComponent
- );
+ export const MyObservableComponent = (props) => {
+ const annotationProp = useObservable(annotation$);
+ if (annotationProp === undefined) {
+ return null;
+ }
+ return ;
+ };
*/
export const annotation$ = new BehaviorSubject(null);
@@ -74,4 +77,5 @@ 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 BehaviorSubject(false);
+export const annotationsRefresh$ = new BehaviorSubject(Date.now());
+export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now());
diff --git a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts
index 19f77d97a5708..8de903a422f34 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts
+++ b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts
@@ -12,6 +12,11 @@ export interface ForecastData {
results: any;
}
+export interface ForecastDateRange {
+ earliest: number;
+ latest: number;
+}
+
export const mlForecastService: {
getForecastData: (
job: Job,
@@ -23,4 +28,6 @@ export const mlForecastService: {
interval: string,
aggType: any
) => Observable;
+
+ getForecastDateRange: (job: Job, forecastId: string) => Promise;
};
diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts
index 2ad2a148f05d1..bca32e9528f64 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts
+++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts
@@ -6,11 +6,12 @@
import { Observable } from 'rxjs';
import { Annotation } from '../../../../common/types/annotations';
+import { Dictionary } from '../../../../common/types/common';
import { AggFieldNamePair } from '../../../../common/types/fields';
import { Category } from '../../../../common/types/categories';
import { ExistingJobsAndGroups } from '../job_service';
import { PrivilegesResponse } from '../../../../common/types/privileges';
-import { MlSummaryJobs } from '../../../../common/types/jobs';
+import { MlJobWithTimeRange, MlSummaryJobs } from '../../../../common/types/jobs';
import { MlServerDefaults, MlServerLimits } from '../ml_server_info';
import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types';
import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
@@ -135,6 +136,9 @@ declare interface Ml {
jobs: {
jobsSummary(jobIds: string[]): Promise;
+ jobsWithTimerange(
+ dateFormatTz: string
+ ): Promise<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary }>;
jobs(jobIds: string[]): Promise;
groups(): Promise;
updateGroups(updatedJobs: string[]): Promise;
diff --git a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx
index 2085c2a5dc77f..86c07a3577f7b 100644
--- a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx
@@ -6,4 +6,7 @@
import { Subject } from 'rxjs';
-export const mlTimefilterRefresh$ = new Subject();
+import { Refresh } from '../routing/use_refresh';
+
+export const mlTimefilterRefresh$ = new Subject>();
+export const mlTimefilterTimeChange$ = new Subject>();
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js
deleted file mode 100644
index 32b4fa3df3cf0..0000000000000
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import ngMock from 'ng_mock';
-import expect from '@kbn/expect';
-
-describe('ML - Time Series Explorer Directive', () => {
- let $scope;
- let $compile;
- let $element;
-
- beforeEach(ngMock.module('kibana'));
- beforeEach(() => {
- ngMock.inject(function($injector) {
- $compile = $injector.get('$compile');
- const $rootScope = $injector.get('$rootScope');
- $scope = $rootScope.$new();
- });
- });
-
- afterEach(() => {
- $scope.$destroy();
- });
-
- it('Initialize Time Series Explorer Directive', done => {
- ngMock.inject(function() {
- expect(() => {
- $element = $compile(' ')($scope);
- }).to.not.throwError();
-
- // directive has scope: false
- const scope = $element.isolateScope();
- expect(scope).to.eql(undefined);
- done();
- });
- });
-});
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx
index bc6896a1a66ba..df5412e609a9c 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx
@@ -22,21 +22,13 @@ export interface Entity {
fieldValues: any;
}
-function getEntityControlOptions(entity: Entity): EuiComboBoxOptionProps[] {
- if (!Array.isArray(entity.fieldValues)) {
- return [];
- }
-
- return entity.fieldValues.map(value => {
- return { label: value };
- });
-}
-
interface EntityControlProps {
entity: Entity;
entityFieldValueChanged: (entity: Entity, fieldValue: any) => void;
+ isLoading: boolean;
onSearchChange: (entity: Entity, queryTerm: string) => void;
forceSelection: boolean;
+ options: EuiComboBoxOptionProps[];
}
interface EntityControlState {
@@ -55,17 +47,11 @@ export class EntityControl extends Component 0) ||
@@ -79,11 +65,13 @@ export class EntityControl extends Component {
- this.props.loadForForecastId(forecastId);
+ this.props.setForecastId(forecastId);
this.closeModal();
};
@@ -279,7 +279,7 @@ export const ForecastingModal = injectI18n(
this.setState({
jobClosingState: PROGRESS_STATES.DONE,
});
- this.props.loadForForecastId(forecastId);
+ this.props.setForecastId(forecastId);
this.closeAfterRunningForecast();
})
.catch(response => {
@@ -297,10 +297,10 @@ export const ForecastingModal = injectI18n(
this.setState({
jobClosingState: PROGRESS_STATES.ERROR,
});
- this.props.loadForForecastId(forecastId);
+ this.props.setForecastId(forecastId);
});
} else {
- this.props.loadForForecastId(forecastId);
+ this.props.setForecastId(forecastId);
this.closeAfterRunningForecast();
}
} else {
@@ -327,7 +327,7 @@ export const ForecastingModal = injectI18n(
);
// Try and load any results which may have been created.
- this.props.loadForForecastId(forecastId);
+ this.props.setForecastId(forecastId);
this.setState({ forecastProgress: PROGRESS_STATES.ERROR });
clearInterval(this.forecastChecker);
}
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
index 4d10d73bcc048..d8e9e4379395a 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js
@@ -11,7 +11,7 @@
import PropTypes from 'prop-types';
import React from 'react';
-
+import useObservable from 'react-use/lib/useObservable';
import _ from 'lodash';
import d3 from 'd3';
import moment from 'moment';
@@ -23,7 +23,6 @@ import {
getMultiBucketImpactLabel,
} from '../../../../../common/util/anomaly_utils';
import { annotation$ } from '../../../services/annotations_service';
-import { injectObservablesAsProps } from '../../../util/observable_utils';
import { formatValue } from '../../../formatters/format_value';
import {
LINE_CHART_ANOMALY_RADIUS,
@@ -97,16 +96,16 @@ const TimeseriesChartIntl = injectI18n(
static propTypes = {
annotation: PropTypes.object,
autoZoomDuration: PropTypes.number,
+ bounds: PropTypes.object,
contextAggregationInterval: PropTypes.object,
contextChartData: PropTypes.array,
contextForecastData: PropTypes.array,
contextChartSelected: PropTypes.func.isRequired,
- detectorIndex: PropTypes.string,
+ detectorIndex: PropTypes.number,
focusAggregationInterval: PropTypes.object,
focusAnnotationData: PropTypes.array,
focusChartData: PropTypes.array,
focusForecastData: PropTypes.array,
- skipRefresh: PropTypes.bool.isRequired,
modelPlotEnabled: PropTypes.bool.isRequired,
renderFocusChartOnly: PropTypes.bool.isRequired,
selectedJob: PropTypes.object,
@@ -114,7 +113,6 @@ const TimeseriesChartIntl = injectI18n(
showModelBounds: PropTypes.bool.isRequired,
svgWidth: PropTypes.number.isRequired,
swimlaneData: PropTypes.array,
- timefilter: PropTypes.object.isRequired,
zoomFrom: PropTypes.object,
zoomTo: PropTypes.object,
zoomFromFocusLoaded: PropTypes.object,
@@ -234,10 +232,6 @@ const TimeseriesChartIntl = injectI18n(
}
componentDidUpdate() {
- if (this.props.skipRefresh) {
- return;
- }
-
if (this.props.renderFocusChartOnly === false) {
this.renderChart();
this.drawContextChartSelection();
@@ -887,13 +881,12 @@ const TimeseriesChartIntl = injectI18n(
}
createZoomInfoElements(zoomGroup, fcsWidth) {
- const { autoZoomDuration, modelPlotEnabled, timefilter, intl } = this.props;
+ const { autoZoomDuration, bounds, modelPlotEnabled, intl } = this.props;
const setZoomInterval = this.setZoomInterval.bind(this);
// Create zoom duration links applicable for the current time span.
// Don't add links for any durations which would give a brush extent less than 10px.
- const bounds = timefilter.getActiveBounds();
const boundsSecs = bounds.max.unix() - bounds.min.unix();
const minSecs = (10 / this.vizWidth) * boundsSecs;
@@ -968,7 +961,7 @@ const TimeseriesChartIntl = injectI18n(
}
drawContextElements(cxtGroup, cxtWidth, cxtChartHeight, swlHeight) {
- const { contextChartData, contextForecastData, modelPlotEnabled, timefilter } = this.props;
+ const { bounds, contextChartData, contextForecastData, modelPlotEnabled } = this.props;
const data = contextChartData;
@@ -1034,7 +1027,6 @@ const TimeseriesChartIntl = injectI18n(
.attr('y2', cxtChartHeight + swlHeight);
// Add x axis.
- const bounds = timefilter.getActiveBounds();
const timeBuckets = new TimeBuckets();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
@@ -1362,13 +1354,12 @@ const TimeseriesChartIntl = injectI18n(
};
calculateContextXAxisDomain = () => {
- const { contextAggregationInterval, swimlaneData, timefilter } = this.props;
+ const { bounds, contextAggregationInterval, swimlaneData } = this.props;
// Calculates the x axis domain for the context elements.
// Elasticsearch aggregation returns points at start of bucket,
// so set the x-axis min to the start of the first aggregation interval,
// and the x-axis max to the end of the last aggregation interval.
// Context chart and swimlane use the same aggregation interval.
- const bounds = timefilter.getActiveBounds();
let earliest = bounds.min.valueOf();
if (swimlaneData !== undefined && swimlaneData.length > 0) {
@@ -1406,9 +1397,8 @@ const TimeseriesChartIntl = injectI18n(
};
setZoomInterval(ms) {
- const { timefilter, zoomTo } = this.props;
+ const { bounds, zoomTo } = this.props;
- const bounds = timefilter.getActiveBounds();
const minBoundsMs = bounds.min.valueOf();
const maxBoundsMs = bounds.max.valueOf();
@@ -1726,7 +1716,10 @@ const TimeseriesChartIntl = injectI18n(
}
);
-export const TimeseriesChart = injectObservablesAsProps(
- { annotation: annotation$ },
- TimeseriesChartIntl
-);
+export const TimeseriesChart = props => {
+ const annotationProp = useObservable(annotation$);
+ if (annotationProp === undefined) {
+ return null;
+ }
+ return ;
+};
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js
index fb52d191013f7..cc77ad9f1a985 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js
@@ -46,7 +46,6 @@ function getTimeseriesChartPropsMock() {
showModelBounds: true,
svgWidth: 1600,
timefilter: {},
- skipRefresh: false,
};
}
diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
index ac4bc6186e5b4..3edbbc1af2323 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts
@@ -10,6 +10,12 @@ import { FC } from 'react';
declare const TimeSeriesExplorer: FC<{
appStateHandler: (action: string, payload: any) => void;
dateFormatTz: string;
- globalState: any;
+ selectedJobIds: string[];
+ selectedDetectorIndex: number;
+ selectedEntities: any[];
+ selectedForecastId: string;
+ setGlobalState: (arg: any) => void;
+ tableInterval: string;
+ tableSeverity: number;
timefilter: Timefilter;
}>;
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 0ab10c4fe69cd..016f054430fa3 100644
--- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
+++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js
@@ -8,7 +8,7 @@
* React component for rendering Single Metric Viewer.
*/
-import { debounce, difference, each, find, first, get, has, isEqual, without } from 'lodash';
+import { debounce, difference, each, find, get, has, isEqual, without } from 'lodash';
import moment from 'moment-timezone';
import { Subject, Subscription, forkJoin } from 'rxjs';
import { map, debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
@@ -36,42 +36,34 @@ import { toastNotifications } from 'ui/notify';
import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public';
import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search';
-import { parseInterval } from '../../../common/util/parse_interval';
import {
isModelPlotEnabled,
isSourceDataChartableForDetector,
- isTimeSeriesViewJob,
isTimeSeriesViewDetector,
mlFunctionToESAggregation,
} from '../../../common/util/job_utils';
-import { ChartTooltip } from '../components/chart_tooltip';
-import {
- jobSelectServiceFactory,
- setGlobalState,
- getSelectedJobIds,
-} from '../components/job_selector/job_select_service_utils';
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
import { AnnotationsTable } from '../components/annotations/annotations_table';
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
+import { ChartTooltip } from '../components/chart_tooltip';
import { EntityControl } from './components/entity_control';
import { ForecastingModal } from './components/forecasting_modal/forecasting_modal';
import { JobSelector } from '../components/job_selector';
+import { getTimeRangeFromSelection } from '../components/job_selector/job_select_service_utils';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { NavigationMenu } from '../components/navigation_menu';
-import { severity$, SelectSeverity } from '../components/controls/select_severity/select_severity';
-import { interval$, SelectInterval } from '../components/controls/select_interval/select_interval';
+import { SelectInterval } from '../components/controls/select_interval/select_interval';
+import { SelectSeverity } from '../components/controls/select_severity/select_severity';
import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart';
import { TimeseriesexplorerNoJobsFound } from './components/timeseriesexplorer_no_jobs_found';
import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data';
-import { annotationsRefresh$ } from '../services/annotations_service';
import { ml } from '../services/ml_api_service';
import { mlFieldFormatService } from '../services/field_format_service';
import { mlForecastService } from '../services/forecast_service';
import { mlJobService } from '../services/job_service';
import { mlResultsService } from '../services/results_service';
-import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
import { getBoundsRoundedToInterval } from '../util/time_buckets';
@@ -86,7 +78,6 @@ import {
calculateDefaultFocusRange,
calculateInitialFocusRange,
createTimeSeriesJobData,
- getAutoZoomDuration,
processForecastResults,
processMetricPlotResults,
processRecordScoreResults,
@@ -102,33 +93,57 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV
defaultMessage: 'all',
});
+function getEntityControlOptions(fieldValues) {
+ if (!Array.isArray(fieldValues)) {
+ return [];
+ }
+
+ return fieldValues.map(value => {
+ return { label: value };
+ });
+}
+
+function getViewableDetectors(selectedJob) {
+ const jobDetectors = selectedJob.analysis_config.detectors;
+ const viewableDetectors = [];
+ each(jobDetectors, (dtr, index) => {
+ if (isTimeSeriesViewDetector(selectedJob, index)) {
+ viewableDetectors.push({
+ index,
+ detector_description: dtr.detector_description,
+ });
+ }
+ });
+ return viewableDetectors;
+}
+
function getTimeseriesexplorerDefaultState() {
return {
chartDetails: undefined,
+ contextAggregationInterval: undefined,
contextChartData: undefined,
contextForecastData: undefined,
// Not chartable if e.g. model plot with terms for a varp detector
dataNotChartable: false,
- detectorId: undefined,
- detectors: [],
- entities: [],
+ entitiesLoading: false,
+ entityValues: {},
focusAnnotationData: [],
focusChartData: undefined,
focusForecastData: undefined,
fullRefresh: true,
hasResults: false,
- jobs: [],
// Counter to keep track of what data sets have been loaded.
loadCounter: 0,
loading: false,
modelPlotEnabled: false,
- selectedJob: undefined,
// Toggles display of annotations in the focus chart
showAnnotations: mlAnnotationsEnabled,
showAnnotationsCheckbox: mlAnnotationsEnabled,
// Toggles display of forecast data in the focus chart
showForecast: true,
showForecastCheckbox: false,
+ // Toggles display of model bounds in the focus chart
+ showModelBounds: true,
showModelBoundsCheckbox: false,
svgWidth: 0,
tableData: undefined,
@@ -136,9 +151,6 @@ function getTimeseriesexplorerDefaultState() {
zoomTo: undefined,
zoomFromFocusLoaded: undefined,
zoomToFocusLoaded: undefined,
-
- // Toggles display of model bounds in the focus chart
- showModelBounds: true,
};
}
@@ -174,26 +186,23 @@ const containerPadding = 24;
export class TimeSeriesExplorer extends React.Component {
static propTypes = {
appStateHandler: PropTypes.func.isRequired,
+ autoZoomDuration: PropTypes.number,
+ bounds: PropTypes.object,
dateFormatTz: PropTypes.string.isRequired,
- globalState: PropTypes.object.isRequired,
- timefilter: PropTypes.object.isRequired,
+ jobsWithTimeRange: PropTypes.array.isRequired,
+ lastRefresh: PropTypes.number.isRequired,
+ selectedJobIds: PropTypes.arrayOf(PropTypes.string),
+ selectedDetectorIndex: PropTypes.number,
+ selectedEntities: PropTypes.object,
+ selectedForecastId: PropTypes.string,
+ tableInterval: PropTypes.string,
+ tableSeverity: PropTypes.number,
};
state = getTimeseriesexplorerDefaultState();
subscriptions = new Subscription();
- _criteriaFields = null;
-
- constructor(props) {
- super(props);
- const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(
- props.globalState
- );
- this.jobSelectService$ = jobSelectService$;
- this.unsubscribeFromGlobalState = unsubscribeFromGlobalState;
- }
-
resizeRef = createRef();
resizeChecker = undefined;
resizeHandler = () => {
@@ -209,13 +218,10 @@ export class TimeSeriesExplorer extends React.Component {
contextChart$ = new Subject();
detectorIndexChangeHandler = e => {
+ const { appStateHandler } = this.props;
const id = e.target.value;
if (id !== undefined) {
- this.setState({ detectorId: id }, () => {
- this.updateControlsForDetector(() =>
- this.loadEntityValues(() => this.saveSeriesPropertiesAndRefresh())
- );
- });
+ appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +id);
}
};
@@ -245,7 +251,7 @@ export class TimeSeriesExplorer extends React.Component {
previousShowModelBounds = undefined;
tableFilter = (field, value, operator) => {
- const { entities } = this.state;
+ const entities = this.getControlsForDetector();
const entity = entities.find(({ fieldName }) => fieldName === field);
if (entity === undefined) {
@@ -272,35 +278,14 @@ export class TimeSeriesExplorer extends React.Component {
};
appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities);
-
- this.updateControlsForDetector(() => {
- this.refresh();
- });
};
contextChartSelectedInitCallDone = false;
- /**
- * Gets default range from component state.
- */
- getDefaultRangeFromState() {
- const {
- autoZoomDuration,
- contextAggregationInterval,
- contextChartData,
- contextForecastData,
- } = this.state;
-
- return calculateDefaultFocusRange(
- autoZoomDuration,
- contextAggregationInterval,
- contextChartData,
- contextForecastData
- );
- }
-
getFocusAggregationInterval(selection) {
- const { jobs, selectedJob } = this.state;
+ const { selectedJobIds } = this.props;
+ const jobs = createTimeSeriesJobData(mlJobService.jobs);
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
// Calculate the aggregation interval for the focus chart.
const bounds = { min: moment(selection.from), max: moment(selection.to) };
@@ -312,13 +297,13 @@ export class TimeSeriesExplorer extends React.Component {
* Gets focus data for the current component state/
*/
getFocusData(selection) {
- const { detectorId, entities, modelPlotEnabled, selectedJob } = this.state;
-
- const { appStateHandler } = this.props;
+ const { selectedJobIds, selectedForecastId, selectedDetectorIndex } = this.props;
+ const { modelPlotEnabled } = this.state;
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
+ const entityControls = this.getControlsForDetector();
// Calculate the aggregation interval for the focus chart.
const bounds = { min: moment(selection.from), max: moment(selection.to) };
-
const focusAggregationInterval = this.getFocusAggregationInterval(selection);
// Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete.
@@ -327,12 +312,12 @@ export class TimeSeriesExplorer extends React.Component {
const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false);
return getFocusData(
- this._criteriaFields,
- +detectorId,
+ this.getCriteriaFields(selectedDetectorIndex, entityControls),
+ selectedDetectorIndex,
focusAggregationInterval,
- appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID),
+ selectedForecastId,
modelPlotEnabled,
- entities.filter(entity => entity.fieldValue.length > 0),
+ entityControls.filter(entity => entity.fieldValue.length > 0),
searchBounds,
selectedJob,
TIME_FIELD_NAME
@@ -345,10 +330,10 @@ export class TimeSeriesExplorer extends React.Component {
entityFieldValueChanged = (entity, fieldValue) => {
const { appStateHandler } = this.props;
- const { entities } = this.state;
+ const entityControls = this.getControlsForDetector();
const resultEntities = {
- ...entities.reduce((appStateEntities, appStateEntity) => {
+ ...entityControls.reduce((appStateEntities, appStateEntity) => {
appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue;
return appStateEntities;
}, {}),
@@ -356,29 +341,33 @@ export class TimeSeriesExplorer extends React.Component {
};
appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities);
-
- this.updateControlsForDetector(() => {
- this.refresh();
- });
};
entityFieldSearchChanged = debounce((entity, queryTerm) => {
- this.loadEntityValues({
+ const entityControls = this.getControlsForDetector();
+ this.loadEntityValues(entityControls, {
[entity.fieldType]: queryTerm,
});
}, 500);
loadAnomaliesTableData = (earliestMs, latestMs) => {
- const { dateFormatTz } = this.props;
- const { selectedJob } = this.state;
+ const {
+ dateFormatTz,
+ selectedDetectorIndex,
+ selectedJobIds,
+ tableInterval,
+ tableSeverity,
+ } = this.props;
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
+ const entityControls = this.getControlsForDetector();
return ml.results
.getAnomaliesTableData(
[selectedJob.job_id],
- this._criteriaFields,
+ this.getCriteriaFields(selectedDetectorIndex, entityControls),
[],
- interval$.getValue().val,
- severity$.getValue().val,
+ tableInterval,
+ tableSeverity,
earliestMs,
latestMs,
dateFormatTz,
@@ -427,16 +416,18 @@ export class TimeSeriesExplorer extends React.Component {
/**
* Loads available entity values.
+ * @param {Array} entities - Entity controls configuration
* @param {Object} searchTerm - Search term for partition, e.g. { partition_field: 'partition' }
- * @param callback - Callback to execute after component state update.
*/
- loadEntityValues = async (searchTerm = {}, callback = () => {}) => {
- const { timefilter } = this.props;
- const { detectorId, entities, selectedJob } = this.state;
+ loadEntityValues = async (entities, searchTerm = {}) => {
+ this.setState({ entitiesLoading: true });
+
+ const { bounds, selectedJobIds, selectedDetectorIndex } = this.props;
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
- // Populate the entity input datalists with aggregated values. No need to pass through finish().
- const bounds = timefilter.getActiveBounds();
- const detectorIndex = +detectorId;
+ // Populate the entity input datalists with the values from the top records by score
+ // for the selected detector across the full time range. No need to pass through finish().
+ const detectorIndex = selectedDetectorIndex;
const {
partition_field: partitionField,
@@ -457,98 +448,46 @@ export class TimeSeriesExplorer extends React.Component {
)
.toPromise();
- this.setState(
- {
- entities: entities.map(entity => {
- const newEntity = { ...entity };
- if (partitionField?.name === entity.fieldName) {
- newEntity.fieldValues = partitionField.values;
- }
- if (overField?.name === entity.fieldName) {
- newEntity.fieldValues = overField.values;
- }
- if (byField?.name === entity.fieldName) {
- newEntity.fieldValues = byField.values;
- }
- return newEntity;
- }),
- },
- callback
- );
- };
-
- loadForForecastId = forecastId => {
- const { appStateHandler, timefilter } = this.props;
- const { autoZoomDuration, contextChartData, selectedJob } = this.state;
-
- mlForecastService
- .getForecastDateRange(selectedJob, forecastId)
- .then(resp => {
- const bounds = timefilter.getActiveBounds();
- const earliest = moment(resp.earliest || timefilter.getTime().from);
- const latest = moment(resp.latest || timefilter.getTime().to);
-
- // Store forecast ID in the appState.
- appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId);
-
- // Set the zoom to centre on the start of the forecast range, depending
- // on the time range of the forecast and data.
- const earliestDataDate = first(contextChartData).date;
- const zoomLatestMs = Math.min(earliest + autoZoomDuration / 2, latest.valueOf());
- const zoomEarliestMs = Math.max(
- zoomLatestMs - autoZoomDuration,
- earliestDataDate.getTime()
- );
+ const entityValues = {};
+ entities.forEach(entity => {
+ let fieldValues;
- const zoomState = {
- from: moment(zoomEarliestMs).toISOString(),
- to: moment(zoomLatestMs).toISOString(),
- };
- appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
-
- // Ensure the forecast data will be shown if hidden previously.
- this.setState({ showForecast: true });
+ if (partitionField?.name === entity.fieldName) {
+ fieldValues = partitionField.values;
+ }
+ if (overField?.name === entity.fieldName) {
+ fieldValues = overField.values;
+ }
+ if (byField?.name === entity.fieldName) {
+ fieldValues = byField.values;
+ }
+ entityValues[entity.fieldName] = fieldValues;
+ });
- if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) {
- const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf());
- const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf());
+ this.setState({ entitiesLoading: false, entityValues });
+ };
- timefilter.setTime({
- from: moment(earliestMs).toISOString(),
- to: moment(latestMs).toISOString(),
- });
- } else {
- // Refresh to show the requested forecast data.
- this.refresh();
- }
- })
- .catch(resp => {
- console.log(
- 'Time series explorer - error loading time range of forecast from elasticsearch:',
- resp
- );
- });
+ setForecastId = forecastId => {
+ this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId);
};
- refresh = (fullRefresh = true) => {
- // Skip the refresh if:
- // a) The global state's `skipRefresh` was set to true by the job selector to avoid race conditions
- // when loading the Single Metric Viewer after a job/group and time range update.
- // b) A 'soft' refresh without a full page reload is already happening.
- if (
- get(this.props.globalState, 'ml.skipRefresh') ||
- (this.state.loading && fullRefresh === false)
- ) {
+ loadSingleMetricData = (fullRefresh = true) => {
+ const {
+ autoZoomDuration,
+ bounds,
+ selectedDetectorIndex,
+ selectedForecastId,
+ selectedJobIds,
+ zoom,
+ } = this.props;
+
+ if (selectedJobIds === undefined) {
return;
}
- const { appStateHandler, timefilter } = this.props;
- const {
- detectorId: currentDetectorId,
- entities: currentEntities,
- loadCounter: currentLoadCounter,
- selectedJob: currentSelectedJob,
- } = this.state;
+ const { loadCounter: currentLoadCounter } = this.state;
+
+ const currentSelectedJob = mlJobService.getJob(selectedJobIds[0]);
if (currentSelectedJob === undefined) {
return;
@@ -558,6 +497,7 @@ export class TimeSeriesExplorer extends React.Component {
// Only when `fullRefresh` is true we'll reset all data
// and show the loading spinner within the page.
+ const entityControls = this.getControlsForDetector();
this.setState(
{
fullRefresh,
@@ -572,8 +512,8 @@ export class TimeSeriesExplorer extends React.Component {
focusForecastData: undefined,
modelPlotEnabled: isModelPlotEnabled(
currentSelectedJob,
- +currentDetectorId,
- currentEntities
+ selectedDetectorIndex,
+ entityControls
),
hasResults: false,
dataNotChartable: false,
@@ -581,15 +521,11 @@ export class TimeSeriesExplorer extends React.Component {
: {}),
},
() => {
- const {
- detectorId,
- entities,
- loadCounter,
- jobs,
- modelPlotEnabled,
- selectedJob,
- } = this.state;
- const detectorIndex = +detectorId;
+ const { loadCounter, modelPlotEnabled } = this.state;
+
+ const jobs = createTimeSeriesJobData(mlJobService.jobs);
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
+ const detectorIndex = selectedDetectorIndex;
let awaitingCount = 3;
@@ -609,19 +545,16 @@ export class TimeSeriesExplorer extends React.Component {
// Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically
// selecting the specified range in the context chart, and so loading that date range in the focus chart.
if (stateUpdate.contextChartData.length) {
- // Calculate the 'auto' zoom duration which shows data at bucket span granularity.
- stateUpdate.autoZoomDuration = getAutoZoomDuration(jobs, selectedJob);
-
// Check for a zoom parameter in the appState (URL).
let focusRange = calculateInitialFocusRange(
- appStateHandler(APP_STATE_ACTION.GET_ZOOM),
+ zoom,
stateUpdate.contextAggregationInterval,
- timefilter
+ bounds
);
if (focusRange === undefined) {
focusRange = calculateDefaultFocusRange(
- stateUpdate.autoZoomDuration,
+ autoZoomDuration,
stateUpdate.contextAggregationInterval,
stateUpdate.contextChartData,
stateUpdate.contextForecastData
@@ -636,7 +569,7 @@ export class TimeSeriesExplorer extends React.Component {
}
};
- const nonBlankEntities = currentEntities.filter(entity => {
+ const nonBlankEntities = entityControls.filter(entity => {
return entity.fieldValue.length > 0;
});
@@ -654,8 +587,6 @@ export class TimeSeriesExplorer extends React.Component {
return;
}
- const bounds = timefilter.getActiveBounds();
-
// Calculate the aggregation interval for the context chart.
// Context chart swimlane will display bucket anomaly score at the same interval.
stateUpdate.contextAggregationInterval = calculateAggregationInterval(
@@ -706,7 +637,7 @@ export class TimeSeriesExplorer extends React.Component {
mlResultsService
.getRecordMaxScoreByTime(
selectedJob.job_id,
- this._criteriaFields,
+ this.getCriteriaFields(detectorIndex, entityControls),
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
stateUpdate.contextAggregationInterval.expression
@@ -728,7 +659,7 @@ export class TimeSeriesExplorer extends React.Component {
.getChartDetails(
selectedJob,
detectorIndex,
- entities,
+ entityControls,
searchBounds.min.valueOf(),
searchBounds.max.valueOf()
)
@@ -744,8 +675,7 @@ export class TimeSeriesExplorer extends React.Component {
});
// Plus query for forecast data if there is a forecastId stored in the appState.
- const forecastId = appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID);
- if (forecastId !== undefined) {
+ if (selectedForecastId !== undefined) {
awaitingCount++;
let aggType = undefined;
const detector = selectedJob.analysis_config.detectors[detectorIndex];
@@ -757,7 +687,7 @@ export class TimeSeriesExplorer extends React.Component {
.getForecastData(
selectedJob,
detectorIndex,
- forecastId,
+ selectedForecastId,
nonBlankEntities,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
@@ -771,13 +701,11 @@ export class TimeSeriesExplorer extends React.Component {
})
.catch(resp => {
console.log(
- `Time series explorer - error loading data for forecast ID ${forecastId}`,
+ `Time series explorer - error loading data for forecast ID ${selectedForecastId}`,
resp
);
});
}
-
- this.loadEntityValues();
}
);
};
@@ -786,15 +714,21 @@ export class TimeSeriesExplorer extends React.Component {
* Updates local state of detector related controls from the global state.
* @param callback to invoke after a state update.
*/
- updateControlsForDetector = (callback = () => {}) => {
- const { appStateHandler } = this.props;
- const { detectorId, selectedJob } = this.state;
+ getControlsForDetector = () => {
+ const { selectedDetectorIndex, selectedEntities, selectedJobIds } = this.props;
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
+
+ const entities = [];
+
+ if (selectedJob === undefined) {
+ return entities;
+ }
+
// Update the entity dropdown control(s) according to the partitioning fields for the selected detector.
- const detectorIndex = +detectorId;
+ const detectorIndex = selectedDetectorIndex;
const detector = selectedJob.analysis_config.detectors[detectorIndex];
- const entities = [];
- const entitiesState = appStateHandler(APP_STATE_ACTION.GET_ENTITIES);
+ const entitiesState = selectedEntities;
const partitionFieldName = get(detector, 'partition_field_name');
const overFieldName = get(detector, 'over_field_name');
const byFieldName = get(detector, 'by_field_name');
@@ -825,9 +759,7 @@ export class TimeSeriesExplorer extends React.Component {
entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue });
}
- this.updateCriteriaFields(detectorIndex, entities);
-
- this.setState({ entities }, callback);
+ return entities;
};
/**
@@ -835,10 +767,10 @@ export class TimeSeriesExplorer extends React.Component {
* @param detectorIndex
* @param entities
*/
- updateCriteriaFields(detectorIndex, entities) {
+ getCriteriaFields(detectorIndex, entities) {
// Only filter on the entity if the field has a value.
const nonBlankEntities = entities.filter(entity => entity.fieldValue.length > 0);
- this._criteriaFields = [
+ return [
{
fieldName: 'detector_index',
fieldValue: detectorIndex,
@@ -847,47 +779,21 @@ export class TimeSeriesExplorer extends React.Component {
];
}
- loadForJobId(jobId, jobs) {
- const { appStateHandler } = this.props;
-
- // Validation that the ID is for a time series job must already have been performed.
- // Check if the job was created since the page was first loaded.
- let jobPickerSelectedJob = find(jobs, { id: jobId });
- if (jobPickerSelectedJob === undefined) {
- const newJobs = [];
- each(mlJobService.jobs, job => {
- if (isTimeSeriesViewJob(job) === true) {
- const bucketSpan = parseInterval(job.analysis_config.bucket_span);
- newJobs.push({
- id: job.job_id,
- selected: false,
- bucketSpanSeconds: bucketSpan.asSeconds(),
- });
- }
- });
- this.setState({ jobs: newJobs });
- jobPickerSelectedJob = find(newJobs, { id: jobId });
- }
+ loadForJobId(jobId) {
+ const { appStateHandler, selectedDetectorIndex } = this.props;
const selectedJob = mlJobService.getJob(jobId);
- // Read the detector index and entities out of the AppState.
- const jobDetectors = selectedJob.analysis_config.detectors;
- const viewableDetectors = [];
- each(jobDetectors, (dtr, index) => {
- if (isTimeSeriesViewDetector(selectedJob, index)) {
- viewableDetectors.push({
- index: '' + index,
- detector_description: dtr.detector_description,
- });
- }
- });
- const detectors = viewableDetectors;
+ if (selectedJob === undefined) {
+ return;
+ }
+
+ const detectors = getViewableDetectors(selectedJob);
// Check the supplied index is valid.
- const appStateDtrIdx = appStateHandler(APP_STATE_ACTION.GET_DETECTOR_INDEX);
- let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +viewableDetectors[0].index;
- if (find(viewableDetectors, { index: '' + detectorIndex }) === undefined) {
+ const appStateDtrIdx = selectedDetectorIndex;
+ let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : detectors[0].index;
+ if (find(detectors, { index: detectorIndex }) === undefined) {
const warningText = i18n.translate(
'xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage',
{
@@ -899,179 +805,22 @@ export class TimeSeriesExplorer extends React.Component {
}
);
toastNotifications.addWarning(warningText);
- detectorIndex = +viewableDetectors[0].index;
- appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorIndex);
+ detectorIndex = detectors[0].index;
}
- // Store the detector index as a string so it can be used as ng-model in a select control.
- const detectorId = '' + detectorIndex;
+ const detectorId = detectorIndex;
- this.setState({ detectorId, detectors, selectedJob }, () => {
- this.updateControlsForDetector(() => {
- // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh.
- mlFieldFormatService
- .populateFormats([jobId])
- .catch(err => {
- console.log('Error populating field formats:', err);
- })
- // Load the data - if the FieldFormats failed to populate
- // the default formatting will be used for metric values.
- .then(() => {
- this.refresh();
- });
- });
+ if (detectorId !== selectedDetectorIndex) {
+ appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorId);
+ }
+
+ // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh.
+ mlFieldFormatService.populateFormats([jobId]).catch(err => {
+ console.log('Error populating field formats:', err);
});
}
- saveSeriesPropertiesAndRefresh = () => {
- const { appStateHandler } = this.props;
- const { detectorId, entities } = this.state;
-
- appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +detectorId);
- appStateHandler(
- APP_STATE_ACTION.SET_ENTITIES,
- entities.reduce((appStateEntities, entity) => {
- appStateEntities[entity.fieldName] = entity.fieldValue;
- return appStateEntities;
- }, {})
- );
-
- this.refresh();
- };
-
componentDidMount() {
- const { appStateHandler, globalState, timefilter } = this.props;
-
- this.setState({ jobs: [] });
-
- // Get the job info needed by the visualization, then do the first load.
- if (mlJobService.jobs.length > 0) {
- const jobs = createTimeSeriesJobData(mlJobService.jobs);
- this.setState({ jobs });
- } else {
- this.setState({ loading: false });
- }
-
- // Reload the anomalies table if the Interval or Threshold controls are changed.
- const tableControlsListener = () => {
- const { zoomFrom, zoomTo } = this.state;
- if (zoomFrom !== undefined && zoomTo !== undefined) {
- this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res =>
- this.setState(res)
- );
- }
- };
-
- this.subscriptions.add(annotationsRefresh$.subscribe(this.refresh));
- this.subscriptions.add(interval$.subscribe(tableControlsListener));
- this.subscriptions.add(severity$.subscribe(tableControlsListener));
- this.subscriptions.add(
- mlTimefilterRefresh$.subscribe(() => {
- this.refresh(true);
- })
- );
-
- // Listen for changes to job selection.
- this.subscriptions.add(
- this.jobSelectService$.subscribe(({ selection: selectedJobIds }) => {
- const jobs = createTimeSeriesJobData(mlJobService.jobs);
-
- this.contextChartSelectedInitCallDone = false;
- this.setState({ fullRefresh: false, loading: true, showForecastCheckbox: false });
-
- const timeSeriesJobIds = jobs.map(j => j.id);
-
- // Check if any of the jobs set in the URL are not time series jobs
- // (e.g. if switching to this view straight from the Anomaly Explorer).
- const invalidIds = difference(selectedJobIds, timeSeriesJobIds);
- selectedJobIds = without(selectedJobIds, ...invalidIds);
- if (invalidIds.length > 0) {
- let warningText = i18n.translate(
- 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage',
- {
- defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`,
- values: {
- invalidIdsCount: invalidIds.length,
- invalidIds,
- },
- }
- );
- if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) {
- warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', {
- defaultMessage: ', auto selecting first job',
- });
- }
- toastNotifications.addWarning(warningText);
- }
-
- if (selectedJobIds.length > 1) {
- // if more than one job or a group has been loaded from the URL
- if (selectedJobIds.length > 1) {
- // if more than one job, select the first job from the selection.
- toastNotifications.addWarning(
- i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
- defaultMessage: 'You can only view one job at a time in this dashboard',
- })
- );
-
- setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] });
- this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true });
- } else {
- // if a group has been loaded
- if (selectedJobIds.length > 0) {
- // if the group contains valid jobs, select the first
- toastNotifications.addWarning(
- i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
- defaultMessage: 'You can only view one job at a time in this dashboard',
- })
- );
-
- setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] });
- this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true });
- } else if (jobs.length > 0) {
- // if there are no valid jobs in the group but there are valid jobs
- // in the list of all jobs, select the first
- setGlobalState(globalState, { selectedIds: [jobs[0].id] });
- this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true });
- } else {
- // if there are no valid jobs left.
- this.setState({ loading: false });
- }
- }
- } else if (invalidIds.length > 0 && selectedJobIds.length > 0) {
- // if some ids have been filtered out because they were invalid.
- // refresh the URL with the first valid id
- setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] });
- this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true });
- } else if (selectedJobIds.length > 0) {
- // normal behavior. a job ID has been loaded from the URL
- if (
- this.state.selectedJob !== undefined &&
- selectedJobIds[0] !== this.state.selectedJob.job_id
- ) {
- // Clear the detectorIndex, entities and forecast info.
- appStateHandler(APP_STATE_ACTION.CLEAR);
- }
- this.loadForJobId(selectedJobIds[0], jobs);
- } else {
- if (selectedJobIds.length === 0 && jobs.length > 0) {
- // no jobs were loaded from the URL, so add the first job
- // from the full jobs list.
- setGlobalState(globalState, { selectedIds: [jobs[0].id] });
- this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true });
- } else {
- // Jobs exist, but no time series jobs.
- this.setState({ loading: false });
- }
- }
- })
- );
-
- timefilter.enableTimeRangeSelector();
- timefilter.enableAutoRefreshSelector();
-
- this.subscriptions.add(timefilter.getTimeUpdate$().subscribe(() => this.refresh(false)));
-
// Required to redraw the time series chart when the container is resized.
this.resizeChecker = new ResizeChecker(this.resizeRef.current);
this.resizeChecker.on('resize', () => {
@@ -1106,23 +855,6 @@ export class TimeSeriesExplorer extends React.Component {
return;
}
- const defaultRange = this.getDefaultRangeFromState();
-
- if (
- (selection.from.getTime() !== defaultRange[0].getTime() ||
- selection.to.getTime() !== defaultRange[1].getTime()) &&
- isNaN(Date.parse(selection.from)) === false &&
- isNaN(Date.parse(selection.to)) === false
- ) {
- const zoomState = {
- from: selection.from.toISOString(),
- to: selection.to.toISOString(),
- };
- appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
- } else {
- appStateHandler(APP_STATE_ACTION.UNSET_ZOOM);
- }
-
if (
(this.contextChartSelectedInitCallDone === false && focusChartData === undefined) ||
zoomFromFocusLoaded.getTime() !== selection.from.getTime() ||
@@ -1137,7 +869,9 @@ export class TimeSeriesExplorer extends React.Component {
}
}),
switchMap(selection => {
- const { jobs, selectedJob } = this.state;
+ const { selectedJobIds } = this.props;
+ const jobs = createTimeSeriesJobData(mlJobService.jobs);
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
// Calculate the aggregation interval for the focus chart.
const bounds = { min: moment(selection.from), max: moment(selection.to) };
@@ -1180,39 +914,267 @@ export class TimeSeriesExplorer extends React.Component {
...refreshFocusData,
...tableData,
});
+ const zoomState = {
+ from: selection.from.toISOString(),
+ to: selection.to.toISOString(),
+ };
+ this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
})
);
+
+ this.componentDidUpdate();
+ }
+
+ /**
+ * returns true/false if setGlobalState has been triggered
+ * or returns the job id which should be loaded.
+ */
+ checkJobSelection() {
+ const { jobsWithTimeRange, selectedJobIds, setGlobalState } = this.props;
+
+ const jobs = createTimeSeriesJobData(mlJobService.jobs);
+ const timeSeriesJobIds = jobs.map(j => j.id);
+
+ // Check if any of the jobs set in the URL are not time series jobs
+ // (e.g. if switching to this view straight from the Anomaly Explorer).
+ const invalidIds = difference(selectedJobIds, timeSeriesJobIds);
+ const validSelectedJobIds = without(selectedJobIds, ...invalidIds);
+ if (invalidIds.length > 0) {
+ let warningText = i18n.translate(
+ 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage',
+ {
+ defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`,
+ values: {
+ invalidIdsCount: invalidIds.length,
+ invalidIds,
+ },
+ }
+ );
+ if (validSelectedJobIds.length === 0 && timeSeriesJobIds.length > 0) {
+ warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', {
+ defaultMessage: ', auto selecting first job',
+ });
+ }
+ toastNotifications.addWarning(warningText);
+ }
+
+ if (validSelectedJobIds.length > 1) {
+ // if more than one job or a group has been loaded from the URL
+ if (validSelectedJobIds.length > 1) {
+ // if more than one job, select the first job from the selection.
+ toastNotifications.addWarning(
+ i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
+ defaultMessage: 'You can only view one job at a time in this dashboard',
+ })
+ );
+ setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] });
+ return true;
+ } else {
+ // if a group has been loaded
+ if (selectedJobIds.length > 0) {
+ // if the group contains valid jobs, select the first
+ toastNotifications.addWarning(
+ i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
+ defaultMessage: 'You can only view one job at a time in this dashboard',
+ })
+ );
+ setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] });
+ return 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
+ const jobIds = [jobs[0].id];
+ const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds);
+ setGlobalState({
+ ...{ ml: { jobIds } },
+ ...(time !== undefined ? { time } : {}),
+ });
+ return true;
+ } else {
+ // if there are no valid jobs left.
+ return false;
+ }
+ }
+ } else if (invalidIds.length > 0 && validSelectedJobIds.length > 0) {
+ // if some ids have been filtered out because they were invalid.
+ // refresh the URL with the first valid id
+ setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] });
+ return true;
+ } else if (validSelectedJobIds.length > 0) {
+ // normal behavior. a job ID has been loaded from the URL
+ // Clear the detectorIndex, entities and forecast info.
+ return validSelectedJobIds[0];
+ } else {
+ if (validSelectedJobIds.length === 0 && jobs.length > 0) {
+ // no jobs were loaded from the URL, so add the first job
+ // from the full jobs list.
+ const jobIds = [jobs[0].id];
+ const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds);
+ setGlobalState({
+ ...{ ml: { jobIds } },
+ ...(time !== undefined ? { time } : {}),
+ });
+ return true;
+ } else {
+ // Jobs exist, but no time series jobs.
+ return false;
+ }
+ }
+ }
+
+ componentDidUpdate(previousProps) {
+ if (
+ previousProps === undefined ||
+ !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds)
+ ) {
+ const update = this.checkJobSelection();
+ // - true means a setGlobalState got triggered and
+ // we'll just wait for the next React render.
+ // - false means there are either no jobs or no time based jobs present.
+ // - if we get back a string it means we got back a job id we can load.
+ if (update === true) {
+ return;
+ } else if (update === false) {
+ this.setState({ loading: false });
+ return;
+ } else if (typeof update === 'string') {
+ this.contextChartSelectedInitCallDone = false;
+ this.setState({ fullRefresh: false, loading: true }, () => {
+ this.loadForJobId(update);
+ });
+ }
+ }
+
+ if (
+ this.props.bounds !== undefined &&
+ this.props.selectedJobIds !== undefined &&
+ (previousProps === undefined ||
+ !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) ||
+ previousProps.selectedDetectorIndex !== this.props.selectedDetectorIndex ||
+ !isEqual(previousProps.selectedEntities, this.props.selectedEntities))
+ ) {
+ const entityControls = this.getControlsForDetector();
+ this.loadEntityValues(entityControls);
+ }
+
+ if (
+ previousProps === undefined ||
+ previousProps.selectedForecastId !== this.props.selectedForecastId
+ ) {
+ if (this.props.selectedForecastId !== undefined) {
+ // Ensure the forecast data will be shown if hidden previously.
+ this.setState({ showForecast: true });
+ }
+ }
+
+ if (
+ previousProps === undefined ||
+ !isEqual(previousProps.bounds, this.props.bounds) ||
+ !isEqual(previousProps.lastRefresh, this.props.lastRefresh) ||
+ !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) ||
+ !isEqual(previousProps.selectedEntities, this.props.selectedEntities) ||
+ !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) ||
+ !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) ||
+ !isEqual(previousProps.zoom, this.props.zoom)
+ ) {
+ const fullRefresh =
+ previousProps === undefined ||
+ !isEqual(previousProps.bounds, this.props.bounds) ||
+ !isEqual(previousProps.lastRefresh, this.props.lastRefresh) ||
+ !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) ||
+ !isEqual(previousProps.selectedEntities, this.props.selectedEntities) ||
+ !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) ||
+ !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds);
+ this.loadSingleMetricData(fullRefresh);
+ }
+
+ if (previousProps === undefined) {
+ return;
+ }
+
+ // Reload the anomalies table if the Interval or Threshold controls are changed.
+ const tableControlsListener = () => {
+ const { zoomFrom, zoomTo } = this.state;
+ if (zoomFrom !== undefined && zoomTo !== undefined) {
+ this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res =>
+ this.setState(res)
+ );
+ }
+ };
+
+ if (
+ previousProps.tableInterval !== this.props.tableInterval ||
+ previousProps.tableSeverity !== this.props.tableSeverity
+ ) {
+ tableControlsListener();
+ }
+
+ if (
+ this.props.autoZoomDuration === undefined ||
+ this.props.selectedForecastId !== undefined ||
+ this.state.contextAggregationInterval === undefined ||
+ this.state.contextChartData === undefined ||
+ this.state.contextChartData.length === 0
+ ) {
+ return;
+ }
+
+ const defaultRange = calculateDefaultFocusRange(
+ this.props.autoZoomDuration,
+ this.state.contextAggregationInterval,
+ this.state.contextChartData,
+ this.state.contextForecastData
+ );
+
+ const selection = {
+ from: this.state.zoomFrom,
+ to: this.state.zoomTo,
+ };
+
+ if (
+ (selection.from.getTime() !== defaultRange[0].getTime() ||
+ selection.to.getTime() !== defaultRange[1].getTime()) &&
+ isNaN(Date.parse(selection.from)) === false &&
+ isNaN(Date.parse(selection.to)) === false
+ ) {
+ const zoomState = {
+ from: selection.from.toISOString(),
+ to: selection.to.toISOString(),
+ };
+ this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState);
+ }
}
componentWillUnmount() {
this.subscriptions.unsubscribe();
this.resizeChecker.destroy();
- this.unsubscribeFromGlobalState();
}
render() {
- const { dateFormatTz, globalState, timefilter } = this.props;
-
const {
autoZoomDuration,
+ bounds,
+ dateFormatTz,
+ lastRefresh,
+ selectedDetectorIndex,
+ selectedJobIds,
+ } = this.props;
+
+ const {
chartDetails,
contextAggregationInterval,
contextChartData,
contextForecastData,
dataNotChartable,
- detectors,
- detectorId,
- entities,
+ entityValues,
focusAggregationInterval,
focusAnnotationData,
focusChartData,
focusForecastData,
fullRefresh,
hasResults,
- jobs,
loading,
modelPlotEnabled,
- selectedJob,
showAnnotations,
showAnnotationsCheckbox,
showForecast,
@@ -1228,11 +1190,6 @@ export class TimeSeriesExplorer extends React.Component {
zoomToFocusLoaded,
} = this.state;
- const fieldNamesWithEmptyValues = entities
- .filter(({ fieldValue }) => !fieldValue)
- .map(({ fieldName }) => fieldName);
- const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0;
-
const chartProps = {
modelPlotEnabled,
contextChartData,
@@ -1244,7 +1201,6 @@ export class TimeSeriesExplorer extends React.Component {
focusChartData,
focusForecastData,
focusAggregationInterval,
- skipRefresh: loading || !!get(this.props.globalState, 'ml.skipRefresh'),
svgWidth,
zoomFrom,
zoomTo,
@@ -1253,17 +1209,14 @@ export class TimeSeriesExplorer extends React.Component {
autoZoomDuration,
};
- const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState);
const jobSelectorProps = {
dateFormatTz,
- globalState,
- jobSelectService$: this.jobSelectService$,
- selectedJobIds,
- selectedGroups,
singleSelection: true,
timeseriesOnly: true,
};
+ const jobs = createTimeSeriesJobData(mlJobService.jobs);
+
if (jobs.length === 0) {
return (
@@ -1272,7 +1225,27 @@ export class TimeSeriesExplorer extends React.Component {
);
}
- const detectorSelectOptions = detectors.map(d => ({
+ if (
+ selectedJobIds === undefined ||
+ selectedJobIds.length > 1 ||
+ selectedDetectorIndex === undefined ||
+ mlJobService.getJob(selectedJobIds[0]) === undefined
+ ) {
+ return (
+
+ );
+ }
+
+ const selectedJob = mlJobService.getJob(selectedJobIds[0]);
+ const entityControls = this.getControlsForDetector();
+
+ const fieldNamesWithEmptyValues = entityControls
+ .filter(({ fieldValue }) => !fieldValue)
+ .map(({ fieldName }) => fieldName);
+
+ const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0;
+
+ const detectorSelectOptions = getViewableDetectors(selectedJob).map(d => ({
value: d.index,
text: d.detector_description,
}));
@@ -1285,12 +1258,14 @@ export class TimeSeriesExplorer extends React.Component {
isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) &&
this.previousShowAnnotations === showAnnotations &&
this.previousShowForecast === showForecast &&
- this.previousShowModelBounds === showModelBounds
+ this.previousShowModelBounds === showModelBounds &&
+ this.previousLastRefresh === lastRefresh
) {
renderFocusChartOnly = false;
}
this.previousChartProps = chartProps;
+ this.previousLastRefresh = lastRefresh;
this.previousShowAnnotations = showAnnotations;
this.previousShowForecast = showForecast;
this.previousShowModelBounds = showModelBounds;
@@ -1337,12 +1312,13 @@ export class TimeSeriesExplorer extends React.Component {
>
- {entities.map(entity => {
+ {entityControls.map(entity => {
const entityKey = `${entity.fieldName}`;
const forceSelection = !hasEmptyFieldValues && !entity.fieldValue;
hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection;
@@ -1350,9 +1326,11 @@ export class TimeSeriesExplorer extends React.Component {
);
})}
@@ -1361,9 +1339,9 @@ export class TimeSeriesExplorer extends React.Component {
@@ -1386,7 +1364,7 @@ export class TimeSeriesExplorer extends React.Component {
hasResults === false && (
)}
@@ -1488,13 +1466,13 @@ export class TimeSeriesExplorer extends React.Component {
{showAnnotations && focusAnnotationData.length > 0 && (
@@ -1547,8 +1525,8 @@ export class TimeSeriesExplorer extends React.Component {
)}
- {arePartitioningFieldsProvided && jobs.length > 0 && (
-