[ML] Anomaly Explorer Rare/Population Charts (#23423)

This PR introduces custom charts for detectors that use a rare function (Event Distribution Chart) as well as detectors that use an over field (Population Distribution Chart).
This commit is contained in:
Walter Rafelsberger 2018-10-05 17:07:45 +02:00 committed by GitHub
parent 584100198f
commit c4ee9dd87e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1067 additions and 240 deletions

View file

@ -27,6 +27,7 @@ Object {
"entityFields": Array [
Object {
"fieldName": "airline",
"fieldType": "partition",
"fieldValue": "JAL",
},
],

View file

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerChartTooltip Render tooltip based on infoTooltip data. 1`] = `
<div
className="ml-explorer-chart-info-tooltip"
>
<TooltipDefinitionList
toolTipData={
Array [
Object {
"description": "mock-job-id",
"title": "job ID",
},
Object {
"description": "15m",
"title": "aggregation interval",
},
Object {
"description": "avg responsetime",
"title": "chart function",
},
Object {
"description": "JAL",
"title": "airline",
},
]
}
/>
</div>
`;

View file

@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerChartTooltip Render tooltip based on infoTooltip data. 1`] = `
<div
className="explorer-chart-info-tooltip"
>
job ID:
mock-job-id
<br />
aggregation interval:
15m
<br />
chart function:
avg responsetime
<span
key="airline_JAL_0"
>
<br />
airline
:
JAL
</span>
</div>
`;

View file

@ -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",
},
],

View file

