Merge pull request #2601 from spenceralger/extendedStats

Extended stats Agg
This commit is contained in:
Joe Fleming 2015-01-19 12:56:45 -07:00
commit bcb6dd9c4f
23 changed files with 536 additions and 34 deletions

View file

@ -12,6 +12,10 @@ define(function (require) {
yScale: yScale
};
if (point.y === 'NaN') {
return;
}
if (series) {
point.series = unwrap(row[series.i]);
}

View file

@ -15,12 +15,14 @@ define(function (require) {
if (!multiY) {
var point = partGetPoint(row, aspects.y);
addToSiri(series, point, point.series);
if (point) addToSiri(series, point, point.series);
return;
}
aspects.y.forEach(function (y) {
var point = partGetPoint(row, y);
if (!point) return;
var prefix = point.series ? point.series + ': ' : '';
var seriesId = prefix + y.agg.id;
var seriesLabel = prefix + y.col.title;

View file

@ -0,0 +1,17 @@
<div class="form-group" ng-controller="aggParam.controller">
<label for="field">
Metrics
</label>
<!-- validate that there is atleast one checkbox selected -->
<input type="hidden" ng-model="names[0]" name="first" required>
<p class="text-danger" ng-if="aggForm.first.$invalid">
select at least one stat
</p>
<div ng-repeat="stat in statNames" class="checkbox">
<label>
<input ng-model="map[stat]" type="checkbox"> {{stat}}
</label>
</div>
</div>

View file

@ -9,6 +9,7 @@ define(function (require) {
Private(require('components/agg_types/metrics/sum')),
Private(require('components/agg_types/metrics/min')),
Private(require('components/agg_types/metrics/max')),
Private(require('components/agg_types/metrics/extended_stats')),
Private(require('components/agg_types/metrics/cardinality')),
Private(require('components/agg_types/metrics/percentiles'))
],

View file

@ -0,0 +1,84 @@
define(function (require) {
return function AggTypeMetricExtendedStatsProvider(Private) {
var _ = require('lodash');
var MetricAggType = Private(require('components/agg_types/metrics/_metric_agg_type'));
var getResponseAggConfig = Private(require('components/agg_types/metrics/_get_response_agg_config'));
var valueProps = {
makeLabel: function () {
return this.key + ' of ' + this.fieldDisplayName();
}
};
var statNames = [
'count',
'min',
'max',
'avg',
'sum',
'sum_of_squares',
'variance',
'std_deviation'
];
var exStatsType = new MetricAggType({
name: 'extended_stats',
title: 'Extended Stats',
makeLabel: function (agg) {
return 'Extended Stats on ' + agg.fieldDisplayName();
},
params: [
{
name: 'field',
filterFieldTypes: 'number'
},
{
name: 'names',
editor: require('text!components/agg_types/controls/extended_stats.html'),
default: statNames.slice(),
write: _.noop,
controller: function ($scope) {
$scope.map = mapList();
$scope.names = listMap();
$scope.statNames = statNames;
$scope.$watchCollection('agg.params.names', function (names) {
if (names === $scope.names) return;
$scope.names = _.intersection(statNames, names || []);
$scope.map = mapList();
});
$scope.$watchCollection('map', function () {
$scope.names = $scope.agg.params.names = listMap();
});
function mapList() {
return _.transform($scope.names, function (map, key) {
map[key] = true;
}, {});
}
function listMap() {
return _.transform(statNames, function (list, stat) {
if ($scope.map[stat]) list.push(stat);
}, []);
}
}
}
],
getResponseAggs: function (agg) {
var ValueAggConfig = getResponseAggConfig(agg, valueProps);
return _.map(agg.params.names, function (name) {
return new ValueAggConfig(name);
});
},
getValue: function (agg, bucket) {
return bucket[agg.parentId][agg.key];
}
});
exStatsType.statNames = statNames;
return exStatsType;
};
});

View file

@ -53,6 +53,8 @@ define(function (require) {
});
},
getValue: function (agg, bucket) {
// percentiles for 1, 5, and 10 will come back as 1.0, 5.0, and 10.0 so we
// parse the keys and respond with the value that matches
return _.find(bucket[agg.parentId].values, function (value, key) {
return agg.key === parseFloat(key);
});

View file

@ -0,0 +1 @@
function(value){this.$viewValue=value;//changetodirtyif(this.$pristine){this.$dirty=true;this.$pristine=false;$animate.removeClass($element,PRISTINE_CLASS);$animate.addClass($element,DIRTY_CLASS);parentForm.$setDirty();}forEach(this.$parsers,function(fn){value=fn(value);});if(this.$modelValue!==value){this.$modelValue=value;ngModelSet($scope,value);forEach(this.$viewChangeListeners,function(listener){try{listener();}catch(e){$exceptionHandler(e);}});}}

View file

@ -0,0 +1,45 @@
define(function (require) {
var _ = require('lodash');
var $ = require('jquery');
var KbnFormController = require('components/fancy_forms/kbn_form');
var KbnModelController = require('components/fancy_forms/kbn_model');
require('modules')
.get('kibana')
.config(function ($provide) {
function decorateDirectiveController(DecorativeController) {
return function ($delegate, $injector) {
// directive providers are arrays
$delegate.forEach(function (directive) {
// get metadata about all init fns
var chain = [directive.controller, DecorativeController].map(function (fn) {
var deps = $injector.annotate(fn);
return { deps: deps, fn: _.isArray(fn) ? _.last(fn) : fn };
});
// replace the controller with one that will setup the actual controller
directive.controller = function stub() {
var allDeps = _.toArray(arguments);
return chain.reduce(function (controller, link, i) {
var deps = allDeps.splice(0, link.deps.length);
return link.fn.apply(controller, deps) || controller;
}, this);
};
// set the deps of our new controller to be the merged deps of every fn
directive.controller.$inject = chain.reduce(function (deps, link) {
return deps.concat(link.deps);
}, []);
});
return $delegate;
};
}
$provide.decorator('formDirective', decorateDirectiveController(KbnFormController));
$provide.decorator('ngFormDirective', decorateDirectiveController(KbnFormController));
$provide.decorator('ngModelDirective', decorateDirectiveController(KbnModelController));
});
});

View file

@ -0,0 +1,26 @@
define(function (require) {
var _ = require('lodash');
/**
* Extension of Angular's FormController class
* that provides helpers for error handling/validation.
*
* @param {$scope} $scope
*/
function KbnFormController($scope, $element) {
var self = this;
self.errorCount = function () {
return _.reduce(self.$error, function (count, controls, errorType) {
return count + _.size(controls);
}, 0);
};
self.describeErrors = function () {
var count = self.errorCount();
return count + ' Error' + (count === 1 ? '' : 's');
};
}
return KbnFormController;
});

View file

@ -0,0 +1,102 @@
define(function (require) {
var _ = require('lodash');
var SVV_CHECKSUM = require('text!components/fancy_forms/_set_view_value.checksum');
var PRISTINE_CLASS = 'ng-pristine';
var DIRTY_CLASS = 'ng-dirty';
// http://goo.gl/eJofve
var nullFormCtrl = {
$addControl: _.noop,
$removeControl: _.noop,
$setValidity: _.noop,
$setDirty: _.noop,
$setPristine: _.noop
};
/**
* Extension of Angular's NgModelController class
* that ensures models are marked "dirty" after
* they move from an invalid state to valid.
*
* @param {$scope} $scope
*/
function KbnModelController($scope, $element, $animate) {
var ngModel = this;
// verify that angular works the way we are assuming it does
if (String(ngModel.$setViewValue).replace(/\s+/g, '') !== SVV_CHECKSUM) {
throw new Error('ngModelController.$setViewValue has updated but KbnModelController has not!');
}
/**
* Get the form a model belongs to
*
* @return {NgFormController} - the parent controller of a noop controller
*/
ngModel.$getForm = function () {
return $element.inheritedData('$formController') || nullFormCtrl;
};
/**
* Update the ngModel to be "dirty" if it is pristine.
*
* @return {undefined}
*/
ngModel.$setDirty = function () {
if (ngModel.$dirty) return;
ngModel.$dirty = true;
ngModel.$pristine = false;
$animate.removeClass($element, PRISTINE_CLASS);
$animate.addClass($element, DIRTY_CLASS);
ngModel.$getForm().$setDirty();
};
/**
* While the model is pristine, ensure that the model
* gets set to dirty if it becomes invalid. If the model
* becomes dirty of other reasons stop watching and
* waitForPristine()
*
* @return {undefined}
*/
function watchForDirtyOrInvalid() {
var unwatch = $scope.$watch(get, react);
function get() {
return ngModel.$dirty || ngModel.$invalid;
}
function react(is, was) {
if (is === was) return;
unwatch();
waitForPristine();
ngModel.$setDirty();
}
}
/**
* Once a model becomes dirty, there is no longer a need
* for a watcher. Instead, we will react to the $setPristine
* method being called. This is the only way for a model to go
* from dirty -> pristine.
*
* @return {[type]} [description]
*/
function waitForPristine() {
var fn = ngModel.$setPristine;
ngModel.$setPristine = function () {
var ret = fn.apply(this, arguments);
if (ngModel.$pristine) {
ngModel.$setPristine = fn;
watchForDirtyOrInvalid();
}
return ret;
};
}
if (ngModel.$dirty) waitForPristine();
else watchForDirtyOrInvalid();
}
return KbnModelController;
});

View file

@ -16,6 +16,7 @@ define(function (require) {
require('components/watch_multi');
require('components/bind');
require('components/listen');
require('components/fancy_forms/fancy_forms');
require('directives/click_focus');
require('directives/info');
require('directives/spinner');

View file

@ -23,7 +23,7 @@
<!-- error -->
<span ng-if="!editorOpen && aggForm.$invalid" class="vis-editor-agg-header-description danger">
{{ describeError() }}
{{ aggForm.describeErrors() }}
</span>
<!-- controls !!!actually disabling buttons will break tooltips¡¡¡ -->

View file

@ -38,18 +38,6 @@ define(function (require) {
return label ? label : '';
};
/**
* Describe the errors in this agg
* @return {[type]} [description]
*/
$scope.describeError = function () {
var count = _.reduce($scope.aggForm.$error, function (count, controls, errorType) {
return count + _.size(controls);
}, 0);
return count + ' Error' + (count > 1 ? 's' : '');
};
function move(below, agg) {
_.move($scope.vis.aggs, agg, below, function (otherAgg) {
return otherAgg.schema.group === agg.schema.group;

View file

@ -18,6 +18,11 @@
<!-- apply/discard -->
<li class="vis-editor-sidebar-buttons sidebar-item">
<p
ng-if="visualizeEditor.$invalid"
class="text-center text-danger sidebar-item-text">
<i class="fa fa-warning"></i> {{visualizeEditor.describeErrors()}}
</p>
<button
ng-disabled="!vis.dirty || visualizeEditor.$invalid"
type="submit"
@ -28,7 +33,7 @@
ng-disabled="!vis.dirty"
type="button"
ng-click="resetEditableVis()"
class="sidebar-item-button warn">
class="sidebar-item-button default">
Discard
</button>
</li>

View file

@ -15,7 +15,7 @@
margin-bottom: 0px;
}
.sidebar-list-header {
&-header {
padding-left: 10px;
padding-right: 10px;
color: @sidebar-header-color;
@ -35,8 +35,19 @@
}
}
.sidebar-item-title {
&-title,
&-text,
&-button {
margin: 0;
padding: 5px 10px;
text-align: center;
width: 100%;
border: none;
border-radius: 0;
}
&-title {
text-align: left;
white-space: nowrap;
.ellipsis();
@ -51,11 +62,11 @@
}
}
.sidebar-item-button {
padding: 5px 10px;
text-align: center;
width: 100%;
border-radius: 0;
&-text {
background: white;
}
&-button {
font-size: inherit;
&[disabled] {
@ -87,6 +98,10 @@
background-color: @btn-danger-bg;
color: @btn-danger-color
}
&.default {
.btn-default();
}
}
.active {

View file

@ -114,12 +114,20 @@ ul.navbar-inline li {
}
.top-fixed {
position:fixed;
bottom:0px;
position: fixed;
bottom: 0px;
}
.checkbox input[type="checkbox"] {
float: none;
.checkbox label {
.display(flex);
.align-items(center);
padding-left: 0;
input[type="checkbox"] {
float: none;
margin: 0 4px;
position: static;
}
}
notifications {

View file

@ -8,7 +8,7 @@ define(function (require) {
getPoint = Private(require('components/agg_response/point_series/_get_point'));
}));
it('properly unwraps and scales values without the a series', function () {
it('properly unwraps and scales values without a series', function () {
var row = [ { value: 1 }, { value: 2 }];
var point = getPoint({ i: 0 }, null, 5, row, { i: 1 });
@ -29,5 +29,11 @@ define(function (require) {
.and.have.property('y', 3)
.and.have.property('aggConfigResult', row[2]);
});
it('ignores points with a y value of NaN', function () {
var row = [ { value: 1 }, { value: 'NaN' }];
var point = getPoint({ i: 0 }, null, 5, row, { i: 1 });
expect(point).to.be(void 0);
});
}];
});

View file

@ -98,7 +98,9 @@ define(function (require) {
var rows = [
['0', 3],
['1', 3],
['1', 'NaN'],
['0', 3],
['0', 'NaN'],
['1', 3],
['0', 3],
['1', 3]

View file

@ -4,7 +4,7 @@ define(function (require) {
run(require('specs/components/agg_response/point_series/_add_to_siri'));
run(require('specs/components/agg_response/point_series/_fake_x_aspect'));
run(require('specs/components/agg_response/point_series/_get_aspects'));
run(require('specs/components/agg_response/point_series/_get_points'));
run(require('specs/components/agg_response/point_series/_get_point'));
run(require('specs/components/agg_response/point_series/_get_series'));
run(require('specs/components/agg_response/point_series/_init_x_axis'));
run(require('specs/components/agg_response/point_series/_init_y_axis'));

View file

@ -1,5 +0,0 @@
define(function (require) {
return ['AggParams', function () {
}];
});

View file

@ -6,7 +6,7 @@ define(function (require) {
require('specs/components/agg_types/_bucket_count_between'),
require('specs/components/agg_types/buckets/_histogram'),
require('specs/components/agg_types/buckets/_date_histogram'),
require('specs/components/agg_types/_metric_aggs')
require('specs/components/agg_types/metrics/_extended_stats')
].forEach(function (s) {
describe(s[0], s[1]);
});

View file

@ -0,0 +1,104 @@
define(function (require) {
return ['¡Extended Stats!', function () {
var _ = require('lodash');
var $ = require('jquery');
var vis;
var agg;
beforeEach(module('kibana'));
beforeEach(inject(function (Private) {
var Vis = Private(require('components/vis/vis'));
var indexPattern = Private(require('fixtures/stubbed_logstash_index_pattern'));
// the vis which wraps the agg
vis = new Vis(indexPattern, {
type: 'histogram',
aggs: [
{ type: 'extended_stats', params: { field: 'bytes' } }
]
});
// the extended_stats agg
agg = vis.aggs[0];
}));
describe('#makeLabel', function () {
it('makes pretty labels', function () {
expect(agg.makeLabel()).to.be('Extended Stats on bytes');
});
});
describe('names param', function () {
it('defaults to the full metric list', function () {
expect(_.size(agg.params.names)).to.be.greaterThan(0);
expect(agg.params.names).to.eql(agg.type.statNames);
});
describe('editor controller', function () {
var $el;
var $scope;
var $rootScope;
beforeEach(inject(function ($injector, $compile) {
$rootScope = $injector.get('$rootScope');
$scope = $rootScope.$new();
$scope.agg = agg;
$scope.aggParam = agg.type.params.byName.names;
$el = $($scope.aggParam.editor);
$compile($el)($scope);
}));
afterEach(function () {
$el.remove();
$scope.$destroy();
});
it('reflects the selected names as selected checkboxes', function () {
agg.params.names = _.sample(agg.type.statNames, 3);
$rootScope.$apply();
var $checks = $el.find('input[type=checkbox]');
expect($checks).to.have.length(agg.type.statNames.length);
$checks.each(function () {
var $check = $(this);
var name = $check.parent().text().trim();
var index = agg.params.names.indexOf(name);
if (!$check.is(':checked')) expect(index).to.be(-1);
else expect(index).to.be.greaterThan(-1);
});
});
it('syncs the checked boxes with the name list', function () {
agg.params.names = [];
$rootScope.$apply();
var $checks = $el.find('input[type=checkbox]');
expect($checks).to.have.length(agg.type.statNames.length);
$checks.each(function (i) {
var $check = $(this).click();
$rootScope.$apply();
var name = $check.parent().text().trim();
var index = agg.params.names.indexOf(name);
expect(index).to.be(i);
expect(agg.params.names).to.have.length(i + 1);
});
});
});
});
describe('#getResponseAggs', function () {
it('creates a response agg for each name', function () {
var aggs = agg.type.getResponseAggs(agg);
expect(agg.params.names).to.eql(_.pluck(aggs, 'key'));
});
});
}];
});

View file

@ -0,0 +1,94 @@
define(function (require) {
var $ = require('jquery');
describe('fancy forms', function () {
var $baseEl = $('<form>').append(
$('<input ng-model="val" required>')
);
var $el;
var $scope;
var $compile;
var $rootScope;
var ngForm;
var ngModel;
beforeEach(module('kibana'));
beforeEach(inject(function ($injector) {
$rootScope = $injector.get('$rootScope');
$compile = $injector.get('$compile');
$scope = $rootScope.$new();
$el = $baseEl.clone();
$compile($el)($scope);
$scope.$apply();
ngForm = $el.controller('form');
ngModel = $el.find('input').controller('ngModel');
}));
describe('ngFormController', function () {
it('counts errors', function () {
expect(ngForm.errorCount()).to.be(1);
});
it('clears errors', function () {
$scope.val = 'someting';
$scope.$apply();
expect(ngForm.errorCount()).to.be(0);
});
it('describes 0 errors', function () {
$scope.val = 'someting';
$scope.$apply();
expect(ngForm.describeErrors()).to.be('0 Errors');
});
it('describes 1 error', function () {
$scope.$apply();
expect(ngForm.describeErrors()).to.be('1 Error');
});
});
describe('ngModelController', function () {
it('gives access to the ngFormController', function () {
expect(ngModel.$getForm()).to.be(ngForm);
});
it('allows setting the model dirty', function () {
expect($el.find('input.ng-dirty')).to.have.length(0);
ngModel.$setDirty();
expect($el.find('input.ng-dirty')).to.have.length(1);
});
it('sets the model dirty when it moves from valid to invalid', function () {
// clear out the old scope/el
$scope.$destroy();
$el = $baseEl.clone();
$scope = $rootScope.$new();
// start with a valid value
$scope.val = 'something';
$compile($el)($scope);
$rootScope.$apply();
// ensure that the field is valid and pristinve
var $valid = $el.find('input.ng-valid');
expect($valid).to.have.length(1);
expect($valid.hasClass('ng-pristine')).to.be(true);
expect($valid.hasClass('ng-dirty')).to.be(false);
// remove the value without actually setting the view model
$scope.val = null;
$rootScope.$apply();
// ensure that the field is now invalid and dirty
var $invalid = $el.find('input.ng-invalid');
expect($invalid).to.have.length(1);
expect($valid.hasClass('ng-pristine')).to.be(false);
expect($valid.hasClass('ng-dirty')).to.be(true);
});
});
});
});