diff --git a/x-pack/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js b/x-pack/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js index 5f5c1c465947c4..9477984f758c00 100644 --- a/x-pack/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js +++ b/x-pack/plugins/ml/public/components/chart_tooltip/__tests__/chart_tooltip.js @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import ngMock from 'ng_mock'; import expect from 'expect.js'; -describe('ML - mlChartTooltipService', () => { - let mlChartTooltipService; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function ($injector) { - mlChartTooltipService = $injector.get('mlChartTooltipService'); - }); - }); +import { mlChartTooltipService } from '../chart_tooltip_service'; +describe('ML - mlChartTooltipService', () => { it('service API duck typing', () => { expect(mlChartTooltipService).to.be.an('object'); expect(mlChartTooltipService.show).to.be.a('function'); diff --git a/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip.js b/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip.js index 8f2861a0aad3cc..1785a6768eeb7e 100644 --- a/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip.js +++ b/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip.js @@ -5,14 +5,14 @@ */ - -import $ from 'jquery'; import template from './chart_tooltip.html'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); -module.directive('mlChartTooltip', function (mlChartTooltipService) { +import { mlChartTooltipService } from './chart_tooltip_service'; + +module.directive('mlChartTooltip', function () { return { restrict: 'E', replace: true, @@ -21,67 +21,4 @@ module.directive('mlChartTooltip', function (mlChartTooltipService) { mlChartTooltipService.element = element; } }; -}) - .service('mlChartTooltipService', function ($timeout) { - this.element = null; - this.fadeTimeout = null; - const doc = document.documentElement; - const FADE_TIMEOUT_MS = 200; - - this.show = function (contents, target, offset = { x: 0, y: 0 }) { - if (this.element !== null) { - - // if a previous fade out was happening, stop it - if (this.fadeTimeout !== null) { - $timeout.cancel(this.fadeTimeout); - } - - // populate the tooltip contents - this.element.html(contents); - - // side bar width - const navOffset = $('.global-nav').width(); - const contentWidth = $('body').width() - navOffset - 10; - const tooltipWidth = this.element.width(); - const scrollTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); - - const pos = target.getBoundingClientRect(); - const x = (pos.left + (offset.x) + 4) - navOffset; - const y = pos.top + (offset.y) + scrollTop; - - if (x + tooltipWidth > contentWidth) { - // the tooltip is hanging off the side of the page, - // so move it to the other side of the target - this.element.css({ - 'left': x - (tooltipWidth + offset.x + 22), - 'top': (y - 28) - }); - } else { - this.element.css({ - 'left': x, - 'top': (y - 28) - }); - } - - this.element.css({ - 'opacity': '0.9', - 'display': 'block' - }); - } - }; - - this.hide = function () { - if (this.element !== null) { - this.element.css({ - 'opacity': '0', - }); - - // after the fade out transition has finished, set the display to - // none so it doesn't block any mouse events underneath it. - this.fadeTimeout = $timeout(() => { - this.element.css('display', 'none'); - this.fadeTimeout = null; - }, FADE_TIMEOUT_MS); - } - }; - }); +}); diff --git a/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js new file mode 100644 index 00000000000000..1c5a3185a3f4dc --- /dev/null +++ b/x-pack/plugins/ml/public/components/chart_tooltip/chart_tooltip_service.js @@ -0,0 +1,77 @@ +/* + * 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 $ from 'jquery'; + +const doc = document.documentElement; +const FADE_TIMEOUT_MS = 200; + +export const mlChartTooltipService = { + element: null, + fadeTimeout: null, +}; + +mlChartTooltipService.show = function (contents, target, offset = { x: 0, y: 0 }) { + if (this.element === null) { + return; + } + + // if a previous fade out was happening, stop it + if (this.fadeTimeout !== null) { + clearTimeout(this.fadeTimeout); + } + + // populate the tooltip contents + this.element.html(contents); + + // side bar width + const navOffset = $('.global-nav').width(); + const contentWidth = $('body').width() - navOffset - 10; + const tooltipWidth = this.element.width(); + const scrollTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); + + const pos = target.getBoundingClientRect(); + const x = (pos.left + (offset.x) + 4) - navOffset; + const y = pos.top + (offset.y) + scrollTop; + + if (x + tooltipWidth > contentWidth) { + // the tooltip is hanging off the side of the page, + // so move it to the other side of the target + this.element.css({ + left: x - (tooltipWidth + offset.x + 22), + top: (y - 28) + }); + } else { + this.element.css({ + left: x, + top: (y - 28) + }); + } + + this.element.css({ + opacity: '0.9', + display: 'block' + }); +}; + +mlChartTooltipService.hide = function () { + if (this.element === null) { + return; + } + + this.element.css({ + opacity: '0', + }); + + // after the fade out transition has finished, set the display to + // none so it doesn't block any mouse events underneath it. + this.fadeTimeout = setTimeout(() => { + this.element.css('display', 'none'); + this.fadeTimeout = null; + }, FADE_TIMEOUT_MS); +}; diff --git a/x-pack/plugins/ml/public/components/field_data_card/document_count_chart_directive.js b/x-pack/plugins/ml/public/components/field_data_card/document_count_chart_directive.js index 9aef817d363da5..1c554625da28a0 100644 --- a/x-pack/plugins/ml/public/components/field_data_card/document_count_chart_directive.js +++ b/x-pack/plugins/ml/public/components/field_data_card/document_count_chart_directive.js @@ -16,18 +16,16 @@ import d3 from 'd3'; import moment from 'moment'; import { parseInterval } from 'ui/utils/parse_interval'; -import { numTicksForDateFormat } from 'plugins/ml/util/chart_utils'; -import { calculateTextWidth } from 'plugins/ml/util/string_utils'; -import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets'; +import { numTicksForDateFormat } from '../../util/chart_utils'; +import { calculateTextWidth } from '../../util/string_utils'; +import { IntervalHelperProvider } from '../../util/ml_time_buckets'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { uiModules } from 'ui/modules'; import { timefilter } from 'ui/timefilter'; const module = uiModules.get('apps/ml'); -module.directive('mlDocumentCountChart', function ( - Private, - mlChartTooltipService) { - +module.directive('mlDocumentCountChart', function (Private) { function link(scope, element, attrs) { const svgWidth = attrs.width ? +attrs.width : 400; const svgHeight = scope.height = attrs.height ? +attrs.height : 400; diff --git a/x-pack/plugins/ml/public/components/field_data_card/metric_distribution_chart_directive.js b/x-pack/plugins/ml/public/components/field_data_card/metric_distribution_chart_directive.js index e9f73251f55dc7..515ae410a32eea 100644 --- a/x-pack/plugins/ml/public/components/field_data_card/metric_distribution_chart_directive.js +++ b/x-pack/plugins/ml/public/components/field_data_card/metric_distribution_chart_directive.js @@ -14,13 +14,14 @@ import _ from 'lodash'; import d3 from 'd3'; -import { numTicks } from 'plugins/ml/util/chart_utils'; +import { numTicks } from '../../util/chart_utils'; import { ordinalSuffix } from 'ui/utils/ordinal_suffix'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); -module.directive('mlMetricDistributionChart', function (mlChartTooltipService) { +module.directive('mlMetricDistributionChart', function () { function link(scope, element, attrs) { const svgWidth = attrs.width ? +attrs.width : 400; diff --git a/x-pack/plugins/ml/public/components/loading_indicator/loading_indicator.js b/x-pack/plugins/ml/public/components/loading_indicator/loading_indicator.js new file mode 100644 index 00000000000000..a363771c77b694 --- /dev/null +++ b/x-pack/plugins/ml/public/components/loading_indicator/loading_indicator.js @@ -0,0 +1,24 @@ +/* + * 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/main.less'; + +import PropTypes from 'prop-types'; +import React from 'react'; + +export function LoadingIndicator({ height }) { + height = height ? +height : 100; + return ( +
+
+
+ ); +} +LoadingIndicator.propTypes = { + height: PropTypes.number +}; diff --git a/x-pack/plugins/ml/public/components/loading_indicator/styles/main.less b/x-pack/plugins/ml/public/components/loading_indicator/styles/main.less index 6b41ed7de1c5a8..addb8a931248b3 100644 --- a/x-pack/plugins/ml/public/components/loading_indicator/styles/main.less +++ b/x-pack/plugins/ml/public/components/loading_indicator/styles/main.less @@ -1,3 +1,4 @@ +/* angular */ ml-loading-indicator { .loading-indicator { text-align: center; @@ -12,3 +13,17 @@ ml-loading-indicator { } } } + +/* react */ +.ml-loading-indicator { + text-align: center; + font-size: 17px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .loading-spinner { + font-size: 24px; + } +} diff --git a/x-pack/plugins/ml/public/explorer/explorer.html b/x-pack/plugins/ml/public/explorer/explorer.html index 1abfcbdff8b6c6..81f2b2e76985f1 100644 --- a/x-pack/plugins/ml/public/explorer/explorer.html +++ b/x-pack/plugins/ml/public/explorer/explorer.html @@ -145,18 +145,8 @@

No {{swimlaneViewByFieldName}} influencers -
- - +
+
, ) helper function to append the - * directive to the DOM and correctly initialize it. Otherwise the rendering of - * the directive would fail because its link() function is dependent on certain - * DOM attributes (e.g. the dynamic width and height of an element). - * The init() function takes care of running the tests only after the initialize - * $scope.$digest() is run. - * Also note the use of done() with these tests, this is required if tests are - * run in an asynchronous manner like using a callback in this case. - */ - -describe('ML - ', () => { - let $scope; - let $compile; - let $element; - - const seriesConfig = { - jobId: 'population-03', - detectorIndex: 0, - metricFunction: 'sum', - timeField: '@timestamp', - interval: '1h', - datafeedConfig: { - datafeed_id: 'datafeed-population-03', - job_id: 'population-03', - query_delay: '60s', - frequency: '600s', - indices: ['filebeat-7.0.0*'], - types: ['doc'], - query: { match_all: { boost: 1 } }, - scroll_size: 1000, - chunking_config: { mode: 'auto' }, - state: 'stopped' - }, - metricFieldName: 'nginx.access.body_sent.bytes', - functionDescription: 'sum', - bucketSpanSeconds: 3600, - detectorLabel: 'high_sum(nginx.access.body_sent.bytes) over nginx.access.remote_ip (population-03)', - fieldName: 'nginx.access.body_sent.bytes', - entityFields: [{ - fieldName: 'nginx.access.remote_ip', - fieldValue: '72.57.0.53', - $$hashKey: 'object:813' - }], - infoTooltip: `
job ID: population-03
- aggregation interval: 1h
chart function: sum nginx.access.body_sent.bytes
- nginx.access.remote_ip: 72.57.0.53
`, - loading: false, - plotEarliest: 1487534400000, - plotLatest: 1488168000000, - selectedEarliest: 1487808000000, - selectedLatest: 1487894399999 - }; - - beforeEach(() => { - ngMock.module('kibana'); - ngMock.inject(function (_$compile_, $rootScope) { - $compile = _$compile_; - $scope = $rootScope.$new(); - }); - }); - - afterEach(function () { - $scope.$destroy(); - }); - - it('Initialize', () => { - $element = $compile('')($scope); - $scope.$digest(); - - // without setting any attributes and corresponding data - // the directive just ends up being empty. - expect($element.find('.content-wrapper').html()).to.be(''); - expect($element.find('ml-loading-indicator .loading-indicator').length).to.be(0); - }); - - it('Loading status active, no chart', () => { - $scope.seriesConfig = { - loading: true - }; - - $element = $compile('')($scope); - $scope.$digest(); - - // test if the loading indicator is shown - expect($element.find('ml-loading-indicator .loading-indicator').length).to.be(1); - }); - - describe('ML - data rendering', () => { - // 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, tests) { - // First we create the element including a wrapper which sets the width: - $element = angular.element('
'); - // Add the element to the body so it gets rendered - $element.appendTo(document.body); - - $scope.seriesConfig = { - ...seriesConfig, - chartData, - chartLimits: chartLimits(chartData) - }; - - // Compile the directive and run a $digest() - $compile($element)($scope); - $scope.$evalAsync(tests); - $scope.$digest(); - } - - afterEach(function () { - // remove the element from the DOM - $element.remove(); - }); - - it('Anomaly Explorer Chart with multiple data points', (done) => { - // prepare data for the test case - const chartData = [ - { - date: new Date('2017-02-23T08:00:00.000Z'), - value: 228243469, anomalyScore: 63.32916, numberOfCauses: 1, - actual: [228243469], typical: [133107.7703441773] - }, - { date: new Date('2017-02-23T09:00:00.000Z'), value: null }, - { date: new Date('2017-02-23T10:00:00.000Z'), value: null }, - { date: new Date('2017-02-23T11:00:00.000Z'), value: null }, - { - date: new Date('2017-02-23T12:00:00.000Z'), - value: 625736376, anomalyScore: 97.32085, numberOfCauses: 1, - actual: [625736376], typical: [132830.424736973] - }, - { - date: new Date('2017-02-23T13:00:00.000Z'), - value: 201039318, anomalyScore: 59.83488, numberOfCauses: 1, - actual: [201039318], typical: [132739.5267403542] - } - ]; - - init(chartData, () => { - // the loading indicator should not be shown - expect($element.find('ml-loading-indicator .loading-indicator').length).to.be(0); - - // test if all expected elements are present - const svg = $element.find('svg'); - expect(svg.length).to.be(1); - - const lineChart = svg.find('g.line-chart'); - expect(lineChart.length).to.be(1); - - const rects = lineChart.find('rect'); - expect(rects.length).to.be(2); - - const chartBorder = angular.element(rects[0]); - expect(+chartBorder.attr('x')).to.be(0); - expect(+chartBorder.attr('y')).to.be(0); - expect(+chartBorder.attr('height')).to.be(170); - - const selectedInterval = angular.element(rects[1]); - expect(selectedInterval.attr('class')).to.be('selected-interval'); - expect(+selectedInterval.attr('y')).to.be(1); - expect(+selectedInterval.attr('height')).to.be(169); - - // skip this test for now - // TODO find out why this doesn't work in IE11 - // const xAxisTicks = lineChart.find('.x.axis .tick'); - // expect(xAxisTicks.length).to.be(4); - const yAxisTicks = lineChart.find('.y.axis .tick'); - expect(yAxisTicks.length).to.be(10); - - const paths = lineChart.find('path'); - expect(angular.element(paths[0]).attr('class')).to.be('domain'); - expect(angular.element(paths[1]).attr('class')).to.be('domain'); - - const line = angular.element(paths[2]); - expect(line.attr('class')).to.be('values-line'); - // this is not feasible to test because of minimal differences - // across various browsers - // expect(line.attr('d')) - // .to.be('M205.56285511363637,152.3732523349513M215.3515625,7.72727272727272L217.79873934659093,162.27272727272728'); - expect(line.attr('d')).not.to.be(undefined); - - const dots = lineChart.find('g.values-dots circle'); - expect(dots.length).to.be(1); - - const dot = angular.element(dots[0]); - expect(dot.attr('r')).to.be('1.5'); - - const chartMarkers = lineChart.find('g.chart-markers circle'); - expect(chartMarkers.length).to.be(3); - expect(chartMarkers.toArray().map(d => +angular.element(d).attr('r'))).to.eql([7, 7, 7]); - - done(); - }); - }); - - it('Anomaly Explorer Chart with single data point', (done) => { - const chartData = [ - { - date: new Date('2017-02-23T08:00:00.000Z'), - value: 228243469, anomalyScore: 63.32916, numberOfCauses: 1, - actual: [228243469], typical: [228243469] - } - ]; - - init(chartData, () => { - const svg = $element.find('svg'); - const lineChart = svg.find('g.line-chart'); - const yAxisTicks = lineChart.find('.y.axis .tick'); - expect(yAxisTicks.length).to.be(13); - done(); - }); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explore_series.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explore_series.js new file mode 100644 index 00000000000000..48b11391f5eb81 --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explore_series.js @@ -0,0 +1,74 @@ +/* + * 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 _ from 'lodash'; +import moment from 'moment'; +import rison from 'rison-node'; + +import chrome from 'ui/chrome'; +import { timefilter } from 'ui/timefilter'; + +export function exploreSeries(series) { + // Open the Single Metric dashboard over the same overall bounds and + // zoomed in to the same time as the current chart. + const bounds = timefilter.getActiveBounds(); + const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z + const to = bounds.max.toISOString(); + + const zoomFrom = moment(series.plotEarliest).toISOString(); + const zoomTo = moment(series.plotLatest).toISOString(); + + // Pass the detector index and entity fields (i.e. by, over, partition fields) + // to identify the particular series to view. + // Initially pass them in the mlTimeSeriesExplorer part of the AppState. + // TODO - do we want to pass the entities via the filter? + const entityCondition = {}; + _.each(series.entityFields, (entity) => { + entityCondition[entity.fieldName] = entity.fieldValue; + }); + + // Use rison to build the URL . + const _g = rison.encode({ + ml: { + jobIds: [series.jobId] + }, + refreshInterval: { + display: 'Off', + pause: false, + value: 0 + }, + time: { + from: from, + to: to, + mode: 'absolute' + } + }); + + const _a = rison.encode({ + mlTimeSeriesExplorer: { + zoom: { + from: zoomFrom, + to: zoomTo + }, + detectorIndex: series.detectorIndex, + entities: entityCondition, + }, + filters: [], + query: { + query_string: { + analyze_wildcard: true, + query: '*' + } + } + }); + + let path = chrome.getBasePath(); + path += '/app/ml#/timeseriesexplorer'; + path += '?_g=' + _g; + path += '&_a=' + encodeURIComponent(_a); + window.open(path, '_blank'); + +} diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js similarity index 82% rename from x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js rename to x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js index befa1afc4dd3bd..5932ba8786d278 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.js @@ -4,43 +4,64 @@ * you may not use this file except in compliance with the Elastic License. */ - - /* - * AngularJS directive for rendering a chart of anomalies in the raw data in + * React component for rendering a chart of anomalies in the raw data in * the Machine Learning Explorer dashboard. */ -import './styles/explorer_chart_directive.less'; +import './styles/explorer_chart.less'; + +import PropTypes from 'prop-types'; +import React from 'react'; import _ from 'lodash'; import d3 from 'd3'; -import angular from 'angular'; +import $ from 'jquery'; import moment from 'moment'; -import { formatValue } from 'plugins/ml/formatters/format_value'; -import { getSeverityWithLow } from 'plugins/ml/../common/util/anomaly_utils'; -import { drawLineChartDots, numTicksForDateFormat } from 'plugins/ml/util/chart_utils'; +// 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 { drawLineChartDots, numTicksForDateFormat } from '../../util/chart_utils'; import { TimeBuckets } from 'ui/time_buckets'; -import loadingIndicatorWrapperTemplate from 'plugins/ml/components/loading_indicator/loading_indicator_wrapper.html'; -import { mlEscape } from 'plugins/ml/util/string_utils'; -import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; +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 { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); +const CONTENT_WRAPPER_HEIGHT = 215; -module.directive('mlExplorerChart', function ( - mlChartTooltipService, - Private, - mlSelectSeverityService) { +export class ExplorerChart extends React.Component { + static propTypes = { + seriesConfig: PropTypes.object, + mlSelectSeverityService: PropTypes.object.isRequired + } - function link(scope, element) { - console.log('ml-explorer-chart directive link series config:', scope.seriesConfig); - if (typeof scope.seriesConfig === 'undefined') { + componentDidMount() { + this.renderChart(); + } + + componentDidUpdate() { + this.renderChart(); + } + + renderChart() { + const { + 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 config = scope.seriesConfig; + const fieldFormat = mlFieldFormatService.getFieldFormat(config.jobId, config.detectorIndex); let vizWidth = 0; @@ -56,35 +77,22 @@ module.directive('mlExplorerChart', function ( let lineChartGroup; let lineChartValuesLine = null; - // create a chart loading placeholder - scope.isLoading = config.loading; - if (Array.isArray(config.chartData)) { - // make sure we wait for the previous digest cycle to finish - // or the chart's wrapping elements might not have their - // right widths yet and we need them to define the SVG's width - scope.$evalAsync(() => { - init(config.chartLimits); - drawLineChart(config.chartData); - }); - } - - element.on('$destroy', function () { - scope.$destroy(); - }); + init(config.chartLimits); + drawLineChart(config.chartData); function init(chartLimits) { - const $el = angular.element('ml-explorer-chart'); + 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.get(0)).select('.content-wrapper'); + 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') - .attr('width', svgWidth) + .attr('width', svgWidth) .attr('height', svgHeight); // Set the size of the left margin according to the width of the largest y axis tick label. @@ -122,7 +130,7 @@ module.directive('mlExplorerChart', function ( d3.select('.temp-axis-label').remove(); margin.left = (Math.max(maxYAxisLabelWidth, 40)); - vizWidth = svgWidth - margin.left - margin.right; + 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 @@ -319,12 +327,37 @@ module.directive('mlExplorerChart', function ( } } - return { - restrict: 'E', - scope: { - seriesConfig: '=' - }, - link: link, - template: loadingIndicatorWrapperTemplate - }; -}); + shouldComponentUpdate() { + // Prevents component 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 ( +
+ {isLoading && ( + + )} + {!isLoading && ( +
+ )} +
+ ); + } +} diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js new file mode 100644 index 00000000000000..ce40308003f5f5 --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js @@ -0,0 +1,207 @@ +/* + * 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. + */ + +// 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() + } +})); + +import { mount } from 'enzyme'; +import React from 'react'; + +import { ExplorerChart } from './explorer_chart'; +import { chartLimits } from '../../util/chart_utils'; + +describe('ExplorerChart', () => { + const seriesConfig = { + jobId: 'population-03', + detectorIndex: 0, + metricFunction: 'sum', + timeField: '@timestamp', + interval: '1h', + datafeedConfig: { + datafeed_id: 'datafeed-population-03', + job_id: 'population-03', + query_delay: '60s', + frequency: '600s', + indices: ['filebeat-7.0.0*'], + types: ['doc'], + query: { match_all: { boost: 1 } }, + scroll_size: 1000, + chunking_config: { mode: 'auto' }, + state: 'stopped' + }, + metricFieldName: 'nginx.access.body_sent.bytes', + functionDescription: 'sum', + bucketSpanSeconds: 3600, + detectorLabel: 'high_sum(nginx.access.body_sent.bytes) over nginx.access.remote_ip (population-03)', + fieldName: 'nginx.access.body_sent.bytes', + entityFields: [{ + fieldName: 'nginx.access.remote_ip', + fieldValue: '72.57.0.53', + $$hashKey: 'object:813' + }], + infoTooltip: `
job ID: population-03
+ aggregation interval: 1h
chart function: sum nginx.access.body_sent.bytes
+ nginx.access.remote_ip: 72.57.0.53
`, + loading: false, + plotEarliest: 1487534400000, + plotLatest: 1488168000000, + selectedEarliest: 1487808000000, + selectedLatest: 1487894399999 + }; + + 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 = () => { + return 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', () => { + // prepare data for the test case + const chartData = [ + { + date: new Date('2017-02-23T08:00:00.000Z'), + value: 228243469, anomalyScore: 63.32916, numberOfCauses: 1, + actual: [228243469], typical: [133107.7703441773] + }, + { date: new Date('2017-02-23T09:00:00.000Z'), value: null }, + { date: new Date('2017-02-23T10:00:00.000Z'), value: null }, + { date: new Date('2017-02-23T11:00:00.000Z'), value: null }, + { + date: new Date('2017-02-23T12:00:00.000Z'), + value: 625736376, anomalyScore: 97.32085, numberOfCauses: 1, + actual: [625736376], typical: [132830.424736973] + }, + { + date: new Date('2017-02-23T13:00:00.000Z'), + value: 201039318, anomalyScore: 59.83488, numberOfCauses: 1, + actual: [201039318], typical: [132739.5267403542] + } + ]; + + const wrapper = init(chartData); + + // 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(1); + expect(+selectedInterval.getAttribute('height')).toBe(169); + + const xAxisTicks = wrapper.getDOMNode().querySelector('.x').querySelectorAll('.tick'); + expect([...xAxisTicks]).toHaveLength(0); + const yAxisTicks = wrapper.getDOMNode().querySelector('.y').querySelectorAll('.tick'); + expect([...yAxisTicks]).toHaveLength(10); + + const paths = wrapper.getDOMNode().querySelectorAll('path'); + expect(paths[0].getAttribute('class')).toBe('domain'); + expect(paths[1].getAttribute('class')).toBe('domain'); + expect(paths[2].getAttribute('class')).toBe('values-line'); + expect(paths[2].getAttribute('d')).toBe('MNaN,159.33024504444444MNaN,9.166257955555556LNaN,169.60736875555557'); + + const dots = wrapper.getDOMNode().querySelector('.values-dots').querySelectorAll('circle'); + expect([...dots]).toHaveLength(1); + expect(dots[0].getAttribute('r')).toBe('1.5'); + + const chartMarkers = wrapper.getDOMNode().querySelector('.chart-markers').querySelectorAll('circle'); + expect([...chartMarkers]).toHaveLength(3); + expect([...chartMarkers].map(d => +d.getAttribute('r'))).toEqual([7, 7, 7]); + }); + + it('Anomaly Explorer Chart with single data point', () => { + const chartData = [ + { + date: new Date('2017-02-23T08:00:00.000Z'), + value: 228243469, anomalyScore: 63.32916, numberOfCauses: 1, + actual: [228243469], typical: [228243469] + } + ]; + + const wrapper = init(chartData); + + const yAxisTicks = wrapper.getDOMNode().querySelector('.y').querySelectorAll('.tick'); + expect([...yAxisTicks]).toHaveLength(13); + }); +}); diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js index 07a9bc0885e605..ab167cb423127d 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_config_builder.js @@ -14,87 +14,79 @@ import _ from 'lodash'; import { parseInterval } from 'ui/utils/parse_interval'; -import { buildConfigFromDetector } from 'plugins/ml/util/chart_config_builder'; -import { mlEscape } from 'plugins/ml/util/string_utils'; -import { mlJobService } from 'plugins/ml/services/job_service'; - -export function explorerChartConfigBuilder() { - - const compiledTooltip = _.template( - '
job ID: <%= jobId %>
' + - 'aggregation interval: <%= aggregationInterval %>
' + - 'chart function: <%= chartFunction %>' + - '<% for(let i = 0; i < entityFields.length; ++i) { %>' + - '
<%= entityFields[i].fieldName %>: <%= entityFields[i].fieldValue %>' + - '<% } %>' + - '
'); - - // Builds the chart configuration for the provided anomaly record, returning - // an object with properties used for the display (series function and field, aggregation interval etc), - // and properties for the data feed used for the job (index pattern, time field etc). - function buildConfig(record) { - const job = mlJobService.getJob(record.job_id); - const detectorIndex = record.detector_index; - const config = buildConfigFromDetector(job, detectorIndex); - - // Add extra properties used by the explorer dashboard charts. - config.functionDescription = record.function_description; - config.bucketSpanSeconds = parseInterval(job.analysis_config.bucket_span).asSeconds(); - - config.detectorLabel = record.function; - if ((_.has(mlJobService.detectorsByJob, record.job_id)) && - (detectorIndex < mlJobService.detectorsByJob[record.job_id].length)) { - config.detectorLabel = mlJobService.detectorsByJob[record.job_id][detectorIndex].detector_description; - } else { - if (record.field_name !== undefined) { - config.detectorLabel += ` ${config.fieldName}`; - } - } - +import { buildConfigFromDetector } from '../../util/chart_config_builder'; +import { mlEscape } from '../../util/string_utils'; +import { mlJobService } from '../../services/job_service'; + +// Builds the chart configuration for the provided anomaly record, returning +// an object with properties used for the display (series function and field, aggregation interval etc), +// and properties for the data feed used for the job (index pattern, time field etc). +export function buildConfig(record) { + const job = mlJobService.getJob(record.job_id); + const detectorIndex = record.detector_index; + const config = buildConfigFromDetector(job, detectorIndex); + + // Add extra properties used by the explorer dashboard charts. + config.functionDescription = record.function_description; + config.bucketSpanSeconds = parseInterval(job.analysis_config.bucket_span).asSeconds(); + + config.detectorLabel = record.function; + if ((_.has(mlJobService.detectorsByJob, record.job_id)) && + (detectorIndex < mlJobService.detectorsByJob[record.job_id].length)) { + config.detectorLabel = mlJobService.detectorsByJob[record.job_id][detectorIndex].detector_description; + } else { if (record.field_name !== undefined) { - config.fieldName = record.field_name; - config.metricFieldName = record.field_name; - } - - // Add the 'entity_fields' i.e. the partition, by, over fields which - // define the metric series to be plotted. - config.entityFields = []; - if (_.has(record, 'partition_field_name')) { - config.entityFields.push({ fieldName: record.partition_field_name, fieldValue: record.partition_field_value }); + config.detectorLabel += ` ${config.fieldName}`; } + } - if (_.has(record, 'over_field_name')) { - config.entityFields.push({ fieldName: record.over_field_name, fieldValue: record.over_field_value }); - } + if (record.field_name !== undefined) { + config.fieldName = record.field_name; + config.metricFieldName = record.field_name; + } - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - if (_.has(record, 'by_field_name') && !(_.has(record, 'over_field_name'))) { - config.entityFields.push({ fieldName: record.by_field_name, fieldValue: record.by_field_value }); - } + // Add the 'entity_fields' i.e. the partition, by, over fields which + // define the metric series to be plotted. + config.entityFields = []; + if (_.has(record, 'partition_field_name')) { + config.entityFields.push({ + fieldName: record.partition_field_name, + fieldValue: record.partition_field_value + }); + } - // Build the tooltip for the chart info icon, showing further details on what is being plotted. - let functionLabel = config.metricFunction; - if (config.metricFieldName !== undefined) { - functionLabel += ` ${mlEscape(config.metricFieldName)}`; - } + if (_.has(record, 'over_field_name')) { + config.entityFields.push({ + fieldName: record.over_field_name, + fieldValue: record.over_field_value + }); + } - config.infoTooltip = compiledTooltip({ - jobId: record.job_id, - aggregationInterval: config.interval, - chartFunction: functionLabel, - entityFields: config.entityFields.map((f) => ({ - fieldName: mlEscape(f.fieldName), - fieldValue: mlEscape(f.fieldValue), - })) + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + if (_.has(record, 'by_field_name') && !(_.has(record, 'over_field_name'))) { + config.entityFields.push({ + fieldName: record.by_field_name, + fieldValue: record.by_field_value }); + } - return config; + // Build the tooltip data for the chart info icon, showing further details on what is being plotted. + let functionLabel = config.metricFunction; + if (config.metricFieldName !== undefined) { + functionLabel += ` ${mlEscape(config.metricFieldName)}`; } - return { - buildConfig + config.infoTooltip = { + jobId: record.job_id, + aggregationInterval: config.interval, + chartFunction: functionLabel, + entityFields: config.entityFields.map((f) => ({ + fieldName: mlEscape(f.fieldName), + fieldValue: mlEscape(f.fieldValue), + })) }; -} + return config; +} diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js new file mode 100644 index 00000000000000..c0da82233e0670 --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_tooltip.js @@ -0,0 +1,37 @@ +/* + * 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 ( +
+ job ID: {jobId}
+ aggregation interval: {aggregationInterval}
+ chart function: {chartFunction} + {entityFields.map((entityField, i) => { + return ( + +
{entityField.fieldName}: {entityField.fieldValue} +
+ ); + })} +
+ ); +} +ExplorerChartTooltip.propTypes = { + jobId: PropTypes.string.isRequired, + aggregationInterval: PropTypes.string, + chartFunction: PropTypes.string, + entityFields: PropTypes.array +}; diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.html b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.html deleted file mode 100644 index 34e9ac9f9b014e..00000000000000 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.html +++ /dev/null @@ -1,25 +0,0 @@ -
-
- -
-
-
- {{series.detectorLabel}} - - {{series.detectorLabel}} - - {{entity.fieldName}} {{entity.fieldValue}} - -
- - - - View - -
- -
- -
- -
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 new file mode 100644 index 00000000000000..2fb75120fdb44f --- /dev/null +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.js @@ -0,0 +1,85 @@ +/* + * 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'; + +import { EuiIconTip } from '@elastic/eui'; + +import { ExplorerChart } from './explorer_chart'; +import { ExplorerChartTooltip } from './explorer_chart_tooltip'; + +export function ExplorerChartsContainer({ + exploreSeries, + seriesToPlot, + layoutCellsPerChart, + tooManyBuckets, + mlSelectSeverityService +}) { + return ( +
+ {(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 ( +
+
+
+ {(entityFields.length > 0) && ( + {detectorLabel} - + )} + {(entityFields.length === 0) && ( + {detectorLabel} + )} + {entityFields.map((entity, j) => { + return ( + {entity.fieldName} {entity.fieldValue} + ); + })} +
+ } position="left" size="s" /> + {tooManyBuckets && ( + + )} + exploreSeries(series)}> + View