mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
add top_hit metric (#7302)
* add top_hits aggregation * support nested fields * added choice of the order and select by default the index pattern associated time field * improved parameters name and changed aggregation from latest to top to better reflect what this aggregation may accomplish * made variable a constant * use shorter name for variable * prevent to sort terms on top_hits * Added the top_hits agg to line/pie/tile vis. Added support for function in prop_filter to allow more flexibility when filtering the list of values. The _term order is now the default ordering instead of _custom. * simplified tests * changed as per code review * Top Hit should support all field types on the metric vis * renamed metric to top hit * check if there is any result from buckets * support scripted fields * - added logic for trying to get the field value from the source or from a doc_values field. - added onlyAggregatable option for a field agg param to decide whether or not to retain only aggregatable fields. * improved loops in getValuesAtPath method * do not try to get the doc_values field of an IP field * improved field tests on the onlyAggregatable option * use indexPattern.formatField to get all the values at a given path * do not show the analyzed warning * support to sort on scripted field * corrected tests and rely on the field's formatter instead of trying to return a nice string * do not rely on init_default_field_props to set the doc_values property * added test for the doc_values property * - corrected source formatter - add option to show/hide analyzed warning of a field * set the default value of the showAnalyzedWarning in fieldparam * added description of the showAnalyzedWarning option * use aggParam object to access the options of the field parameter * flattenHit: support object in array * added aggregation option to support multivalued fields and top_hits aggregation with a size different than 1 * disable concatenate aggregation type for visualizations other than table and metric * corrected display of arrays in the metric vis * removed condition on the IP field type when retrieving doc values * - do not set a default value for the aggregate with property since it is dependent on the field - added tooltips for "aggregate with" and "size" parameters * filter out incompatble aggregate options and auto select the option if there is only one * simplified condition * lint * fix min/max/sum/average aggregate of elements having only null/undefined values * added top_hits metric to heatmap visualization
This commit is contained in:
parent
656f7c4316
commit
64a3e72526
27 changed files with 1003 additions and 88 deletions
|
@ -65,7 +65,7 @@ export default function HeatmapVisType(Private) {
|
|||
title: 'Value',
|
||||
min: 1,
|
||||
max: 1,
|
||||
aggFilter: ['count', 'avg', 'median', 'sum', 'min', 'max', 'cardinality', 'std_dev'],
|
||||
aggFilter: ['count', 'avg', 'median', 'sum', 'min', 'max', 'cardinality', 'std_dev', 'top_hits'],
|
||||
defaults: [
|
||||
{ schema: 'metric', type: 'count' }
|
||||
]
|
||||
|
|
|
@ -70,7 +70,7 @@ export default function HistogramVisType(Private) {
|
|||
title: 'Dot Size',
|
||||
min: 0,
|
||||
max: 1,
|
||||
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality']
|
||||
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits']
|
||||
},
|
||||
{
|
||||
group: 'buckets',
|
||||
|
|
|
@ -44,7 +44,7 @@ export default function HistogramVisType(Private) {
|
|||
title: 'Slice Size',
|
||||
min: 1,
|
||||
max: 1,
|
||||
aggFilter: ['sum', 'count', 'cardinality'],
|
||||
aggFilter: ['sum', 'count', 'cardinality', 'top_hits'],
|
||||
defaults: [
|
||||
{ schema: 'metric', type: 'count' }
|
||||
]
|
||||
|
|
|
@ -89,7 +89,7 @@ export default function TileMapVisType(Private, getAppState, courier, config) {
|
|||
title: 'Value',
|
||||
min: 1,
|
||||
max: 1,
|
||||
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'],
|
||||
aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits'],
|
||||
defaults: [
|
||||
{ schema: 'metric', type: 'count' }
|
||||
]
|
||||
|
|
|
@ -82,11 +82,18 @@ uiModules
|
|||
// build collection of agg params html
|
||||
type.params.forEach(function (param, i) {
|
||||
let aggParam;
|
||||
let fields;
|
||||
// if field param exists, compute allowed fields
|
||||
if (param.name === 'field') {
|
||||
fields = $aggParamEditorsScope.indexedFields;
|
||||
} else if (param.type === 'field') {
|
||||
fields = $aggParamEditorsScope[`${param.name}Options`] = param.getFieldOptions($scope.agg);
|
||||
}
|
||||
|
||||
if ($aggParamEditorsScope.indexedFields) {
|
||||
const hasIndexedFields = $aggParamEditorsScope.indexedFields.length > 0;
|
||||
if (fields) {
|
||||
const hasIndexedFields = fields.length > 0;
|
||||
const isExtraParam = i > 0;
|
||||
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if their are no indexed fields.
|
||||
if (!hasIndexedFields && isExtraParam) { // don't draw the rest of the options if there are no indexed fields.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,14 +21,7 @@ describe('metric vis', function () {
|
|||
$scope.processTableGroups({
|
||||
tables: [{
|
||||
columns: [{ title: 'Count' }],
|
||||
rows: [[4301021]],
|
||||
aggConfig: function () {
|
||||
return {
|
||||
fieldFormatter: function () {
|
||||
return formatter;
|
||||
}
|
||||
};
|
||||
}
|
||||
rows: [[ { toString: () => formatter(4301021) } ]]
|
||||
}]
|
||||
});
|
||||
|
||||
|
@ -44,14 +37,7 @@ describe('metric vis', function () {
|
|||
{ title: '1st percentile of bytes' },
|
||||
{ title: '99th percentile of bytes' }
|
||||
],
|
||||
rows: [[182, 445842.4634666484]],
|
||||
aggConfig: function () {
|
||||
return {
|
||||
fieldFormatter: function () {
|
||||
return formatter;
|
||||
}
|
||||
};
|
||||
}
|
||||
rows: [[ { toString: () => formatter(182) }, { toString: () => formatter(445842.4634666484) } ]]
|
||||
}]
|
||||
});
|
||||
|
||||
|
|
|
@ -17,14 +17,11 @@ module.controller('KbnMetricVisController', function ($scope, $element, Private)
|
|||
$scope.processTableGroups = function (tableGroups) {
|
||||
tableGroups.tables.forEach(function (table) {
|
||||
table.columns.forEach(function (column, i) {
|
||||
const fieldFormatter = table.aggConfig(column).fieldFormatter();
|
||||
let value = table.rows[0][i];
|
||||
|
||||
value = isInvalid(value) ? '?' : fieldFormatter(value);
|
||||
const value = table.rows[0][i];
|
||||
|
||||
metrics.push({
|
||||
label: column.title,
|
||||
value: value
|
||||
value: value.toString('html')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -32,8 +29,12 @@ module.controller('KbnMetricVisController', function ($scope, $element, Private)
|
|||
|
||||
$scope.$watch('esResponse', function (resp) {
|
||||
if (resp) {
|
||||
const options = {
|
||||
asAggConfigResults: true
|
||||
};
|
||||
|
||||
metrics.length = 0;
|
||||
$scope.processTableGroups(tabifyAggResponse($scope.vis, resp));
|
||||
$scope.processTableGroups(tabifyAggResponse($scope.vis, resp, options));
|
||||
$element.trigger('renderComplete');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ function stubbedLogstashFields() {
|
|||
// | | |aggregatable
|
||||
// | | | |searchable
|
||||
// name type | | | | |metadata
|
||||
['bytes', 'number', true, true, true, true, { count: 10 } ],
|
||||
['bytes', 'number', true, true, true, true, { count: 10, docValues: true } ],
|
||||
['ssl', 'boolean', true, true, true, true, { count: 20 } ],
|
||||
['@timestamp', 'date', true, true, true, true, { count: 30 } ],
|
||||
['time', 'date', true, true, true, true, { count: 30 } ],
|
||||
|
@ -20,6 +20,7 @@ function stubbedLogstashFields() {
|
|||
['geo.coordinates', 'geo_point', true, true, true, true ],
|
||||
['extension', 'string', true, true, true, true ],
|
||||
['machine.os', 'string', true, true, true, true ],
|
||||
['machine.os.raw', 'string', true, false, true, true, { docValues: true } ],
|
||||
['geo.src', 'string', true, true, true, true ],
|
||||
['_id', 'string', false, false, true, true ],
|
||||
['_type', 'string', false, false, true, true ],
|
||||
|
@ -41,6 +42,7 @@ function stubbedLogstashFields() {
|
|||
] = row;
|
||||
|
||||
const {
|
||||
docValues = false,
|
||||
count = 0,
|
||||
script,
|
||||
lang = script ? 'expression' : undefined,
|
||||
|
@ -50,6 +52,7 @@ function stubbedLogstashFields() {
|
|||
return {
|
||||
name,
|
||||
type,
|
||||
doc_values: docValues,
|
||||
indexed,
|
||||
analyzed,
|
||||
aggregatable,
|
||||
|
|
|
@ -8,6 +8,7 @@ import getComputedFields from 'ui/index_patterns/_get_computed_fields';
|
|||
import RegistryFieldFormatsProvider from 'ui/registry/field_formats';
|
||||
import IndexPatternsFlattenHitProvider from 'ui/index_patterns/_flatten_hit';
|
||||
import IndexPatternsFieldProvider from 'ui/index_patterns/_field';
|
||||
|
||||
export default function (Private) {
|
||||
const fieldFormats = Private(RegistryFieldFormatsProvider);
|
||||
const flattenHit = Private(IndexPatternsFlattenHitProvider);
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
describe('Terms Agg', function () {
|
||||
describe('order agg editor UI', function () {
|
||||
it('defaults to the first metric agg');
|
||||
it('adds "custom metric" option');
|
||||
it('lists all metric agg responses');
|
||||
it('lists individual values of a multi-value metric');
|
||||
it('selects "custom metric" if there are no metric aggs');
|
||||
it('is emptied if the selected metric is removed');
|
||||
it('displays a metric editor if "custom metric" is selected');
|
||||
it('saves the "custom metric" to state and refreshes from it');
|
||||
it('invalidates the form if the metric agg form is not complete');
|
||||
});
|
||||
});
|
156
src/ui/public/agg_types/__tests__/buckets/terms.js
Normal file
156
src/ui/public/agg_types/__tests__/buckets/terms.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
import expect from 'expect.js';
|
||||
import ngMock from 'ng_mock';
|
||||
import AggTypesIndexProvider from 'ui/agg_types/index';
|
||||
|
||||
describe('Terms Agg', function () {
|
||||
describe('order agg editor UI', function () {
|
||||
|
||||
let $rootScope;
|
||||
|
||||
function init({ responseValueAggs = [] }) {
|
||||
ngMock.module('kibana');
|
||||
ngMock.inject(function (Private, $controller, _$rootScope_) {
|
||||
const terms = Private(AggTypesIndexProvider).byName.terms;
|
||||
const orderAggController = terms.params.byName.orderAgg.controller;
|
||||
|
||||
$rootScope = _$rootScope_;
|
||||
$rootScope.agg = {
|
||||
id: 'test',
|
||||
params: {},
|
||||
type: terms,
|
||||
vis: {
|
||||
aggs: []
|
||||
}
|
||||
};
|
||||
$rootScope.responseValueAggs = responseValueAggs;
|
||||
$controller(orderAggController, { $scope: $rootScope });
|
||||
$rootScope.$digest();
|
||||
});
|
||||
}
|
||||
|
||||
it('defaults to the first metric agg', function () {
|
||||
init({
|
||||
responseValueAggs: [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'count'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'agg2',
|
||||
type: {
|
||||
name: 'count'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect($rootScope.agg.params.orderBy).to.be('agg1');
|
||||
});
|
||||
|
||||
it('defaults to the first metric agg that is compatible with the terms bucket', function () {
|
||||
init({
|
||||
responseValueAggs: [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'top_hits'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'agg2',
|
||||
type: {
|
||||
name: 'percentiles'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'agg3',
|
||||
type: {
|
||||
name: 'median'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'agg4',
|
||||
type: {
|
||||
name: 'std_dev'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'agg5',
|
||||
type: {
|
||||
name: 'count'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect($rootScope.agg.params.orderBy).to.be('agg5');
|
||||
});
|
||||
|
||||
it('defaults to the _term metric if no agg is compatible', function () {
|
||||
init({
|
||||
responseValueAggs: [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'top_hits'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect($rootScope.agg.params.orderBy).to.be('_term');
|
||||
});
|
||||
|
||||
it('selects _term if there are no metric aggs', function () {
|
||||
init({});
|
||||
expect($rootScope.agg.params.orderBy).to.be('_term');
|
||||
});
|
||||
|
||||
it('selects _term if the selected metric becomes incompatible', function () {
|
||||
init({
|
||||
responseValueAggs: [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'count'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect($rootScope.agg.params.orderBy).to.be('agg1');
|
||||
$rootScope.responseValueAggs = [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'top_hits'
|
||||
}
|
||||
}
|
||||
];
|
||||
$rootScope.$digest();
|
||||
expect($rootScope.agg.params.orderBy).to.be('_term');
|
||||
});
|
||||
|
||||
it('selects _term if the selected metric is removed', function () {
|
||||
init({
|
||||
responseValueAggs: [
|
||||
{
|
||||
id: 'agg1',
|
||||
type: {
|
||||
name: 'count'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect($rootScope.agg.params.orderBy).to.be('agg1');
|
||||
$rootScope.responseValueAggs = [];
|
||||
$rootScope.$digest();
|
||||
expect($rootScope.agg.params.orderBy).to.be('_term');
|
||||
});
|
||||
|
||||
it('adds "custom metric" option');
|
||||
it('lists all metric agg responses');
|
||||
it('lists individual values of a multi-value metric');
|
||||
it('displays a metric editor if "custom metric" is selected');
|
||||
it('saves the "custom metric" to state and refreshes from it');
|
||||
it('invalidates the form if the metric agg form is not complete');
|
||||
});
|
||||
});
|
342
src/ui/public/agg_types/__tests__/metrics/top_hit.js
Normal file
342
src/ui/public/agg_types/__tests__/metrics/top_hit.js
Normal file
|
@ -0,0 +1,342 @@
|
|||
import _ from 'lodash';
|
||||
import expect from 'expect.js';
|
||||
import ngMock from 'ng_mock';
|
||||
import TopHitProvider from 'ui/agg_types/metrics/top_hit';
|
||||
import VisProvider from 'ui/vis';
|
||||
import StubbedIndexPattern from 'fixtures/stubbed_logstash_index_pattern';
|
||||
|
||||
describe('Top hit metric', function () {
|
||||
let aggDsl;
|
||||
let topHitMetric;
|
||||
let aggConfig;
|
||||
|
||||
function init({ field, sortOrder = 'desc', aggregate = 'concat', size = 1 }) {
|
||||
ngMock.module('kibana');
|
||||
ngMock.inject(function (Private) {
|
||||
const Vis = Private(VisProvider);
|
||||
const indexPattern = Private(StubbedIndexPattern);
|
||||
topHitMetric = Private(TopHitProvider);
|
||||
|
||||
const params = {};
|
||||
if (field) {
|
||||
params.field = field;
|
||||
}
|
||||
params.sortOrder = {
|
||||
val: sortOrder
|
||||
};
|
||||
params.aggregate = {
|
||||
val: aggregate
|
||||
};
|
||||
params.size = size;
|
||||
const vis = new Vis(indexPattern, {
|
||||
title: 'New Visualization',
|
||||
type: 'metric',
|
||||
params: {
|
||||
fontSize: 60,
|
||||
handleNoResults: true
|
||||
},
|
||||
aggs: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'top_hits',
|
||||
schema: 'metric',
|
||||
params
|
||||
}
|
||||
],
|
||||
listeners: {}
|
||||
});
|
||||
|
||||
// Grab the aggConfig off the vis (we don't actually use the vis for anything else)
|
||||
aggConfig = vis.aggs[0];
|
||||
aggDsl = aggConfig.toDsl();
|
||||
});
|
||||
}
|
||||
|
||||
it('should return a label prefixed with Last if sorting in descending order', function () {
|
||||
init({ field: 'bytes' });
|
||||
expect(topHitMetric.makeLabel(aggConfig)).to.eql('Last bytes');
|
||||
});
|
||||
|
||||
it('should return a label prefixed with First if sorting in ascending order', function () {
|
||||
init({
|
||||
field: 'bytes',
|
||||
sortOrder: 'asc'
|
||||
});
|
||||
expect(topHitMetric.makeLabel(aggConfig)).to.eql('First bytes');
|
||||
});
|
||||
|
||||
it('should request the _source field', function () {
|
||||
init({ field: '_source' });
|
||||
expect(aggDsl.top_hits._source).to.be(true);
|
||||
expect(aggDsl.top_hits.docvalue_fields).to.be(undefined);
|
||||
});
|
||||
|
||||
it('should request both for the source and doc_values fields', function () {
|
||||
init({ field: 'bytes' });
|
||||
expect(aggDsl.top_hits._source).to.be('bytes');
|
||||
expect(aggDsl.top_hits.docvalue_fields).to.eql([ 'bytes' ]);
|
||||
});
|
||||
|
||||
it('should only request for the source if the field does not have the doc_values property', function () {
|
||||
init({ field: 'ssl' });
|
||||
expect(aggDsl.top_hits._source).to.be('ssl');
|
||||
expect(aggDsl.top_hits.docvalue_fields).to.be(undefined);
|
||||
});
|
||||
|
||||
describe('try to get the value from the top hit', function () {
|
||||
it('should return null if there is no hit', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: '@tags' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(null);
|
||||
});
|
||||
|
||||
it('should return undefined if the field does not appear in the source', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
bytes: 123
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: '@tags' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(undefined);
|
||||
});
|
||||
|
||||
it('should return the field value from the top hit', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
'@tags': 'aaa'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: '@tags' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.be('aaa');
|
||||
});
|
||||
|
||||
it('should return the object if the field value is an object', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
'@tags': {
|
||||
label: 'aaa'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: '@tags' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql({ label: 'aaa' });
|
||||
});
|
||||
|
||||
it('should return an array if the field has more than one values', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
'@tags': [ 'aaa', 'bbb' ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: '@tags' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql([ 'aaa', 'bbb' ]);
|
||||
});
|
||||
|
||||
it('should get the value from the doc_values field if the source does not have that field', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
'machine.os': 'linux'
|
||||
},
|
||||
fields: {
|
||||
'machine.os.raw': [ 'linux' ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: 'machine.os.raw' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.be('linux');
|
||||
});
|
||||
|
||||
it('should return undefined if the field is not in the source nor in the doc_values field', function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
bytes: 12345
|
||||
},
|
||||
fields: {
|
||||
bytes: 12345
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: 'machine.os.raw' });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.be(undefined);
|
||||
});
|
||||
|
||||
describe('Multivalued field and first/last X docs', function () {
|
||||
it('should return a label prefixed with Last X docs if sorting in descending order', function () {
|
||||
init({
|
||||
field: 'bytes',
|
||||
size: 2
|
||||
});
|
||||
expect(topHitMetric.makeLabel(aggConfig)).to.eql('Last 2 bytes');
|
||||
});
|
||||
|
||||
it('should return a label prefixed with First X docs if sorting in ascending order', function () {
|
||||
init({
|
||||
field: 'bytes',
|
||||
size: 2,
|
||||
sortOrder: 'asc'
|
||||
});
|
||||
expect(topHitMetric.makeLabel(aggConfig)).to.eql('First 2 bytes');
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
description: 'concat values with a comma',
|
||||
type: 'concat',
|
||||
data: [ 1, 2, 3 ],
|
||||
result: [ 1, 2, 3 ]
|
||||
},
|
||||
{
|
||||
description: 'sum up the values',
|
||||
type: 'sum',
|
||||
data: [ 1, 2, 3 ],
|
||||
result: 6
|
||||
},
|
||||
{
|
||||
description: 'take the minimum value',
|
||||
type: 'min',
|
||||
data: [ 1, 2, 3 ],
|
||||
result: 1
|
||||
},
|
||||
{
|
||||
description: 'take the maximum value',
|
||||
type: 'max',
|
||||
data: [ 1, 2, 3 ],
|
||||
result: 3
|
||||
},
|
||||
{
|
||||
description: 'take the average value',
|
||||
type: 'average',
|
||||
data: [ 1, 2, 3 ],
|
||||
result: 2
|
||||
},
|
||||
{
|
||||
description: 'support null/undefined',
|
||||
type: 'min',
|
||||
data: [ undefined, null ],
|
||||
result: null
|
||||
},
|
||||
{
|
||||
description: 'support null/undefined',
|
||||
type: 'max',
|
||||
data: [ undefined, null ],
|
||||
result: null
|
||||
},
|
||||
{
|
||||
description: 'support null/undefined',
|
||||
type: 'sum',
|
||||
data: [ undefined, null ],
|
||||
result: null
|
||||
},
|
||||
{
|
||||
description: 'support null/undefined',
|
||||
type: 'average',
|
||||
data: [ undefined, null ],
|
||||
result: null
|
||||
}
|
||||
]
|
||||
.forEach(agg => {
|
||||
it(`should return the result of the ${agg.type} aggregation over the last doc - ${agg.description}`, function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
bytes: agg.data
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: 'bytes', aggregate: agg.type });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql(agg.result);
|
||||
});
|
||||
|
||||
it(`should return the result of the ${agg.type} aggregation over the last X docs - ${agg.description}`, function () {
|
||||
const bucket = {
|
||||
'1': {
|
||||
hits: {
|
||||
hits: [
|
||||
{
|
||||
_source: {
|
||||
bytes: _.dropRight(agg.data, 1)
|
||||
}
|
||||
},
|
||||
{
|
||||
_source: {
|
||||
bytes: _.last(agg.data)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init({ field: 'bytes', aggregate: agg.type });
|
||||
expect(topHitMetric.getValue(aggConfig, bucket)).to.eql(agg.result);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,18 +1,22 @@
|
|||
import _ from 'lodash';
|
||||
import expect from 'expect.js';
|
||||
import { reject } from 'lodash';
|
||||
import ngMock from 'ng_mock';
|
||||
import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern';
|
||||
import AggTypesParamTypesBaseProvider from 'ui/agg_types/param_types/base';
|
||||
import AggTypesParamTypesFieldProvider from 'ui/agg_types/param_types/field';
|
||||
|
||||
describe('Field', function () {
|
||||
|
||||
let BaseAggParam;
|
||||
let FieldAggParam;
|
||||
let indexPattern;
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
// fetch out deps
|
||||
beforeEach(ngMock.inject(function (Private) {
|
||||
BaseAggParam = Private(AggTypesParamTypesBaseProvider);
|
||||
FieldAggParam = Private(AggTypesParamTypesFieldProvider);
|
||||
indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
|
||||
}));
|
||||
|
||||
describe('constructor', function () {
|
||||
|
@ -24,4 +28,34 @@ describe('Field', function () {
|
|||
expect(aggParam).to.be.a(BaseAggParam);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldOptions', function () {
|
||||
it('should return only aggregatable fields by default', function () {
|
||||
const aggParam = new FieldAggParam({
|
||||
name: 'field'
|
||||
});
|
||||
|
||||
const fields = aggParam.getFieldOptions({
|
||||
getIndexPattern: () => indexPattern
|
||||
});
|
||||
expect(fields).to.not.have.length(0);
|
||||
for (const field of fields) {
|
||||
expect(field.aggregatable).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return all fields if onlyAggregatable is false', function () {
|
||||
const aggParam = new FieldAggParam({
|
||||
name: 'field'
|
||||
});
|
||||
|
||||
aggParam.onlyAggregatable = false;
|
||||
|
||||
const fields = aggParam.getFieldOptions({
|
||||
getIndexPattern: () => indexPattern
|
||||
});
|
||||
const nonAggregatableFields = reject(fields, 'aggregatable');
|
||||
expect(nonAggregatableFields).to.not.be.empty();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,12 +16,13 @@ export default function TermsAggDefinition(Private) {
|
|||
const createFilter = Private(AggTypesBucketsCreateFilterTermsProvider);
|
||||
const routeBasedNotifier = Private(routeBasedNotifierProvider);
|
||||
|
||||
const aggFilter = ['!top_hits', '!percentiles', '!median', '!std_dev'];
|
||||
const orderAggSchema = (new Schemas([
|
||||
{
|
||||
group: 'none',
|
||||
name: 'orderAgg',
|
||||
title: 'Order Agg',
|
||||
aggFilter: ['!percentiles', '!median', '!std_dev']
|
||||
aggFilter: aggFilter
|
||||
}
|
||||
])).all[0];
|
||||
|
||||
|
@ -94,9 +95,15 @@ export default function TermsAggDefinition(Private) {
|
|||
$scope.$watch('responseValueAggs', updateOrderAgg);
|
||||
$scope.$watch('agg.params.orderBy', updateOrderAgg);
|
||||
|
||||
// Returns true if the agg is not compatible with the terms bucket
|
||||
$scope.rejectAgg = function (agg) {
|
||||
// aggFilter elements all starts with a '!'
|
||||
// so the index of agg.type.name in a filter is 1 if it is included
|
||||
return Boolean(aggFilter.find((filter) => filter.indexOf(agg.type.name) === 1));
|
||||
};
|
||||
|
||||
function updateOrderAgg() {
|
||||
const agg = $scope.agg;
|
||||
const aggs = agg.vis.aggs;
|
||||
const params = agg.params;
|
||||
const orderBy = params.orderBy;
|
||||
const paramDef = agg.type.params.byName.orderAgg;
|
||||
|
@ -105,7 +112,11 @@ export default function TermsAggDefinition(Private) {
|
|||
if (!orderBy && prevOrderBy === INIT) {
|
||||
// abort until we get the responseValueAggs
|
||||
if (!$scope.responseValueAggs) return;
|
||||
params.orderBy = (_.first($scope.responseValueAggs) || { id: 'custom' }).id;
|
||||
let respAgg = _($scope.responseValueAggs).filter((agg) => !$scope.rejectAgg(agg)).first();
|
||||
if (!respAgg) {
|
||||
respAgg = { id: '_term' };
|
||||
}
|
||||
params.orderBy = respAgg.id;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -115,15 +126,10 @@ export default function TermsAggDefinition(Private) {
|
|||
// we aren't creating a custom aggConfig
|
||||
if (!orderBy || orderBy !== 'custom') {
|
||||
params.orderAgg = null;
|
||||
|
||||
if (orderBy === '_term') {
|
||||
params.orderBy = '_term';
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure that orderBy is set to a valid agg
|
||||
if (!_.find($scope.responseValueAggs, { id: orderBy })) {
|
||||
params.orderBy = null;
|
||||
const respAgg = _($scope.responseValueAggs).filter((agg) => !$scope.rejectAgg(agg)).find({ id: orderBy });
|
||||
if (!respAgg) {
|
||||
params.orderBy = '_term';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
Field
|
||||
</label>
|
||||
|
||||
<span class="pull-right text-warning hintbox-label" ng-show="agg.params.field.analyzed"
|
||||
<span class="pull-right text-warning hintbox-label" ng-show="aggParam.showAnalyzedWarning && agg.params.field.analyzed"
|
||||
ng-click="showAnalyzedFieldWarning = !showAnalyzedFieldWarning">
|
||||
<i class="fa fa-warning"></i> Analyzed Field
|
||||
</span>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<option
|
||||
ng-repeat="respAgg in responseValueAggs track by respAgg.id"
|
||||
value="{{respAgg.id}}"
|
||||
ng-disabled="rejectAgg(respAgg)"
|
||||
ng-selected="agg.params.orderBy === respAgg.id">
|
||||
metric: {{safeMakeLabel(respAgg)}}
|
||||
</option>
|
||||
|
@ -27,4 +28,4 @@
|
|||
group-name="'metrics'">
|
||||
</vis-editor-agg-params>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
37
src/ui/public/agg_types/controls/top_aggregate_and_size.html
Normal file
37
src/ui/public/agg_types/controls/top_aggregate_and_size.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
<div ng-controller="aggParam.controller" class="vis-editor-agg-form-row">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Aggregate With
|
||||
<kbn-info
|
||||
info="Choose a strategy for combining multiple hits or a multi-valued field into a single metric."
|
||||
placement="right">
|
||||
</kbn-info>
|
||||
</label>
|
||||
|
||||
<select
|
||||
required
|
||||
name="aggregate"
|
||||
ng-model="agg.params.aggregate"
|
||||
ng-options="opt as opt.display for opt in options| orderBy: 'display' track by opt.val"
|
||||
class="form-control"
|
||||
></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Size
|
||||
<kbn-info
|
||||
info="Request top-K hits. Multiple hits will be combined via 'aggregate with'."
|
||||
placement="right">
|
||||
</kbn-info>
|
||||
</label>
|
||||
|
||||
<input
|
||||
required
|
||||
name="size"
|
||||
ng-model="agg.params.size"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
29
src/ui/public/agg_types/controls/top_sort.html
Normal file
29
src/ui/public/agg_types/controls/top_sort.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
<div class="form-group">
|
||||
<label for="sort">
|
||||
Sort On
|
||||
</label>
|
||||
|
||||
<select
|
||||
class="form-control"
|
||||
name="sortField"
|
||||
required
|
||||
ng-model="agg.params.sortField"
|
||||
ng-show="sortFieldOptions.length"
|
||||
auto-select-if-only-one="sortFieldOptions"
|
||||
ng-options="field as field.displayName group by field.type for field in sortFieldOptions">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Order
|
||||
</label>
|
||||
|
||||
<select
|
||||
name="sortOrder"
|
||||
ng-model="agg.params.sortOrder"
|
||||
required
|
||||
ng-options="opt as opt.display for opt in aggParam.options track by opt.val"
|
||||
class="form-control">
|
||||
</select>
|
||||
</div>
|
|
@ -6,6 +6,7 @@ import AggTypesMetricsSumProvider from 'ui/agg_types/metrics/sum';
|
|||
import AggTypesMetricsMedianProvider from 'ui/agg_types/metrics/median';
|
||||
import AggTypesMetricsMinProvider from 'ui/agg_types/metrics/min';
|
||||
import AggTypesMetricsMaxProvider from 'ui/agg_types/metrics/max';
|
||||
import AggTypesMetricsTopHitProvider from 'ui/agg_types/metrics/top_hit';
|
||||
import AggTypesMetricsStdDeviationProvider from 'ui/agg_types/metrics/std_deviation';
|
||||
import AggTypesMetricsCardinalityProvider from 'ui/agg_types/metrics/cardinality';
|
||||
import AggTypesMetricsPercentilesProvider from 'ui/agg_types/metrics/percentiles';
|
||||
|
@ -32,7 +33,8 @@ export default function AggTypeService(Private) {
|
|||
Private(AggTypesMetricsStdDeviationProvider),
|
||||
Private(AggTypesMetricsCardinalityProvider),
|
||||
Private(AggTypesMetricsPercentilesProvider),
|
||||
Private(AggTypesMetricsPercentileRanksProvider)
|
||||
Private(AggTypesMetricsPercentileRanksProvider),
|
||||
Private(AggTypesMetricsTopHitProvider)
|
||||
],
|
||||
buckets: [
|
||||
Private(AggTypesBucketsDateHistogramProvider),
|
||||
|
|
203
src/ui/public/agg_types/metrics/top_hit.js
Normal file
203
src/ui/public/agg_types/metrics/top_hit.js
Normal file
|
@ -0,0 +1,203 @@
|
|||
import _ from 'lodash';
|
||||
import MetricAggTypeProvider from 'ui/agg_types/metrics/metric_agg_type';
|
||||
import RegistryFieldFormatsProvider from 'ui/registry/field_formats';
|
||||
import topSortEditor from 'ui/agg_types/controls/top_sort.html';
|
||||
import aggregateAndSizeEditor from 'ui/agg_types/controls/top_aggregate_and_size.html';
|
||||
|
||||
export default function AggTypeMetricTopProvider(Private) {
|
||||
const MetricAggType = Private(MetricAggTypeProvider);
|
||||
const fieldFormats = Private(RegistryFieldFormatsProvider);
|
||||
|
||||
const isNumber = function (type) {
|
||||
return type === 'number';
|
||||
};
|
||||
|
||||
return new MetricAggType({
|
||||
name: 'top_hits',
|
||||
title: 'Top Hit',
|
||||
makeLabel: function (aggConfig) {
|
||||
let prefix = aggConfig.params.sortOrder.val === 'desc' ? 'Last' : 'First';
|
||||
if (aggConfig.params.size !== 1) {
|
||||
prefix += ` ${aggConfig.params.size}`;
|
||||
}
|
||||
return `${prefix} ${aggConfig.params.field.displayName}`;
|
||||
},
|
||||
params: [
|
||||
{
|
||||
name: 'field',
|
||||
onlyAggregatable: false,
|
||||
showAnalyzedWarning: false,
|
||||
filterFieldTypes: function (vis, value) {
|
||||
if (vis.type.name === 'table' || vis.type.name === 'metric') {
|
||||
return true;
|
||||
}
|
||||
return value === 'number';
|
||||
},
|
||||
write(agg, output) {
|
||||
const field = agg.params.field;
|
||||
output.params = {};
|
||||
|
||||
if (field.scripted) {
|
||||
output.params.script_fields = {
|
||||
[ field.name ]: {
|
||||
script: {
|
||||
inline: field.script,
|
||||
lang: field.lang
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (field.doc_values) {
|
||||
output.params.docvalue_fields = [ field.name ];
|
||||
}
|
||||
output.params._source = field.name === '_source' ? true : field.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'aggregate',
|
||||
type: 'optioned',
|
||||
editor: aggregateAndSizeEditor,
|
||||
options: [
|
||||
{
|
||||
display: 'Min',
|
||||
isCompatibleType: isNumber,
|
||||
isCompatibleVis: _.constant(true),
|
||||
disabled: true,
|
||||
val: 'min'
|
||||
},
|
||||
{
|
||||
display: 'Max',
|
||||
isCompatibleType: isNumber,
|
||||
isCompatibleVis: _.constant(true),
|
||||
disabled: true,
|
||||
val: 'max'
|
||||
},
|
||||
{
|
||||
display: 'Sum',
|
||||
isCompatibleType: isNumber,
|
||||
isCompatibleVis: _.constant(true),
|
||||
disabled: true,
|
||||
val: 'sum'
|
||||
},
|
||||
{
|
||||
display: 'Average',
|
||||
isCompatibleType: isNumber,
|
||||
isCompatibleVis: _.constant(true),
|
||||
disabled: true,
|
||||
val: 'average'
|
||||
},
|
||||
{
|
||||
display: 'Concatenate',
|
||||
isCompatibleType: _.constant(true),
|
||||
isCompatibleVis: function (name) {
|
||||
return name === 'metric' || name === 'table';
|
||||
},
|
||||
disabled: true,
|
||||
val: 'concat'
|
||||
}
|
||||
],
|
||||
controller: function ($scope) {
|
||||
$scope.options = [];
|
||||
$scope.$watchGroup([ 'agg.vis.type.name', 'agg.params.field.type' ], function ([ visName, fieldType ]) {
|
||||
if (fieldType && visName) {
|
||||
$scope.options = _.filter($scope.aggParam.options, option => {
|
||||
return option.isCompatibleVis(visName) && option.isCompatibleType(fieldType);
|
||||
});
|
||||
if ($scope.options.length === 1) {
|
||||
$scope.agg.params.aggregate = $scope.options[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
write: _.noop
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
editor: null, // size setting is done together with the aggregation setting
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'sortField',
|
||||
type: 'field',
|
||||
editor: null,
|
||||
filterFieldTypes: [ 'number', 'date', 'ip', 'string' ],
|
||||
default: function (agg) {
|
||||
return agg.vis.indexPattern.timeFieldName;
|
||||
},
|
||||
write: _.noop // prevent default write, it is handled below
|
||||
},
|
||||
{
|
||||
name: 'sortOrder',
|
||||
type: 'optioned',
|
||||
default: 'desc',
|
||||
editor: topSortEditor,
|
||||
options: [
|
||||
{ display: 'Descending', val: 'desc' },
|
||||
{ display: 'Ascending', val: 'asc' }
|
||||
],
|
||||
write(agg, output) {
|
||||
const sortField = agg.params.sortField;
|
||||
const sortOrder = agg.params.sortOrder;
|
||||
|
||||
if (sortField.scripted) {
|
||||
output.params.sort = [
|
||||
{
|
||||
_script: {
|
||||
script: {
|
||||
inline: sortField.script,
|
||||
lang: sortField.lang
|
||||
},
|
||||
type: sortField.type,
|
||||
order: sortOrder.val
|
||||
}
|
||||
}
|
||||
];
|
||||
} else {
|
||||
output.params.sort = [
|
||||
{
|
||||
[ sortField.name ]: {
|
||||
order: sortOrder.val
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
getValue(agg, bucket) {
|
||||
const hits = _.get(bucket, `${agg.id}.hits.hits`);
|
||||
if (!hits || !hits.length) {
|
||||
return null;
|
||||
}
|
||||
const path = agg.params.field.name;
|
||||
|
||||
let values = _(hits).map(hit => {
|
||||
return path === '_source' ? hit._source : agg.vis.indexPattern.flattenHit(hit, true)[path];
|
||||
})
|
||||
.flatten()
|
||||
.value();
|
||||
|
||||
if (values.length === 1) {
|
||||
values = values[0];
|
||||
}
|
||||
|
||||
if (_.isArray(values)) {
|
||||
if (!_.compact(values).length) {
|
||||
return null;
|
||||
}
|
||||
switch (agg.params.aggregate.val) {
|
||||
case 'max':
|
||||
return _.max(values);
|
||||
case 'min':
|
||||
return _.min(values);
|
||||
case 'sum':
|
||||
return _.sum(values);
|
||||
case 'average':
|
||||
return _.sum(values) / values.length;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -18,6 +18,10 @@ export default function FieldAggParamFactory(Private, $filter) {
|
|||
FieldAggParam.prototype.editor = editorHtml;
|
||||
FieldAggParam.prototype.scriptable = true;
|
||||
FieldAggParam.prototype.filterFieldTypes = '*';
|
||||
// retain only the fields with the aggregatable property if the onlyAggregatable option is true
|
||||
FieldAggParam.prototype.onlyAggregatable = true;
|
||||
// show a warning about the field being analyzed
|
||||
FieldAggParam.prototype.showAnalyzedWarning = true;
|
||||
|
||||
/**
|
||||
* Called to serialize values for saving an aggConfig object
|
||||
|
@ -36,14 +40,20 @@ export default function FieldAggParamFactory(Private, $filter) {
|
|||
const indexPattern = aggConfig.getIndexPattern();
|
||||
let fields = indexPattern.fields.raw;
|
||||
|
||||
fields = fields.filter(f => f.aggregatable);
|
||||
if (this.onlyAggregatable) {
|
||||
fields = fields.filter(f => f.aggregatable);
|
||||
}
|
||||
|
||||
if (!this.scriptable) {
|
||||
fields = fields.filter(field => !field.scripted);
|
||||
}
|
||||
|
||||
if (this.filterFieldTypes) {
|
||||
fields = $filter('fieldType')(fields, this.filterFieldTypes);
|
||||
let filters = this.filterFieldTypes;
|
||||
if (_.isFunction(this.filterFieldTypes)) {
|
||||
filters = this.filterFieldTypes.bind(this, aggConfig.vis);
|
||||
}
|
||||
fields = $filter('fieldType')(fields, filters);
|
||||
fields = $filter('orderBy')(fields, ['type', 'name']);
|
||||
}
|
||||
|
||||
|
|
58
src/ui/public/filters/__tests__/prop_filter.js
Normal file
58
src/ui/public/filters/__tests__/prop_filter.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import expect from 'expect.js';
|
||||
import propFilter from 'ui/filters/_prop_filter';
|
||||
|
||||
describe('prop filter', function () {
|
||||
let nameFilter;
|
||||
|
||||
beforeEach(function () {
|
||||
nameFilter = propFilter('name');
|
||||
});
|
||||
|
||||
function getObjects(...names) {
|
||||
const count = new Map();
|
||||
const objects = [];
|
||||
|
||||
for (const name of names) {
|
||||
if (!count.has(name)) {
|
||||
count.set(name, 1);
|
||||
}
|
||||
objects.push({
|
||||
name: name,
|
||||
title: `${name} ${count.get(name)}`
|
||||
});
|
||||
count.set(name, count.get(name) + 1);
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
it('should keep only the tables', function () {
|
||||
const objects = getObjects('table', 'table', 'pie');
|
||||
expect(nameFilter(objects, 'table')).to.eql(getObjects('table', 'table'));
|
||||
});
|
||||
|
||||
it('should support comma-separated values', function () {
|
||||
const objects = getObjects('table', 'line', 'pie');
|
||||
expect(nameFilter(objects, 'table,line')).to.eql(getObjects('table', 'line'));
|
||||
});
|
||||
|
||||
it('should support an array of values', function () {
|
||||
const objects = getObjects('table', 'line', 'pie');
|
||||
expect(nameFilter(objects, [ 'table', 'line' ])).to.eql(getObjects('table', 'line'));
|
||||
});
|
||||
|
||||
it('should return all objects', function () {
|
||||
const objects = getObjects('table', 'line', 'pie');
|
||||
expect(nameFilter(objects, '*')).to.eql(objects);
|
||||
});
|
||||
|
||||
it('should allow negation', function () {
|
||||
const objects = getObjects('table', 'line', 'pie');
|
||||
expect(nameFilter(objects, [ '!line' ])).to.eql(getObjects('table', 'pie'));
|
||||
});
|
||||
|
||||
it('should support a function for specifying what should be kept', function () {
|
||||
const objects = getObjects('table', 'line', 'pie');
|
||||
const line = (value) => value === 'line';
|
||||
expect(nameFilter(objects, line)).to.eql(getObjects('line'));
|
||||
});
|
||||
});
|
|
@ -13,13 +13,18 @@ function propFilter(prop) {
|
|||
* must contain
|
||||
*
|
||||
* @param {array} list - array of items to filter
|
||||
* @param {array|string} filters - the values to match against the list. Can be
|
||||
* an array, a single value as a string, or a comma
|
||||
* -seperated list of items
|
||||
* @param {function|array|string} filters - the values to match against the list
|
||||
* - if a function, it is expected to take the field property as argument and returns true to keep it.
|
||||
* - Can be also an array, a single value as a string, or a comma-seperated list of items
|
||||
* @return {array} - the filtered list
|
||||
*/
|
||||
return function (list, filters) {
|
||||
if (!filters) return filters;
|
||||
|
||||
if (_.isFunction(filters)) {
|
||||
return list.filter((item) => filters(item[prop]));
|
||||
}
|
||||
|
||||
if (!_.isArray(filters)) filters = filters.split(',');
|
||||
if (_.contains(filters, '*')) return list;
|
||||
|
||||
|
|
|
@ -4,18 +4,17 @@ import ngMock from 'ng_mock';
|
|||
import IndexPatternsFlattenHitProvider from 'ui/index_patterns/_flatten_hit';
|
||||
|
||||
describe('IndexPattern#flattenHit()', function () {
|
||||
|
||||
|
||||
let flattenHit;
|
||||
let config;
|
||||
let hit;
|
||||
let flat;
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
beforeEach(ngMock.inject(function (Private, $injector) {
|
||||
const indexPattern = {
|
||||
fields: {
|
||||
byName: {
|
||||
'tags.text': { type: 'string' },
|
||||
'tags.label': { type: 'string' },
|
||||
'message': { type: 'string' },
|
||||
'geo.coordinates': { type: 'geo_point' },
|
||||
'geo.dest': { type: 'string' },
|
||||
|
@ -33,7 +32,12 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
}
|
||||
};
|
||||
|
||||
flattenHit = Private(IndexPatternsFlattenHitProvider)(indexPattern).uncached;
|
||||
const cachedFlatten = Private(IndexPatternsFlattenHitProvider)(indexPattern);
|
||||
flattenHit = function (hit, deep = false) {
|
||||
delete hit.$$_flattened;
|
||||
return cachedFlatten(hit, deep);
|
||||
};
|
||||
|
||||
config = $injector.get('config');
|
||||
|
||||
hit = {
|
||||
|
@ -46,7 +50,10 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
},
|
||||
bytes: 10039103,
|
||||
'@timestamp': (new Date()).toString(),
|
||||
tags: [{ text: 'foo' }, { text: 'bar' }],
|
||||
tags: [
|
||||
{ text: 'foo', label: [ 'FOO1', 'FOO2' ] },
|
||||
{ text: 'bar', label: 'BAR' }
|
||||
],
|
||||
groups: ['loners'],
|
||||
noMapping: true,
|
||||
team: [
|
||||
|
@ -61,11 +68,11 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
random: [0.12345]
|
||||
}
|
||||
};
|
||||
|
||||
flat = flattenHit(hit);
|
||||
}));
|
||||
|
||||
it('flattens keys as far down as the mapping goes', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('geo.coordinates', hit._source.geo.coordinates);
|
||||
expect(flat).to.not.have.property('geo.coordinates.lat');
|
||||
expect(flat).to.not.have.property('geo.coordinates.lon');
|
||||
|
@ -77,22 +84,42 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
});
|
||||
|
||||
it('flattens keys not in the mapping', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('noMapping', true);
|
||||
expect(flat).to.have.property('groups');
|
||||
expect(flat.groups).to.eql(['loners']);
|
||||
});
|
||||
|
||||
it('flattens conflicting types in the mapping', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.not.have.property('user');
|
||||
expect(flat).to.have.property('user.name', hit._source.user.name);
|
||||
expect(flat).to.have.property('user.id', hit._source.user.id);
|
||||
});
|
||||
|
||||
it('preserves objects in arrays', function () {
|
||||
it('should preserve objects in arrays if deep argument is false', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('tags', hit._source.tags);
|
||||
});
|
||||
|
||||
it('should expand objects in arrays if deep argument is true', function () {
|
||||
const flat = flattenHit(hit, true);
|
||||
|
||||
expect(flat['tags.text']).to.be.eql([ 'foo', 'bar' ]);
|
||||
});
|
||||
|
||||
it('should support arrays when expanding objects in arrays if deep argument is true', function () {
|
||||
const flat = flattenHit(hit, true);
|
||||
|
||||
expect(flat['tags.label']).to.be.eql([ 'FOO1', 'FOO2', 'BAR' ]);
|
||||
});
|
||||
|
||||
it('does not enter into nested fields', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('team', hit._source.team);
|
||||
expect(flat).to.not.have.property('team.name');
|
||||
expect(flat).to.not.have.property('team.role');
|
||||
|
@ -101,24 +128,28 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
});
|
||||
|
||||
it('unwraps script fields', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('delta', 42);
|
||||
});
|
||||
|
||||
it('assumes that all fields are "computed fields"', function () {
|
||||
const flat = flattenHit(hit);
|
||||
|
||||
expect(flat).to.have.property('random', 0.12345);
|
||||
});
|
||||
|
||||
it('ignores fields that start with an _ and are not in the metaFields', function () {
|
||||
config.set('metaFields', ['_metaKey']);
|
||||
hit.fields._notMetaKey = [100];
|
||||
flat = flattenHit(hit);
|
||||
const flat = flattenHit(hit);
|
||||
expect(flat).to.not.have.property('_notMetaKey');
|
||||
});
|
||||
|
||||
it('includes underscore-prefixed keys that are in the metaFields', function () {
|
||||
config.set('metaFields', ['_metaKey']);
|
||||
hit.fields._metaKey = [100];
|
||||
flat = flattenHit(hit);
|
||||
const flat = flattenHit(hit);
|
||||
expect(flat).to.have.property('_metaKey', 100);
|
||||
});
|
||||
|
||||
|
@ -126,7 +157,7 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
hit.fields._metaKey = [100];
|
||||
|
||||
config.set('metaFields', ['_metaKey']);
|
||||
flat = flattenHit(hit);
|
||||
let flat = flattenHit(hit);
|
||||
expect(flat).to.have.property('_metaKey', 100);
|
||||
|
||||
config.set('metaFields', []);
|
||||
|
@ -137,7 +168,7 @@ describe('IndexPattern#flattenHit()', function () {
|
|||
it('handles fields that are not arrays, like _timestamp', function () {
|
||||
hit.fields._metaKey = 20000;
|
||||
config.set('metaFields', ['_metaKey']);
|
||||
flat = flattenHit(hit);
|
||||
const flat = flattenHit(hit);
|
||||
expect(flat).to.have.property('_metaKey', 20000);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
// Takes a hit, merges it with any stored/scripted fields, and with the metaFields
|
||||
// returns a flattened version
|
||||
export default function FlattenHitProvider(config) {
|
||||
|
@ -8,7 +9,7 @@ export default function FlattenHitProvider(config) {
|
|||
metaFields = value;
|
||||
});
|
||||
|
||||
function flattenHit(indexPattern, hit) {
|
||||
function flattenHit(indexPattern, hit, deep) {
|
||||
const flat = {};
|
||||
|
||||
// recursively merge _source
|
||||
|
@ -18,13 +19,28 @@ export default function FlattenHitProvider(config) {
|
|||
_.forOwn(obj, function (val, key) {
|
||||
key = keyPrefix + key;
|
||||
|
||||
if (flat[key] !== void 0) return;
|
||||
if (deep) {
|
||||
const isNestedField = fields[key] && fields[key].type === 'nested';
|
||||
const isArrayOfObjects = _.isArray(val) && _.isPlainObject(_.first(val));
|
||||
if (isArrayOfObjects && !isNestedField) {
|
||||
_.each(val, v => flatten(v, key));
|
||||
return;
|
||||
}
|
||||
} else if (flat[key] !== void 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidMapping = (fields[key] && fields[key].type !== 'conflict');
|
||||
const hasValidMapping = fields[key] && fields[key].type !== 'conflict';
|
||||
const isValue = !_.isPlainObject(val);
|
||||
|
||||
if (hasValidMapping || isValue) {
|
||||
flat[key] = val;
|
||||
if (!flat[key]) {
|
||||
flat[key] = val;
|
||||
} else if (_.isArray(flat[key])) {
|
||||
flat[key].push(val);
|
||||
} else {
|
||||
flat[key] = [ flat[key], val ];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -48,13 +64,8 @@ export default function FlattenHitProvider(config) {
|
|||
}
|
||||
|
||||
return function flattenHitWrapper(indexPattern) {
|
||||
function cachedFlatten(hit) {
|
||||
return hit.$$_flattened || (hit.$$_flattened = flattenHit(indexPattern, hit));
|
||||
}
|
||||
|
||||
cachedFlatten.uncached = _.partial(flattenHit, indexPattern);
|
||||
|
||||
return cachedFlatten;
|
||||
return function cachedFlatten(hit, deep = false) {
|
||||
return hit.$$_flattened || (hit.$$_flattened = flattenHit(indexPattern, hit, deep));
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,11 @@ describe('_source formatting', function () {
|
|||
convertHtml = format.getConverterFor('html');
|
||||
}));
|
||||
|
||||
it('should use the text content type if a field is not passed', function () {
|
||||
const hit = _.first(hits);
|
||||
expect(convertHtml(hit._source)).to.be(JSON.stringify(hit._source));
|
||||
});
|
||||
|
||||
it('uses the _source, field, and hit to create a <dl>', function () {
|
||||
const hit = _.first(hits);
|
||||
const $dl = $(convertHtml(hit._source, indexPattern.fields.byName._source, hit));
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function _SourceFormatProvider(Private, shortDotsFilter) {
|
|||
Source.prototype._convert = {
|
||||
text: angular.toJson,
|
||||
html: function sourceToHtml(source, field, hit) {
|
||||
if (!field) return this.getConverter('text')(source, field, hit);
|
||||
if (!field) return this.getConverterFor('text')(source, field, hit);
|
||||
|
||||
const highlights = (hit && hit.highlight) || {};
|
||||
const formatted = field.indexPattern.formatHit(hit);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue