From 6ac9a2fd2345872be39035b86c81954aa4631ae1 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Wed, 31 Oct 2018 15:57:27 +0100 Subject: [PATCH] [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. --- .../__mocks__/mock_chart_data_rare.js | 40 +++++ .../__mocks__/mock_series_config_rare.json | 31 ++-- .../explorer_chart_distribution.js | 25 ++- .../explorer_chart_distribution.test.js | 162 ++++++++++++++++++ .../explorer_charts_container.js | 1 + .../styles/explorer_chart.less | 4 + 6 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_chart_data_rare.js create mode 100644 x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.test.js diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_chart_data_rare.js b/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_chart_data_rare.js new file mode 100644 index 0000000000000..3cc8b41ea7ac3 --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_chart_data_rare.js @@ -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 + ] + } +]; diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_series_config_rare.json b/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_series_config_rare.json index b82583a730323..07c13d7cd8b7f 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_series_config_rare.json +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/__mocks__/mock_series_config_rare.json @@ -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 + } } diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js index 151bd60ad4c6e..26a1d7758f46f 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.js @@ -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); } diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.test.js new file mode 100644 index 0000000000000..9e2a3fc85122f --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -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(); + + // 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(); + + // 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( +
+ +
+ ); + } + + 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); + }); +}); diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js index 3c54899be60cd..b860a6e4f26a5 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js @@ -67,6 +67,7 @@ function ExplorerChartContainer({ {detectorLabel}
y-axis event distribution split by "{byField.fieldName}" ); + wrapLabel = true; } } diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less index 2215aa7838126..ab7452a3c0796 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/styles/explorer_chart.less @@ -118,3 +118,7 @@ .ml-explorer-chart-content-wrapper { height: 215px; } + +.ml-explorer-chart-axis-emphasis { + font-weight: bold; +}