[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:
Walter Rafelsberger 2018-10-31 15:57:27 +01:00 committed by GitHub
parent f8d0604050
commit 6ac9a2fd23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 249 additions and 14 deletions

View file

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

View file

@ -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": "&#x2F;?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
}
}

View file

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

View file

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

View file

@ -67,6 +67,7 @@ function ExplorerChartContainer({
{detectorLabel}<br />y-axis event distribution split by &quot;{byField.fieldName}&quot;
</React.Fragment>
);
wrapLabel = true;
}
}

View file

@ -118,3 +118,7 @@
.ml-explorer-chart-content-wrapper {
height: 215px;
}
.ml-explorer-chart-axis-emphasis {
font-weight: bold;
}