mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
92f05ee9b3
commit
a565761b94
16 changed files with 396 additions and 392 deletions
|
@ -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 {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
|
@ -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]');
|
||||
});
|
||||
|
||||
});
|
|
@ -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' });
|
||||
});
|
||||
|
||||
});
|
|
@ -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);
|
98
x-pack/plugins/ml/public/formatters/format_value.js
Normal file
98
x-pack/plugins/ml/public/formatters/format_value.js
Normal 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);
|
||||
|
111
x-pack/plugins/ml/public/formatters/metric_change_description.js
Normal file
111
x-pack/plugins/ml/public/formatters/metric_change_description.js
Normal 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;
|
||||
};
|
||||
});
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue