[ML] Convert Angular filters to formatter functions (#18681)

* [ML] Convert Angular filters to formatter functions

* [ML] Clean up Angular filter wrappers
This commit is contained in:
Pete Harverson 2018-05-02 09:47:27 +01:00 committed by GitHub
parent 92f05ee9b3
commit a565761b94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 396 additions and 392 deletions

View file

@ -17,6 +17,7 @@ import rison from 'rison-node';
import { notify } from 'ui/notify';
import { ES_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
import { parseInterval } from 'plugins/ml/../common/util/parse_interval';
import { formatValue } from 'plugins/ml/formatters/format_value';
import { getUrlForRecord } from 'plugins/ml/util/custom_url_utils';
import { replaceStringTokens, mlEscape } from 'plugins/ml/util/string_utils';
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
@ -35,8 +36,7 @@ import template from './anomalies_table.html';
import 'plugins/ml/components/controls';
import 'plugins/ml/components/paginated_table';
import 'plugins/ml/filters/format_value';
import 'plugins/ml/filters/metric_change_description';
import 'plugins/ml/formatters/metric_change_description';
import './expanded_row/expanded_row_directive';
import './influencers_cell/influencers_cell_directive';
@ -53,8 +53,7 @@ module.directive('mlAnomaliesTable', function (
Private,
mlAnomaliesTableService,
mlSelectIntervalService,
mlSelectSeverityService,
formatValueFilter) {
mlSelectSeverityService) {
return {
restrict: 'E',
@ -854,9 +853,11 @@ module.directive('mlAnomaliesTable', function (
if (addActual !== undefined) {
if (_.has(record, 'actual')) {
tableRow.push({
markup: formatValueFilter(record.actual, record.source.function, fieldFormat),
markup: formatValue(record.actual, record.source.function, fieldFormat),
// Store the unformatted value as a number so that sorting works correctly.
value: Number(record.actual),
// actual and typical values in anomaly record results will be arrays.
value: Array.isArray(record.actual) && record.actual.length === 1 ?
Number(record.actual[0]) : String(record.actual),
scope: rowScope });
} else {
tableRow.push({ markup: '', value: '' });
@ -864,9 +865,10 @@ module.directive('mlAnomaliesTable', function (
}
if (addTypical !== undefined) {
if (_.has(record, 'typical')) {
const typicalVal = Number(record.typical);
const typicalVal = Array.isArray(record.typical) && record.typical.length === 1 ?
Number(record.typical[0]) : String(record.typical);
tableRow.push({
markup: formatValueFilter(record.typical, record.source.function, fieldFormat),
markup: formatValue(record.typical, record.source.function, fieldFormat),
value: typicalVal,
scope: rowScope });
@ -875,10 +877,15 @@ module.directive('mlAnomaliesTable', function (
// and add a description cell if not time_of_week/day.
const detectorFunc = record.source.function;
if (detectorFunc !== 'time_of_week' && detectorFunc !== 'time_of_day') {
const actualVal = Number(record.actual);
const factor = (actualVal > typicalVal) ? actualVal / typicalVal : typicalVal / actualVal;
let factor = 0;
if (Array.isArray(record.typical) && record.typical.length === 1 &&
Array.isArray(record.actual) && record.actual.length === 1) {
const actualVal = Number(record.actual[0]);
factor = (actualVal > typicalVal) ? actualVal / typicalVal : typicalVal / actualVal;
}
tableRow.push({
markup: `<span ng-bind-html="${actualVal} | metricChangeDescription:${typicalVal}"></span>`,
markup: `<span ng-bind-html="[${record.actual}] | metricChangeDescription:[${typicalVal}]"></span>`,
value: Math.abs(factor),
scope: rowScope });
} else {

View file

@ -23,7 +23,7 @@ import {
showActualForFunction,
showTypicalForFunction
} from 'plugins/ml/util/anomaly_utils';
import 'plugins/ml/filters/format_value';
import 'plugins/ml/formatters/format_value';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');

View file

@ -13,7 +13,7 @@
import _ from 'lodash';
import 'plugins/ml/lib/angular_bootstrap_patch';
import 'plugins/ml/filters/abbreviate_whole_number';
import 'plugins/ml/formatters/abbreviate_whole_number';
import template from './influencers_list.html';
import { getSeverity } from 'plugins/ml/util/anomaly_utils';

View file

@ -18,10 +18,10 @@ import d3 from 'd3';
import angular from 'angular';
import moment from 'moment';
import { formatValue } from 'plugins/ml/formatters/format_value';
import { getSeverityWithLow } from 'plugins/ml/util/anomaly_utils';
import { drawLineChartDots, numTicksForDateFormat } from 'plugins/ml/util/chart_utils';
import { TimeBuckets } from 'ui/time_buckets';
import 'plugins/ml/filters/format_value';
import loadingIndicatorWrapperTemplate from 'plugins/ml/components/loading_indicator/loading_indicator_wrapper.html';
import { mlEscape } from 'plugins/ml/util/string_utils';
import { FieldFormatServiceProvider } from 'plugins/ml/services/field_format_service';
@ -30,7 +30,6 @@ import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlExplorerChart', function (
formatValueFilter,
mlChartTooltipService,
Private,
mlSelectSeverityService) {
@ -287,10 +286,10 @@ module.directive('mlExplorerChart', function (
if (_.has(marker, 'actual')) {
// Display the record actual in preference to the chart value, which may be
// different depending on the aggregation interval of the chart.
contents += (`<br/>actual: ${formatValueFilter(marker.actual, config.functionDescription, fieldFormat)}`);
contents += (`<br/>typical: ${formatValueFilter(marker.typical, config.functionDescription, fieldFormat)}`);
contents += (`<br/>actual: ${formatValue(marker.actual, config.functionDescription, fieldFormat)}`);
contents += (`<br/>typical: ${formatValue(marker.typical, config.functionDescription, fieldFormat)}`);
} else {
contents += (`<br/>value: ${formatValueFilter(marker.value, config.functionDescription, fieldFormat)}`);
contents += (`<br/>value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`);
if (_.has(marker, 'byFieldName') && _.has(marker, 'numberOfCauses')) {
const numberOfCauses = marker.numberOfCauses;
const byFieldName = mlEscape(marker.byFieldName);
@ -304,7 +303,7 @@ module.directive('mlExplorerChart', function (
}
}
} else {
contents += `value: ${formatValueFilter(marker.value, config.functionDescription, fieldFormat)}`;
contents += `value: ${formatValue(marker.value, config.functionDescription, fieldFormat)}`;
}
if (_.has(marker, 'scheduledEvents')) {

View file

@ -1,62 +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 ngMock from 'ng_mock';
import expect from 'expect.js';
let filter;
const init = function () {
// Load the application
ngMock.module('kibana');
// Create the scope
ngMock.inject(function ($filter) {
filter = $filter('abbreviateWholeNumber');
});
};
describe('ML - abbreviateWholeNumber filter', () => {
beforeEach(function () {
init();
});
it('should have an abbreviateWholeNumber filter', () => {
expect(filter).to.not.be(null);
});
it('returns the correct format using default max digits', () => {
expect(filter(1)).to.be(1);
expect(filter(12)).to.be(12);
expect(filter(123)).to.be(123);
expect(filter(1234)).to.be('1k');
expect(filter(12345)).to.be('12k');
expect(filter(123456)).to.be('123k');
expect(filter(1234567)).to.be('1m');
expect(filter(12345678)).to.be('12m');
expect(filter(123456789)).to.be('123m');
expect(filter(1234567890)).to.be('1b');
expect(filter(5555555555555.55)).to.be('6t');
});
it('returns the correct format using custom max digits', () => {
expect(filter(1, 4)).to.be(1);
expect(filter(12, 4)).to.be(12);
expect(filter(123, 4)).to.be(123);
expect(filter(1234, 4)).to.be(1234);
expect(filter(12345, 4)).to.be('12k');
expect(filter(123456, 6)).to.be(123456);
expect(filter(1234567, 4)).to.be('1m');
expect(filter(12345678, 3)).to.be('12m');
expect(filter(123456789, 9)).to.be(123456789);
expect(filter(1234567890, 3)).to.be('1b');
expect(filter(5555555555555.55, 5)).to.be('6t');
});
});

View file

@ -1,89 +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 ngMock from 'ng_mock';
import expect from 'expect.js';
import moment from 'moment';
let filter;
const init = function () {
// Load the application
ngMock.module('kibana');
// Create the scope
ngMock.inject(function ($filter) {
filter = $filter('formatValue');
});
};
describe('ML - formatValue filter', () => {
beforeEach(function () {
init();
});
it('should have a formatValue filter', () => {
expect(filter).to.not.be(null);
});
// Just check the return value is in the expected format, and
// not the exact value as this will be timezone specific.
it('correctly formats time_of_week value from numeric input', () => {
const formattedValue = filter(1483228800, 'time_of_week');
const result = moment(formattedValue, 'ddd hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats time_of_day value from numeric input', () => {
const formattedValue = filter(1483228800, 'time_of_day');
const result = moment(formattedValue, 'hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats number values from numeric input', () => {
expect(filter(1483228800, 'mean')).to.be(1483228800);
expect(filter(1234.5678, 'mean')).to.be(1234.6);
expect(filter(0.00012345, 'mean')).to.be(0.000123);
expect(filter(0, 'mean')).to.be(0);
expect(filter(-0.12345, 'mean')).to.be(-0.123);
expect(filter(-1234.5678, 'mean')).to.be(-1234.6);
expect(filter(-100000.1, 'mean')).to.be(-100000);
});
it('correctly formats time_of_week value from array input', () => {
const formattedValue = filter([1483228800], 'time_of_week');
const result = moment(formattedValue, 'ddd hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats time_of_day value from array input', () => {
const formattedValue = filter([1483228800], 'time_of_day');
const result = moment(formattedValue, 'hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats number values from array input', () => {
expect(filter([1483228800], 'mean')).to.be(1483228800);
expect(filter([1234.5678], 'mean')).to.be(1234.6);
expect(filter([0.00012345], 'mean')).to.be(0.000123);
expect(filter([0], 'mean')).to.be(0);
expect(filter([-0.12345], 'mean')).to.be(-0.123);
expect(filter([-1234.5678], 'mean')).to.be(-1234.6);
expect(filter([-100000.1], 'mean')).to.be(-100000);
});
it('correctly formats multi-valued array', () => {
const result = filter([500, 1000], 'mean');
expect(result instanceof Array).to.be(true);
expect(result.length).to.be(2);
expect(result[0]).to.be(500);
expect(result[1]).to.be(1000);
});
});

View file

@ -1,52 +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 ngMock from 'ng_mock';
import expect from 'expect.js';
let filter;
const init = function () {
// Load the application
ngMock.module('kibana');
// Create the scope
ngMock.inject(function ($filter) {
filter = $filter('metricChangeDescription');
});
};
describe('ML - metricChangeDescription filter', () => {
beforeEach(function () {
init();
});
it('should have a metricChangeDescription filter', () => {
expect(filter).to.not.be(null);
});
it('returns correct description if actual > typical', () => {
expect(filter(1.01, 1)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> Unusually high');
expect(filter(1.123, 1)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> 1.1x higher');
expect(filter(2, 1)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> 2x higher');
expect(filter(9.5, 1)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> 10x higher');
expect(filter(1000, 1)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> More than 100x higher');
expect(filter(1, 0)).to.be('<i class="fa fa-arrow-up" aria-hidden="true"></i> Unexpected non-zero value');
});
it('returns correct description if actual < typical', () => {
expect(filter(1, 1.01)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> Unusually low');
expect(filter(1, 1.123)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> 1.1x lower');
expect(filter(1, 2)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> 2x lower');
expect(filter(1, 9.5)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> 10x lower');
expect(filter(1, 1000)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> More than 100x lower');
expect(filter(0, 1)).to.be('<i class="fa fa-arrow-down" aria-hidden="true"></i> Unexpected zero value');
});
});

View file

@ -1,85 +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.
*/
/*
* AngularJS filter generally used for formatting 'typical' and 'actual' values
* from machine learning results. For detectors which use the time_of_week or time_of_day
* functions, the filter converts the raw number, which is the number of seconds since
* midnight, into a human-readable date/time format.
*/
import _ from 'lodash';
import moment from 'moment';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.filter('formatValue', function () {
const SIGFIGS_IF_ROUNDING = 3; // Number of sigfigs to use for values < 10
function formatValue(value, fx, fieldFormat) {
// If the analysis function is time_of_week/day, format as day/time.
if (fx === 'time_of_week') {
const d = new Date();
const i = parseInt(value);
d.setTime(i * 1000);
return moment(d).format('ddd hh:mm');
} else if (fx === 'time_of_day') {
const d = new Date();
const i = parseInt(value);
d.setTime(i * 1000);
return moment(d).format('hh:mm');
} else {
if (fieldFormat !== undefined) {
return fieldFormat.convert(value, 'text');
} else {
// If no Kibana FieldFormat object provided,
// format the value depending on its magnitude.
const absValue = Math.abs(value);
if (absValue >= 10000 || absValue === Math.floor(absValue)) {
// Output 0 decimal places if whole numbers or >= 10000
if (fieldFormat !== undefined) {
return fieldFormat.convert(value, 'text');
} else {
return Number(value.toFixed(0));
}
} else if (absValue >= 10) {
// Output to 1 decimal place between 10 and 10000
return Number(value.toFixed(1));
}
else {
// For values < 10, output to 3 significant figures
let multiple;
if (value > 0) {
multiple = Math.pow(10, SIGFIGS_IF_ROUNDING - Math.floor(Math.log(value) / Math.LN10) - 1);
} else {
multiple = Math.pow(10, SIGFIGS_IF_ROUNDING - Math.floor(Math.log(-1 * value) / Math.LN10) - 1);
}
return (Math.round(value * multiple)) / multiple;
}
}
}
}
return function (value, fx, fieldFormat) {
// actual and typical values in results will be arrays.
// Unless the array is multi-valued (as it will be for multi-variate analyses),
// simply return the formatted single value.
if (Array.isArray(value)) {
if (value.length === 1) {
return formatValue(value[0], fx, fieldFormat);
} else {
return _.map(value, function (val) { return formatValue(val, fx); });
}
} else {
return formatValue(value, fx, fieldFormat);
}
};
});

View file

@ -1,62 +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.
*/
/*
* AngularJS filter for producing a concise textual description of how the
* actual value compares to the typical value for a time series anomaly.
*/
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.filter('metricChangeDescription', function () {
return function (actual, typical) {
if (actual === typical) {
// Very unlikely, but just in case.
return 'actual same as typical';
}
// For actual / typical gives output of the form:
// 4 / 2 2x higher
// 2 / 10 5x lower
// 1000 / 1 More than 100x higher
// 999 / 1000 Unusually low
// 100 / -100 Unusually high
// 0 / 100 Unexpected zero value
// 1 / 0 Unexpected non-zero value
const isHigher = actual > typical;
const iconClass = isHigher ? 'fa-arrow-up' : 'fa-arrow-down';
if (typical !== 0 && actual !== 0) {
const factor = isHigher ? actual / typical : typical / actual;
const direction = isHigher ? 'higher' : 'lower';
if (factor > 1.5) {
if (factor <= 100) {
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> ' + Math.round(factor) + 'x ' + direction;
} else {
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> More than 100x ' + direction;
}
}
if (factor >= 1.05) {
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> ' + factor.toPrecision(2) + 'x ' + direction;
} else {
const dir = isHigher ? 'high' : 'low';
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> Unusually ' + dir;
}
} else {
if (actual === 0) {
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> Unexpected zero value';
} else {
return '<i class="fa ' + iconClass + '" aria-hidden="true"></i> Unexpected non-zero value';
}
}
};
});

View file

@ -0,0 +1,42 @@
/*
* 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 expect from 'expect.js';
import { abbreviateWholeNumber } from '../abbreviate_whole_number';
describe('ML - abbreviateWholeNumber formatter', () => {
it('returns the correct format using default max digits', () => {
expect(abbreviateWholeNumber(1)).to.be(1);
expect(abbreviateWholeNumber(12)).to.be(12);
expect(abbreviateWholeNumber(123)).to.be(123);
expect(abbreviateWholeNumber(1234)).to.be('1k');
expect(abbreviateWholeNumber(12345)).to.be('12k');
expect(abbreviateWholeNumber(123456)).to.be('123k');
expect(abbreviateWholeNumber(1234567)).to.be('1m');
expect(abbreviateWholeNumber(12345678)).to.be('12m');
expect(abbreviateWholeNumber(123456789)).to.be('123m');
expect(abbreviateWholeNumber(1234567890)).to.be('1b');
expect(abbreviateWholeNumber(5555555555555.55)).to.be('6t');
});
it('returns the correct format using custom max digits', () => {
expect(abbreviateWholeNumber(1, 4)).to.be(1);
expect(abbreviateWholeNumber(12, 4)).to.be(12);
expect(abbreviateWholeNumber(123, 4)).to.be(123);
expect(abbreviateWholeNumber(1234, 4)).to.be(1234);
expect(abbreviateWholeNumber(12345, 4)).to.be('12k');
expect(abbreviateWholeNumber(123456, 6)).to.be(123456);
expect(abbreviateWholeNumber(1234567, 4)).to.be('1m');
expect(abbreviateWholeNumber(12345678, 3)).to.be('12m');
expect(abbreviateWholeNumber(123456789, 9)).to.be(123456789);
expect(abbreviateWholeNumber(1234567890, 3)).to.be('1b');
expect(abbreviateWholeNumber(5555555555555.55, 5)).to.be('6t');
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 expect from 'expect.js';
import moment from 'moment';
import { formatValue } from '../format_value';
describe('ML - formatValue formatter', () => {
// Just check the return value is in the expected format, and
// not the exact value as this will be timezone specific.
it('correctly formats time_of_week value from numeric input', () => {
const formattedValue = formatValue(1483228800, 'time_of_week');
const result = moment(formattedValue, 'ddd hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats time_of_day value from numeric input', () => {
const formattedValue = formatValue(1483228800, 'time_of_day');
const result = moment(formattedValue, 'hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats number values from numeric input', () => {
expect(formatValue(1483228800, 'mean')).to.be(1483228800);
expect(formatValue(1234.5678, 'mean')).to.be(1234.6);
expect(formatValue(0.00012345, 'mean')).to.be(0.000123);
expect(formatValue(0, 'mean')).to.be(0);
expect(formatValue(-0.12345, 'mean')).to.be(-0.123);
expect(formatValue(-1234.5678, 'mean')).to.be(-1234.6);
expect(formatValue(-100000.1, 'mean')).to.be(-100000);
});
it('correctly formats time_of_week value from array input', () => {
const formattedValue = formatValue([1483228800], 'time_of_week');
const result = moment(formattedValue, 'ddd hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats time_of_day value from array input', () => {
const formattedValue = formatValue([1483228800], 'time_of_day');
const result = moment(formattedValue, 'hh:mm', true).isValid();
expect(result).to.be(true);
});
it('correctly formats number values from array input', () => {
expect(formatValue([1483228800], 'mean')).to.be(1483228800);
expect(formatValue([1234.5678], 'mean')).to.be(1234.6);
expect(formatValue([0.00012345], 'mean')).to.be(0.000123);
expect(formatValue([0], 'mean')).to.be(0);
expect(formatValue([-0.12345], 'mean')).to.be(-0.123);
expect(formatValue([-1234.5678], 'mean')).to.be(-1234.6);
expect(formatValue([-100000.1], 'mean')).to.be(-100000);
});
it('correctly formats multi-valued array', () => {
expect(formatValue([30.3, 26.2], 'lat_long')).to.be('[30.3,26.2]');
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 expect from 'expect.js';
import { getMetricChangeDescription } from '../metric_change_description';
describe('ML - metricChangeDescription formatter', () => {
it('returns correct icon and message if actual > typical', () => {
expect(getMetricChangeDescription(1.01, 1)).to.eql({ iconType: 'sortUp', message: 'Unusually high' });
expect(getMetricChangeDescription(1.123, 1)).to.eql({ iconType: 'sortUp', message: '1.1x higher' });
expect(getMetricChangeDescription(2, 1)).to.eql({ iconType: 'sortUp', message: '2x higher' });
expect(getMetricChangeDescription(9.5, 1)).to.eql({ iconType: 'sortUp', message: '10x higher' });
expect(getMetricChangeDescription(1000, 1)).to.eql({ iconType: 'sortUp', message: 'More than 100x higher' });
expect(getMetricChangeDescription(1, 0)).to.eql({ iconType: 'sortUp', message: 'Unexpected non-zero value' });
});
it('returns correct icon and message if actual < typical', () => {
expect(getMetricChangeDescription(1, 1.01)).to.eql({ iconType: 'sortDown', message: 'Unusually low' });
expect(getMetricChangeDescription(1, 1.123)).to.eql({ iconType: 'sortDown', message: '1.1x lower' });
expect(getMetricChangeDescription(1, 2)).to.eql({ iconType: 'sortDown', message: '2x lower' });
expect(getMetricChangeDescription(1, 9.5)).to.eql({ iconType: 'sortDown', message: '10x lower' });
expect(getMetricChangeDescription(1, 1000)).to.eql({ iconType: 'sortDown', message: 'More than 100x lower' });
expect(getMetricChangeDescription(0, 1)).to.eql({ iconType: 'sortDown', message: 'Unexpected zero value' });
});
});

View file

@ -6,7 +6,7 @@
/*
* AngularJS filter to abbreviate large whole numbers with metric prefixes.
* Formatter to abbreviate large whole numbers with metric prefixes.
* Uses numeral.js to format numbers longer than the specified number of
* digits with metric abbreviations e.g. 12345 as 12k, or 98000000 as 98m.
*/
@ -15,14 +15,14 @@ import numeral from '@elastic/numeral';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.filter('abbreviateWholeNumber', function () {
return function (value, maxDigits) {
const maxNumDigits = (maxDigits !== undefined ? maxDigits : 3);
if (Math.abs(value) < Math.pow(10, maxNumDigits)) {
return value;
} else {
return numeral(value).format('0a');
}
};
});
export function abbreviateWholeNumber(value, maxDigits) {
const maxNumDigits = (maxDigits !== undefined ? maxDigits : 3);
if (Math.abs(value) < Math.pow(10, maxNumDigits)) {
return value;
} else {
return numeral(value).format('0a');
}
}
// TODO - remove the filter once all uses of the abbreviateWholeNumber Angular filter have been removed.
module.filter('abbreviateWholeNumber', () => abbreviateWholeNumber);

View file

@ -0,0 +1,98 @@
/*
* 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.
*/
/*
* Formatter for 'typical' and 'actual' values from machine learning results.
* For detectors which use the time_of_week or time_of_day
* functions, the filter converts the raw number, which is the number of seconds since
* midnight, into a human-readable date/time format.
*/
import moment from 'moment';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
const SIGFIGS_IF_ROUNDING = 3; // Number of sigfigs to use for values < 10
// Formats the value of an actual or typical field from a machine learning anomaly record.
// mlFunction is the 'function' field from the ML record containing what the user entered e.g. 'high_count',
// (as opposed to the 'function_description' field which holds an ML-built display hint for the function e.g. 'count'.
export function formatValue(value, mlFunction, fieldFormat) {
// actual and typical values in anomaly record results will be arrays.
// Unless the array is multi-valued (as it will be for multi-variate analyses such as lat_long),
// simply return the formatted single value.
if (Array.isArray(value)) {
if (value.length === 1) {
return formatSingleValue(value[0], mlFunction, fieldFormat);
} else {
// Return with array style formatting.
const values = value.map(val => formatSingleValue(val, mlFunction, fieldFormat));
return `[${values}]`;
}
} else {
return formatSingleValue(value, mlFunction, fieldFormat);
}
}
// Formats a single value according to the specifield ML function.
// If a Kibana fieldFormat is not supplied, will fall back to default
// formatting depending on the magnitude of the value.
function formatSingleValue(value, mlFunction, fieldFormat) {
if (value === undefined || value === null) {
return '';
}
// If the analysis function is time_of_week/day, format as day/time.
if (mlFunction === 'time_of_week') {
const d = new Date();
const i = parseInt(value);
d.setTime(i * 1000);
return moment(d).format('ddd hh:mm');
} else if (mlFunction === 'time_of_day') {
const d = new Date();
const i = parseInt(value);
d.setTime(i * 1000);
return moment(d).format('hh:mm');
} else {
if (fieldFormat !== undefined) {
return fieldFormat.convert(value, 'text');
} else {
// If no Kibana FieldFormat object provided,
// format the value depending on its magnitude.
const absValue = Math.abs(value);
if (absValue >= 10000 || absValue === Math.floor(absValue)) {
// Output 0 decimal places if whole numbers or >= 10000
if (fieldFormat !== undefined) {
return fieldFormat.convert(value, 'text');
} else {
return Number(value.toFixed(0));
}
} else if (absValue >= 10) {
// Output to 1 decimal place between 10 and 10000
return Number(value.toFixed(1));
}
else {
// For values < 10, output to 3 significant figures
let multiple;
if (value > 0) {
multiple = Math.pow(10, SIGFIGS_IF_ROUNDING - Math.floor(Math.log(value) / Math.LN10) - 1);
} else {
multiple = Math.pow(10, SIGFIGS_IF_ROUNDING - Math.floor(Math.log(-1 * value) / Math.LN10) - 1);
}
return (Math.round(value * multiple)) / multiple;
}
}
}
}
// TODO - remove the filter once all uses of the formatValue Angular filter have been removed.
module.filter('formatValue', () => formatValue);

View file

@ -0,0 +1,111 @@
/*
* 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.
*/
/*
* Produces a concise textual description of how the
* actual value compares to the typical value for an anomaly.
*/
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
// Returns an Object containing a text message and EuiIcon type to
// describe how the actual value compares to the typical.
export function getMetricChangeDescription(actualProp, typicalProp) {
if (actualProp === undefined || typicalProp === undefined) {
return { iconType: 'empty', message: '' };
}
let iconType;
let message;
// For metric functions, actual and typical will be single value arrays.
let actual = actualProp;
let typical = typicalProp;
if (Array.isArray(actualProp)) {
if (actualProp.length === 1) {
actual = actualProp[0];
} else {
// TODO - do we want to enhance the description depending on detector?
// e.g. 'Unusual location' if using a lat_long detector.
return {
iconType: 'alert',
message: 'Unusual values'
};
}
}
if (Array.isArray(typicalProp)) {
if (typicalProp.length === 1) {
typical = typicalProp[0];
}
}
if (actual === typical) {
// Very unlikely, but just in case.
message = 'actual same as typical';
} else {
// For actual / typical gives output of the form:
// 4 / 2 2x higher
// 2 / 10 5x lower
// 1000 / 1 More than 100x higher
// 999 / 1000 Unusually low
// 100 / -100 Unusually high
// 0 / 100 Unexpected zero value
// 1 / 0 Unexpected non-zero value
const isHigher = actual > typical;
iconType = isHigher ? 'sortUp' : 'sortDown';
if (typical !== 0 && actual !== 0) {
const factor = isHigher ? actual / typical : typical / actual;
const direction = isHigher ? 'higher' : 'lower';
if (factor > 1.5) {
if (factor <= 100) {
message = `${Math.round(factor)}x ${direction}`;
} else {
message = `More than 100x ${direction}`;
}
} else if (factor >= 1.05) {
message = `${factor.toPrecision(2)}x ${direction}`;
} else {
message = `Unusually ${isHigher ? 'high' : 'low'}`;
}
} else {
if (actual === 0) {
message = 'Unexpected zero value';
} else {
message = 'Unexpected non-zero value';
}
}
}
return { iconType, message };
}
// TODO - remove the filter once all uses of the metricChangeDescription Angular filter have been removed.
module.filter('metricChangeDescription', function () {
return function (actual, typical) {
const {
iconType,
message
} = getMetricChangeDescription(actual, typical);
switch (iconType) {
case 'sortUp':
return `<i class="fa fa-arrow-up"></i> ${message}`;
case 'sortDown':
return `<i class="fa fa-arrow-down"></i> ${message}`;
case 'alert':
return `<i class="fa fa-exclamation-triangle"></i> ${message}`;
}
return message;
};
});

View file

@ -20,6 +20,7 @@ import 'ui/timefilter';
import { ResizeChecker } from 'ui/resize_checker';
import { formatValue } from 'plugins/ml/formatters/format_value';
import { getSeverityWithLow } from 'plugins/ml/util/anomaly_utils';
import {
drawLineChartDots,
@ -29,7 +30,6 @@ import {
import { TimeBuckets } from 'ui/time_buckets';
import ContextChartMask from 'plugins/ml/timeseriesexplorer/context_chart_mask';
import { findNearestChartPointToTime } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils';
import 'plugins/ml/filters/format_value';
import { mlEscape } from 'plugins/ml/util/string_utils';
import { FieldFormatServiceProvider } from 'plugins/ml/services/field_format_service';
@ -41,7 +41,6 @@ module.directive('mlTimeseriesChart', function (
$timeout,
timefilter,
mlAnomaliesTableService,
formatValueFilter,
Private,
mlChartTooltipService) {
@ -969,10 +968,10 @@ module.directive('mlTimeseriesChart', function (
if (_.has(marker, 'actual')) {
// Display the record actual in preference to the chart value, which may be
// different depending on the aggregation interval of the chart.
contents += `actual: ${formatValueFilter(marker.actual, marker.function, fieldFormat)}`;
contents += `<br/>typical: ${formatValueFilter(marker.typical, marker.function, fieldFormat)}`;
contents += `actual: ${formatValue(marker.actual, marker.function, fieldFormat)}`;
contents += `<br/>typical: ${formatValue(marker.typical, marker.function, fieldFormat)}`;
} else {
contents += `value: ${formatValueFilter(marker.value, marker.function, fieldFormat)}`;
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
if (_.has(marker, 'byFieldName') && _.has(marker, 'numberOfCauses')) {
const numberOfCauses = marker.numberOfCauses;
const byFieldName = mlEscape(marker.byFieldName);
@ -986,21 +985,21 @@ module.directive('mlTimeseriesChart', function (
}
}
} else {
contents += `value: ${formatValueFilter(marker.value, marker.function, fieldFormat)}`;
contents += `<br/>upper bounds: ${formatValueFilter(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValueFilter(marker.lower, marker.function, fieldFormat)}`;
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
contents += `<br/>upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValue(marker.lower, marker.function, fieldFormat)}`;
}
} else {
// TODO - need better formatting for small decimals.
if (_.get(marker, 'isForecast', false) === true) {
contents += `prediction: ${formatValueFilter(marker.value, marker.function, fieldFormat)}`;
contents += `prediction: ${formatValue(marker.value, marker.function, fieldFormat)}`;
} else {
contents += `value: ${formatValueFilter(marker.value, marker.function, fieldFormat)}`;
contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`;
}
if (scope.modelPlotEnabled === true) {
contents += `<br/>upper bounds: ${formatValueFilter(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValueFilter(marker.lower, marker.function, fieldFormat)}`;
contents += `<br/>upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`;
contents += `<br/>lower bounds: ${formatValue(marker.lower, marker.function, fieldFormat)}`;
}
}