mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
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:
parent
6b4383a509
commit
4cda7caf95
26 changed files with 809 additions and 624 deletions
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
|
@ -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>
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
ml-explorer-chart {
|
||||
ml-explorer-chart,
|
||||
.ml-explorer-chart-container {
|
||||
display: block;
|
||||
|
||||
svg {
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue