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.