From 454abeca69e86004986844927adc715655c497fb Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 1 May 2018 11:14:26 +0100 Subject: [PATCH 1/2] [ML] Convert Angular filters to formatter functions --- .../anomalies_table_directive.js | 29 +++-- .../expanded_row/expanded_row_directive.js | 2 +- .../influencers_list_directive.js | 2 +- .../explorer_chart_directive.js | 11 +- .../__tests__/abbreviate_whole_number.js | 62 ---------- .../public/filters/__tests__/format_value.js | 89 -------------- .../__tests__/metric_change_description.js | 52 -------- .../plugins/ml/public/filters/format_value.js | 85 -------------- .../filters/metric_change_description.js | 62 ---------- .../__tests__/abbreviate_whole_number.js | 42 +++++++ .../formatters/__tests__/format_value.js | 65 ++++++++++ .../__tests__/metric_change_description.js | 33 ++++++ .../abbreviate_whole_number.js | 20 ++-- .../ml/public/formatters/format_value.js | 101 ++++++++++++++++ .../formatters/metric_change_description.js | 111 ++++++++++++++++++ .../timeseries_chart_directive.js | 23 ++-- 16 files changed, 400 insertions(+), 389 deletions(-) delete mode 100644 x-pack/plugins/ml/public/filters/__tests__/abbreviate_whole_number.js delete mode 100644 x-pack/plugins/ml/public/filters/__tests__/format_value.js delete mode 100644 x-pack/plugins/ml/public/filters/__tests__/metric_change_description.js delete mode 100644 x-pack/plugins/ml/public/filters/format_value.js delete mode 100644 x-pack/plugins/ml/public/filters/metric_change_description.js create mode 100644 x-pack/plugins/ml/public/formatters/__tests__/abbreviate_whole_number.js create mode 100644 x-pack/plugins/ml/public/formatters/__tests__/format_value.js create mode 100644 x-pack/plugins/ml/public/formatters/__tests__/metric_change_description.js rename x-pack/plugins/ml/public/{filters => formatters}/abbreviate_whole_number.js (56%) create mode 100644 x-pack/plugins/ml/public/formatters/format_value.js create mode 100644 x-pack/plugins/ml/public/formatters/metric_change_description.js diff --git a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js index 6ef455aa04c10..960d4bc37ad44 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js @@ -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: ``, + markup: ``, value: Math.abs(factor), scope: rowScope }); } else { diff --git a/x-pack/plugins/ml/public/components/anomalies_table/expanded_row/expanded_row_directive.js b/x-pack/plugins/ml/public/components/anomalies_table/expanded_row/expanded_row_directive.js index 64d65d1360bb3..fd87f5b7c2a5e 100644 --- a/x-pack/plugins/ml/public/components/anomalies_table/expanded_row/expanded_row_directive.js +++ b/x-pack/plugins/ml/public/components/anomalies_table/expanded_row/expanded_row_directive.js @@ -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'); diff --git a/x-pack/plugins/ml/public/components/influencers_list/influencers_list_directive.js b/x-pack/plugins/ml/public/components/influencers_list/influencers_list_directive.js index 574c56be510ce..fb2daccb2732a 100644 --- a/x-pack/plugins/ml/public/components/influencers_list/influencers_list_directive.js +++ b/x-pack/plugins/ml/public/components/influencers_list/influencers_list_directive.js @@ -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'; diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js index 71bfef368e78b..62ee6e53fb227 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart_directive.js @@ -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 += (`
actual: ${formatValueFilter(marker.actual, config.functionDescription, fieldFormat)}`); - contents += (`
typical: ${formatValueFilter(marker.typical, config.functionDescription, fieldFormat)}`); + contents += (`
actual: ${formatValue(marker.actual, config.functionDescription, fieldFormat)}`); + contents += (`
typical: ${formatValue(marker.typical, config.functionDescription, fieldFormat)}`); } else { - contents += (`
value: ${formatValueFilter(marker.value, config.functionDescription, fieldFormat)}`); + contents += (`
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')) { diff --git a/x-pack/plugins/ml/public/filters/__tests__/abbreviate_whole_number.js b/x-pack/plugins/ml/public/filters/__tests__/abbreviate_whole_number.js deleted file mode 100644 index 3015b6b59cdec..0000000000000 --- a/x-pack/plugins/ml/public/filters/__tests__/abbreviate_whole_number.js +++ /dev/null @@ -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'); - }); - -}); diff --git a/x-pack/plugins/ml/public/filters/__tests__/format_value.js b/x-pack/plugins/ml/public/filters/__tests__/format_value.js deleted file mode 100644 index 6ae7794168547..0000000000000 --- a/x-pack/plugins/ml/public/filters/__tests__/format_value.js +++ /dev/null @@ -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); - }); - -}); diff --git a/x-pack/plugins/ml/public/filters/__tests__/metric_change_description.js b/x-pack/plugins/ml/public/filters/__tests__/metric_change_description.js deleted file mode 100644 index e6045be4e09ec..0000000000000 --- a/x-pack/plugins/ml/public/filters/__tests__/metric_change_description.js +++ /dev/null @@ -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(' Unusually high'); - expect(filter(1.123, 1)).to.be(' 1.1x higher'); - expect(filter(2, 1)).to.be(' 2x higher'); - expect(filter(9.5, 1)).to.be(' 10x higher'); - expect(filter(1000, 1)).to.be(' More than 100x higher'); - expect(filter(1, 0)).to.be(' Unexpected non-zero value'); - }); - - it('returns correct description if actual < typical', () => { - expect(filter(1, 1.01)).to.be(' Unusually low'); - expect(filter(1, 1.123)).to.be(' 1.1x lower'); - expect(filter(1, 2)).to.be(' 2x lower'); - expect(filter(1, 9.5)).to.be(' 10x lower'); - expect(filter(1, 1000)).to.be(' More than 100x lower'); - expect(filter(0, 1)).to.be(' Unexpected zero value'); - }); - -}); diff --git a/x-pack/plugins/ml/public/filters/format_value.js b/x-pack/plugins/ml/public/filters/format_value.js deleted file mode 100644 index 7ba54cb2311e8..0000000000000 --- a/x-pack/plugins/ml/public/filters/format_value.js +++ /dev/null @@ -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); - } - }; -}); diff --git a/x-pack/plugins/ml/public/filters/metric_change_description.js b/x-pack/plugins/ml/public/filters/metric_change_description.js deleted file mode 100644 index 8eee882bbc7e9..0000000000000 --- a/x-pack/plugins/ml/public/filters/metric_change_description.js +++ /dev/null @@ -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 ' ' + Math.round(factor) + 'x ' + direction; - } else { - return ' More than 100x ' + direction; - } - } - - if (factor >= 1.05) { - return ' ' + factor.toPrecision(2) + 'x ' + direction; - } else { - const dir = isHigher ? 'high' : 'low'; - return ' Unusually ' + dir; - } - - } else { - if (actual === 0) { - return ' Unexpected zero value'; - } else { - return ' Unexpected non-zero value'; - } - } - - }; -}); - diff --git a/x-pack/plugins/ml/public/formatters/__tests__/abbreviate_whole_number.js b/x-pack/plugins/ml/public/formatters/__tests__/abbreviate_whole_number.js new file mode 100644 index 0000000000000..582d9bac39d72 --- /dev/null +++ b/x-pack/plugins/ml/public/formatters/__tests__/abbreviate_whole_number.js @@ -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'); + }); + +}); diff --git a/x-pack/plugins/ml/public/formatters/__tests__/format_value.js b/x-pack/plugins/ml/public/formatters/__tests__/format_value.js new file mode 100644 index 0000000000000..ff5548b73551e --- /dev/null +++ b/x-pack/plugins/ml/public/formatters/__tests__/format_value.js @@ -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]'); + }); + +}); diff --git a/x-pack/plugins/ml/public/formatters/__tests__/metric_change_description.js b/x-pack/plugins/ml/public/formatters/__tests__/metric_change_description.js new file mode 100644 index 0000000000000..9c4b1516915aa --- /dev/null +++ b/x-pack/plugins/ml/public/formatters/__tests__/metric_change_description.js @@ -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' }); + }); + +}); diff --git a/x-pack/plugins/ml/public/filters/abbreviate_whole_number.js b/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js similarity index 56% rename from x-pack/plugins/ml/public/filters/abbreviate_whole_number.js rename to x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js index ab7d455356813..fd95790159ff1 100644 --- a/x-pack/plugins/ml/public/filters/abbreviate_whole_number.js +++ b/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js @@ -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,18 @@ import numeral from '@elastic/numeral'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); +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', 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'); - } + return abbreviateWholeNumber(value, maxDigits); }; }); - diff --git a/x-pack/plugins/ml/public/formatters/format_value.js b/x-pack/plugins/ml/public/formatters/format_value.js new file mode 100644 index 0000000000000..72e68ce752d3d --- /dev/null +++ b/x-pack/plugins/ml/public/formatters/format_value.js @@ -0,0 +1,101 @@ +/* + * 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', function () { + return function (value, mlFunction, fieldFormat) { + return formatValue(value, mlFunction, fieldFormat); + }; +}); diff --git a/x-pack/plugins/ml/public/formatters/metric_change_description.js b/x-pack/plugins/ml/public/formatters/metric_change_description.js new file mode 100644 index 0000000000000..2edc89da376f9 --- /dev/null +++ b/x-pack/plugins/ml/public/formatters/metric_change_description.js @@ -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 ` ${message}`; + case 'sortDown': + return ` ${message}`; + case 'alert': + return ` ${message}`; + } + + return message; + }; +}); + diff --git a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js b/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js index dee114edbbf11..d7968313b18fa 100644 --- a/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js +++ b/x-pack/plugins/ml/public/timeseriesexplorer/timeseries_chart_directive.js @@ -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 += `
typical: ${formatValueFilter(marker.typical, marker.function, fieldFormat)}`; + contents += `actual: ${formatValue(marker.actual, marker.function, fieldFormat)}`; + contents += `
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 += `
upper bounds: ${formatValueFilter(marker.upper, marker.function, fieldFormat)}`; - contents += `
lower bounds: ${formatValueFilter(marker.lower, marker.function, fieldFormat)}`; + contents += `value: ${formatValue(marker.value, marker.function, fieldFormat)}`; + contents += `
upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`; + contents += `
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 += `
upper bounds: ${formatValueFilter(marker.upper, marker.function, fieldFormat)}`; - contents += `
lower bounds: ${formatValueFilter(marker.lower, marker.function, fieldFormat)}`; + contents += `
upper bounds: ${formatValue(marker.upper, marker.function, fieldFormat)}`; + contents += `
lower bounds: ${formatValue(marker.lower, marker.function, fieldFormat)}`; } } From d6dd9b87018d45a33261702f3d225b2cbb38f52c Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Tue, 1 May 2018 11:53:11 +0100 Subject: [PATCH 2/2] [ML] Clean up Angular filter wrappers --- .../ml/public/formatters/abbreviate_whole_number.js | 6 +----- x-pack/plugins/ml/public/formatters/format_value.js | 7 ++----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js b/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js index fd95790159ff1..51748c71fe109 100644 --- a/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js +++ b/x-pack/plugins/ml/public/formatters/abbreviate_whole_number.js @@ -25,8 +25,4 @@ export function abbreviateWholeNumber(value, maxDigits) { } // TODO - remove the filter once all uses of the abbreviateWholeNumber Angular filter have been removed. -module.filter('abbreviateWholeNumber', function () { - return function (value, maxDigits) { - return abbreviateWholeNumber(value, maxDigits); - }; -}); +module.filter('abbreviateWholeNumber', () => abbreviateWholeNumber); diff --git a/x-pack/plugins/ml/public/formatters/format_value.js b/x-pack/plugins/ml/public/formatters/format_value.js index 72e68ce752d3d..40eb8202782df 100644 --- a/x-pack/plugins/ml/public/formatters/format_value.js +++ b/x-pack/plugins/ml/public/formatters/format_value.js @@ -94,8 +94,5 @@ function formatSingleValue(value, mlFunction, fieldFormat) { } // TODO - remove the filter once all uses of the formatValue Angular filter have been removed. -module.filter('formatValue', function () { - return function (value, mlFunction, fieldFormat) { - return formatValue(value, mlFunction, fieldFormat); - }; -}); +module.filter('formatValue', () => formatValue); +