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 @@ +