diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9102807b6e1..2fafde3aebe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
- [Issue #219](https://github.com/grafana/grafana/issues/219). Templating: Template variable value selection is now a typeahead autocomplete dropdown
**New features and improvements**
+- [Issue #117](https://github.com/grafana/grafana/issues/117). Graphite: Graphite query builder can now handle functions that multiple series as arguments!
- [Issue #281](https://github.com/grafana/grafana/issues/281). Graphite: Metric node/segment selection is now a textbox with autocomplete dropdown, allow for custom glob expression for single node segment without entering text editor mode.
- [Issue #578](https://github.com/grafana/grafana/issues/578). Dashboard: Row option to display row title even when the row is visible
- [Issue #672](https://github.com/grafana/grafana/issues/672). Dashboard: panel fullscreen & edit state is present in url, can now link to graph in edit & fullscreen mode.
diff --git a/src/app/controllers/graphiteTarget.js b/src/app/controllers/graphiteTarget.js
index 493f01eb562..5671dd0dd04 100644
--- a/src/app/controllers/graphiteTarget.js
+++ b/src/app/controllers/graphiteTarget.js
@@ -9,11 +9,13 @@ function (angular, _, config, gfunc, Parser) {
'use strict';
var module = angular.module('grafana.controllers');
+ var targetLetters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O'];
module.controller('GraphiteTargetCtrl', function($scope, $sce, templateSrv) {
$scope.init = function() {
$scope.target.target = $scope.target.target || '';
+ $scope.targetLetter = targetLetters[$scope.$index];
parseTarget();
};
@@ -69,6 +71,14 @@ function (angular, _, config, gfunc, Parser) {
$scope.functions.push(innerFunc);
break;
+ case 'series-ref':
+ if ($scope.segments.length === 0) {
+ func.params[index] = astNode.value;
+ }
+ else {
+ func.params[index - 1] = astNode.value;
+ }
+ break;
case 'string':
case 'number':
if ((index-1) >= func.def.params.length) {
@@ -81,9 +91,7 @@ function (angular, _, config, gfunc, Parser) {
else {
func.params[index - 1] = astNode.value;
}
-
break;
-
case 'metric':
if ($scope.segments.length > 0) {
throw { message: 'Multiple metric params not supported, use text editor.' };
@@ -113,8 +121,10 @@ function (angular, _, config, gfunc, Parser) {
return $scope.datasource.metricFindQuery(path)
.then(function(segments) {
if (segments.length === 0) {
- $scope.segments = $scope.segments.splice(0, fromIndex);
- $scope.segments.push(new MetricSegment('select metric'));
+ if (path !== '') {
+ $scope.segments = $scope.segments.splice(0, fromIndex);
+ $scope.segments.push(new MetricSegment('select metric'));
+ }
return;
}
if (segments[0].expandable) {
@@ -144,8 +154,7 @@ function (angular, _, config, gfunc, Parser) {
$scope.getAltSegments = function (index) {
$scope.altSegments = [];
- var query = index === 0 ?
- '*' : getSegmentPathUpTo(index) + '.*';
+ var query = index === 0 ? '*' : getSegmentPathUpTo(index) + '.*';
return $scope.datasource.metricFindQuery(query)
.then(function(segments) {
@@ -226,6 +235,10 @@ function (angular, _, config, gfunc, Parser) {
if (!newFunc.params.length && newFunc.added) {
$scope.targetChanged();
}
+
+ if ($scope.segments.length === 1 && $scope.segments[0].value === 'select metric') {
+ $scope.segments = [];
+ }
};
$scope.moveAliasFuncLast = function() {
diff --git a/src/app/directives/graphiteFuncEditor.js b/src/app/directives/graphiteFuncEditor.js
index 57f1294a2df..db92240bb40 100644
--- a/src/app/directives/graphiteFuncEditor.js
+++ b/src/app/directives/graphiteFuncEditor.js
@@ -69,7 +69,6 @@ function (angular, _, $) {
function inputBlur(paramIndex) {
/*jshint validthis:true */
-
var $input = $(this);
var $link = $input.prev();
@@ -88,7 +87,6 @@ function (angular, _, $) {
function inputKeyPress(paramIndex, e) {
/*jshint validthis:true */
-
if(e.which === 13) {
inputBlur.call(this, paramIndex);
}
@@ -147,7 +145,7 @@ function (angular, _, $) {
$funcLink.appendTo(elem);
_.each(funcDef.params, function(param, index) {
- if (param.optional && func.params.length !== index + 1) {
+ if (param.optional && func.params.length <= index) {
return;
}
diff --git a/src/app/directives/graphiteSegment.js b/src/app/directives/graphiteSegment.js
index 0f1e4397d25..032ea99c39a 100644
--- a/src/app/directives/graphiteSegment.js
+++ b/src/app/directives/graphiteSegment.js
@@ -64,10 +64,7 @@ function (angular, app, _, $) {
};
$scope.source = function(query, callback) {
- console.log("source!", callback);
- if (options) {
- return options;
- }
+ if (options) { return options; }
$scope.$apply(function() {
$scope.getAltSegments($scope.$index).then(function() {
diff --git a/src/app/partials/graphite/editor.html b/src/app/partials/graphite/editor.html
index 2a314615963..9b93297ef75 100755
--- a/src/app/partials/graphite/editor.html
+++ b/src/app/partials/graphite/editor.html
@@ -47,6 +47,9 @@
+ -
+ {{targetLetter}}
+
-
- -
-
-
+
-
diff --git a/src/app/services/graphite/gfunc.js b/src/app/services/graphite/gfunc.js
index 2a379bdda73..7015ef74929 100644
--- a/src/app/services/graphite/gfunc.js
+++ b/src/app/services/graphite/gfunc.js
@@ -59,13 +59,23 @@ function (_) {
addFuncDef({
name: 'diffSeries',
+ params: [
+ { name: 'other', type: 'value_or_series', optional: true },
+ { name: 'other', type: 'value_or_series', optional: true },
+ { name: 'other', type: 'value_or_series', optional: true }
+ ],
+ defaultParams: ['$B'],
category: categories.Calculate,
});
addFuncDef({
name: 'asPercent',
- params: [{ name: 'other', type: 'value_or_series', optional: true }],
- defaultParams: ['$B'],
+ params: [
+ { name: 'other', type: 'value_or_series', optional: true },
+ { name: 'other', type: 'value_or_series', optional: true },
+ { name: 'other', type: 'value_or_series', optional: true }
+ ],
+ defaultParams: ['#A'],
category: categories.Calculate,
});
@@ -508,7 +518,7 @@ function (_) {
}, this);
- if (metricExp !== undefined) {
+ if (metricExp) {
parameters.unshift(metricExp);
}
diff --git a/src/app/services/graphite/graphiteDatasource.js b/src/app/services/graphite/graphiteDatasource.js
index f7c217d30d0..9a450c2a0c6 100644
--- a/src/app/services/graphite/graphiteDatasource.js
+++ b/src/app/services/graphite/graphiteDatasource.js
@@ -210,31 +210,50 @@ function (angular, _, $, config, kbn, moment) {
return $http(options);
};
+ GraphiteDatasource.prototype._seriesRefLetters = [
+ '#A', '#B', '#C', '#D',
+ '#E', '#F', '#G', '#H',
+ '#I', '#J', '#K', '#L',
+ '#M', '#N', '#O'
+ ];
+
GraphiteDatasource.prototype.buildGraphiteParams = function(options) {
- var clean_options = [];
- var graphite_options = ['target', 'targets', 'from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout'];
+ var graphite_options = ['from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout'];
+ var clean_options = [], targets = {};
+ var target, targetValue, i;
+ var regex = /(\#[A-Z])/g;
if (options.format !== 'png') {
options['format'] = 'json';
}
- _.each(options, function (value, key) {
- if ($.inArray(key, graphite_options) === -1) {
- return;
+ for (i = 0; i < options.targets.length; i++) {
+ target = options.targets[i];
+ targetValue = templateSrv.replace(target.target);
+ targets[this._seriesRefLetters[i]] = targetValue;
+ }
+
+ function nestedSeriesRegexReplacer(match) {
+ return targets[match];
+ }
+
+ for (i = 0; i < options.targets.length; i++) {
+ target = options.targets[i];
+ if (!target.target || target.hide) {
+ continue;
}
- if (key === "targets") {
- _.each(value, function (value) {
- if (value.target && !value.hide) {
- var targetValue = templateSrv.replace(value.target);
- clean_options.push("target=" + encodeURIComponent(targetValue));
- }
- }, this);
- }
- else if (value) {
- clean_options.push(key + "=" + encodeURIComponent(value));
- }
- }, this);
+ targetValue = targets[this._seriesRefLetters[i]];
+ targetValue = targetValue.replace(regex, nestedSeriesRegexReplacer);
+
+ clean_options.push("target=" + encodeURIComponent(targetValue));
+ }
+
+ _.each(options, function (value, key) {
+ if ($.inArray(key, graphite_options) === -1) { return; }
+ clean_options.push(key + "=" + encodeURIComponent(value));
+ });
+
return clean_options;
};
diff --git a/src/app/services/graphite/lexer.js b/src/app/services/graphite/lexer.js
index 05249b9bff9..ee37e6a22ba 100644
--- a/src/app/services/graphite/lexer.js
+++ b/src/app/services/graphite/lexer.js
@@ -128,6 +128,7 @@ define([
i === 93 || // templateEnd ]
i === 63 || // ?
i === 37 || // %
+ i === 35 || // #
i >= 97 && i <= 122; // a-z
}
diff --git a/src/app/services/graphite/parser.js b/src/app/services/graphite/parser.js
index c30e32e7d05..c5ee82deb38 100644
--- a/src/app/services/graphite/parser.js
+++ b/src/app/services/graphite/parser.js
@@ -157,6 +157,7 @@ define([
var param =
this.functionCall() ||
this.numericLiteral() ||
+ this.seriesRefExpression() ||
this.metricExpression() ||
this.stringLiteral();
@@ -168,6 +169,24 @@ define([
return [param].concat(this.functionParameters());
},
+ seriesRefExpression: function() {
+ if (!this.match('identifier')) {
+ return null;
+ }
+
+ var value = this.tokens[this.index].value;
+ if (!value.match(/\#[A-Z]/)) {
+ return null;
+ }
+
+ var token = this.consumeToken();
+
+ return {
+ type: 'series-ref',
+ value: token.value
+ };
+ },
+
numericLiteral: function () {
if (!this.match('number')) {
return null;
diff --git a/src/css/less/grafana.less b/src/css/less/grafana.less
index 88160790fd2..a410da93d85 100644
--- a/src/css/less/grafana.less
+++ b/src/css/less/grafana.less
@@ -211,8 +211,10 @@ input[type=text].grafana-function-param-input {
.grafana-target-controls-left {
list-style: none;
float: left;
- width: 30px;
margin: 0px;
+ li {
+ display: inline-block;
+ }
}
.grafana-target-controls {
diff --git a/src/test/specs/graphiteDatasource-specs.js b/src/test/specs/graphiteDatasource-specs.js
new file mode 100644
index 00000000000..a6a9c892601
--- /dev/null
+++ b/src/test/specs/graphiteDatasource-specs.js
@@ -0,0 +1,84 @@
+define([
+ './helpers',
+ 'services/graphite/graphiteDatasource'
+], function(helpers) {
+ 'use strict';
+
+ describe('graphiteDatasource', function() {
+ var ctx = new helpers.ServiceTestContext();
+
+ beforeEach(module('grafana.services'));
+ beforeEach(ctx.providePhase());
+ beforeEach(ctx.createService('GraphiteDatasource'));
+ beforeEach(function() {
+ ctx.ds = new ctx.service({ url: [''] });
+ });
+
+ describe('When querying influxdb with one target using query editor target spec', function() {
+ var query = {
+ range: { from: 'now-1h', to: 'now' },
+ targets: [{ target: 'prod1.count' }, {target: 'prod2.count'}],
+ maxDataPoints: 500
+ };
+
+ var response = [{ target: 'prod1.count', points: [[10, 1], [12,1]], }];
+ var results;
+ var request;
+
+ beforeEach(function() {
+
+ ctx.$httpBackend.expectPOST('/render', function(body) { request = body; return true; })
+ .respond(response);
+
+ ctx.ds.query(query).then(function(data) { results = data; });
+ ctx.$httpBackend.flush();
+ });
+
+ it('should generate the correct query', function() {
+ ctx.$httpBackend.verifyNoOutstandingExpectation();
+ });
+
+ it('should query correctly', function() {
+ var params = request.split('&');
+ expect(params).to.contain('target=prod1.count');
+ expect(params).to.contain('target=prod2.count');
+ expect(params).to.contain('from=-1h');
+ expect(params).to.contain('until=now');
+ });
+
+ it('should return series list', function() {
+ expect(results.data.length).to.be(1);
+ expect(results.data[0].target).to.be('prod1.count');
+ });
+
+ });
+
+ describe('building graphite params', function() {
+
+ it('should uri escape targets', function() {
+ var results = ctx.ds.buildGraphiteParams({
+ targets: [{target: 'prod1.{test,test2}'}, {target: 'prod2.count'}]
+ });
+ expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D');
+ });
+
+ it('should replace target placeholder', function() {
+ var results = ctx.ds.buildGraphiteParams({
+ targets: [{target: 'series1'}, {target: 'series2'}, {target: 'asPercent(#A,#B)'}]
+ });
+ expect(results[2]).to.be('target=asPercent(series1%2Cseries2)');
+ });
+
+ it('should ignore empty targets', function() {
+ var results = ctx.ds.buildGraphiteParams({
+ targets: [{target: 'series1'}, {target: ''}]
+ });
+ expect(results.length).to.be(2);
+ });
+
+ });
+
+ });
+
+});
+
diff --git a/src/test/specs/graphiteTargetCtrl-specs.js b/src/test/specs/graphiteTargetCtrl-specs.js
index 0681bf6c4de..978bf3ba4ba 100644
--- a/src/test/specs/graphiteTargetCtrl-specs.js
+++ b/src/test/specs/graphiteTargetCtrl-specs.js
@@ -64,6 +64,59 @@ define([
});
});
+ describe('when adding function before any metric segment', function() {
+ beforeEach(function() {
+ ctx.scope.target.target = '';
+ ctx.scope.datasource.metricFindQuery.returns(ctx.$q.when([{expandable: true}]));
+ ctx.scope.init();
+ ctx.scope.$digest();
+
+ ctx.scope.$parent = { get_data: sinon.spy() };
+ ctx.scope.addFunction(gfunc.getFuncDef('asPercent'));
+ });
+
+ it('should add function and remove select metric link', function() {
+ expect(ctx.scope.segments.length).to.be(0);
+ });
+ });
+
+ describe('when initalizing target without metric expression and only function', function() {
+ beforeEach(function() {
+ ctx.scope.target.target = 'asPercent(#A, #B)';
+ ctx.scope.datasource.metricFindQuery.returns(ctx.$q.when([]));
+ ctx.scope.init();
+ ctx.scope.$digest();
+ ctx.scope.$parent = { get_data: sinon.spy() };
+ });
+
+ it('should not add select metric segment', function() {
+ expect(ctx.scope.segments.length).to.be(0);
+ });
+
+ it('should add both series refs as params', function() {
+ expect(ctx.scope.functions[0].params.length).to.be(2);
+ });
+
+ });
+
+ describe('when initalizing target without metric expression and function with series-ref', function() {
+ beforeEach(function() {
+ ctx.scope.target.target = 'asPercent(metric.node.count, #A)';
+ ctx.scope.datasource.metricFindQuery.returns(ctx.$q.when([]));
+ ctx.scope.init();
+ ctx.scope.$digest();
+ ctx.scope.$parent = { get_data: sinon.spy() };
+ });
+
+ it('should add segments', function() {
+ expect(ctx.scope.segments.length).to.be(3);
+ });
+
+ it('should have correct func params', function() {
+ expect(ctx.scope.functions[0].params.length).to.be(1);
+ });
+ });
+
describe('targetChanged', function() {
beforeEach(function() {
ctx.scope.datasource.metricFindQuery.returns(ctx.$q.when([{expandable: false}]));
diff --git a/src/test/specs/parser-specs.js b/src/test/specs/parser-specs.js
index 802474a5814..33c234ae823 100644
--- a/src/test/specs/parser-specs.js
+++ b/src/test/specs/parser-specs.js
@@ -156,6 +156,16 @@ define([
expect(rootNode.segments[1].value).to.be('test');
});
+ it('series parameters', function() {
+ var parser = new Parser('asPercent(#A, #B)');
+ var rootNode = parser.getAst();
+ expect(rootNode.type).to.be('function');
+ expect(rootNode.params[0].type).to.be('series-ref');
+ expect(rootNode.params[0].value).to.be('#A');
+ expect(rootNode.params[1].value).to.be('#B');
+ });
+
+
});
});
diff --git a/src/test/test-main.js b/src/test/test-main.js
index 04f25f91005..342f3143a9e 100644
--- a/src/test/test-main.js
+++ b/src/test/test-main.js
@@ -121,6 +121,7 @@ require([
'specs/timeSeries-specs',
'specs/row-ctrl-specs',
'specs/graphiteTargetCtrl-specs',
+ 'specs/graphiteDatasource-specs',
'specs/influxSeries-specs',
'specs/influxQueryBuilder-specs',
'specs/influxdb-datasource-specs',