[ML] Display a ordinal y axis for low cardinality rare charts. (#24852)
Introduces a categorical/ordinal y axis for rare charts with a cardinality of <= 10. This also adds unit tests for the rare/population chart which are the bulk of the PR.
This commit is contained in:
parent
f8d0604050
commit
6ac9a2fd23
6 changed files with 249 additions and 14 deletions
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const chartData = [
|
||||
{
|
||||
date: 1487899800000,
|
||||
entity: '200',
|
||||
value: 1741.5652200000002
|
||||
},
|
||||
{
|
||||
date: 1487899800000,
|
||||
entity: '404',
|
||||
value: 494.30564000000004
|
||||
},
|
||||
{
|
||||
date: 1487899800000,
|
||||
entity: '304',
|
||||
value: 160.93672
|
||||
},
|
||||
{
|
||||
date: 1487899800000,
|
||||
entity: '301',
|
||||
value: 57.4774
|
||||
},
|
||||
{
|
||||
date: 1487837700000,
|
||||
value: 42,
|
||||
entity: '303',
|
||||
anomalyScore: 84.08759,
|
||||
actual: [
|
||||
1
|
||||
],
|
||||
typical: [
|
||||
0.00028318796131582025
|
||||
]
|
||||
}
|
||||
];
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"jobId": "ffb-rare-url-0921",
|
||||
"jobId": "ffb-rare-by-response-code-0942",
|
||||
"detectorIndex": 0,
|
||||
"metricFunction": "count",
|
||||
"timeField": "@timestamp",
|
||||
"interval": "15m",
|
||||
"datafeedConfig": {
|
||||
"datafeed_id": "datafeed-ffb-rare-url-0921",
|
||||
"job_id": "ffb-rare-url-0921",
|
||||
"query_delay": "115433ms",
|
||||
"datafeed_id": "datafeed-ffb-rare-by-response-code-0942",
|
||||
"job_id": "ffb-rare-by-response-code-0942",
|
||||
"query_delay": "66615ms",
|
||||
"indices": [
|
||||
"filebeat-6.0.0-2017-nginx-elasticco-anon"
|
||||
],
|
||||
|
@ -25,25 +25,32 @@
|
|||
},
|
||||
"functionDescription": "rare",
|
||||
"bucketSpanSeconds": 900,
|
||||
"detectorLabel": "rare by \"nginx.access.url\"",
|
||||
"detectorLabel": "rare by \"nginx.access.response_code\"",
|
||||
"entityFields": [
|
||||
{
|
||||
"fieldName": "nginx.access.url",
|
||||
"fieldValue": "/?node=4.1.1,5,7",
|
||||
"fieldName": "nginx.access.response_code",
|
||||
"fieldValue": "303",
|
||||
"fieldType": "by"
|
||||
}
|
||||
],
|
||||
"infoTooltip": {
|
||||
"jobId": "ffb-rare-url-0921",
|
||||
"jobId": "ffb-rare-by-response-code-0942",
|
||||
"aggregationInterval": "15m",
|
||||
"chartFunction": "count",
|
||||
"entityFields": [
|
||||
{
|
||||
"fieldName": "nginx.access.url",
|
||||
"fieldValue": "/?node=4.1.1,5,7"
|
||||
"fieldName": "nginx.access.response_code",
|
||||
"fieldValue": "303"
|
||||
}
|
||||
]
|
||||
},
|
||||
"loading": true,
|
||||
"chartData": null
|
||||
"loading": false,
|
||||
"plotEarliest": 1487774250000,
|
||||
"plotLatest": 1487900250000,
|
||||
"selectedEarliest": 1487836800000,
|
||||
"selectedLatest": 1487837699999,
|
||||
"chartLimits": {
|
||||
"max": 9294.095580000001,
|
||||
"min": 5.74774
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,13 @@ import { CHART_TYPE } from '../explorer_constants';
|
|||
|
||||
const CONTENT_WRAPPER_HEIGHT = 215;
|
||||
|
||||
// If a rare/event-distribution chart has a cardinality of 10 or less,
|
||||
// then the chart will display the y axis labels for each lane of events.
|
||||
// If cardinality is higher, then the axis will just be hidden.
|
||||
// Cardinality in this case refers to the available for display,
|
||||
// not the cardinality of the full source data set.
|
||||
const Y_AXIS_LABEL_THRESHOLD = 10;
|
||||
|
||||
export class ExplorerChartDistribution extends React.Component {
|
||||
static propTypes = {
|
||||
seriesConfig: PropTypes.object,
|
||||
|
@ -189,8 +196,15 @@ export class ExplorerChartDistribution extends React.Component {
|
|||
.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) {
|
||||
// Set the size of the left margin according to the width of the largest y axis tick label
|
||||
// if the chart is either a population chart or a rare chart below the cardinality threshold.
|
||||
if (
|
||||
chartType === CHART_TYPE.POPULATION_DISTRIBUTION
|
||||
|| (
|
||||
chartType === CHART_TYPE.EVENT_DISTRIBUTION
|
||||
&& scaleCategories.length <= Y_AXIS_LABEL_THRESHOLD
|
||||
)
|
||||
) {
|
||||
margin.left = (Math.max(maxYAxisLabelWidth, 40));
|
||||
}
|
||||
vizWidth = svgWidth - margin.left - margin.right;
|
||||
|
@ -281,6 +295,13 @@ export class ExplorerChartDistribution extends React.Component {
|
|||
.attr('class', 'y axis')
|
||||
.call(yAxis);
|
||||
|
||||
// emphasize the y axis label this rare chart is actually about
|
||||
if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) {
|
||||
axes.select('.y').selectAll('text').each(function (d) {
|
||||
d3.select(this).classed('ml-explorer-chart-axis-emphasis', (d === highlight));
|
||||
});
|
||||
}
|
||||
|
||||
if (tooManyBuckets === false) {
|
||||
removeLabelOverlap(gAxis, tickValuesStart, interval, vizWidth);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* 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 { chartData as mockChartData } from './__mocks__/mock_chart_data_rare';
|
||||
import seriesConfig from './__mocks__/mock_series_config_rare.json';
|
||||
|
||||
// Mock TimeBuckets and mlFieldFormatService, they don't play well
|
||||
// with the jest based test setup yet.
|
||||
jest.mock('ui/time_buckets', () => ({
|
||||
TimeBuckets: function () {
|
||||
this.setBounds = jest.fn();
|
||||
this.setInterval = jest.fn();
|
||||
this.getScaledDateFormat = jest.fn();
|
||||
}
|
||||
}));
|
||||
jest.mock('../../services/field_format_service', () => ({
|
||||
mlFieldFormatService: {
|
||||
getFieldFormat: jest.fn()
|
||||
}
|
||||
}));
|
||||
jest.mock('ui/chrome', () => ({
|
||||
getBasePath: (path) => path,
|
||||
getUiSettingsClient: () => ({
|
||||
get: () => null
|
||||
}),
|
||||
}));
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { ExplorerChartDistribution } from './explorer_chart_distribution';
|
||||
import { chartLimits } from '../../util/chart_utils';
|
||||
|
||||
describe('ExplorerChart', () => {
|
||||
const mlSelectSeverityServiceMock = {
|
||||
state: {
|
||||
get: () => ({
|
||||
val: ''
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
|
||||
const originalGetBBox = SVGElement.prototype.getBBox;
|
||||
beforeEach(() => SVGElement.prototype.getBBox = () => mockedGetBBox);
|
||||
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
|
||||
|
||||
test('Initialize', () => {
|
||||
const wrapper = mount(<ExplorerChartDistribution mlSelectSeverityService={mlSelectSeverityServiceMock} />);
|
||||
|
||||
// without setting any attributes and corresponding data
|
||||
// the directive just ends up being empty.
|
||||
expect(wrapper.isEmptyRender()).toBeTruthy();
|
||||
expect(wrapper.find('.content-wrapper')).toHaveLength(0);
|
||||
expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Loading status active, no chart', () => {
|
||||
const config = {
|
||||
loading: true
|
||||
};
|
||||
|
||||
const wrapper = mount(<ExplorerChartDistribution seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />);
|
||||
|
||||
// test if the loading indicator is shown
|
||||
expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// For the following tests the directive needs to be rendered in the actual DOM,
|
||||
// because otherwise there wouldn't be a width available which would
|
||||
// trigger SVG errors. We use a fixed width to be able to test for
|
||||
// fine grained attributes of the chart.
|
||||
|
||||
// basically a parameterized beforeEach
|
||||
function init(chartData) {
|
||||
const config = {
|
||||
...seriesConfig,
|
||||
chartData,
|
||||
chartLimits: chartLimits(chartData)
|
||||
};
|
||||
|
||||
// We create the element including a wrapper which sets the width:
|
||||
return mount(
|
||||
<div style={{ width: '500px' }}>
|
||||
<ExplorerChartDistribution seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
it('Anomaly Explorer Chart with multiple data points', () => {
|
||||
const wrapper = init(mockChartData);
|
||||
|
||||
// the loading indicator should not be shown
|
||||
expect(wrapper.find('.ml-loading-indicator .loading-spinner')).toHaveLength(0);
|
||||
|
||||
// test if all expected elements are present
|
||||
// need to use getDOMNode() because the chart is not rendered via react itself
|
||||
const svg = wrapper.getDOMNode().getElementsByTagName('svg');
|
||||
expect(svg).toHaveLength(1);
|
||||
|
||||
const lineChart = svg[0].getElementsByClassName('line-chart');
|
||||
expect(lineChart).toHaveLength(1);
|
||||
|
||||
const rects = lineChart[0].getElementsByTagName('rect');
|
||||
expect(rects).toHaveLength(2);
|
||||
|
||||
const chartBorder = rects[0];
|
||||
expect(+chartBorder.getAttribute('x')).toBe(0);
|
||||
expect(+chartBorder.getAttribute('y')).toBe(0);
|
||||
expect(+chartBorder.getAttribute('height')).toBe(170);
|
||||
|
||||
const selectedInterval = rects[1];
|
||||
expect(selectedInterval.getAttribute('class')).toBe('selected-interval');
|
||||
expect(+selectedInterval.getAttribute('y')).toBe(2);
|
||||
expect(+selectedInterval.getAttribute('height')).toBe(166);
|
||||
|
||||
const xAxisTicks = wrapper.getDOMNode().querySelector('.x').querySelectorAll('.tick');
|
||||
expect([...xAxisTicks]).toHaveLength(0);
|
||||
const yAxisTicks = wrapper.getDOMNode().querySelector('.y').querySelectorAll('.tick');
|
||||
expect([...yAxisTicks]).toHaveLength(5);
|
||||
const emphasizedAxisLabel = wrapper.getDOMNode().querySelectorAll('.ml-explorer-chart-axis-emphasis');
|
||||
expect(emphasizedAxisLabel).toHaveLength(1);
|
||||
expect(emphasizedAxisLabel[0].innerHTML).toBe('303');
|
||||
|
||||
const paths = wrapper.getDOMNode().querySelectorAll('path');
|
||||
expect(paths[0].getAttribute('class')).toBe('domain');
|
||||
expect(paths[1].getAttribute('class')).toBe('domain');
|
||||
expect(paths[2]).toBe(undefined);
|
||||
|
||||
const dots = wrapper.getDOMNode().querySelector('.values-dots').querySelectorAll('circle');
|
||||
expect([...dots]).toHaveLength(5);
|
||||
expect(dots[0].getAttribute('r')).toBe('1.5');
|
||||
|
||||
const chartMarkers = wrapper.getDOMNode().querySelector('.chart-markers').querySelectorAll('circle');
|
||||
expect([...chartMarkers]).toHaveLength(5);
|
||||
expect([...chartMarkers].map(d => +d.getAttribute('r'))).toEqual([7, 7, 7, 7, 7]);
|
||||
});
|
||||
|
||||
it('Anomaly Explorer Chart with single data point', () => {
|
||||
const chartData = [
|
||||
{
|
||||
date: 1487837700000,
|
||||
value: 42,
|
||||
entity: '303',
|
||||
anomalyScore: 84.08759,
|
||||
actual: [
|
||||
1
|
||||
],
|
||||
typical: [
|
||||
0.00028318796131582025
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const wrapper = init(chartData);
|
||||
const yAxisTicks = wrapper.getDOMNode().querySelector('.y').querySelectorAll('.tick');
|
||||
expect([...yAxisTicks]).toHaveLength(1);
|
||||
});
|
||||
});
|
|
@ -67,6 +67,7 @@ function ExplorerChartContainer({
|
|||
{detectorLabel}<br />y-axis event distribution split by "{byField.fieldName}"
|
||||
</React.Fragment>
|
||||
);
|
||||
wrapLabel = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -118,3 +118,7 @@
|
|||
.ml-explorer-chart-content-wrapper {
|
||||
height: 215px;
|
||||
}
|
||||
|
||||
.ml-explorer-chart-axis-emphasis {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue