diff --git a/src/core_plugins/kbn_vislib_vis_types/public/heatmap.js b/src/core_plugins/kbn_vislib_vis_types/public/heatmap.js index 718aa32755317..53316d79529c2 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/heatmap.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/heatmap.js @@ -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' } ] diff --git a/src/core_plugins/kbn_vislib_vis_types/public/line.js b/src/core_plugins/kbn_vislib_vis_types/public/line.js index 35e618cf5953d..d5bea19176df7 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/line.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/line.js @@ -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', diff --git a/src/core_plugins/kbn_vislib_vis_types/public/pie.js b/src/core_plugins/kbn_vislib_vis_types/public/pie.js index 670b6b290b869..1b915b60ca2ab 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/pie.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/pie.js @@ -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' } ] diff --git a/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js b/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js index 952b2a9d893cb..b45de4efda3d1 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js @@ -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' } ] diff --git a/src/core_plugins/kibana/public/visualize/editor/agg_params.js b/src/core_plugins/kibana/public/visualize/editor/agg_params.js index 12db500fd7cad..5b55216d0e076 100644 --- a/src/core_plugins/kibana/public/visualize/editor/agg_params.js +++ b/src/core_plugins/kibana/public/visualize/editor/agg_params.js @@ -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`] = getIndexedFields(param); + } - 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; } } @@ -133,6 +140,31 @@ uiModules .append(param.editor) .get(0); } + + function getIndexedFields(param) { + let fields = _.filter($scope.agg.vis.indexPattern.fields.raw, 'aggregatable'); + const fieldTypes = param.filterFieldTypes; + + if (fieldTypes) { + const filter = _.isFunction(fieldTypes) ? fieldTypes.bind(this, $scope.agg.vis) : fieldTypes; + fields = $filter('fieldType')(fields, filter); + fields = $filter('orderBy')(fields, ['type', 'name']); + } + + return new IndexedArray({ + + /** + * @type {Array} + */ + index: ['name'], + + /** + * [group description] + * @type {Array} + */ + initialSet: fields + }); + } } }; }); diff --git a/src/core_plugins/kibana/server/lib/__tests__/init_default_field_props.js b/src/core_plugins/kibana/server/lib/__tests__/init_default_field_props.js deleted file mode 100644 index 3a1d0d309407e..0000000000000 --- a/src/core_plugins/kibana/server/lib/__tests__/init_default_field_props.js +++ /dev/null @@ -1,100 +0,0 @@ -import initDefaultFieldProps from '../init_default_field_props'; -import expect from 'expect.js'; -import _ from 'lodash'; -let fields; - -const testData = [ - { - 'name': 'ip', - 'type': 'ip' - }, { - 'name': '@timestamp', - 'type': 'date' - }, { - 'name': 'agent', - 'type': 'string' - }, { - 'name': 'bytes', - 'type': 'number' - }, - { - 'name': 'geo.coordinates', - 'type': 'geo_point' - } -]; - -describe('initDefaultFieldProps', function () { - - beforeEach(function () { - fields = _.cloneDeep(testData); - }); - - it('should throw an error if no argument is passed or the argument is not an array', function () { - expect(initDefaultFieldProps).to.throwException(/requires an array argument/); - expect(initDefaultFieldProps).withArgs({}).to.throwException(/requires an array argument/); - }); - - it('should set the same defaults for everything but strings', function () { - const results = initDefaultFieldProps(fields); - _.forEach(results, function (field) { - if (field.type !== 'string') { - expect(field).to.have.property('indexed', true); - expect(field).to.have.property('analyzed', false); - expect(field).to.have.property('doc_values', true); - expect(field).to.have.property('scripted', false); - expect(field).to.have.property('count', 0); - } - }); - }); - - it('should make string fields analyzed', function () { - const results = initDefaultFieldProps(fields); - _.forEach(results, function (field) { - if (field.type === 'string' && !_.contains(field.name, 'keyword')) { - expect(field).to.have.property('indexed', true); - expect(field).to.have.property('analyzed', true); - expect(field).to.have.property('doc_values', false); - expect(field).to.have.property('scripted', false); - expect(field).to.have.property('count', 0); - } - }); - }); - - it('should create an extra raw non-analyzed field for strings', function () { - const results = initDefaultFieldProps(fields); - const rawField = _.find(results, function (field) { - return _.contains(field.name, 'keyword'); - }); - expect(rawField).to.have.property('indexed', true); - expect(rawField).to.have.property('analyzed', false); - expect(rawField).to.have.property('doc_values', true); - expect(rawField).to.have.property('scripted', false); - expect(rawField).to.have.property('count', 0); - }); - - it('should apply some overrides to metafields', function () { - const results = initDefaultFieldProps([{name: '_source'}, {name: '_timestamp'}]); - const expected = [ - { - name: '_source', - indexed: false, - analyzed: false, - doc_values: false, - count: 0, - scripted: false, - type: '_source' - }, - { - name: '_timestamp', - indexed: true, - analyzed: false, - doc_values: false, - count: 0, - scripted: false, - type: 'date' - } - ]; - - expect(_.isEqual(expected, results)).to.be.ok(); - }); -}); diff --git a/src/core_plugins/kibana/server/lib/init_default_field_props.js b/src/core_plugins/kibana/server/lib/init_default_field_props.js deleted file mode 100644 index a5b0486f5d32d..0000000000000 --- a/src/core_plugins/kibana/server/lib/init_default_field_props.js +++ /dev/null @@ -1,50 +0,0 @@ -import _ from 'lodash'; -import mappingOverrides from './mapping_overrides'; - -module.exports = function initDefaultFieldProps(fields) { - if (fields === undefined || !_.isArray(fields)) { - throw new Error('requires an array argument'); - } - - const results = []; - - _.forEach(fields, function (field) { - const newField = _.cloneDeep(field); - results.push(newField); - - if (newField.type === 'string') { - _.defaults(newField, { - indexed: true, - analyzed: true, - doc_values: false, - scripted: false, - count: 0 - }); - - results.push({ - name: newField.name + '.keyword', - type: 'string', - indexed: true, - analyzed: false, - doc_values: true, - scripted: false, - count: 0 - }); - } - else { - _.defaults(newField, { - indexed: true, - analyzed: false, - doc_values: true, - scripted: false, - count: 0 - }); - } - - if (mappingOverrides[newField.name]) { - _.assign(newField, mappingOverrides[newField.name]); - } - }); - - return results; -}; diff --git a/src/core_plugins/kibana/server/lib/mapping_overrides.js b/src/core_plugins/kibana/server/lib/mapping_overrides.js deleted file mode 100644 index ad944c0ed42af..0000000000000 --- a/src/core_plugins/kibana/server/lib/mapping_overrides.js +++ /dev/null @@ -1,38 +0,0 @@ -export default { - _source: { - type: '_source', - indexed: false, - analyzed: false, - doc_values: false - }, - _index: { - type: 'string', - indexed: false, - analyzed: false, - doc_values: false - }, - _type: { - type: 'string', - indexed: false, - analyzed: false, - doc_values: false - }, - _id: { - type: 'string', - indexed: false, - analyzed: false, - doc_values: false - }, - _timestamp: { - type: 'date', - indexed: true, - analyzed: false, - doc_values: false - }, - _score: { - type: 'number', - indexed: false, - analyzed: false, - doc_values: false - } -}; diff --git a/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js b/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js index af11e615e94a8..3e0672db84d58 100644 --- a/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js +++ b/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js @@ -20,15 +20,8 @@ describe('metric vis', function () { it('should set the metric label and value', function () { $scope.processTableGroups({ tables: [{ - columns: [{title: 'Count'}], - rows: [[4301021]], - aggConfig: function () { - return { - fieldFormatter: function () { - return formatter; - } - }; - } + columns: [{ title: 'Count' }], + 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) } ]] }] }); diff --git a/src/core_plugins/metric_vis/public/metric_vis_controller.js b/src/core_plugins/metric_vis/public/metric_vis_controller.js index c9efb01d417a9..e3fddf42a36a2 100644 --- a/src/core_plugins/metric_vis/public/metric_vis_controller.js +++ b/src/core_plugins/metric_vis/public/metric_vis_controller.js @@ -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'); } }); diff --git a/src/fixtures/logstash_fields.js b/src/fixtures/logstash_fields.js index 4cdfdd43d4aa7..b44d822a6fd03 100644 --- a/src/fixtures/logstash_fields.js +++ b/src/fixtures/logstash_fields.js @@ -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, diff --git a/src/test_utils/stub_index_pattern.js b/src/test_utils/stub_index_pattern.js index c3be4bcbd87e8..19c643983e279 100644 --- a/src/test_utils/stub_index_pattern.js +++ b/src/test_utils/stub_index_pattern.js @@ -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) { let fieldFormats = Private(RegistryFieldFormatsProvider); let flattenHit = Private(IndexPatternsFlattenHitProvider); diff --git a/src/ui/public/agg_types/__tests__/buckets/_terms.js b/src/ui/public/agg_types/__tests__/buckets/_terms.js deleted file mode 100644 index ce117c7d96b08..0000000000000 --- a/src/ui/public/agg_types/__tests__/buckets/_terms.js +++ /dev/null @@ -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'); - }); -}); diff --git a/src/ui/public/agg_types/__tests__/buckets/terms.js b/src/ui/public/agg_types/__tests__/buckets/terms.js new file mode 100644 index 0000000000000..d5fd937b0b3d8 --- /dev/null +++ b/src/ui/public/agg_types/__tests__/buckets/terms.js @@ -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'); + }); +}); diff --git a/src/ui/public/agg_types/__tests__/metrics/top_hit.js b/src/ui/public/agg_types/__tests__/metrics/top_hit.js new file mode 100644 index 0000000000000..04429a447787c --- /dev/null +++ b/src/ui/public/agg_types/__tests__/metrics/top_hit.js @@ -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); + }); + }); + }); + }); +}); diff --git a/src/ui/public/agg_types/__tests__/param_types/_field.js b/src/ui/public/agg_types/__tests__/param_types/_field.js index cc2679fc0d4b8..d44321381a7cf 100644 --- a/src/ui/public/agg_types/__tests__/param_types/_field.js +++ b/src/ui/public/agg_types/__tests__/param_types/_field.js @@ -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(); + }); + }); }); diff --git a/src/ui/public/agg_types/buckets/terms.js b/src/ui/public/agg_types/buckets/terms.js index 1a326ec8e8e81..8698699e8ae32 100644 --- a/src/ui/public/agg_types/buckets/terms.js +++ b/src/ui/public/agg_types/buckets/terms.js @@ -16,12 +16,13 @@ export default function TermsAggDefinition(Private) { let createFilter = Private(AggTypesBucketsCreateFilterTermsProvider); const routeBasedNotifier = Private(routeBasedNotifierProvider); + const aggFilter = ['!top_hits', '!percentiles', '!median', '!std_dev']; let 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() { let agg = $scope.agg; - let aggs = agg.vis.aggs; let params = agg.params; let orderBy = params.orderBy; let 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; } diff --git a/src/ui/public/agg_types/controls/field.html b/src/ui/public/agg_types/controls/field.html index 14d0b56d98467..5ef83e18a3219 100644 --- a/src/ui/public/agg_types/controls/field.html +++ b/src/ui/public/agg_types/controls/field.html @@ -3,7 +3,7 @@ Field - Analyzed Field diff --git a/src/ui/public/agg_types/controls/order_agg.html b/src/ui/public/agg_types/controls/order_agg.html index 2b44fe77fbd74..a407660380939 100644 --- a/src/ui/public/agg_types/controls/order_agg.html +++ b/src/ui/public/agg_types/controls/order_agg.html @@ -9,6 +9,7 @@ @@ -27,4 +28,4 @@ group-name="'metrics'"> - \ No newline at end of file + diff --git a/src/ui/public/agg_types/controls/top_aggregate_and_size.html b/src/ui/public/agg_types/controls/top_aggregate_and_size.html new file mode 100644 index 0000000000000..76e490ddc9970 --- /dev/null +++ b/src/ui/public/agg_types/controls/top_aggregate_and_size.html @@ -0,0 +1,37 @@ +
+
+ + + +
+
+ + + +
+
diff --git a/src/ui/public/agg_types/controls/top_sort.html b/src/ui/public/agg_types/controls/top_sort.html new file mode 100644 index 0000000000000..f55c265090576 --- /dev/null +++ b/src/ui/public/agg_types/controls/top_sort.html @@ -0,0 +1,29 @@ +
+ + + +
+ +
+ + + +
diff --git a/src/ui/public/agg_types/index.js b/src/ui/public/agg_types/index.js index 762a9dbab4a99..45beb0d3518e6 100644 --- a/src/ui/public/agg_types/index.js +++ b/src/ui/public/agg_types/index.js @@ -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), diff --git a/src/ui/public/agg_types/metrics/top_hit.js b/src/ui/public/agg_types/metrics/top_hit.js new file mode 100644 index 0000000000000..94c88578378f1 --- /dev/null +++ b/src/ui/public/agg_types/metrics/top_hit.js @@ -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; + } + }); +} diff --git a/src/ui/public/agg_types/param_types/field.js b/src/ui/public/agg_types/param_types/field.js index 611c6b545390a..798b857a7ceee 100644 --- a/src/ui/public/agg_types/param_types/field.js +++ b/src/ui/public/agg_types/param_types/field.js @@ -7,7 +7,7 @@ import IndexedArray from 'ui/indexed_array'; import Notifier from 'ui/notify/notifier'; export default function FieldAggParamFactory(Private, $filter) { - let BaseAggParam = Private(AggTypesParamTypesBaseProvider); + const BaseAggParam = Private(AggTypesParamTypesBaseProvider); const notifier = new Notifier(); _.class(FieldAggParam).inherits(BaseAggParam); @@ -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']); } @@ -88,7 +98,7 @@ export default function FieldAggParamFactory(Private, $filter) { * @return {undefined} */ FieldAggParam.prototype.write = function (aggConfig, output) { - let field = aggConfig.getField(); + const field = aggConfig.getField(); if (!field) { throw new TypeError('"field" is a required parameter'); @@ -105,4 +115,4 @@ export default function FieldAggParamFactory(Private, $filter) { }; return FieldAggParam; -}; +} diff --git a/src/ui/public/filters/__tests__/prop_filter.js b/src/ui/public/filters/__tests__/prop_filter.js new file mode 100644 index 0000000000000..a9c6cdc57123d --- /dev/null +++ b/src/ui/public/filters/__tests__/prop_filter.js @@ -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')); + }); +}); diff --git a/src/ui/public/filters/_prop_filter.js b/src/ui/public/filters/_prop_filter.js index 19d817df80662..04e2194c68776 100644 --- a/src/ui/public/filters/_prop_filter.js +++ b/src/ui/public/filters/_prop_filter.js @@ -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; diff --git a/src/ui/public/index_patterns/__tests__/flatten_hit.js b/src/ui/public/index_patterns/__tests__/flatten_hit.js index 632e959b263cc..9d69bf82bb9ef 100644 --- a/src/ui/public/index_patterns/__tests__/flatten_hit.js +++ b/src/ui/public/index_patterns/__tests__/flatten_hit.js @@ -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) { let 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); }); }); diff --git a/src/ui/public/index_patterns/_flatten_hit.js b/src/ui/public/index_patterns/_flatten_hit.js index 7d137cfd070e6..87eff457547f9 100644 --- a/src/ui/public/index_patterns/_flatten_hit.js +++ b/src/ui/public/index_patterns/_flatten_hit.js @@ -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,23 +9,38 @@ export default function FlattenHitProvider(config) { metaFields = value; }); - function flattenHit(indexPattern, hit) { - let flat = {}; + function flattenHit(indexPattern, hit, deep) { + const flat = {}; // recursively merge _source - let fields = indexPattern.fields.byName; + const fields = indexPattern.fields.byName; (function flatten(obj, keyPrefix) { keyPrefix = keyPrefix ? keyPrefix + '.' : ''; _.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; + } - let hasValidMapping = (fields[key] && fields[key].type !== 'conflict'); - let isValue = !_.isPlainObject(val); + 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)); + }; }; -}; - +} diff --git a/src/ui/public/stringify/__tests__/_source.js b/src/ui/public/stringify/__tests__/_source.js index 63d4691c33b09..381205b872213 100644 --- a/src/ui/public/stringify/__tests__/_source.js +++ b/src/ui/public/stringify/__tests__/_source.js @@ -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
', function () { let hit = _.first(hits); let $dl = $(convertHtml(hit._source, indexPattern.fields.byName._source, hit)); diff --git a/src/ui/public/stringify/types/source.js b/src/ui/public/stringify/types/source.js index 9662bfebef4ec..8b8f7392b2831 100644 --- a/src/ui/public/stringify/types/source.js +++ b/src/ui/public/stringify/types/source.js @@ -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); let highlights = (hit && hit.highlight) || {}; let formatted = field.indexPattern.formatHit(hit); diff --git a/src/ui/public/vis/agg_config.js b/src/ui/public/vis/agg_config.js index 86a8872e3649f..87333eb2e1018 100644 --- a/src/ui/public/vis/agg_config.js +++ b/src/ui/public/vis/agg_config.js @@ -152,7 +152,13 @@ export default function AggConfigFactory(Private, fieldTypeFilter) { const fieldOptions = this.getFieldOptions(); if (fieldOptions) { - field = fieldOptions.byName[this.fieldName()] || null; + let prevField = fieldOptions.byName[this.fieldName()] || null; + let filters = fieldOptions.filterFieldTypes; + if (_.isFunction(fieldOptions.filterFieldTypes)) { + filters = fieldOptions.filterFieldTypes.bind(this, this.vis); + } + let fieldOpts = fieldTypeFilter(this.vis.indexPattern.fields, filters); + field = _.contains(fieldOpts, prevField) ? prevField : null; } return this.fillDefaults({ row: this.params.row, field: field });