diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_config_builder.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_config_builder.test.js.snap
index f899ee14003b7..ca27cd1065623 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_config_builder.test.js.snap
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_config_builder.test.js.snap
@@ -27,6 +27,7 @@ Object {
"entityFields": Array [
Object {
"fieldName": "airline",
+ "fieldType": "partition",
"fieldValue": "JAL",
},
],
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_info_tooltip.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_info_tooltip.test.js.snap
new file mode 100644
index 0000000000000..b331c5e496c26
--- /dev/null
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_info_tooltip.test.js.snap
@@ -0,0 +1,30 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExplorerChartTooltip Render tooltip based on infoTooltip data. 1`] = `
+
+
+
+`;
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_tooltip.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_tooltip.test.js.snap
deleted file mode 100644
index c602bc0373c51..0000000000000
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_chart_tooltip.test.js.snap
+++ /dev/null
@@ -1,24 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ExplorerChartTooltip Render tooltip based on infoTooltip data. 1`] = `
-
- job ID:
- mock-job-id
-
- aggregation interval:
- 15m
-
- chart function:
- avg responsetime
-
-
- airline
- :
- JAL
-
-
-`;
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
index f93ba62f6c04c..e9292ed69d6c5 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap
@@ -2,7 +2,6 @@
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = `
Object {
- "layoutCellsPerChart": 12,
"seriesToPlot": Array [],
"timeFieldName": "timestamp",
"tooManyBuckets": false,
@@ -11,7 +10,7 @@ Object {
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = `
Object {
- "layoutCellsPerChart": 12,
+ "chartsPerRow": 1,
"seriesToPlot": Array [
Object {
"bucketSpanSeconds": 900,
@@ -40,6 +39,7 @@ Object {
"entityFields": Array [
Object {
"fieldName": "airline",
+ "fieldType": "partition",
"fieldValue": "AAL",
},
],
@@ -71,7 +71,7 @@ Object {
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 3`] = `
Object {
- "layoutCellsPerChart": 12,
+ "chartsPerRow": 1,
"seriesToPlot": Array [
Object {
"bucketSpanSeconds": 900,
@@ -582,6 +582,7 @@ Object {
"entityFields": Array [
Object {
"fieldName": "airline",
+ "fieldType": "partition",
"fieldValue": "AAL",
},
],
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label.test.js.snap
index 0654b24dcf60a..3054cc4cf3957 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label.test.js.snap
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label.test.js.snap
@@ -14,28 +14,25 @@ exports[`ExplorerChartLabelBadge Render the chart label in one line. 1`] = `
-
-
-
-
+ }
+ key="nginx.access.remote_ip 72.57.0.53"
+ />
-
-
-
-
-
-
+ }
+ key="nginx.access.remote_ip 72.57.0.53"
+ />
+
`;
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label_badge.test.js.snap b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label_badge.test.js.snap
index 9853aaa7907d9..56ad3c24a885a 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label_badge.test.js.snap
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/__snapshots__/explorer_chart_label_badge.test.js.snap
@@ -5,6 +5,7 @@ exports[`ExplorerChartLabelBadge Render entity label badge. 1`] = `
className="ml-explorer-chart-label-badge"
>
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js
index 218f270f806c5..c6dd2cc357aca 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js
@@ -14,7 +14,7 @@ import {
} from '@elastic/eui';
import { ExplorerChartLabelBadge } from './explorer_chart_label_badge';
-import { ExplorerChartTooltip } from '../../explorer_chart_tooltip';
+import { ExplorerChartInfoTooltip } from '../../explorer_chart_info_tooltip';
export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, wrapLabel = false }) {
// Depending on whether we wrap the entityField badges to a new line, we render this differently:
@@ -32,18 +32,15 @@ export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, w
(entityFields.length === 0 || detectorLabel.length === 0)
) ? ( ) : ( – );
- const entityFieldBadges = entityFields.map((entity) => {
- return (
-
-
-
- );
- });
+ const entityFieldBadges = entityFields.map((entity) => (
+
+ ));
const infoIcon = (
}
+ className="ml-explorer-chart-eui-icon-tip"
+ content={}
position="top"
size="s"
/>
@@ -62,7 +59,7 @@ export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, w
)}
{wrapLabel && (
- {entityFieldBadges}
+ {entityFieldBadges}
)}
);
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label_badge.js b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label_badge.js
index 6a0d34991d895..133005c2226f4 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label_badge.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label_badge.js
@@ -16,7 +16,7 @@ import {
export function ExplorerChartLabelBadge({ entity }) {
return (
-
+
{entity.fieldName} {entity.fieldValue}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label.less b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label.less
index c9065f9404f0e..ecafa9bd4356a 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label.less
@@ -1,33 +1,9 @@
-.ml-explorer-chart-label {
- font-weight: normal;
- /* account 80px for the "View" link and potential alert icon */
- max-width: calc(~"100% - 80px");
- overflow: hidden;
+.ml-explorer-chart-eui-icon-tip {
+ max-width: none;
}
-.ml-explorer-chart-label-detector {
- vertical-align: middle;
- /* account 100px for the "View" link and info icon */
- max-width: calc(~"100% - 100px");
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- display: inline-block;
-}
-
-.ml-explorer-chart-info-icon {
- margin: 0;
- vertical-align: middle;
- display: inline-block;
-}
-
-/* only used when the field badges get wrapped to a new line */
-.ml-explorer-chart-label-fields {
- width: 100%;
- /* use a fixed height to avoid layout issues when
- only some charts don't have entity fields */
- height: 22px;
- overflow: hidden;
- white-space: nowrap;
- line-height: 0;
+.ml-explorer-chart-label-badges {
+ margin-top: 3px;
+ /* let this overflow but not interfere with the flex layout */
+ width: 0;
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label_badge.less b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label_badge.less
index 69b13432f05c9..80a8faa3af65e 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label_badge.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/styles/explorer_chart_label_badge.less
@@ -4,7 +4,6 @@
Used in the Explorer Chart label badge to display an entity's
field_name as `normal` and field_value as `strong`.
*/
-.ml-explorer-chart-label-badge {
+.ml-reset-font-weight {
font-weight: normal;
- vertical-align: middle;
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js
index ab167cb423127..f3dfb2bea62a6 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js
@@ -51,14 +51,16 @@ export function buildConfig(record) {
if (_.has(record, 'partition_field_name')) {
config.entityFields.push({
fieldName: record.partition_field_name,
- fieldValue: record.partition_field_value
+ fieldValue: record.partition_field_value,
+ fieldType: 'partition'
});
}
if (_.has(record, 'over_field_name')) {
config.entityFields.push({
fieldName: record.over_field_name,
- fieldValue: record.over_field_value
+ fieldValue: record.over_field_value,
+ fieldType: 'over'
});
}
@@ -68,7 +70,8 @@ export function buildConfig(record) {
if (_.has(record, 'by_field_name') && !(_.has(record, 'over_field_name'))) {
config.entityFields.push({
fieldName: record.by_field_name,
- fieldValue: record.by_field_value
+ fieldValue: record.by_field_value,
+ fieldType: 'by'
});
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js
new file mode 100644
index 0000000000000..1ffa0f9ed3757
--- /dev/null
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js
@@ -0,0 +1,463 @@
+/*
+ * 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 chart of anomalies in the raw data in
+ * the Machine Learning Explorer dashboard.
+ */
+
+import './styles/explorer_chart.less';
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import _ from 'lodash';
+import d3 from 'd3';
+import $ from 'jquery';
+import moment from 'moment';
+
+// don't use something like plugins/ml/../common
+// because it won't work with the jest tests
+import { formatValue } from '../../formatters/format_value';
+import { getSeverityWithLow } from '../../../common/util/anomaly_utils';
+import {
+ getChartType,
+ getTickValues,
+ numTicksForDateFormat,
+ removeLabelOverlap
+} from '../../util/chart_utils';
+import { TimeBuckets } from 'ui/time_buckets';
+import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
+import { mlEscape } from '../../util/string_utils';
+import { mlFieldFormatService } from '../../services/field_format_service';
+import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
+
+import { CHART_TYPE } from '../explorer_constants';
+
+const CONTENT_WRAPPER_HEIGHT = 215;
+
+export class ExplorerChartDistribution extends React.Component {
+ static propTypes = {
+ seriesConfig: PropTypes.object,
+ mlSelectSeverityService: PropTypes.object.isRequired
+ }
+
+ componentDidMount() {
+ this.renderChart();
+ }
+
+ componentDidUpdate() {
+ this.renderChart();
+ }
+
+ renderChart() {
+ const {
+ tooManyBuckets,
+ mlSelectSeverityService
+ } = this.props;
+
+ const element = this.rootNode;
+ const config = this.props.seriesConfig;
+
+ if (
+ typeof config === 'undefined' ||
+ Array.isArray(config.chartData) === false
+ ) {
+ // just return so the empty directive renders without an error later on
+ return;
+ }
+
+ const fieldFormat = mlFieldFormatService.getFieldFormat(config.jobId, config.detectorIndex);
+
+ let vizWidth = 0;
+ const chartHeight = 170;
+ const LINE_CHART_ANOMALY_RADIUS = 7;
+ const SCHEDULED_EVENT_MARKER_HEIGHT = 5;
+
+ const chartType = getChartType(config);
+
+ // Left margin is adjusted later for longest y-axis label.
+ const margin = { top: 10, right: 0, bottom: 30, left: 0 };
+ if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ margin.left = 60;
+ }
+
+ let lineChartXScale = null;
+ let lineChartYScale = null;
+ let lineChartGroup;
+ let lineChartValuesLine = null;
+
+ const CHART_Y_ATTRIBUTE = (chartType === CHART_TYPE.EVENT_DISTRIBUTION) ? 'entity' : 'value';
+
+ let highlight = config.chartData.find(d => (d.anomalyScore !== undefined));
+ highlight = highlight && highlight.entity;
+
+ const filteredChartData = init(config);
+ drawRareChart(filteredChartData);
+
+ function init({ chartData }) {
+ const $el = $('.ml-explorer-chart');
+
+ // Clear any existing elements from the visualization,
+ // then build the svg elements for the chart.
+ const chartElement = d3.select(element).select('.content-wrapper');
+ chartElement.select('svg').remove();
+
+ const svgWidth = $el.width();
+ const svgHeight = chartHeight + margin.top + margin.bottom;
+
+ const svg = chartElement.append('svg')
+ .classed('ml-explorer-chart-svg', true)
+ .attr('width', svgWidth)
+ .attr('height', svgHeight);
+
+ const categoryLimit = 30;
+ const scaleCategories = d3.nest()
+ .key(d => d.entity)
+ .entries(chartData)
+ .sort((a, b) => {
+ return b.values.length - a.values.length;
+ })
+ .filter((d, i) => {
+ // only filter for rare charts
+ if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
+ return true;
+ }
+ return (i < categoryLimit || d.key === highlight);
+ })
+ .map(d => d.key);
+
+ chartData = chartData.filter((d) => {
+ return (scaleCategories.includes(d.entity));
+ });
+
+ if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ const focusData = chartData.filter((d) => {
+ return d.entity === highlight;
+ }).map(d => d.value);
+ const focusExtent = d3.extent(focusData);
+
+ // now again filter chartData to include only the data points within the domain
+ chartData = chartData.filter((d) => {
+ return (d.value <= focusExtent[1]);
+ });
+
+ lineChartYScale = d3.scale.linear()
+ .range([chartHeight, 0])
+ .domain([0, focusExtent[1]])
+ .nice();
+ } else if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
+ // avoid overflowing the border of the highlighted area
+ const rowMargin = 5;
+ lineChartYScale = d3.scale.ordinal()
+ .rangePoints([rowMargin, chartHeight - rowMargin])
+ .domain(scaleCategories);
+ } else {
+ throw `chartType '${chartType}' not supported`;
+ }
+
+ const yAxis = d3.svg.axis().scale(lineChartYScale)
+ .orient('left')
+ .innerTickSize(0)
+ .outerTickSize(0)
+ .tickPadding(10);
+
+ let maxYAxisLabelWidth = 0;
+ const tempLabelText = svg.append('g')
+ .attr('class', 'temp-axis-label tick');
+ const tempLabelTextData = (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) ? lineChartYScale.ticks() : scaleCategories;
+ tempLabelText.selectAll('text.temp.axis').data(tempLabelTextData)
+ .enter()
+ .append('text')
+ .text((d) => {
+ if (fieldFormat !== undefined) {
+ return fieldFormat.convert(d, 'text');
+ } else {
+ if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ return lineChartYScale.tickFormat()(d);
+ }
+ return d;
+ }
+ })
+ .each(function () {
+ maxYAxisLabelWidth = Math.max(this.getBBox().width + yAxis.tickPadding(), maxYAxisLabelWidth);
+ })
+ .remove();
+ d3.select('.temp-axis-label').remove();
+
+ // Set the size of the left margin according to the width of the largest y axis tick label.
+ if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ margin.left = (Math.max(maxYAxisLabelWidth, 40));
+ }
+ vizWidth = svgWidth - margin.left - margin.right;
+
+ // Set the x axis domain to match the request plot range.
+ // This ensures ranges on different charts will match, even when there aren't
+ // data points across the full range, and the selected anomalous region is centred.
+ lineChartXScale = d3.time.scale()
+ .range([0, vizWidth])
+ .domain([config.plotEarliest, config.plotLatest]);
+
+ lineChartValuesLine = d3.svg.line()
+ .x(d => lineChartXScale(d.date))
+ .y(d => lineChartYScale(d[CHART_Y_ATTRIBUTE]))
+ .defined(d => d.value !== null);
+
+ lineChartGroup = svg.append('g')
+ .attr('class', 'line-chart')
+ .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
+
+ return chartData;
+ }
+
+ function drawRareChart(data) {
+ // Add border round plot area.
+ lineChartGroup.append('rect')
+ .attr('x', 0)
+ .attr('y', 0)
+ .attr('height', chartHeight)
+ .attr('width', vizWidth)
+ .style('stroke', '#cccccc')
+ .style('fill', 'none')
+ .style('stroke-width', 1);
+
+ drawRareChartAxes();
+ drawRareChartHighlightedSpan();
+ drawRareChartDots(data, lineChartGroup, lineChartValuesLine);
+ drawRareChartMarkers(data);
+ }
+
+ function drawRareChartAxes() {
+ // Get the scaled date format to use for x axis tick labels.
+ const timeBuckets = new TimeBuckets();
+ const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) };
+ timeBuckets.setBounds(bounds);
+ timeBuckets.setInterval('auto');
+ const xAxisTickFormat = timeBuckets.getScaledDateFormat();
+
+ const emphasisStart = Math.max(config.selectedEarliest, config.plotEarliest);
+ const emphasisEnd = Math.min(config.selectedLatest, config.plotLatest);
+ // +1 ms to account for the ms that was substracted for query aggregations.
+ const interval = emphasisEnd - emphasisStart + 1;
+ const tickValues = getTickValues(emphasisStart, interval, config.plotEarliest, config.plotLatest);
+
+ const xAxis = d3.svg.axis().scale(lineChartXScale)
+ .orient('bottom')
+ .innerTickSize(-chartHeight)
+ .outerTickSize(0)
+ .tickPadding(10)
+ .tickFormat(d => moment(d).format(xAxisTickFormat));
+
+ // With tooManyBuckets the chart would end up with no x-axis labels
+ // because the ticks are based on the span of the emphasis section,
+ // and the highlighted area spans the whole chart.
+ if (tooManyBuckets === false) {
+ xAxis.tickValues(tickValues);
+ } else {
+ xAxis.ticks(numTicksForDateFormat(vizWidth, xAxisTickFormat));
+ }
+
+ const yAxis = d3.svg.axis().scale(lineChartYScale)
+ .orient('left')
+ .innerTickSize(0)
+ .outerTickSize(0)
+ .tickPadding(10);
+
+ if (fieldFormat !== undefined) {
+ yAxis.tickFormat(d => fieldFormat.convert(d, 'text'));
+ }
+
+ const axes = lineChartGroup.append('g');
+
+ const gAxis = axes.append('g')
+ .attr('class', 'x axis')
+ .attr('transform', 'translate(0,' + chartHeight + ')')
+ .call(xAxis);
+
+ axes.append('g')
+ .attr('class', 'y axis')
+ .call(yAxis);
+
+ if (tooManyBuckets === false) {
+ removeLabelOverlap(gAxis, emphasisStart, interval, vizWidth);
+ }
+ }
+
+ function drawRareChartDots(dotsData, rareChartGroup, rareChartValuesLine, radius = 1.5) {
+ // check if `g.values-dots` already exists, if not create it
+ // in both cases assign the element to `dotGroup`
+ const dotGroup = (rareChartGroup.select('.values-dots').empty())
+ ? rareChartGroup.append('g').classed('values-dots', true)
+ : rareChartGroup.select('.values-dots');
+
+ // use d3's enter/update/exit pattern to render the dots
+ const dots = dotGroup.selectAll('circle').data(dotsData);
+
+ dots.enter().append('circle')
+ .classed('values-dots-circle', true)
+ .classed('values-dots-circle-blur', (d) => {
+ return (d.entity !== highlight);
+ })
+ .attr('r', d => ((d.entity === highlight) ? (radius * 1.5) : radius));
+
+ dots
+ .attr('cx', rareChartValuesLine.x())
+ .attr('cy', rareChartValuesLine.y());
+
+ dots.exit().remove();
+ }
+
+ function drawRareChartHighlightedSpan() {
+ // Draws a rectangle which highlights the time span that has been selected for view.
+ // Note depending on the overall time range and the bucket span, the selected time
+ // span may be longer than the range actually being plotted.
+ const rectStart = Math.max(config.selectedEarliest, config.plotEarliest);
+ const rectEnd = Math.min(config.selectedLatest, config.plotLatest);
+ const rectWidth = lineChartXScale(rectEnd) - lineChartXScale(rectStart);
+
+ lineChartGroup.append('rect')
+ .attr('class', 'selected-interval')
+ .attr('x', lineChartXScale(new Date(rectStart)) + 2)
+ .attr('y', 2)
+ .attr('rx', 3)
+ .attr('ry', 3)
+ .attr('width', rectWidth - 4)
+ .attr('height', chartHeight - 4);
+ }
+
+ function drawRareChartMarkers(data) {
+ // Render circle markers for the points.
+ // These are used for displaying tooltips on mouseover.
+ // Don't render dots where value=null (data gaps)
+ const dots = lineChartGroup.append('g')
+ .attr('class', 'chart-markers')
+ .selectAll('.metric-value')
+ .data(data.filter(d => d.value !== null));
+
+ // Remove dots that are no longer needed i.e. if number of chart points has decreased.
+ dots.exit().remove();
+ // Create any new dots that are needed i.e. if number of chart points has increased.
+ dots.enter().append('circle')
+ .attr('r', LINE_CHART_ANOMALY_RADIUS)
+ .on('mouseover', function (d) {
+ showLineChartTooltip(d, this);
+ })
+ .on('mouseout', () => mlChartTooltipService.hide());
+
+ // Update all dots to new positions.
+ const threshold = mlSelectSeverityService.state.get('threshold');
+ dots.attr('cx', function (d) { return lineChartXScale(d.date); })
+ .attr('cy', function (d) { return lineChartYScale(d[CHART_Y_ATTRIBUTE]); })
+ .attr('class', function (d) {
+ let markerClass = 'metric-value';
+ if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) {
+ markerClass += ' anomaly-marker ';
+ markerClass += getSeverityWithLow(d.anomalyScore);
+ }
+ return markerClass;
+ });
+
+ // Add rectangular markers for any scheduled events.
+ const scheduledEventMarkers = lineChartGroup.select('.chart-markers').selectAll('.scheduled-event-marker')
+ .data(data.filter(d => d.scheduledEvents !== undefined));
+
+ // Remove markers that are no longer needed i.e. if number of chart points has decreased.
+ scheduledEventMarkers.exit().remove();
+ // Create any new markers that are needed i.e. if number of chart points has increased.
+ scheduledEventMarkers.enter().append('rect')
+ .attr('width', LINE_CHART_ANOMALY_RADIUS * 2)
+ .attr('height', SCHEDULED_EVENT_MARKER_HEIGHT)
+ .attr('class', 'scheduled-event-marker')
+ .attr('rx', 1)
+ .attr('ry', 1);
+
+ // Update all markers to new positions.
+ scheduledEventMarkers.attr('x', (d) => lineChartXScale(d.date) - LINE_CHART_ANOMALY_RADIUS)
+ .attr('y', (d) => lineChartYScale(d[CHART_Y_ATTRIBUTE]) - (SCHEDULED_EVENT_MARKER_HEIGHT / 2));
+
+ }
+
+ function showLineChartTooltip(marker, circle) {
+ // Show the time and metric values in the tooltip.
+ // Uses date, value, upper, lower and anomalyScore (optional) marker properties.
+ const formattedDate = moment(marker.date).format('MMMM Do YYYY, HH:mm');
+ let contents = `${formattedDate}
`;
+
+ if (_.has(marker, 'entity')) {
+ contents += `${marker.entity}
`;
+ }
+
+ if (_.has(marker, 'anomalyScore')) {
+ const score = parseInt(marker.anomalyScore);
+ const displayScore = (score > 0 ? score : '< 1');
+ contents += `anomaly score: ${displayScore}`;
+ if (chartType !== CHART_TYPE.EVENT_DISTRIBUTION) {
+ contents += (`
value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`);
+ if (typeof marker.numberOfCauses === 'undefined' || marker.numberOfCauses === 1) {
+ contents += (`
typical: ${formatValue(marker.typical, config.functionDescription, fieldFormat)}`);
+ }
+ if (typeof marker.byFieldName !== 'undefined' && _.has(marker, 'numberOfCauses')) {
+ const numberOfCauses = marker.numberOfCauses;
+ const byFieldName = mlEscape(marker.byFieldName);
+ if (numberOfCauses === 1) {
+ contents += `
1 unusual ${byFieldName} value`;
+ } else if (numberOfCauses < 10) {
+ contents += `
${numberOfCauses} unusual ${byFieldName} values`;
+ } else {
+ // Maximum of 10 causes are stored in the record, so '10' may mean more than 10.
+ contents += `
${numberOfCauses}+ unusual ${byFieldName} values`;
+ }
+ }
+ }
+ } else if (chartType !== CHART_TYPE.EVENT_DISTRIBUTION) {
+ contents += `value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`;
+ }
+
+ if (_.has(marker, 'scheduledEvents')) {
+ contents += `
Scheduled events:
${marker.scheduledEvents.map(mlEscape).join('
')}`;
+ }
+
+ mlChartTooltipService.show(contents, circle, {
+ x: LINE_CHART_ANOMALY_RADIUS * 2,
+ y: 0
+ });
+ }
+ }
+
+ shouldComponentUpdate() {
+ // Always return true, d3 will take care of appropriate re-rendering.
+ return true;
+ }
+
+ setRef(componentNode) {
+ this.rootNode = componentNode;
+ }
+
+ render() {
+ const {
+ seriesConfig
+ } = this.props;
+
+ if (typeof seriesConfig === 'undefined') {
+ // just return so the empty directive renders without an error later on
+ return null;
+ }
+
+ // create a chart loading placeholder
+ const isLoading = seriesConfig.loading;
+
+ return (
+
+ {isLoading && (
+
+ )}
+ {!isLoading && (
+
+ )}
+
+ );
+ }
+}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.js
new file mode 100644
index 0000000000000..5cd4551f68af2
--- /dev/null
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.js
@@ -0,0 +1,84 @@
+/*
+ * 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 './styles/explorer_chart_info_tooltip.less';
+
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { CHART_TYPE } from '../explorer_constants';
+
+const CHART_DESCRIPTION = {
+ [CHART_TYPE.EVENT_DISTRIBUTION]: 'The gray dots depict the distribution of occurences over time for a sample of by_field_values with \
+more frequent event types at the top and rarer ones at the bottom.',
+ [CHART_TYPE.POPULATION_DISTRIBUTION]: 'The gray dots depict the distribution of values over time for a sample of over_field_values.'
+};
+
+import { EuiSpacer } from '@elastic/eui';
+
+function TooltipDefinitionList({ toolTipData }) {
+ return (
+
+ {toolTipData.map(({ title, description }) => (
+
+ - {title}
+ - {description}
+
+ ))}
+
+ );
+}
+
+export function ExplorerChartInfoTooltip({
+ jobId,
+ aggregationInterval,
+ chartFunction,
+ chartType,
+ entityFields = [],
+}) {
+ const chartDescription = CHART_DESCRIPTION[chartType];
+
+ const toolTipData = [
+ {
+ title: 'job ID',
+ description: jobId,
+ },
+ {
+ title: 'aggregation interval',
+ description: aggregationInterval,
+ },
+ {
+ title: 'chart function',
+ description: chartFunction,
+ },
+ ];
+
+ entityFields.forEach((entityField) => {
+ toolTipData.push({
+ title: entityField.fieldName,
+ description: entityField.fieldValue
+ });
+ });
+
+ return (
+
+
+ {chartDescription && (
+
+
+ {chartDescription}
+
+ )}
+
+ );
+}
+ExplorerChartInfoTooltip.propTypes = {
+ jobId: PropTypes.string.isRequired,
+ aggregationInterval: PropTypes.string,
+ chartFunction: PropTypes.string,
+ entityFields: PropTypes.array
+};
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.test.js
similarity index 81%
rename from x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.test.js
rename to x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.test.js
index c83ed6af6333b..6db4360fa7f20 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_info_tooltip.test.js
@@ -8,7 +8,7 @@
import { shallow } from 'enzyme';
import React from 'react';
-import { ExplorerChartTooltip } from './explorer_chart_tooltip';
+import { ExplorerChartInfoTooltip } from './explorer_chart_info_tooltip';
describe('ExplorerChartTooltip', () => {
test('Render tooltip based on infoTooltip data.', () => {
@@ -22,7 +22,7 @@ describe('ExplorerChartTooltip', () => {
jobId: 'mock-job-id'
};
- const wrapper = shallow();
+ const wrapper = shallow();
expect(wrapper).toMatchSnapshot();
});
});
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js
similarity index 98%
rename from x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js
rename to x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js
index c3cdd110a2e6e..2b04b3de05723 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.js
@@ -46,7 +46,7 @@ import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tool
const CONTENT_WRAPPER_HEIGHT = 215;
const CONTENT_WRAPPER_CLASS = 'ml-explorer-chart-content-wrapper';
-export class ExplorerChart extends React.Component {
+export class ExplorerChartSingleMetric extends React.Component {
static propTypes = {
tooManyBuckets: PropTypes.bool,
seriesConfig: PropTypes.object,
@@ -359,8 +359,9 @@ export class ExplorerChart extends React.Component {
if (_.has(marker, 'byFieldName') && _.has(marker, 'numberOfCauses')) {
const numberOfCauses = marker.numberOfCauses;
const byFieldName = mlEscape(marker.byFieldName);
- if (numberOfCauses < 10) {
- // If numberOfCauses === 1, won't go into this block as actual/typical copied to top level fields.
+ if (numberOfCauses === 1) {
+ contents += `
1 unusual ${byFieldName} value`;
+ } else if (numberOfCauses < 10) {
contents += `
${numberOfCauses} unusual ${byFieldName} values`;
} else {
// Maximum of 10 causes are stored in the record, so '10' may mean more than 10.
@@ -384,7 +385,7 @@ export class ExplorerChart extends React.Component {
}
shouldComponentUpdate() {
- // Prevents component re-rendering
+ // Always return true, d3 will take care of appropriate re-rendering.
return true;
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.test.js
similarity index 92%
rename from x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js
rename to x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.test.js
index 9a845cd148ac6..fd922c9805cb8 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_single_metric.test.js
@@ -31,7 +31,7 @@ jest.mock('ui/chrome', () => ({
import { mount } from 'enzyme';
import React from 'react';
-import { ExplorerChart } from './explorer_chart';
+import { ExplorerChartSingleMetric } from './explorer_chart_single_metric';
import { chartLimits } from '../../util/chart_utils';
describe('ExplorerChart', () => {
@@ -49,7 +49,7 @@ describe('ExplorerChart', () => {
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
test('Initialize', () => {
- const wrapper = mount();
+ const wrapper = mount();
// without setting any attributes and corresponding data
// the directive just ends up being empty.
@@ -63,7 +63,7 @@ describe('ExplorerChart', () => {
loading: true
};
- const wrapper = mount();
+ const wrapper = mount();
// test if the loading indicator is shown
expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(1);
@@ -85,7 +85,7 @@ describe('ExplorerChart', () => {
// We create the element including a wrapper which sets the width:
return mount(
-
+
);
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js
deleted file mode 100644
index c0da82233e067..0000000000000
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js
+++ /dev/null
@@ -1,37 +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 PropTypes from 'prop-types';
-import React from 'react';
-
-export function ExplorerChartTooltip({
- jobId,
- aggregationInterval,
- chartFunction,
- entityFields = [],
-}) {
- return (
-
- job ID: {jobId}
- aggregation interval: {aggregationInterval}
- chart function: {chartFunction}
- {entityFields.map((entityField, i) => {
- return (
-
-
{entityField.fieldName}: {entityField.fieldValue}
-
- );
- })}
-
- );
-}
-ExplorerChartTooltip.propTypes = {
- jobId: PropTypes.string.isRequired,
- aggregationInterval: PropTypes.string,
- chartFunction: PropTypes.string,
- entityFields: PropTypes.array
-};
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
index 3b305b54343a4..1e4a335abf583 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js
@@ -9,92 +9,148 @@ import React from 'react';
import {
EuiButtonEmpty,
+ EuiFlexGrid,
+ EuiFlexGroup,
+ EuiFlexItem,
EuiIconTip,
EuiToolTip
} from '@elastic/eui';
import {
+ getChartType,
getExploreSeriesLink,
isLabelLengthAboveThreshold
} from '../../util/chart_utils';
-import { ExplorerChart } from './explorer_chart';
+import { ExplorerChartDistribution } from './explorer_chart_distribution';
+import { ExplorerChartSingleMetric } from './explorer_chart_single_metric';
import { ExplorerChartLabel } from './components/explorer_chart_label';
+import { CHART_TYPE } from '../explorer_constants';
+
const textTooManyBuckets = `This selection contains too many buckets to be displayed.
The dashboard is best viewed over a shorter time range.`;
const textViewButton = 'Open in Single Metric Viewer';
+// create a somewhat unique ID
+// from charts metadata for React's key attribute
+function getChartId(series) {
+ const {
+ jobId,
+ detectorLabel,
+ entityFields
+ } = series;
+ const entities = entityFields.map((ef) => `${ef.fieldName}/${ef.fieldValue}`).join(',');
+ const id = `${jobId}_${detectorLabel}_${entities}`;
+ return id;
+}
+
+// Wrapper for a single explorer chart
+function ExplorerChartContainer({
+ series,
+ tooManyBuckets,
+ mlSelectSeverityService,
+ wrapLabel
+}) {
+ const {
+ detectorLabel,
+ entityFields
+ } = series;
+
+ const chartType = getChartType(series);
+
+ return (
+
+
+
+
+
+
+
+ {tooManyBuckets && (
+
+
+
+ )}
+
+ window.open(getExploreSeriesLink(series), '_blank')}
+ >
+ View
+
+
+
+
+
+ {(() => {
+ if (chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })()}
+
+ );
+}
+
+// Flex layout wrapper for all explorer charts
export function ExplorerChartsContainer({
+ chartsPerRow,
seriesToPlot,
- layoutCellsPerChart,
tooManyBuckets,
mlSelectSeverityService
}) {
+ // 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:
+ const chartsWidth = (chartsPerRow === 1) ? 'calc(100% - 20px)' : 'auto';
+ const chartsColumns = (chartsPerRow === 1) ? 0 : chartsPerRow;
+
const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series));
return (
-
- {(seriesToPlot.length > 0) &&
- seriesToPlot.map((series) => {
-
- // create a somewhat unique ID from charts metadata for React's key attribute
- const {
- jobId,
- detectorLabel,
- entityFields,
- } = series;
- const entities = entityFields.map((ef) => `${ef.fieldName}/${ef.fieldValue}`).join(',');
- const id = `${jobId}_${detectorLabel}_${entities}`;
-
- return (
-
-
- {tooManyBuckets && (
-
-
-
- )}
-
- window.open(getExploreSeriesLink(series), '_blank')}
- >
- View
-
-
-
-
-
-
- );
- })
- }
-
+
+ {(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
+
+
+
+ ))}
+
);
}
ExplorerChartsContainer.propTypes = {
seriesToPlot: PropTypes.array.isRequired,
- layoutCellsPerChart: PropTypes.number.isRequired,
tooManyBuckets: PropTypes.bool.isRequired,
mlSelectSeverityService: PropTypes.object.isRequired,
mlChartTooltipService: PropTypes.object.isRequired
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js
index 1036caccfe168..415e277417cff 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js
@@ -61,7 +61,7 @@ timefilter.setTime({
to: moment(seriesConfig.selectedLatest).toISOString()
});
-import { shallow } from 'enzyme';
+import { shallow, mount } from 'enzyme';
import React from 'react';
import { chartLimits } from '../../util/chart_utils';
@@ -86,23 +86,23 @@ describe('ExplorerChartsContainer', () => {
test('Minimal Initialization', () => {
const wrapper = shallow();
- expect(wrapper.html()).toBe('');
+ expect(wrapper.html()).toBe('');
});
test('Initialization with chart data', () => {
- const wrapper = shallow( {
// We test child components with snapshots separately
// so we just do some high level sanity check here.
- expect(wrapper.find('.ml-explorer-chart-container').children()).toHaveLength(3);
+ expect(wrapper.find('.ml-explorer-chart-container').children()).toHaveLength(2);
});
});
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
index 45f2ae31cac2e..44a2287675a88 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_directive.js
@@ -52,8 +52,8 @@ module.directive('mlExplorerChartsContainer', function (
function updateComponent(data) {
const props = {
+ chartsPerRow: data.chartsPerRow,
seriesToPlot: data.seriesToPlot,
- layoutCellsPerChart: data.layoutCellsPerChart,
// convert truthy/falsy value to Boolean
tooManyBuckets: !!data.tooManyBuckets,
mlSelectSeverityService,
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.js
index 7116488c41a1e..11b1f14bec36e 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.js
@@ -16,11 +16,15 @@
import _ from 'lodash';
import { buildConfig } from './explorer_chart_config_builder';
-import { chartLimits } from '../../util/chart_utils';
+import {
+ chartLimits,
+ getChartType
+} from '../../util/chart_utils';
import { isTimeSeriesViewDetector } from '../../../common/util/job_utils';
import { mlResultsService } from '../../services/results_service';
import { mlJobService } from '../../services/job_service';
+import { CHART_TYPE } from '../explorer_constants';
export function explorerChartsContainerServiceFactory(
mlSelectSeverityService,
@@ -33,14 +37,12 @@ export function explorerChartsContainerServiceFactory(
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 DEFAULT_LAYOUT_CELLS_PER_CHART = 12;
const MAX_CHARTS_PER_ROW = 4;
function getDefaultData() {
return {
seriesToPlot: [],
// default values, will update on every re-render
- layoutCellsPerChart: DEFAULT_LAYOUT_CELLS_PER_CHART,
tooManyBuckets: false,
timeFieldName: 'timestamp'
};
@@ -67,7 +69,7 @@ export function explorerChartsContainerServiceFactory(
chartsPerRow = 1;
}
- data.layoutCellsPerChart = DEFAULT_LAYOUT_CELLS_PER_CHART / chartsPerRow;
+ data.chartsPerRow = chartsPerRow;
// Build the data configs of the anomalies to be displayed.
// TODO - implement paging?
@@ -144,6 +146,38 @@ export function explorerChartsContainerServiceFactory(
);
}
+ // 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');
+ }
+
+ const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
+ return mlResultsService.getEventDistributionData(
+ config.datafeedConfig.indices,
+ config.datafeedConfig.types,
+ splitField,
+ filterField,
+ datafeedQuery,
+ config.metricFunction,
+ config.metricFieldName,
+ config.timeField,
+ range.min,
+ range.max,
+ config.interval
+ );
+ }
+
// 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,
@@ -151,7 +185,8 @@ export function explorerChartsContainerServiceFactory(
const seriesPromises = seriesConfigs.map(seriesConfig => Promise.all([
getMetricData(seriesConfig, chartRange),
getRecordsForCriteria(seriesConfig, chartRange),
- getScheduledEvents(seriesConfig, chartRange)
+ getScheduledEvents(seriesConfig, chartRange),
+ getEventDistribution(seriesConfig, chartRange)
]));
function processChartData(response, seriesIndex) {
@@ -159,6 +194,8 @@ export function explorerChartsContainerServiceFactory(
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]);
// Return dataset in format used by the chart.
// i.e. array of Objects with keys date (timestamp), value,
@@ -167,18 +204,34 @@ export function explorerChartsContainerServiceFactory(
return [];
}
- const chartData = _.map(metricData, (value, time) => ({
- date: +time,
- value: value
- }));
+ let chartData;
+ 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) => {
+ if (value > 0) {
+ 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, find closest time in chartData set.
const recordTime = record[ML_TIME_FIELD_NAME];
- let chartPoint = findNearestChartPointToTime(chartData, recordTime);
+ let chartPoint = findNearestChartPointToTime(chartDataForPointSearch, recordTime);
if (chartPoint === undefined) {
// In case there is a record with a time after that of the last chart point, set the score
@@ -220,7 +273,7 @@ export function explorerChartsContainerServiceFactory(
// which correspond to times of scheduled events for the job.
if (scheduledEvents !== undefined) {
_.each(scheduledEvents, (events, time) => {
- const chartPoint = findNearestChartPointToTime(chartData, time);
+ const chartPoint = findNearestChartPointToTime(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.
@@ -232,6 +285,19 @@ export function explorerChartsContainerServiceFactory(
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));
+ });
+ }
+
+ return chartData;
+ }
+
function findNearestChartPointToTime(chartData, time) {
let chartPoint;
for (let i = 0; i < chartData.length; i++) {
@@ -492,8 +558,8 @@ export function explorerChartsContainerServiceFactory(
if ((maxMs - minMs) < maxTimeSpan) {
// Expand out to cover as much as the requested time span as possible.
- minMs = Math.max(earliestMs, maxMs - maxTimeSpan);
- maxMs = Math.min(latestMs, minMs + maxTimeSpan);
+ minMs = Math.max(earliestMs, minMs - maxTimeSpan);
+ maxMs = Math.min(latestMs, maxMs + maxTimeSpan);
}
chartRange = { min: minMs, max: maxMs };
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js
index df97ff35bd07b..c966bb11721d0 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js
@@ -26,6 +26,9 @@ jest.mock('../../services/results_service', () => ({
},
getScheduledEventsByBucket() {
return Promise.resolve(mockSeriesPromisesResponse[0][2]);
+ },
+ getEventDistributionData() {
+ return Promise.resolve([]);
}
}
}));
@@ -54,7 +57,6 @@ const mockChartContainer = {
function mockGetDefaultData() {
return {
seriesToPlot: [],
- layoutCellsPerChart: 12,
tooManyBuckets: false,
timeFieldName: 'timestamp'
};
@@ -82,7 +84,7 @@ describe('explorerChartsContainerService', () => {
callbackData.push(mockGetDefaultData());
callbackData.push({
...mockGetDefaultData(),
- layoutCellsPerChart: 6
+ chartsPerRow: 2
});
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
@@ -99,7 +101,9 @@ describe('explorerChartsContainerService', () => {
function callback(data) {
if (callbackData.length > 0) {
- expect(data).toEqual(callbackData.shift());
+ expect(data).toEqual({
+ ...callbackData.shift()
+ });
}
if (callbackData.length === 0) {
done();
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
index f11fb405d0559..6ab1382384078 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less
@@ -1,7 +1,5 @@
-ml-explorer-chart,
.ml-explorer-chart-container {
- display: block;
- padding-bottom: 10px;
+ overflow: hidden;
.ml-explorer-chart-svg {
font-size: 12px;
@@ -54,11 +52,16 @@ ml-explorer-chart,
stroke-width: 2;
}
- .values-dots circle {
+ .values-dots circle,
+ .values-dots-circle {
fill: #32a7c2;
stroke-width: 0;
}
+ .values-dots circle.values-dots-circle-blur {
+ fill: #aaa;
+ }
+
.metric-value {
opacity: 1;
fill: #32a7c2;
@@ -107,6 +110,10 @@ ml-explorer-chart,
}
}
+.ml-explorer-chart {
+ overflow: hidden;
+}
+
.ml-explorer-chart-content-wrapper {
height: 215px;
}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart_info_tooltip.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart_info_tooltip.less
new file mode 100644
index 0000000000000..0139ae1259478
--- /dev/null
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart_info_tooltip.less
@@ -0,0 +1,34 @@
+.ml-explorer-chart-info-tooltip {
+ max-width: 384px;
+}
+
+.ml-explorer-chart-description {
+ font-size: 12px;
+ font-style: italic;
+}
+
+.ml-explorer-chart-info-tooltip .mlDescriptionList > * {
+ margin-top: 3px;
+}
+
+.ml-explorer-chart-info-tooltip .mlDescriptionList {
+ display: grid;
+ grid-template-columns: max-content auto;
+
+ .mlDescriptionList__title {
+ color: #fff;
+ font-size: 12px;
+ font-weight: normal;
+ white-space: nowrap;
+ grid-column-start: 1;
+ }
+
+ .mlDescriptionList__description {
+ color: #fff;
+ font-size: 12px;
+ font-weight: bold;
+ padding-left: 8px;
+ max-width: 256px;
+ grid-column-start: 2;
+ }
+}
diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
index 04a55fb0bc316..36f23bff87bf4 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
+++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_charts_container.less
@@ -104,7 +104,6 @@
/* wrapper class for the top right alert icon and view button */
.ml-explorer-chart-icons {
- float:right;
padding-left: 5px;
/* counter-margin for EuiButtonEmpty's padding */
margin: 2px -8px 0 0;
diff --git a/x-pack/plugins/ml/public/explorer/explorer_constants.js b/x-pack/plugins/ml/public/explorer/explorer_constants.js
index 7d69bc563b3f4..0d4a54c6860c4 100644
--- a/x-pack/plugins/ml/public/explorer/explorer_constants.js
+++ b/x-pack/plugins/ml/public/explorer/explorer_constants.js
@@ -20,3 +20,9 @@ export const SWIMLANE_TYPE = {
OVERALL: 'overall',
VIEW_BY: 'viewBy'
};
+
+export const CHART_TYPE = {
+ EVENT_DISTRIBUTION: 'event_distribution',
+ POPULATION_DISTRIBUTION: 'population_distribution',
+ SINGLE_METRIC: 'single_metric',
+};
diff --git a/x-pack/plugins/ml/public/services/results_service.js b/x-pack/plugins/ml/public/services/results_service.js
index 6cd9e8ef6e3d4..5973d94e724be 100644
--- a/x-pack/plugins/ml/public/services/results_service.js
+++ b/x-pack/plugins/ml/public/services/results_service.js
@@ -9,6 +9,7 @@
// Service for carrying out Elasticsearch queries to obtain data for the
// Ml Results dashboards.
import _ from 'lodash';
+// import d3 from 'd3';
import { ML_MEDIAN_PERCENTS } from '../../common/util/job_utils';
import { escapeForElasticsearchQuery } from '../util/string_utils';
@@ -1391,6 +1392,146 @@ function getEventRateData(
});
}
+// Queries Elasticsearch to obtain event distribution i.e. the count
+// of entities over time.
+// index can be a String, or String[], of index names to search.
+// Extra query object can be supplied, or pass null if no additional query.
+// Returned response contains a results property, which is an object
+// of document counts against time (epoch millis).
+const SAMPLER_TOP_TERMS_SHARD_SIZE = 200;
+function getEventDistributionData(
+ index,
+ types,
+ splitField,
+ filterField = null,
+ query,
+ metricFunction,
+ metricFieldName,
+ timeFieldName,
+ earliestMs,
+ latestMs,
+ interval) {
+ return new Promise((resolve, reject) => {
+ // only get this data for count (used by rare chart)
+ if (metricFunction !== 'count' || splitField === undefined) {
+ return resolve([]);
+ }
+
+ // Build the criteria to use in the bool filter part of the request.
+ // Add criteria for the types, time range, entity fields,
+ // plus any additional supplied query.
+ const mustCriteria = [];
+ const shouldCriteria = [];
+
+ if (types && types.length) {
+ mustCriteria.push({ terms: { _type: types } });
+ }
+
+ mustCriteria.push({
+ range: {
+ [timeFieldName]: {
+ gte: earliestMs,
+ lte: latestMs,
+ format: 'epoch_millis'
+ }
+ }
+ });
+
+ if (query) {
+ mustCriteria.push(query);
+ }
+
+ if (filterField !== null) {
+ mustCriteria.push({
+ term: {
+ [filterField.fieldName]: filterField.fieldValue
+ }
+ });
+ }
+
+ const body = {
+ query: {
+ bool: {
+ must: mustCriteria
+ }
+ },
+ size: 0,
+ _source: {
+ excludes: []
+ },
+ aggs: {
+ byTime: {
+ date_histogram: {
+ field: timeFieldName,
+ interval: interval,
+ min_doc_count: 0
+ },
+ aggs: {
+ sample: {
+ sampler: {
+ shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE
+ },
+ aggs: {
+ entities: {
+ terms: {
+ field: splitField.fieldName,
+ size: 10
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+
+ if (shouldCriteria.length > 0) {
+ body.query.bool.should = shouldCriteria;
+ body.query.bool.minimum_should_match = shouldCriteria.length / 2;
+ }
+
+ if (metricFieldName !== undefined && metricFieldName !== '') {
+ body.aggs.byTime.aggs = {};
+
+ const metricAgg = {
+ [metricFunction]: {
+ field: metricFieldName
+ }
+ };
+
+ if (metricFunction === 'percentiles') {
+ metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS];
+ }
+ body.aggs.byTime.aggs.metric = metricAgg;
+ }
+
+ ml.esSearch({
+ index,
+ body
+ })
+ .then((resp) => {
+ // normalize data
+ const dataByTime = _.get(resp, ['aggregations', 'byTime', 'buckets'], []);
+ const data = dataByTime.reduce((d, dataForTime) => {
+ const date = +dataForTime.key;
+ const entities = _.get(dataForTime, ['sample', 'entities', 'buckets'], []);
+ entities.forEach((entity) => {
+ d.push({
+ date,
+ entity: entity.key,
+ value: entity.doc_count
+ });
+ });
+ return d;
+ }, []);
+ resolve(data);
+ })
+ .catch((resp) => {
+ reject(resp);
+ });
+ });
+}
+
function getModelPlotOutput(
jobId,
detectorIndex,
@@ -1663,6 +1804,7 @@ export const mlResultsService = {
getRecordsForCriteria,
getMetricData,
getEventRateData,
+ getEventDistributionData,
getModelPlotOutput,
getRecordMaxScoreByTime
};
diff --git a/x-pack/plugins/ml/public/util/chart_utils.js b/x-pack/plugins/ml/public/util/chart_utils.js
index e1433a352cb95..4cf3293db01fd 100644
--- a/x-pack/plugins/ml/public/util/chart_utils.js
+++ b/x-pack/plugins/ml/public/util/chart_utils.js
@@ -15,6 +15,7 @@ import rison from 'rison-node';
import chrome from 'ui/chrome';
import { timefilter } from 'ui/timefilter';
+import { CHART_TYPE } from '../explorer/explorer_constants';
export const LINE_CHART_ANOMALY_RADIUS = 7;
export const MULTI_BUCKET_SYMBOL_SIZE = 144; // In square pixels for use with d3 symbol.size
@@ -127,6 +128,29 @@ export function filterAxisLabels(selection, chartWidth) {
});
}
+// feature flags for chart types
+const EVENT_DISTRIBUTION_ENABLED = true;
+const POPULATION_DISTRIBUTION_ENABLED = true;
+
+// get the chart type based on its configuration
+export function getChartType(config) {
+ if (
+ EVENT_DISTRIBUTION_ENABLED &&
+ config.functionDescription === 'rare' &&
+ (config.entityFields.some(f => f.fieldType === 'over') === false)
+ ) {
+ return CHART_TYPE.EVENT_DISTRIBUTION;
+ } else if (
+ POPULATION_DISTRIBUTION_ENABLED &&
+ config.functionDescription === 'count' &&
+ config.entityFields.some(f => f.fieldType === 'over')
+ ) {
+ return CHART_TYPE.POPULATION_DISTRIBUTION;
+ }
+
+ return CHART_TYPE.SINGLE_METRIC;
+}
+
export function getExploreSeriesLink(series) {
// Open the Single Metric dashboard over the same overall bounds and
// zoomed in to the same time as the current chart.