);
}
diff --git a/x-pack/plugins/ml/public/components/anomalies_table/influencers_cell/influencers_cell_directive.js b/x-pack/plugins/ml/public/components/anomalies_table/influencers_cell/influencers_cell_directive.js
deleted file mode 100644
index 09aa97c28f347..0000000000000
--- a/x-pack/plugins/ml/public/components/anomalies_table/influencers_cell/influencers_cell_directive.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-
-
-
-import 'ngreact';
-
-import { InfluencersCell } from './influencers_cell';
-
-import { uiModules } from 'ui/modules';
-const module = uiModules.get('apps/ml', ['react']);
-module.directive('mlInfluencersCell', function (reactDirective) {
- return reactDirective(InfluencersCell, undefined, { restrict: 'E' });
-});
diff --git a/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js b/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js
new file mode 100644
index 0000000000000..d77716049237e
--- /dev/null
+++ b/x-pack/plugins/ml/public/components/anomalies_table/links_menu.js
@@ -0,0 +1,414 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+
+import _ from 'lodash';
+import moment from 'moment';
+import rison from 'rison-node';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+import {
+ EuiButtonEmpty,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiPopover
+} from '@elastic/eui';
+
+import 'ui/timefilter';
+import chrome from 'ui/chrome';
+import { toastNotifications } from 'ui/notify';
+
+import { ES_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
+import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
+import { getFieldTypeFromMapping } from 'plugins/ml/services/mapping_service';
+import { ml } from 'plugins/ml/services/ml_api_service';
+import { mlJobService } from 'plugins/ml/services/job_service';
+import { getUrlForRecord } from 'plugins/ml/util/custom_url_utils';
+import { getIndexPatterns } from 'plugins/ml/util/index_utils';
+import { replaceStringTokens } from 'plugins/ml/util/string_utils';
+
+
+/*
+ * Component for rendering the links menu inside a cell in the anomalies table.
+ */
+export class LinksMenu extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPopoverOpen: false,
+ toasts: []
+ };
+ }
+
+ openCustomUrl = (customUrl) => {
+ const { anomaly, interval, isAggregatedData } = this.props;
+
+ console.log('Anomalies Table - open customUrl for record:', anomaly);
+
+ // If url_value contains $earliest$ and $latest$ tokens, add in times to the source record.
+ // Create a copy of the record as we are adding properties into it.
+ const record = _.cloneDeep(anomaly.source);
+ const timestamp = record.timestamp;
+ const configuredUrlValue = customUrl.url_value;
+ const timeRangeInterval = parseInterval(customUrl.time_range);
+ if (configuredUrlValue.includes('$earliest$')) {
+ let earliestMoment = moment(timestamp);
+ if (timeRangeInterval !== null) {
+ earliestMoment.subtract(timeRangeInterval);
+ } else {
+ earliestMoment = moment(timestamp).startOf(interval);
+ if (interval === 'hour') {
+ // Start from the previous hour.
+ earliestMoment.subtract(1, 'h');
+ }
+ }
+ record.earliest = earliestMoment.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
+ }
+
+ if (configuredUrlValue.includes('$latest$')) {
+ let latestMoment = moment(timestamp).add(record.bucket_span, 's');
+ if (timeRangeInterval !== null) {
+ latestMoment.add(timeRangeInterval);
+ } else {
+ if (isAggregatedData === true) {
+ latestMoment = moment(timestamp).endOf(interval);
+ if (interval === 'hour') {
+ // Show to the end of the next hour.
+ latestMoment.add(1, 'h'); // e.g. 2016-02-08T18:59:59.999Z
+ }
+ }
+ }
+ record.latest = latestMoment.toISOString();
+ }
+
+ // If url_value contains $mlcategoryterms$ or $mlcategoryregex$, add in the
+ // terms and regex for the selected categoryId to the source record.
+ if ((configuredUrlValue.includes('$mlcategoryterms$') || configuredUrlValue.includes('$mlcategoryregex$'))
+ && _.has(record, 'mlcategory')) {
+ const jobId = record.job_id;
+
+ // mlcategory in the source record will be an array
+ // - use first value (will only ever be more than one if influenced by category other than by/partition/over).
+ const categoryId = record.mlcategory[0];
+
+ ml.results.getCategoryDefinition(jobId, categoryId)
+ .then((resp) => {
+ // Prefix each of the terms with '+' so that the Elasticsearch Query String query
+ // run in a drilldown Kibana dashboard has to match on all terms.
+ const termsArray = resp.terms.split(' ').map(term => `+${term}`);
+ record.mlcategoryterms = termsArray.join(' ');
+ record.mlcategoryregex = resp.regex;
+
+ // Replace any tokens in the configured url_value with values from the source record,
+ // and then open link in a new tab/window.
+ const urlPath = replaceStringTokens(customUrl.url_value, record, true);
+ window.open(urlPath, '_blank');
+
+ }).catch((resp) => {
+ console.log('openCustomUrl(): error loading categoryDefinition:', resp);
+ toastNotifications.addDanger(
+ `Unable to open link as an error occurred loading details on category ID ${categoryId}`);
+ });
+
+ } else {
+ // Replace any tokens in the configured url_value with values from the source record,
+ // and then open link in a new tab/window.
+ const urlPath = getUrlForRecord(customUrl, record);
+ window.open(urlPath, '_blank');
+ }
+
+ };
+
+ viewSeries = () => {
+ const record = this.props.anomaly.source;
+ const bounds = this.props.timefilter.getActiveBounds();
+ const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z
+ const to = bounds.max.toISOString();
+
+ // Zoom to show 50 buckets either side of the record.
+ const recordTime = moment(record.timestamp);
+ const zoomFrom = recordTime.subtract(50 * record.bucket_span, 's').toISOString();
+ const zoomTo = recordTime.add(100 * record.bucket_span, 's').toISOString();
+
+ // Extract the by, over and partition fields for the record.
+ const entityCondition = {};
+
+ if (_.has(record, 'partition_field_value')) {
+ entityCondition[record.partition_field_name] = record.partition_field_value;
+ }
+
+ if (_.has(record, 'over_field_value')) {
+ entityCondition[record.over_field_name] = record.over_field_value;
+ }
+
+ if (_.has(record, 'by_field_value')) {
+ // Note that analyses with by and over fields, will have a top-level by_field_name,
+ // but the by_field_value(s) will be in the nested causes array.
+ // TODO - drilldown from cause in expanded row only?
+ entityCondition[record.by_field_name] = record.by_field_value;
+ }
+
+ // Use rison to build the URL .
+ const _g = rison.encode({
+ ml: {
+ jobIds: [record.job_id]
+ },
+ refreshInterval: {
+ display: 'Off',
+ pause: false,
+ value: 0
+ },
+ time: {
+ from: from,
+ to: to,
+ mode: 'absolute'
+ }
+ });
+
+ const _a = rison.encode({
+ mlTimeSeriesExplorer: {
+ zoom: {
+ from: zoomFrom,
+ to: zoomTo
+ },
+ detectorIndex: record.detector_index,
+ entities: entityCondition,
+ },
+ filters: [],
+ query: {
+ query_string: {
+ analyze_wildcard: true,
+ query: '*'
+ }
+ }
+ });
+
+ // Need to encode the _a parameter in case any entities contain unsafe characters such as '+'.
+ let path = `${chrome.getBasePath()}/app/ml#/timeseriesexplorer`;
+ path += `?_g=${_g}&_a=${encodeURIComponent(_a)}`;
+ window.open(path, '_blank');
+ }
+
+ viewExamples = () => {
+ const categoryId = this.props.anomaly.entityValue;
+ const record = this.props.anomaly.source;
+ const indexPatterns = getIndexPatterns();
+
+ const job = mlJobService.getJob(this.props.anomaly.jobId);
+ if (job === undefined) {
+ console.log(`viewExamples(): no job found with ID: ${this.props.anomaly.jobId}`);
+ toastNotifications.addDanger(
+ `Unable to view examples as no details could be found for job ID ${this.props.anomaly.jobId}`);
+ return;
+ }
+ const categorizationFieldName = job.analysis_config.categorization_field_name;
+ const datafeedIndices = job.datafeed_config.indices;
+ // Find the type of the categorization field i.e. text (preferred) or keyword.
+ // Uses the first matching field found in the list of indices in the datafeed_config.
+ // attempt to load the field type using each index. we have to do it this way as _field_caps
+ // doesn't specify which index a field came from unless there is a clash.
+ let i = 0;
+ findFieldType(datafeedIndices[i]);
+
+ function findFieldType(index) {
+ getFieldTypeFromMapping(index, categorizationFieldName)
+ .then((resp) => {
+ if (resp !== '') {
+ createAndOpenUrl(index, resp);
+ } else {
+ i++;
+ if (i < datafeedIndices.length) {
+ findFieldType(datafeedIndices[i]);
+ } else {
+ error();
+ }
+ }
+ })
+ .catch(() => {
+ error();
+ });
+ }
+
+ function createAndOpenUrl(index, categorizationFieldType) {
+ // Find the ID of the index pattern with a title attribute which matches the
+ // index configured in the datafeed. If a Kibana index pattern has not been created
+ // for this index, then the user will see a warning message on the Discover tab advising
+ // them that no matching index pattern has been configured.
+ let indexPatternId = index;
+ for (let j = 0; j < indexPatterns.length; j++) {
+ if (indexPatterns[j].get('title') === index) {
+ indexPatternId = indexPatterns[j].id;
+ break;
+ }
+ }
+
+ // Get the definition of the category and use the terms or regex to view the
+ // matching events in the Kibana Discover tab depending on whether the
+ // categorization field is of mapping type text (preferred) or keyword.
+ ml.results.getCategoryDefinition(record.job_id, categoryId)
+ .then((resp) => {
+ let query = null;
+ // Build query using categorization regex (if keyword type) or terms (if text type).
+ // Check for terms or regex in case categoryId represents an anomaly from the absence of the
+ // categorization field in documents (usually indicated by a categoryId of -1).
+ if (categorizationFieldType === ES_FIELD_TYPES.KEYWORD) {
+ if (resp.regex) {
+ query = `${categorizationFieldName}:/${resp.regex}/`;
+ }
+ } else {
+ if (resp.terms) {
+ query = `${categorizationFieldName}:` + resp.terms.split(' ').join(` AND ${categorizationFieldName}:`);
+ }
+ }
+
+ const recordTime = moment(record.timestamp);
+ const from = recordTime.toISOString();
+ const to = recordTime.add(record.bucket_span, 's').toISOString();
+
+ // Use rison to build the URL .
+ const _g = rison.encode({
+ refreshInterval: {
+ display: 'Off',
+ pause: false,
+ value: 0
+ },
+ time: {
+ from: from,
+ to: to,
+ mode: 'absolute'
+ }
+ });
+
+ const appStateProps = {
+ index: indexPatternId,
+ filters: []
+ };
+ if (query !== null) {
+ appStateProps.query = {
+ query_string: {
+ analyze_wildcard: true,
+ query: query
+ }
+ };
+ }
+ const _a = rison.encode(appStateProps);
+
+ // Need to encode the _a parameter as it will contain characters such as '+' if using the regex.
+ let path = chrome.getBasePath();
+ path += '/app/kibana#/discover';
+ path += '?_g=' + _g;
+ path += '&_a=' + encodeURIComponent(_a);
+ window.open(path, '_blank');
+
+ }).catch((resp) => {
+ console.log('viewExamples(): error loading categoryDefinition:', resp);
+ toastNotifications.addDanger(
+ `Unable to view examples as an error occurred loading details on category ID ${categoryId}`);
+ });
+
+ }
+
+ function error() {
+ console.log(`viewExamples(): error finding type of field ${categorizationFieldName} in indices:`,
+ datafeedIndices);
+ toastNotifications.addDanger(
+ `Unable to view examples of documents with mlcategory ${categoryId} ` +
+ `as no mapping could be found for the categorization field ${categorizationFieldName}`);
+ }
+ };
+
+ onButtonClick = () => {
+ this.setState(prevState => ({
+ isPopoverOpen: !prevState.isPopoverOpen,
+ }));
+ };
+
+ closePopover = () => {
+ this.setState({
+ isPopoverOpen: false,
+ });
+ };
+
+ render() {
+ const { anomaly, showViewSeriesLink } = this.props;
+
+ const button = (
+
+ Open link
+
+ );
+
+ const items = [];
+ if (anomaly.customUrls !== undefined) {
+ anomaly.customUrls.forEach((customUrl, index) => {
+ items.push(
+ { this.closePopover(); this.openCustomUrl(customUrl); }}
+ >
+ {customUrl.url_name}
+
+ );
+ });
+ }
+
+ if (showViewSeriesLink === true && anomaly.isTimeSeriesViewDetector === true) {
+ items.push(
+ { this.closePopover(); this.viewSeries(); }}
+ >
+ View series
+
+ );
+ }
+
+ if (anomaly.entityName === 'mlcategory') {
+ items.push(
+ { this.closePopover(); this.viewExamples(); }}
+ >
+ View examples
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+}
+
+LinksMenu.propTypes = {
+ anomaly: PropTypes.object.isRequired,
+ showViewSeriesLink: PropTypes.bool,
+ isAggregatedData: PropTypes.bool,
+ interval: PropTypes.string,
+ timefilter: PropTypes.object.isRequired
+};
diff --git a/x-pack/plugins/ml/public/components/anomalies_table/styles/main.less b/x-pack/plugins/ml/public/components/anomalies_table/styles/main.less
index 9527ce538d923..a71d790ce538a 100644
--- a/x-pack/plugins/ml/public/components/anomalies_table/styles/main.less
+++ b/x-pack/plugins/ml/public/components/anomalies_table/styles/main.less
@@ -1,126 +1,114 @@
-ml-anomalies-table {
+.ml-anomalies-table {
+ .ml-icon-severity-critical,
+ .ml-icon-severity-major,
+ .ml-icon-severity-minor,
+ .ml-icon-severity-warning,
+ .ml-icon-severity-unknown {
+ color: inherit;
+ text-shadow: none;
+ }
- .anomalies-table {
- padding: 10px;
+ .ml-icon-severity-critical {
+ .euiIcon {
+ fill: #fe5050;
+ }
+ }
- .no-results-item {
- text-align: center;
- padding-top: 10px;
+ .ml-icon-severity-major {
+ .euiIcon {
+ fill: #fba740;
}
+ }
- .table {
- margin-bottom: 35px;
+ .ml-icon-severity-minor {
+ .euiIcon {
+ fill: #fdec25;
+ }
+ }
- td, th {
- color: #2d2d2d;
- font-size: 12px;
- white-space: nowrap;
+ .ml-icon-severity-warning {
+ .euiIcon {
+ fill: #8bc8fb;
+ }
+ }
- .kuiButton.dropdown-toggle {
- font-size: 12px;
- }
+ .ml-icon-severity-unknown {
+ .euiIcon {
+ fill: #c0c0c0;
+ }
+ }
- .fa.fa-arrow-up, .fa.fa-arrow-down {
- font-size: 11px;
- color: #686868;
- }
+ tr th:first-child,
+ tr td:first-child {
+ width: 32px;
+ }
- button {
- border: none;
- }
- button:focus {
- outline: none;
- }
- }
+ .euiTableCellContent {
+ .euiHealth {
+ font-size: inherit;
+ }
- td {
- button {
- font-family: inherit;
- background-color: transparent;
- }
- }
+ .filter-button {
+ opacity: 0.3;
+ width: 20px;
+ padding-top: 2px;
- th {
- button {
- background-color: #FFFFFF;
- padding: 0px 2px;
- }
+ .euiIcon {
+ width: 14px;
+ height: 14px;
}
}
- .agg-table-paginated {
- overflow-x: auto;
- .dropdown-menu {
- left: -60px;
- }
+ .filter-button:hover {
+ opacity: 1;
}
+ }
- .ml-anomaly-interim-result {
- font-style:italic;
- padding-bottom: 3px;
- }
+ .euiContextMenuItem {
+ min-width: 150px
+ }
- .ml-tablerow-expanded {
- width: 100%;
- padding: 5px 20px;
- overflow: hidden;
- font-size: 12px;
-
- table {
- td {
- padding: 0px 0px 2px 0px;
- button {
- padding: 0px 0px;
- }
- }
+ .category-example {
+ display: block;
+ white-space: nowrap;
+ }
- tr>td:first-child {
- padding-left: 2px;
- vertical-align: top;
- }
+ .interim-result {
+ font-style: italic;
+ }
- td:first-child {
- width: 140px;
- }
+ .ml-anomalies-table-details {
+ padding: 4px 32px;
+ max-height: 1000px;
+ overflow-y: auto;
- td:nth-child(2) {
- padding-left: 5px;
- }
+ .anomaly-description-list {
+
+ .euiDescriptionList__title {
+ margin-top: 0px;
+ flex-basis: 15%;
+ font-size: inherit;
}
- }
+ .euiDescriptionList__description {
+ margin-top: 0px;
+ flex-basis: 85%;
+ font-size: inherit;
+ }
- .ml-tablerow-expanded-heading {
- font-weight: bold;
- display: block;
- padding-top: 5px;
- }
+ .filter-button {
+ height: 20px;
+ padding-top: 2px;
- .ml-tablerow-expanded-heading:first-child {
- padding-top: 0px;
- }
+ .euiButtonIcon__icon {
+ -webkit-transform: translateY(-7px);
+ transform: translateY(-7px);
+ }
+ }
- .ml-tablerow-expanded-mv-description {
- display: block;
}
-
}
-}
-.ml-anomalies-table.dropdown-menu {
- min-width: 120px;
- font-size: 12px;
}
-.ml-anomalies-table.dropdown-menu > li > a {
- color: #444444;
- text-decoration: none;
-}
-
-.ml-anomalies-table.dropdown-menu > li > a:hover,
-.ml-anomalies-table.dropdown-menu > li > a:active,
-.ml-anomalies-table.dropdown-menu > li > a:focus {
- color: #ffffff;
- box-shadow: none;
-}
diff --git a/x-pack/plugins/ml/public/explorer/explorer.html b/x-pack/plugins/ml/public/explorer/explorer.html
index 5f34772052e12..9c6d3cb41ac6d 100644
--- a/x-pack/plugins/ml/public/explorer/explorer.html
+++ b/x-pack/plugins/ml/public/explorer/explorer.html
@@ -141,13 +141,10 @@
No {{swimlaneViewByFieldName}} influencers
-
-
-
-
+
+
diff --git a/x-pack/plugins/ml/public/explorer/explorer_controller.js b/x-pack/plugins/ml/public/explorer/explorer_controller.js
index 8fbff648456d4..88491a11d8bc9 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_controller.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_controller.js
@@ -18,6 +18,7 @@ import DragSelect from 'dragselect';
import moment from 'moment';
import 'plugins/ml/components/anomalies_table';
+import 'plugins/ml/components/controls';
import 'plugins/ml/components/influencers_list';
import 'plugins/ml/components/job_select_list';
@@ -32,10 +33,12 @@ import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege';
import { loadIndexPatterns, getIndexPatterns } from 'plugins/ml/util/index_utils';
import { refreshIntervalWatcher } from 'plugins/ml/util/refresh_interval_watcher';
import { IntervalHelperProvider, getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets';
+import { ml } from 'plugins/ml/services/ml_api_service';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { mlJobService } from 'plugins/ml/services/job_service';
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/job_select_service';
+import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
uiRoutes
.when('/explorer/?', {
@@ -60,6 +63,7 @@ module.controller('MlExplorerController', function (
mlCheckboxShowChartsService,
mlExplorerDashboardService,
mlSelectLimitService,
+ mlSelectIntervalService,
mlSelectSeverityService) {
$scope.timeFieldName = 'timestamp';
@@ -75,12 +79,12 @@ module.controller('MlExplorerController', function (
const $mlExplorer = $('.ml-explorer');
const MAX_INFLUENCER_FIELD_VALUES = 10;
+ const MAX_CATEGORY_EXAMPLES = 10;
const VIEW_BY_JOB_LABEL = 'job ID';
const ALLOW_CELL_RANGE_SELECTION = mlExplorerDashboardService.allowCellRangeSelection;
let disableDragSelectOnMouseLeave = true;
$scope.queryFilters = [];
- $scope.anomalyRecords = [];
const dragSelect = new DragSelect({
selectables: document.querySelectorAll('.sl-cell'),
@@ -309,18 +313,36 @@ module.controller('MlExplorerController', function (
// Returns the time range of the cell(s) currently selected in the swimlane.
// If no cell(s) are currently selected, returns the dashboard time range.
const bounds = timefilter.getActiveBounds();
-
- // time property of the cell data is an array, with the elements being
- // the start times of the first and last cell selected.
- const earliestMs = cellData.time[0] !== undefined ? ((cellData.time[0]) * 1000) : bounds.min.valueOf();
+ let earliestMs = bounds.min.valueOf();
let latestMs = bounds.max.valueOf();
- if (cellData.time[1] !== undefined) {
- // Subtract 1 ms so search does not include start of next bucket.
- latestMs = ((cellData.time[1] + cellData.interval) * 1000) - 1;
+
+ if (cellData !== undefined && cellData.time !== 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 = (cellData.time[0] !== undefined) ? cellData.time[0] * 1000 : bounds.min.valueOf();
+ latestMs = bounds.max.valueOf();
+ if (cellData.time[1] !== undefined) {
+ // Subtract 1 ms so search does not include start of next bucket.
+ latestMs = ((cellData.time[1] + cellData.interval) * 1000) - 1;
+ }
}
+
return { earliestMs, latestMs };
}
+ function getSelectionInfluencers(cellData) {
+ const influencers = [];
+
+ if (cellData !== undefined && cellData.fieldName !== undefined &&
+ cellData.fieldName !== VIEW_BY_JOB_LABEL) {
+ cellData.laneLabels.forEach((laneLabel) =>{
+ influencers.push({ fieldName: $scope.swimlaneViewByFieldName, fieldValue: laneLabel });
+ });
+ }
+
+ return influencers;
+ }
+
// Listener for click events in the swimlane and load corresponding anomaly data.
// Empty cellData is passed on clicking outside a cell with score > 0.
const swimlaneCellClickListener = function (cellData) {
@@ -332,8 +354,6 @@ module.controller('MlExplorerController', function (
}
clearSelectedAnomalies();
} else {
- let jobIds = [];
- const influencers = [];
const timerange = getSelectionTimeRange(cellData);
if (cellData.fieldName === undefined) {
@@ -343,22 +363,15 @@ module.controller('MlExplorerController', function (
$scope.viewByLoadedForTimeFormatted = moment(timerange.earliestMs).format('MMMM Do YYYY, HH:mm');
}
- if (cellData.fieldName === VIEW_BY_JOB_LABEL) {
- jobIds = cellData.laneLabels;
- } else {
- jobIds = $scope.getSelectedJobIds();
-
- if (cellData.fieldName !== undefined) {
- cellData.laneLabels.forEach((laneLabel) =>{
- influencers.push({ fieldName: $scope.swimlaneViewByFieldName, fieldValue: laneLabel });
- });
- }
- }
+ const jobIds = (cellData.fieldName === VIEW_BY_JOB_LABEL) ?
+ cellData.laneLabels : $scope.getSelectedJobIds();
+ const influencers = getSelectionInfluencers(cellData);
$scope.cellData = cellData;
+ loadAnomaliesTableData();
+
const args = [jobIds, influencers, timerange.earliestMs, timerange.latestMs];
- loadAnomalies(...args);
- $scope.loadAnomaliesForCharts(...args);
+ loadDataForCharts(...args);
}
};
mlExplorerDashboardService.swimlaneCellClick.watch(swimlaneCellClickListener);
@@ -387,6 +400,12 @@ module.controller('MlExplorerController', function (
};
mlSelectSeverityService.state.watch(anomalyChartsSeverityListener);
+ const tableControlsListener = function () {
+ loadAnomaliesTableData();
+ };
+ mlSelectIntervalService.state.watch(tableControlsListener);
+ mlSelectSeverityService.state.watch(tableControlsListener);
+
const swimlaneLimitListener = function () {
loadViewBySwimlane([]);
clearSelectedAnomalies();
@@ -405,42 +424,37 @@ module.controller('MlExplorerController', function (
mlExplorerDashboardService.swimlaneCellClick.unwatch(swimlaneCellClickListener);
mlExplorerDashboardService.swimlaneRenderDone.unwatch(swimlaneRenderDoneListener);
mlSelectSeverityService.state.unwatch(anomalyChartsSeverityListener);
+ mlSelectIntervalService.state.unwatch(tableControlsListener);
+ mlSelectSeverityService.state.unwatch(tableControlsListener);
mlSelectLimitService.state.unwatch(swimlaneLimitListener);
- $scope.cellData = undefined;
+ delete $scope.cellData;
refreshWatcher.cancel();
// Cancel listening for updates to the global nav state.
navListener();
});
- $scope.loadAnomaliesForCharts = function (jobIds, influencers, earliestMs, latestMs) {
- // Load the top anomalies (by record_score) which will be displayed in the charts.
- // TODO - combine this with loadAnomalies().
- mlResultsService.getRecordsForInfluencer(
- jobIds, influencers, 0, earliestMs, latestMs, 500
- ).then((resp) => {
- $scope.anomalyChartRecords = resp.records;
- console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
-
- if (mlCheckboxShowChartsService.state.get('showCharts')) {
- mlExplorerDashboardService.anomalyDataChange.changed(
- $scope.anomalyChartRecords, earliestMs, latestMs
- );
- }
- });
- };
-
- function loadAnomalies(jobIds, influencers, earliestMs, latestMs) {
- // Loads the anomalies for the table, plus the scores for
- // the Top Influencers List for the influencers in the anomaly records.
-
+ function loadDataForCharts(jobIds, influencers, earliestMs, latestMs) {
+ // Loads the data used to populate the anomaly charts and the Top Influencers List.
if (influencers.length === 0) {
getTopInfluencers(jobIds, earliestMs, latestMs);
}
+ // Load the top anomalies (by record_score) which will be displayed in the charts.
mlResultsService.getRecordsForInfluencer(
jobIds, influencers, 0, earliestMs, latestMs, 500
)
.then((resp) => {
+ if ($scope.cellData !== undefined && _.keys($scope.cellData).length > 0) {
+ $scope.anomalyChartRecords = resp.records;
+ console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
+
+ if (mlCheckboxShowChartsService.state.get('showCharts')) {
+ mlExplorerDashboardService.anomalyDataChange.changed(
+ $scope.anomalyChartRecords, earliestMs, latestMs
+ );
+ }
+ }
+
if (influencers.length > 0) {
// Filter the Top Influencers list to show just the influencers from
// the records in the selected time range.
@@ -483,13 +497,6 @@ module.controller('MlExplorerController', function (
getTopInfluencers(jobIds, earliestMs, latestMs, filterInfluencers);
}
-
- // Use $evalAsync to ensure the update happens after the child scope is updated with the new data.
- $scope.$evalAsync(() => {
- // Sort in descending time order before storing in scope.
- $scope.anomalyRecords = _.chain(resp.records).sortBy(record => record[$scope.timeFieldName]).reverse().value();
- console.log('Explorer anomalies table data set:', $scope.anomalyRecords);
- });
});
}
@@ -767,10 +774,62 @@ module.controller('MlExplorerController', function (
}
}
+ function loadAnomaliesTableData() {
+ const cellData = $scope.cellData;
+ const jobIds = ($scope.cellData !== undefined && cellData.fieldName === VIEW_BY_JOB_LABEL) ?
+ cellData.laneLabels : $scope.getSelectedJobIds();
+ const influencers = getSelectionInfluencers(cellData);
+ const timeRange = getSelectionTimeRange(cellData);
+
+ ml.results.getAnomaliesTableData(
+ jobIds,
+ [],
+ influencers,
+ mlSelectIntervalService.state.get('interval').val,
+ mlSelectSeverityService.state.get('threshold').val,
+ timeRange.earliestMs,
+ timeRange.latestMs,
+ 500,
+ MAX_CATEGORY_EXAMPLES
+ ).then((resp) => {
+ const anomalies = resp.anomalies;
+ const detectorsByJob = mlJobService.detectorsByJob;
+ anomalies.forEach((anomaly) => {
+ // Add a detector property to each anomaly.
+ // Default to functionDescription if no description available.
+ // TODO - when job_service is moved server_side, move this to server endpoint.
+ const jobId = anomaly.jobId;
+ anomaly.detector = _.get(detectorsByJob,
+ [jobId, anomaly.detectorIndex, 'detector_description'],
+ anomaly.source.function_description);
+
+ // Add properties used for building the links menu.
+ // TODO - when job_service is moved server_side, move this to server endpoint.
+ anomaly.isTimeSeriesViewDetector = isTimeSeriesViewDetector(
+ mlJobService.getJob(jobId), anomaly.detectorIndex);
+ if (_.has(mlJobService.customUrlsByJob, jobId)) {
+ anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
+ }
+ });
+
+ $scope.$evalAsync(() => {
+ $scope.tableData = {
+ anomalies,
+ interval: resp.interval,
+ examplesByJobId: resp.examplesByJobId,
+ showViewSeriesLink: true
+ };
+ });
+
+ }).catch((resp) => {
+ console.log('Explorer - error loading data for anomalies table:', resp);
+ });
+ }
+
function clearSelectedAnomalies() {
$scope.anomalyChartRecords = [];
- $scope.anomalyRecords = [];
$scope.viewByLoadedForTimeFormatted = null;
+ delete $scope.cellData;
// With no swimlane selection, display anomalies over all time in the table.
const jobIds = $scope.getSelectedJobIds();
@@ -778,7 +837,8 @@ module.controller('MlExplorerController', function (
const earliestMs = bounds.min.valueOf();
const latestMs = bounds.max.valueOf();
mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords, earliestMs, latestMs);
- loadAnomalies(jobIds, [], earliestMs, latestMs);
+ loadDataForCharts(jobIds, [], earliestMs, latestMs);
+ loadAnomaliesTableData();
}
function calculateSwimlaneBucketInterval() {
diff --git a/x-pack/plugins/ml/public/explorer/styles/main.less b/x-pack/plugins/ml/public/explorer/styles/main.less
index 16acf6d2f3638..534b8cc73a6f9 100644
--- a/x-pack/plugins/ml/public/explorer/styles/main.less
+++ b/x-pack/plugins/ml/public/explorer/styles/main.less
@@ -2,7 +2,6 @@
width: 100%;
display: inline-block;
color: #555;
- font-size: 10px;
.visualize-error {
h4 {
@@ -333,3 +332,5 @@
}
}
+
+
diff --git a/x-pack/plugins/ml/public/services/ml_api_service/results.js b/x-pack/plugins/ml/public/services/ml_api_service/results.js
index a405eacecdebd..5f7b76ce26865 100644
--- a/x-pack/plugins/ml/public/services/ml_api_service/results.js
+++ b/x-pack/plugins/ml/public/services/ml_api_service/results.js
@@ -15,6 +15,7 @@ const basePath = chrome.addBasePath('/api/ml');
export const results = {
getAnomaliesTableData(
jobIds,
+ criteriaFields,
influencers,
aggregationInterval,
threshold,
@@ -28,6 +29,7 @@ export const results = {
method: 'POST',
data: {
jobIds,
+ criteriaFields,
influencers,
aggregationInterval,
threshold,
diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js b/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js
index 681cda5a6b145..93f7797cfc9e6 100644
--- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js
+++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js
@@ -28,6 +28,7 @@ import {
numTicksForDateFormat
} from 'plugins/ml/util/chart_utils';
import { TimeBuckets } from 'ui/time_buckets';
+import { mlAnomaliesTableService } from 'plugins/ml/components/anomalies_table/anomalies_table_service';
import ContextChartMask from 'plugins/ml/timeseriesexplorer/context_chart_mask';
import { findNearestChartPointToTime } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils';
import { mlEscape } from 'plugins/ml/util/string_utils';
@@ -40,7 +41,6 @@ module.directive('mlTimeseriesChart', function (
$compile,
$timeout,
timefilter,
- mlAnomaliesTableService,
Private,
mlChartTooltipService) {
diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html
index 03b078b7535d8..a3549c52a0dfb 100644
--- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html
+++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html
@@ -127,10 +127,9 @@
-
+ table-data="tableData"
+ filter="filter"
+ />
diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js
index 1a15874f73f5f..19b51da3d65ee 100644
--- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js
+++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js
@@ -16,6 +16,7 @@ import _ from 'lodash';
import moment from 'moment';
import 'plugins/ml/components/anomalies_table';
+import 'plugins/ml/components/controls';
import { notify } from 'ui/notify';
import uiRoutes from 'ui/routes';
@@ -41,6 +42,7 @@ import { IntervalHelperProvider, getBoundsRoundedToInterval } from 'plugins/ml/u
import { mlResultsService } from 'plugins/ml/services/results_service';
import template from './timeseriesexplorer.html';
import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
+import { ml } from 'plugins/ml/services/ml_api_service';
import { mlJobService } from 'plugins/ml/services/job_service';
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/job_select_service';
@@ -70,7 +72,8 @@ module.controller('MlTimeSeriesExplorerController', function (
Private,
timefilter,
AppState,
- mlAnomaliesTableService) {
+ mlSelectIntervalService,
+ mlSelectSeverityService) {
$scope.timeFieldName = 'timestamp';
timefilter.enableTimeRangeSelector();
@@ -341,7 +344,7 @@ module.controller('MlTimeSeriesExplorerController', function (
$scope.refreshFocusData = function (fromDate, toDate) {
- // Counter to keep track of what data sets have been loaded.
+ // Counter to keep track of the queries to populate the chart.
let awaitingCount = 3;
// This object is used to store the results of individual remote requests
@@ -356,7 +359,6 @@ module.controller('MlTimeSeriesExplorerController', function (
awaitingCount--;
if (awaitingCount === 0) {
// Tell the results container directives to render the focus chart.
- // Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data.
refreshFocusData.focusChartData = processDataForFocusAnomalies(
refreshFocusData.focusChartData,
refreshFocusData.anomalyRecords,
@@ -366,7 +368,8 @@ module.controller('MlTimeSeriesExplorerController', function (
refreshFocusData.focusChartData,
refreshFocusData.scheduledEvents);
- // All the data is ready now for a scope update
+ // All the data is ready now for a scope update.
+ // Use $evalAsync to ensure the update happens after the child scope is updated with the new data.
$scope.$evalAsync(() => {
$scope = Object.assign($scope, refreshFocusData);
console.log('Time series explorer focus chart data set:', $scope.focusChartData);
@@ -405,7 +408,7 @@ module.controller('MlTimeSeriesExplorerController', function (
console.log('Time series explorer - error getting metric data from elasticsearch:', resp);
});
- // Query 2 - load records across selected time range.
+ // Query 2 - load all the records across selected time range for the chart anomaly markers.
mlResultsService.getRecordsForCriteria(
[$scope.selectedJob.job_id],
$scope.criteriaFields,
@@ -467,6 +470,9 @@ module.controller('MlTimeSeriesExplorerController', function (
});
}
+ // Load the data for the anomalies table.
+ loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf());
+
};
$scope.saveSeriesPropertiesAndRefresh = function () {
@@ -480,6 +486,19 @@ module.controller('MlTimeSeriesExplorerController', function (
$scope.refresh();
};
+ $scope.filter = function (field, value, operator) {
+ const entity = _.find($scope.entities, { fieldName: field });
+ if (entity !== undefined) {
+ if (operator === '+' && entity.fieldValue !== value) {
+ entity.fieldValue = value;
+ $scope.saveSeriesPropertiesAndRefresh();
+ } else if (operator === '-' && entity.fieldValue === value) {
+ entity.fieldValue = '';
+ $scope.saveSeriesPropertiesAndRefresh();
+ }
+ }
+ };
+
$scope.loadForForecastId = function (forecastId) {
mlForecastService.getForecastDateRange(
$scope.selectedJob,
@@ -548,28 +567,23 @@ module.controller('MlTimeSeriesExplorerController', function (
$scope.refresh();
});
- // Add a listener for filter changes triggered from the anomalies table.
- const filterChangeListener = function (field, value, operator) {
- const entity = _.find($scope.entities, { fieldName: field });
- if (entity !== undefined) {
- if (operator === '+' && entity.fieldValue !== value) {
- entity.fieldValue = value;
- $scope.saveSeriesPropertiesAndRefresh();
- } else if (operator === '-' && entity.fieldValue === value) {
- entity.fieldValue = '';
- $scope.saveSeriesPropertiesAndRefresh();
- }
+ // Reload the anomalies table if the Interval or Threshold controls are changed.
+ const tableControlsListener = function () {
+ if ($scope.zoomFrom !== undefined && $scope.zoomTo !== undefined) {
+ loadAnomaliesTableData($scope.zoomFrom.getTime(), $scope.zoomTo.getTime());
}
};
+ mlSelectIntervalService.state.watch(tableControlsListener);
+ mlSelectSeverityService.state.watch(tableControlsListener);
- mlAnomaliesTableService.filterChange.watch(filterChangeListener);
$scope.$on('$destroy', () => {
refreshWatcher.cancel();
- mlAnomaliesTableService.filterChange.unwatch(filterChangeListener);
+ mlSelectIntervalService.state.unwatch(tableControlsListener);
+ mlSelectSeverityService.state.unwatch(tableControlsListener);
});
- // When inside a dashboard in the ML plugin, listen for changes to job selection.
+ // Listen for changes to job selection.
mlJobSelectService.listenJobSelectionChange($scope, (event, selections) => {
// Clear the detectorIndex, entities and forecast info.
if (selections.length > 0) {
@@ -665,6 +679,49 @@ module.controller('MlTimeSeriesExplorerController', function (
});
}
+ function loadAnomaliesTableData(earliestMs, latestMs) {
+ ml.results.getAnomaliesTableData(
+ [$scope.selectedJob.job_id],
+ $scope.criteriaFields,
+ [],
+ mlSelectIntervalService.state.get('interval').val,
+ mlSelectSeverityService.state.get('threshold').val,
+ earliestMs,
+ latestMs,
+ ANOMALIES_MAX_RESULTS
+ ).then((resp) => {
+ const anomalies = resp.anomalies;
+ const detectorsByJob = mlJobService.detectorsByJob;
+ anomalies.forEach((anomaly) => {
+ // Add a detector property to each anomaly.
+ // Default to functionDescription if no description available.
+ // TODO - when job_service is moved server_side, move this to server endpoint.
+ const jobId = anomaly.jobId;
+ anomaly.detector = _.get(detectorsByJob,
+ [jobId, anomaly.detectorIndex, 'detector_description'],
+ anomaly.source.function_description);
+
+ // Add properties used for building the links menu.
+ // TODO - when job_service is moved server_side, move this to server endpoint.
+ if (_.has(mlJobService.customUrlsByJob, jobId)) {
+ anomaly.customUrls = mlJobService.customUrlsByJob[jobId];
+ }
+ });
+
+ $scope.$evalAsync(() => {
+ $scope.tableData = {
+ anomalies,
+ interval: resp.interval,
+ examplesByJobId: resp.examplesByJobId,
+ showViewSeriesLink: false
+ };
+ });
+
+ }).catch((resp) => {
+ console.log('Time series explorer - error loading data for anomalies table:', resp);
+ });
+ }
+
function updateControlsForDetector() {
// Update the entity dropdown control(s) according to the partitioning fields for the selected detector.
const detectorIndex = +$scope.detectorId;
diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js
index a4761d172ac0e..c59d5e1c86041 100644
--- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js
+++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js
@@ -107,9 +107,9 @@ export function processDataForFocusAnomalies(
const recordTime = record[timeFieldName];
let chartPoint = findNearestChartPointToTime(chartData, recordTime);
- // TODO - handle case where there is an anomaly due to the absense of data
+ // TODO - handle case where there is an anomaly due to the absence of data
// and there is no model plot.
- if (chartPoint === undefined && chartData.length) {
+ if (chartPoint === undefined && chartData !== undefined && chartData.length) {
// In case there is a record with a time after that of the last chart point, set the score
// for the last chart point to that of the last record, if that record has a higher score.
const lastChartPoint = chartData[chartData.length - 1];
@@ -167,6 +167,10 @@ export function processScheduledEventsForChart(chartData, scheduledEvents) {
export function findNearestChartPointToTime(chartData, time) {
let chartPoint;
+ if(chartData === undefined) {
+ return chartPoint;
+ }
+
for (let i = 0; i < chartData.length; i++) {
if (chartData[i].date.getTime() === time) {
chartPoint = chartData[i];
diff --git a/x-pack/plugins/ml/server/models/results_service/build_anomaly_table_items.js b/x-pack/plugins/ml/server/models/results_service/build_anomaly_table_items.js
index 56094b7832ce2..e1755f136dec6 100644
--- a/x-pack/plugins/ml/server/models/results_service/build_anomaly_table_items.js
+++ b/x-pack/plugins/ml/server/models/results_service/build_anomaly_table_items.js
@@ -36,10 +36,14 @@ export function buildAnomalyTableItems(anomalyRecords, aggregationInterval) {
// Fill out the remaining properties in each display record
// for the columns to be displayed in the table.
- return displayRecords.map((record) => {
+ const time = (new Date()).getTime();
+ return displayRecords.map((record, index) => {
const source = record.source;
const jobId = source.job_id;
+ // Identify each row with a unique ID which is used by the table for row expansion.
+ record.rowId = `${time}_${index}`;
+
record.jobId = jobId;
record.detectorIndex = source.detector_index;
record.severity = source.record_score;
@@ -64,29 +68,48 @@ export function buildAnomalyTableItems(anomalyRecords, aggregationInterval) {
record.influencers = influencers;
}
+ // Add fields to the display records for the actual and typical values.
+ // To ensure sorting in the EuiTable works correctly, add extra 'sort'
+ // properties which are single numeric values rather than the underlying arrays.
+ // These properties can be removed if EuiTable sorting logic can be customized
+ // - see https://github.com/elastic/eui/issues/425
const functionDescription = source.function_description || '';
const causes = source.causes || [];
if (showActualForFunction(functionDescription) === true) {
if (source.actual !== undefined) {
record.actual = source.actual;
+ record.actualSort = getMetricSortValue(source.actual);
} else {
// If only a single cause, copy values to the top level.
if (causes.length === 1) {
record.actual = causes[0].actual;
+ record.actualSort = getMetricSortValue(causes[0].actual);
}
}
}
if (showTypicalForFunction(functionDescription) === true) {
if (source.typical !== undefined) {
record.typical = source.typical;
+ record.typicalSort = getMetricSortValue(source.typical);
} else {
// If only a single cause, copy values to the top level.
if (causes.length === 1) {
record.typical = causes[0].typical;
+ record.typicalSort = getMetricSortValue(causes[0].typical);
}
}
}
+ // Add a sortable property for the magnitude of the factor by
+ // which the actual value is different from the typical.
+ if (Array.isArray(record.actual) && record.actual.length === 1 &&
+ Array.isArray(record.typical) && record.typical.length === 1) {
+ const actualVal = Number(record.actual[0]);
+ const typicalVal = Number(record.typical[0]);
+ record.metricDescriptionSort = (actualVal > typicalVal) ?
+ actualVal / typicalVal : typicalVal / actualVal;
+ }
+
return record;
});
@@ -160,3 +183,10 @@ function aggregateAnomalies(anomalyRecords, interval) {
return summaryRecords;
}
+
+function getMetricSortValue(value) {
+ // Returns a sortable value for a metric field (actual and typical values)
+ // from the supplied value, which for metric functions will be a single
+ // valued array.
+ return (Array.isArray(value) && value.length > 0) ? value[0] : value;
+}
diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.js b/x-pack/plugins/ml/server/models/results_service/results_service.js
index 07ddcf983b8b0..7b569c26009a5 100644
--- a/x-pack/plugins/ml/server/models/results_service/results_service.js
+++ b/x-pack/plugins/ml/server/models/results_service/results_service.js
@@ -17,6 +17,7 @@ import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patter
// ML Results dashboards.
const DEFAULT_QUERY_SIZE = 500;
+const DEFAULT_MAX_EXAMPLES = 500;
export function resultsServiceProvider(callWithRequest) {
@@ -28,13 +29,14 @@ export function resultsServiceProvider(callWithRequest) {
// anomalies are categorization anomalies in mlcategory.
async function getAnomaliesTableData(
jobIds,
+ criteriaFields,
influencers,
aggregationInterval,
threshold,
earliestMs,
latestMs,
- maxRecords,
- maxExamples) {
+ maxRecords = DEFAULT_QUERY_SIZE,
+ maxExamples = DEFAULT_MAX_EXAMPLES) {
// Build the query to return the matching anomaly record results.
// Add criteria for the time range, record score, plus any specified job IDs.
@@ -74,6 +76,15 @@ export function resultsServiceProvider(callWithRequest) {
});
}
+ // Add in term queries for each of the specified criteria.
+ criteriaFields.forEach((criteria) => {
+ boolCriteria.push({
+ term: {
+ [criteria.fieldName]: criteria.fieldValue
+ }
+ });
+ });
+
// Add a nested query to filter for each of the specified influencers.
if (influencers.length > 0) {
boolCriteria.push({
@@ -108,7 +119,7 @@ export function resultsServiceProvider(callWithRequest) {
const resp = await callWithRequest('search', {
index: ML_RESULTS_INDEX_PATTERN,
- size: maxRecords !== undefined ? maxRecords : DEFAULT_QUERY_SIZE,
+ size: maxRecords,
body: {
query: {
bool: {
diff --git a/x-pack/plugins/ml/server/routes/results_service.js b/x-pack/plugins/ml/server/routes/results_service.js
index efce4f9c50bf0..07f261a07e0f1 100644
--- a/x-pack/plugins/ml/server/routes/results_service.js
+++ b/x-pack/plugins/ml/server/routes/results_service.js
@@ -15,6 +15,7 @@ function getAnomaliesTableData(callWithRequest, payload) {
const rs = resultsServiceProvider(callWithRequest);
const {
jobIds,
+ criteriaFields,
influencers,
aggregationInterval,
threshold,
@@ -24,6 +25,7 @@ function getAnomaliesTableData(callWithRequest, payload) {
maxExamples } = payload;
return rs.getAnomaliesTableData(
jobIds,
+ criteriaFields,
influencers,
aggregationInterval,
threshold,