mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
584100198f
commit
c4ee9dd87e
28 changed files with 1067 additions and 240 deletions
|
@ -27,6 +27,7 @@ Object {
|
|||
"entityFields": Array [
|
||||
Object {
|
||||
"fieldName": "airline",
|
||||
"fieldType": "partition",
|
||||
"fieldValue": "JAL",
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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> </React.Fragment>) : (<React.Fragment> – </React.Fragment>);
|
||||
|
||||
const entityFieldBadges = entityFields.map((entity) => {
|
||||
return (
|
||||
<React.Fragment key={`${entity.fieldName} ${entity.fieldValue}`}>
|
||||
<ExplorerChartLabelBadge entity={entity} />
|
||||
</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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue