[ML] Migrate Explorer Charts to React. (#22622) (#22632)

This migrates the Anomaly Explorer charts to React. The PR aims to change as little of the actual logic to create and render the charts.
This commit is contained in:
Walter Rafelsberger 2018-09-04 10:58:49 +02:00 committed by GitHub
parent 6b4383a509
commit 4cda7caf95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 809 additions and 624 deletions

View file

@ -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';
import { mlChartTooltipService } from '../chart_tooltip_service';
describe('ML - mlChartTooltipService', () => {
let mlChartTooltipService;
beforeEach(ngMock.module('kibana'));
beforeEach(() => {
ngMock.inject(function ($injector) {
mlChartTooltipService = $injector.get('mlChartTooltipService');
});
});
it('service API duck typing', () => {
expect(mlChartTooltipService).to.be.an('object');
expect(mlChartTooltipService.show).to.be.a('function');

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<div className="ml-loading-indicator" style={{ height: `${height}px` }}>
<div className="loading-spinner"><i className="fa fa-spinner fa-spin" /></div>
</div>
);
}
LoadingIndicator.propTypes = {
height: PropTypes.number
};

View file

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

View file

@ -145,18 +145,8 @@
</div>
</div>
<div ng-controller="MlExplorerChartsContainerController" class="euiText">
<ml-explorer-charts-container
series-to-plot="seriesToPlot"
time-field-name="timeFieldName"
plot-earliest="plotEarliest"
plot-latest="plotLatest"
selected-earliest="selectedEarliest"
selected-latest="selectedLatest"
charts-per-row="chartsPerRow"
layout-cells-per-chart="layoutCellsPerChart"
too-many-buckets="tooManyBuckets">
</ml-explorer-charts-container>
<div class="euiText explorer-charts">
<ml-explorer-charts-container />
</div>
<ml-anomalies-table

View file

@ -1,240 +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 angular from 'angular';
import ngMock from 'ng_mock';
import expect from 'expect.js';
import { chartLimits } from 'plugins/ml/util/chart_utils.js';
/*
* This demonstrates two different ways to set up the necessary boilerplate to
* write unit tests for Angular Directives when you want to test the rendered
* result.
*
* Note that the first two tests don't append the directive to the browser's
* DOM. The boilerplate is simpler there because the tests don't rely on
* checking DOM elements/attributes in their rendered state, the tests just
* work if the correct rendered structure is present.
*
* The other two tests use a init(<data>, <tests>) 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 - <ml-explorer-chart>', () => {
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: `<div class=\"explorer-chart-info-tooltip\">job ID: population-03<br/>
aggregation interval: 1h<br/>chart function: sum nginx.access.body_sent.bytes<br/>
nginx.access.remote_ip: 72.57.0.53</div>`,
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('<ml-explorer-chart />')($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('<ml-explorer-chart series-config="seriesConfig" />')($scope);
$scope.$digest();
// test if the loading indicator is shown
expect($element.find('ml-loading-indicator .loading-indicator').length).to.be(1);
});
describe('ML - <ml-explorer-chart> 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('<div style="width: 500px"><ml-explorer-chart series-config="seriesConfig" /></div>');
// 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();
});
});
});
});

View file

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

View file

@ -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 (
<div className="ml-explorer-chart" ref={this.setRef.bind(this)} >
{isLoading && (
<LoadingIndicator height={CONTENT_WRAPPER_HEIGHT} />
)}
{!isLoading && (
<div className="content-wrapper" />
)}
</div>
);
}
}

View file

@ -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: `<div class=\"explorer-chart-info-tooltip\">job ID: population-03<br/>
aggregation interval: 1h<br/>chart function: sum nginx.access.body_sent.bytes<br/>
nginx.access.remote_ip: 72.57.0.53</div>`,
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(<ExplorerChart 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(<ExplorerChart 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' }}>
<ExplorerChart seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} />
</div>
);
}
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);
});
});

View file

@ -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';
import { buildConfigFromDetector } from '../../util/chart_config_builder';
import { mlEscape } from '../../util/string_utils';
import { mlJobService } from '../../services/job_service';
export function explorerChartConfigBuilder() {
// 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);
const compiledTooltip = _.template(
'<div class="explorer-chart-info-tooltip">job ID: <%= jobId %><br/>' +
'aggregation interval: <%= aggregationInterval %><br/>' +
'chart function: <%= chartFunction %>' +
'<% for(let i = 0; i < entityFields.length; ++i) { %>' +
'<br/><%= entityFields[i].fieldName %>: <%= entityFields[i].fieldValue %>' +
'<% } %>' +
'</div>');
// 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}`;
}
}
// 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;
config.detectorLabel += ` ${config.fieldName}`;
}
// 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 });
}
if (_.has(record, 'over_field_name')) {
config.entityFields.push({ fieldName: record.over_field_name, fieldValue: record.over_field_value });
}
// 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 });
}
// 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)}`;
}
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),
}))
});
return config;
}
return {
buildConfig
};
}
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
});
}
if (_.has(record, 'over_field_name')) {
config.entityFields.push({
fieldName: record.over_field_name,
fieldValue: record.over_field_value
});
}
// 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
});
}
// 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)}`;
}
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;
}

View file

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

View file

@ -1,25 +0,0 @@
<div class="explorer-charts">
<div class="row" ng-hide="seriesToPlot.length === 0">
<div ng-repeat="series in seriesToPlot" class="ml-explorer-chart-container col-md-{{layoutCellsPerChart}}">
<div class="explorer-chart-label">
<div class="explorer-chart-label-fields">
<span ng-if="series.entityFields.length > 0">{{series.detectorLabel}} - </span>
<span ng-if="series.entityFields.length === 0">{{series.detectorLabel}}</span>
<span ng-repeat='entity in series.entityFields'>
{{entity.fieldName}} {{entity.fieldValue}}
</span>
</div>
<i aria-hidden="true" class="fa fa-info-circle" tooltip-placement="left" tooltip-html-unsafe="{{series.infoTooltip}}" tooltip-append-to-body="false"></i>
<i ng-if="tooManyBuckets" aria-hidden="true" class="fa fa-exclamation-triangle text-warning" tooltip-placement="bottom" tooltip-append-to-body="true"
tooltip-html-unsafe="This selection contains too many buckets to be displayed. The dashboard is best viewed over a shorter time range." ></i>
<a class="kuiLink" ng-click="exploreSeries(series)" data-toggle="tooltip" tooltip="View series in Single Metric Viewer">
View <i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
<ml-explorer-chart series-config="series" />
</div>
</div>
</div>

View file

@ -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 (
<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="explorer-chart-label">
<div className="explorer-chart-label-fields">
{(entityFields.length > 0) && (
<span>{detectorLabel} - </span>
)}
{(entityFields.length === 0) && (
<span>{detectorLabel}</span>
)}
{entityFields.map((entity, j) => {
return (
<span key={j}>{entity.fieldName} {entity.fieldValue}</span>
);
})}
</div>
<EuiIconTip content={<ExplorerChartTooltip {...series.infoTooltip} />} position="left" size="s" />
{tooManyBuckets && (
<EuiIconTip
content={'This selection contains too many buckets to be displayed.' +
'The dashboard is best viewed over a shorter time range.'}
position="bottom"
size="s"
type="alert"
color="warning"
/>
)}
<a className="kuiLink" onClick={() => exploreSeries(series)}>
View <i className="fa fa-external-link" aria-hidden="true" />
</a>
</div>
<ExplorerChart
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
</div>
);
})
}
</div>
);
}
ExplorerChartsContainer.propTypes = {
exploreSeries: PropTypes.func.isRequired,
seriesToPlot: PropTypes.array.isRequired,
layoutCellsPerChart: PropTypes.number.isRequired,
tooManyBuckets: PropTypes.bool.isRequired,
mlSelectSeverityService: PropTypes.object.isRequired,
mlChartTooltipService: PropTypes.object.isRequired
};

View file

@ -11,23 +11,37 @@
* anomalies in the raw data in the Machine Learning Explorer dashboard.
*/
import './styles/explorer_charts_container_directive.less';
import './styles/explorer_charts_container.less';
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import $ from 'jquery';
import moment from 'moment';
import rison from 'rison-node';
import chrome from 'ui/chrome';
import { timefilter } from 'ui/timefilter';
import template from './explorer_charts_container.html';
import { ExplorerChartsContainer } from './explorer_charts_container';
import { exploreSeries } from './explore_series';
import { explorerChartsContainerServiceFactory } from './explorer_charts_container_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlExplorerChartsContainer', function ($window) {
module.directive('mlExplorerChartsContainer', function (
mlExplorerDashboardService,
mlSelectSeverityService
) {
function link(scope, element) {
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mlSelectSeverityService,
updateComponent
);
mlExplorerDashboardService.anomalyDataChange.watch(anomalyDataChangeListener);
scope.$on('$destroy', () => {
mlExplorerDashboardService.anomalyDataChange.unwatch(anomalyDataChangeListener);
});
// Create a div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
$('body').append('<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">');
@ -36,78 +50,28 @@ module.directive('mlExplorerChartsContainer', function ($window) {
scope.$destroy();
});
scope.exploreSeries = function (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();
function updateComponent(data) {
const props = {
exploreSeries,
seriesToPlot: data.seriesToPlot,
layoutCellsPerChart: data.layoutCellsPerChart,
// convert truthy/falsy value to Boolean
tooManyBuckets: !!data.tooManyBuckets,
mlSelectSeverityService,
mlChartTooltipService
};
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');
};
ReactDOM.render(
React.createElement(ExplorerChartsContainer, props),
element[0]
);
}
}
return {
restrict: 'E',
scope: {
seriesToPlot: '=',
chartsPerRow: '=',
layoutCellsPerChart: '=',
tooManyBuckets: '='
},
link: link,
template
restrict: 'E',
replace: false,
scope: false,
link: link
};
});

View file

@ -16,41 +16,53 @@
import _ from 'lodash';
import $ from 'jquery';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { explorerChartConfigBuilder } from './explorer_chart_config_builder';
import { chartLimits } from 'plugins/ml/util/chart_utils';
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
import { mlResultsService } from 'plugins/ml/services/results_service';
import { mlJobService } from 'plugins/ml/services/job_service';
import { buildConfig } from './explorer_chart_config_builder';
import { chartLimits } from '../../util/chart_utils';
import { isTimeSeriesViewDetector } from '../../../common/util/job_utils';
import { mlResultsService } from '../../services/results_service';
import { mlJobService } from '../../services/job_service';
module.controller('MlExplorerChartsContainerController', function ($scope, $injector) {
const Private = $injector.get('Private');
const mlExplorerDashboardService = $injector.get('mlExplorerDashboardService');
const mlSelectSeverityService = $injector.get('mlSelectSeverityService');
$scope.seriesToPlot = [];
export function explorerChartsContainerServiceFactory(
mlSelectSeverityService,
callback
) {
const $chartContainer = $('.explorer-charts');
const FUNCTION_DESCRIPTIONS_TO_PLOT = ['mean', 'min', 'max', 'sum', 'count', 'distinct_count', 'median', 'rare'];
const CHART_MAX_POINTS = 500;
const ANOMALIES_MAX_RESULTS = 500;
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'
};
}
callback(getDefaultData());
const anomalyDataChangeListener = function (anomalyRecords, earliestMs, latestMs) {
const data = getDefaultData();
const threshold = mlSelectSeverityService.state.get('threshold');
const filteredRecords = _.filter(anomalyRecords, (record) => {
const filteredRecords = anomalyRecords.filter((record) => {
return Number(record.record_score) >= threshold.val;
});
const allSeriesRecords = processRecordsForDisplay(filteredRecords);
// Calculate the number of charts per row, depending on the width available, to a max of 4.
const chartsContainerWidth = $chartContainer.width();
const chartsPerRow = Math.min(Math.max(Math.floor(chartsContainerWidth / 550), 1), 4);
const chartsContainerWidth = Math.floor($chartContainer.width());
const chartsPerRow = Math.min(Math.max(Math.floor(chartsContainerWidth / 550), 1), MAX_CHARTS_PER_ROW);
$scope.chartsPerRow = chartsPerRow;
$scope.layoutCellsPerChart = 12 / $scope.chartsPerRow;
data.layoutCellsPerChart = DEFAULT_LAYOUT_CELLS_PER_CHART / chartsPerRow;
// Build the data configs of the anomalies to be displayed.
// TODO - implement paging?
@ -60,17 +72,20 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
const seriesConfigs = buildDataConfigs(recordsToPlot);
// Calculate the time range of the charts, which is a function of the chart width and max job bucket span.
$scope.tooManyBuckets = false;
const chartRange = calculateChartRange(seriesConfigs, earliestMs, latestMs,
Math.floor(chartsContainerWidth / chartsPerRow), recordsToPlot);
data.tooManyBuckets = false;
const { chartRange, tooManyBuckets } = calculateChartRange(seriesConfigs, earliestMs, latestMs,
Math.floor(chartsContainerWidth / chartsPerRow), recordsToPlot, data.timeFieldName);
data.tooManyBuckets = tooManyBuckets;
// initialize the charts with loading indicators
$scope.seriesToPlot = seriesConfigs.map(config => ({
data.seriesToPlot = seriesConfigs.map(config => ({
...config,
loading: true,
chartData: null
}));
callback(data);
// Query 1 - load the raw metric data.
function getMetricData(config, range) {
const datafeedQuery = _.get(config, 'datafeedConfig.query', null);
@ -250,7 +265,7 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
}, []);
const overallChartLimits = chartLimits(allDataPoints);
$scope.seriesToPlot = response.map((d, i) => ({
data.seriesToPlot = response.map((d, i) => ({
...seriesConfigs[i],
loading: false,
chartData: processedData[i],
@ -260,18 +275,13 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
selectedLatest: latestMs,
chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i])
}));
callback(data);
})
.catch(error => {
console.error(error);
});
};
mlExplorerDashboardService.anomalyDataChange.watch(anomalyDataChangeListener);
$scope.$on('$destroy', () => {
mlExplorerDashboardService.anomalyDataChange.unwatch(anomalyDataChangeListener);
});
function processRecordsForDisplay(anomalyRecords) {
// Aggregate the anomaly data by detector, and entity (by/over/partition).
if (anomalyRecords.length === 0) {
@ -409,11 +419,11 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
function buildDataConfigs(anomalyRecords) {
// Build the chart configuration for each anomaly record.
const configBuilder = Private(explorerChartConfigBuilder);
return anomalyRecords.map(configBuilder.buildConfig);
return anomalyRecords.map(buildConfig);
}
function calculateChartRange(seriesConfigs, earliestMs, latestMs, chartWidth, recordsToPlot) {
function calculateChartRange(seriesConfigs, earliestMs, latestMs, chartWidth, recordsToPlot, timeFieldName) {
let tooManyBuckets = false;
// Calculate the time range for the charts.
// Fit in as many points in the available container width plotted at the job bucket span.
const midpointMs = Math.ceil((earliestMs + latestMs) / 2);
@ -429,20 +439,22 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
// at optimal point spacing.
const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection);
const halfPoints = Math.ceil(plotPoints / 2);
let chartRange = { min: midpointMs - (halfPoints * maxBucketSpanMs),
max: midpointMs + (halfPoints * maxBucketSpanMs) };
let chartRange = {
min: midpointMs - (halfPoints * maxBucketSpanMs),
max: midpointMs + (halfPoints * maxBucketSpanMs)
};
if (plotPoints > CHART_MAX_POINTS) {
$scope.tooManyBuckets = true;
tooManyBuckets = true;
// For each series being plotted, display the record with the highest score if possible.
const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS;
let minMs = recordsToPlot[0][$scope.timeFieldName];
let maxMs = recordsToPlot[0][$scope.timeFieldName];
let minMs = recordsToPlot[0][timeFieldName];
let maxMs = recordsToPlot[0][timeFieldName];
_.each(recordsToPlot, (record) => {
const diffMs = maxMs - minMs;
if (diffMs < maxTimeSpan) {
const recordTime = record[$scope.timeFieldName];
const recordTime = record[timeFieldName];
if (recordTime < minMs) {
if (maxMs - recordTime <= maxTimeSpan) {
minMs = recordTime;
@ -466,7 +478,12 @@ module.controller('MlExplorerChartsContainerController', function ($scope, $inje
chartRange = { min: minMs, max: maxMs };
}
return chartRange;
return {
chartRange,
tooManyBuckets
};
}
});
return anomalyDataChangeListener;
}

View file

@ -6,7 +6,5 @@
import './explorer_charts_container_controller.js';
import './explorer_charts_container_directive.js';
import './explorer_chart_directive.js';
import 'plugins/ml/components/chart_tooltip';

View file

@ -1,4 +1,5 @@
ml-explorer-chart {
ml-explorer-chart,
.ml-explorer-chart-container {
display: block;
svg {

View file

@ -1,6 +1,6 @@
ml-explorer-charts-container {
.explorer-charts {
.explorer-charts {
ml-explorer-charts-container {
.row {
padding: 10px;
@ -114,11 +114,20 @@ ml-explorer-charts-container {
display: inline-block;
}
.euiIcon {
vertical-align: top;
margin: 4px 0 0 4px;
}
a {
float:right;
padding-left: 5px;
}
}
.content-wrapper {
height: 215px;
}
}
}
}

View file

@ -19,11 +19,12 @@ import { getSeverityColor } from 'plugins/ml/../common/util/anomaly_utils';
import { numTicksForDateFormat } from 'plugins/ml/util/chart_utils';
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
import { mlEscape } from 'plugins/ml/util/string_utils';
import { mlChartTooltipService } from '../components/chart_tooltip/chart_tooltip_service';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlExplorerSwimlane', function ($compile, Private, mlExplorerDashboardService, mlChartTooltipService) {
module.directive('mlExplorerSwimlane', function ($compile, Private, mlExplorerDashboardService) {
function link(scope, element) {

View file

@ -6,9 +6,9 @@
import 'plugins/ml/explorer/explorer_controller.js';
import 'plugins/ml/explorer/explorer_dashboard_service.js';
import 'plugins/ml/explorer/explorer_swimlane_directive.js';
import 'plugins/ml/explorer/explorer_controller';
import 'plugins/ml/explorer/explorer_dashboard_service';
import 'plugins/ml/explorer/explorer_swimlane_directive';
import 'plugins/ml/explorer/styles/main.less';
import 'plugins/ml/explorer/explorer_charts';
import 'plugins/ml/explorer/select_limit';

View file

@ -16,13 +16,14 @@ import angular from 'angular';
import moment from 'moment';
import { TimeBuckets } from 'ui/time_buckets';
import { numTicksForDateFormat } from 'plugins/ml/util/chart_utils';
import { mlEscape } from 'plugins/ml/util/string_utils';
import { numTicksForDateFormat } from '../../../../../util/chart_utils';
import { mlEscape } from '../../../../../util/string_utils';
import { mlChartTooltipService } from '../../../../../components/chart_tooltip/chart_tooltip_service';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlPopulationJobChart', function (mlChartTooltipService) {
module.directive('mlPopulationJobChart', function () {
function link(scope, element) {

View file

@ -33,15 +33,12 @@ import ContextChartMask from 'plugins/ml/timeseriesexplorer/context_chart_mask';
import { findChartPointForAnomalyTime } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils';
import { mlEscape } from 'plugins/ml/util/string_utils';
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
import { mlChartTooltipService } from '../components/chart_tooltip/chart_tooltip_service';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlTimeseriesChart', function (
$compile,
$timeout,
Private,
mlChartTooltipService) {
module.directive('mlTimeseriesChart', function () {
function link(scope, element) {

View file

@ -7,7 +7,7 @@
import d3 from 'd3';
import { calculateTextWidth } from 'plugins/ml/util/string_utils';
import { calculateTextWidth } from '../util/string_utils';
import moment from 'moment';
const MAX_LABEL_WIDTH = 100;