@ -14,28 +14,25 @@ exports[`ExplorerChartLabelBadge Render the chart label in one line. 1`] = `
</React.Fragment>
</span>
<React.Fragment>
<React.Fragment
key="nginx.access.remote_ip 72.57.0.53"
>
<ExplorerChartLabelBadge
entity={
Object {
"$$hashKey": "object:813",
"fieldName": "nginx.access.remote_ip",
"fieldValue": "72.57.0.53",
}
<ExplorerChartLabelBadge
entity={
Object {
"$$hashKey": "object:813",
"fieldName": "nginx.access.remote_ip",
"fieldValue": "72.57.0.53",
}
/>
 
</React.Fragment>
}
key="nginx.access.remote_ip 72.57.0.53"
/>
<span
className="ml-explorer-chart-info-icon"
>
<EuiIconTip
aria-label="Info"
className="ml-explorer-chart-eui-icon-tip"
content={
<ExplorerChartTooltip
<ExplorerChartInfoTooltip
aggregationInterval="1h"
chartFunction="sum nginx.access.body_sent.bytes"
entityFields={
@ -77,8 +74,9 @@ exports[`ExplorerChartLabelBadge Render the chart label in two lines. 1`] = `
>
<EuiIconTip
aria-label="Info"
className="ml-explorer-chart-eui-icon-tip"
content={
<ExplorerChartTooltip
<ExplorerChartInfoTooltip
aggregationInterval="1h"
chartFunction="sum nginx.access.body_sent.bytes"
entityFields={
@ -98,23 +96,19 @@ exports[`ExplorerChartLabelBadge Render the chart label in two lines. 1`] = `
/>
</span>
</span>
<div
className="ml-explorer-chart-label-fields"
<span
className="ml-explorer-chart-label-badges"
>
<React.Fragment
key="nginx.access.remote_ip 72.57.0.53"
>
<ExplorerChartLabelBadge
entity={
Object {
"$$hashKey": "object:813",
"fieldName": "nginx.access.remote_ip",
"fieldValue": "72.57.0.53",
}
<ExplorerChartLabelBadge
entity={
Object {
"$$hashKey": "object:813",
"fieldName": "nginx.access.remote_ip",
"fieldValue": "72.57.0.53",
}
/>
 
</React.Fragment>
</div>
}
key="nginx.access.remote_ip 72.57.0.53"
/>
</span>
</React.Fragment>
`;

View file

@ -5,6 +5,7 @@ exports[`ExplorerChartLabelBadge Render entity label badge. 1`] = `
className="ml-explorer-chart-label-badge"
>
<EuiBadge
className="ml-reset-font-weight"
color="hollow"
iconSide="left"
>

View file

@ -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)
) ? (<React.Fragment>&nbsp;</React.Fragment>) : (<React.Fragment>&nbsp;&ndash;&nbsp;</React.Fragment>);
const entityFieldBadges = entityFields.map((entity) => {
return (
<React.Fragment key={`${entity.fieldName} ${entity.fieldValue}`}>
<ExplorerChartLabelBadge entity={entity} />&nbsp;
</React.Fragment>
);
});
const entityFieldBadges = entityFields.map((entity) => (
<ExplorerChartLabelBadge entity={entity} key={`${entity.fieldName} ${entity.fieldValue}`} />
));
const infoIcon = (
<span className="ml-explorer-chart-info-icon">
<EuiIconTip
content={<ExplorerChartTooltip {...infoTooltip} />}
className="ml-explorer-chart-eui-icon-tip"
content={<ExplorerChartInfoTooltip {...infoTooltip} />}
position="top"
size="s"
/>
@ -62,7 +59,7 @@ export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, w
)}
</span>
{wrapLabel && (
<div className="ml-explorer-chart-label-fields">{entityFieldBadges}</div>
<span className="ml-explorer-chart-label-badges">{entityFieldBadges}</span>
)}
</React.Fragment>
);

View file

@ -16,7 +16,7 @@ import {
export function ExplorerChartLabelBadge({ entity }) {
return (
<span className="ml-explorer-chart-label-badge">
<EuiBadge color="hollow">
<EuiBadge color="hollow" className="ml-reset-font-weight">
{entity.fieldName} <strong>{entity.fieldValue}</strong>
</EuiBadge>
</span>

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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'
});
}

View file

@ -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}<br/><hr/>`;
if (_.has(marker, 'entity')) {
contents += `<div>${marker.entity}</div>`;
}
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 += (`<br/>value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`);
if (typeof marker.numberOfCauses === 'undefined' || marker.numberOfCauses === 1) {
contents += (`<br/>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 += `<br/> 1 unusual ${byFieldName} value`;
} else if (numberOfCauses < 10) {
contents += `<br/> ${numberOfCauses} unusual ${byFieldName} values`;
} else {
// Maximum of 10 causes are stored in the record, so '10' may mean more than 10.
contents += `<br/> ${numberOfCauses}+ unusual ${byFieldName} values`;
}
}
}
} else if (chartType !== CHART_TYPE.EVENT_DISTRIBUTION) {
contents += `value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`;
}
if (_.has(marker, 'scheduledEvents')) {
contents += `<div><hr/>Scheduled events:<br/>${marker.scheduledEvents.map(mlEscape).join('<br/>')}</div>`;
}
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 (
<div className="ml-explorer-chart" ref={this.setRef.bind(this)} >
{isLoading && (
<LoadingIndicator height={CONTENT_WRAPPER_HEIGHT} />
)}
{!isLoading && (
<div className="content-wrapper" />
)}
</div>
);
}
}

View file

@ -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 (
<dl className="mlDescriptionList">
{toolTipData.map(({ title, description }) => (
<React.Fragment key={`${title} ${description}`}>
<dt className="mlDescriptionList__title">{title}</dt>
<dd className="mlDescriptionList__description">{description}</dd>
</React.Fragment>
))}
</dl>
);
}
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 (
<div className="ml-explorer-chart-info-tooltip">
<TooltipDefinitionList toolTipData={toolTipData} />
{chartDescription && (
<React.Fragment>
<EuiSpacer size="s" />
<div className="ml-explorer-chart-description">{chartDescription}</div>
</React.Fragment>
)}
</div>
);
}
ExplorerChartInfoTooltip.propTypes = {
jobId: PropTypes.string.isRequired,
aggregationInterval: PropTypes.string,
chartFunction: PropTypes.string,
entityFields: PropTypes.array
};

View file

@ -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(<ExplorerChartTooltip {...infoTooltip} />);
const wrapper = shallow(<ExplorerChartInfoTooltip {...infoTooltip} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -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 += `<br/> 1 unusual ${byFieldName} value`;
} else if (numberOfCauses < 10) {
contents += `<br/> ${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;
}

View file

@ -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(<ExplorerChart mlSelectSeverityService={mlSelectSeverityServiceMock} />);
const wrapper = mount(<ExplorerChartSingleMetric mlSelectSeverityService={mlSelectSeverityServiceMock} />);
// 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(<ExplorerChart seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />);
const wrapper = mount(<ExplorerChartSingleMetric seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />);
// 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(
<div style={{ width: '500px' }}>
<ExplorerChart seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />
<ExplorerChartSingleMetric seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />
</div>
);
}

View file

@ -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 (
<div className="explorer-chart-info-tooltip">
job ID: {jobId}<br />
aggregation interval: {aggregationInterval}<br />
chart function: {chartFunction}
{entityFields.map((entityField, i) => {
return (
<span key={`${entityField.fieldName}_${entityField.fieldValue}_${i}`}>
<br />{entityField.fieldName}: {entityField.fieldValue}
</span>
);
})}
</div>
);
}
ExplorerChartTooltip.propTypes = {
jobId: PropTypes.string.isRequired,
aggregationInterval: PropTypes.string,
chartFunction: PropTypes.string,
entityFields: PropTypes.array
};

View file

@ -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 (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<ExplorerChartLabel
detectorLabel={detectorLabel}
entityFields={entityFields}
infoTooltip={{ ...series.infoTooltip, chartType }}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div className="ml-explorer-chart-icons">
{tooManyBuckets && (
<span className="ml-explorer-chart-icon">
<EuiIconTip
content={textTooManyBuckets}
position="top"
size="s"
type="alert"
color="warning"
/>
</span>
)}
<EuiToolTip
position="top"
content={textViewButton}
>
<EuiButtonEmpty
iconSide="right"
iconType="popout"
size="xs"
onClick={() => window.open(getExploreSeriesLink(series), '_blank')}
>
View
</EuiButtonEmpty>
</EuiToolTip>
</div>
</EuiFlexItem>
</EuiFlexGroup>
{(() => {
if (chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION) {
return (
<ExplorerChartDistribution
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
);
}
return (
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
);
})()}
</React.Fragment>
);
}
// Flex layout wrapper for all explorer charts
export function ExplorerChartsContainer({
chartsPerRow,
seriesToPlot,
layoutCellsPerChart,
tooManyBuckets,
mlSelectSeverityService
}) {
// <EuiFlexGrid> 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 (
<div className="explorer-charts">
{(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 (
<div className={`ml-explorer-chart-container col-md-${layoutCellsPerChart}`} key={id}>
<div className="ml-explorer-chart-icons">
{tooManyBuckets && (
<span className="ml-explorer-chart-icon">
<EuiIconTip
content={textTooManyBuckets}
position="top"
size="s"
type="alert"
color="warning"
/>
</span>
)}
<EuiToolTip
position="top"
content={textViewButton}
>
<EuiButtonEmpty
iconSide="right"
iconType="popout"
size="xs"
onClick={() => window.open(getExploreSeriesLink(series), '_blank')}
>
View
</EuiButtonEmpty>
</EuiToolTip>
</div>
<ExplorerChartLabel
detectorLabel={detectorLabel}
entityFields={entityFields}
infoTooltip={series.infoTooltip}
wrapLabel={wrapLabel}
/>
<ExplorerChart
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
</div>
);
})
}
</div>
<EuiFlexGrid columns={chartsColumns}>
{(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
<EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" style={{ minWidth: chartsWidth }}>
<ExplorerChartContainer
series={series}
tooManyBuckets={tooManyBuckets}
mlSelectSeverityService={mlSelectSeverityService}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}
ExplorerChartsContainer.propTypes = {
seriesToPlot: PropTypes.array.isRequired,
layoutCellsPerChart: PropTypes.number.isRequired,
tooManyBuckets: PropTypes.bool.isRequired,
mlSelectSeverityService: PropTypes.object.isRequired,
mlChartTooltipService: PropTypes.object.isRequired

View file

@ -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(<ExplorerChartsContainer
seriesToPlot={[]}
layoutCellsPerChart={12}
chartsPerRow={1}
tooManyBuckets={false}
mlSelectSeverityService={mlSelectSeverityServiceMock}
mlChartTooltipService={mlChartTooltipService}
/>);
expect(wrapper.html()).toBe('<div class="explorer-charts"></div>');
expect(wrapper.html()).toBe('<div class=\"euiFlexGrid euiFlexGrid--gutterLarge euiFlexGrid--wrap euiFlexGrid--responsive\"></div>');
});
test('Initialization with chart data', () => {
const wrapper = shallow(<ExplorerChartsContainer
const wrapper = mount(<ExplorerChartsContainer
seriesToPlot={[{
...seriesConfig,
chartData,
chartLimits: chartLimits(chartData)
}]}
layoutCellsPerChart={12}
chartsPerRow={1}
tooManyBuckets={false}
mlSelectSeverityService={mlSelectSeverityServiceMock}
mlChartTooltipService={mlChartTooltipService}
@ -110,6 +110,6 @@ describe('ExplorerChartsContainer', () => {
// 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);
});
});

View file

@ -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,

View file

@ -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 };

View file

@ -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();

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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',
};

View file

@ -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
};

View file

@ -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.