Просмотр исходного кода

Merge branch 'influxdb_editor_v3'

Torkel Ödegaard 10 лет назад
Родитель
Сommit
ad15df7222

+ 20 - 15
public/app/core/directives/metric_segment.js

@@ -27,6 +27,7 @@ function (_, $, coreModule) {
         var segment = $scope.segment;
         var options = null;
         var cancelBlur = null;
+        var linkMode = true;
 
         $input.appendTo(elem);
         $button.appendTo(elem);
@@ -55,19 +56,21 @@ function (_, $, coreModule) {
           });
         };
 
-        $scope.switchToLink = function(now) {
-          if (now === true || cancelBlur) {
-            clearTimeout(cancelBlur);
-            cancelBlur = null;
-            $input.hide();
-            $button.show();
-            $scope.updateVariableValue($input.val());
-          }
-          else {
-            // need to have long delay because the blur
-            // happens long before the click event on the typeahead options
-            cancelBlur = setTimeout($scope.switchToLink, 100);
-          }
+        $scope.switchToLink = function() {
+          if (linkMode) { return; }
+
+          clearTimeout(cancelBlur);
+          cancelBlur = null;
+          linkMode = true;
+          $input.hide();
+          $button.show();
+          $scope.updateVariableValue($input.val());
+        };
+
+        $scope.inputBlur = function() {
+          // happens long before the click event on the typeahead options
+          // need to have long delay because the blur
+          cancelBlur = setTimeout($scope.switchToLink, 100);
         };
 
         $scope.source = function(query, callback) {
@@ -98,7 +101,7 @@ function (_, $, coreModule) {
           }
 
           $input.val(value);
-          $scope.switchToLink(true);
+          $scope.switchToLink();
 
           return value;
         };
@@ -139,6 +142,8 @@ function (_, $, coreModule) {
           $input.show();
           $input.focus();
 
+          linkMode = false;
+
           var typeahead = $input.data('typeahead');
           if (typeahead) {
             $input.val('');
@@ -146,7 +151,7 @@ function (_, $, coreModule) {
           }
         });
 
-        $input.blur($scope.switchToLink);
+        $input.blur($scope.inputBlur);
 
         $compile(elem.contents())($scope);
       }

+ 45 - 2
public/app/features/dashboard/dashboardSrv.js

@@ -229,9 +229,9 @@ function (angular, $, _, moment) {
       var i, j, k;
       var oldVersion = this.schemaVersion;
       var panelUpgrades = [];
-      this.schemaVersion = 7;
+      this.schemaVersion = 8;
 
-      if (oldVersion === 7) {
+      if (oldVersion === 8) {
         return;
       }
 
@@ -342,6 +342,49 @@ function (angular, $, _, moment) {
         });
       }
 
+      if (oldVersion < 8) {
+        panelUpgrades.push(function(panel) {
+          _.each(panel.targets, function(target) {
+            // update old influxdb query schema
+            if (target.fields && target.tags && target.groupBy) {
+              if (target.rawQuery) {
+                delete target.fields;
+                delete target.fill;
+              } else {
+                target.select = _.map(target.fields, function(field) {
+                  var parts = [];
+                  parts.push({type: 'field', params: [field.name]});
+                  parts.push({type: field.func, params: []});
+                  if (field.mathExpr) {
+                    parts.push({type: 'math', params: [field.mathExpr]});
+                  }
+                  if (field.asExpr) {
+                    parts.push({type: 'alias', params: [field.asExpr]});
+                  }
+                  return parts;
+                });
+                delete target.fields;
+                _.each(target.groupBy, function(part) {
+                  if (part.type === 'time' && part.interval)  {
+                    part.params = [part.interval];
+                    delete part.interval;
+                  }
+                  if (part.type === 'tag' && part.key) {
+                    part.params = [part.key];
+                    delete part.key;
+                  }
+                });
+
+                if (target.fill) {
+                  target.groupBy.push({type: 'fill', params: [target.fill]});
+                  delete target.fill;
+                }
+              }
+            }
+          });
+        });
+      }
+
       if (panelUpgrades.length === 0) {
         return;
       }

+ 5 - 4
public/app/plugins/datasource/influxdb/datasource.js

@@ -3,11 +3,11 @@ define([
   'lodash',
   'app/core/utils/datemath',
   './influx_series',
-  './query_builder',
+  './influx_query',
   './directives',
   './query_ctrl',
 ],
-function (angular, _, dateMath, InfluxSeries, InfluxQueryBuilder) {
+function (angular, _, dateMath, InfluxSeries, InfluxQuery) {
   'use strict';
 
   var module = angular.module('grafana.services');
@@ -41,8 +41,9 @@ function (angular, _, dateMath, InfluxSeries, InfluxQueryBuilder) {
         queryTargets.push(target);
 
         // build query
-        var queryBuilder = new InfluxQueryBuilder(target);
-        var query =  queryBuilder.build();
+        var queryModel = new InfluxQuery(target);
+        var query =  queryModel.render();
+        console.log(query);
         query = query.replace(/\$interval/g, (target.interval || options.interval));
         return query;
 

+ 214 - 0
public/app/plugins/datasource/influxdb/influx_query.ts

@@ -0,0 +1,214 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ = require('lodash');
+import queryPart = require('./query_part');
+
+class InfluxQuery {
+  target: any;
+  selectModels: any[];
+  groupByParts: any;
+  queryBuilder: any;
+
+  constructor(target) {
+    this.target = target;
+
+    target.tags = target.tags || [];
+    target.groupBy = target.groupBy || [
+      {type: 'time', params: ['$interval']},
+      {type: 'fill', params: ['null']},
+    ];
+    target.select = target.select || [[
+      {type: 'field', params: ['value']},
+      {type: 'mean', params: []},
+    ]];
+
+    this.updateProjection();
+  }
+
+  updateProjection() {
+    this.selectModels = _.map(this.target.select, function(parts: any) {
+      return _.map(parts, queryPart.create);
+    });
+    this.groupByParts = _.map(this.target.groupBy, queryPart.create);
+  }
+
+  updatePersistedParts() {
+    this.target.select = _.map(this.selectModels, function(selectParts) {
+      return _.map(selectParts, function(part: any) {
+        return {type: part.def.type, params: part.params};
+      });
+    });
+  }
+
+  hasGroupByTime() {
+    return _.find(this.target.groupBy, (g: any) => g.type === 'time');
+  }
+
+  hasFill() {
+    return _.find(this.target.groupBy, (g: any) => g.type === 'fill');
+  }
+
+  addGroupBy(value) {
+    var stringParts = value.match(/^(\w+)\((.*)\)$/);
+    var typePart = stringParts[1];
+    var arg = stringParts[2];
+    var partModel = queryPart.create({type: typePart, params: [arg]});
+    var partCount = this.target.groupBy.length;
+
+    if (partCount === 0) {
+      this.target.groupBy.push(partModel.part);
+    } else if (typePart === 'time') {
+      this.target.groupBy.splice(0, 0, partModel.part);
+    } else if (typePart === 'tag') {
+      if (this.target.groupBy[partCount-1].type === 'fill') {
+        this.target.groupBy.splice(partCount-1, 0, partModel.part);
+      } else {
+        this.target.groupBy.push(partModel.part);
+      }
+    } else {
+      this.target.groupBy.push(partModel.part);
+    }
+
+    this.updateProjection();
+  }
+
+  removeGroupByPart(part, index) {
+    var categories = queryPart.getCategories();
+
+    if (part.def.type === 'time') {
+      // remove fill
+      this.target.groupBy = _.filter(this.target.groupBy, (g: any) => g.type !== 'fill');
+      // remove aggregations
+      this.target.select = _.map(this.target.select, (s: any) => {
+        return _.filter(s, (part: any) => {
+          var partModel = queryPart.create(part);
+          if (partModel.def.category === categories.Aggregations) {
+            return false;
+          }
+          if (partModel.def.category === categories.Selectors) {
+            return false;
+          }
+          return true;
+        });
+      });
+    }
+
+    this.target.groupBy.splice(index, 1);
+    this.updateProjection();
+  }
+
+  removeSelect(index: number) {
+    this.target.select.splice(index, 1);
+    this.updateProjection();
+  }
+
+  removeSelectPart(selectParts, part) {
+    // if we remove the field remove the whole statement
+    if (part.def.type === 'field') {
+      if (this.selectModels.length > 1) {
+        var modelsIndex = _.indexOf(this.selectModels, selectParts);
+        this.selectModels.splice(modelsIndex, 1);
+      }
+    } else {
+      var partIndex = _.indexOf(selectParts, part);
+      selectParts.splice(partIndex, 1);
+    }
+
+    this.updatePersistedParts();
+  }
+
+  addSelectPart(selectParts, type) {
+    var partModel = queryPart.create({type: type});
+    partModel.def.addStrategy(selectParts, partModel, this);
+    this.updatePersistedParts();
+  }
+
+  private renderTagCondition(tag, index) {
+    var str = "";
+    var operator = tag.operator;
+    var value = tag.value;
+    if (index > 0) {
+      str = (tag.condition || 'AND') + ' ';
+    }
+
+    if (!operator) {
+      if (/^\/.*\/$/.test(tag.value)) {
+        operator = '=~';
+      } else {
+        operator = '=';
+      }
+    }
+
+    // quote value unless regex
+    if (operator !== '=~' && operator !== '!~') {
+      value = "'" + value + "'";
+    }
+
+    return str + '"' + tag.key + '" ' + operator + ' ' + value;
+  }
+
+  render() {
+    var target = this.target;
+
+    if (target.rawQuery) {
+      return target.query;
+    }
+
+    if (!target.measurement) {
+      throw "Metric measurement is missing";
+    }
+
+    var query = 'SELECT ';
+    var i, y;
+    for (i = 0; i < this.selectModels.length; i++) {
+      let parts = this.selectModels[i];
+      var selectText = "";
+      for (y = 0; y < parts.length; y++) {
+        let part = parts[y];
+        selectText = part.render(selectText);
+      }
+
+      if (i > 0) {
+        query += ', ';
+      }
+      query += selectText;
+    }
+
+    var measurement = target.measurement;
+    if (!measurement.match('^/.*/') && !measurement.match(/^merge\(.*\)/)) {
+measurement = '"' + measurement+ '"';
+    }
+
+    query += ' FROM ' + measurement + ' WHERE ';
+    var conditions = _.map(target.tags, (tag, index) => {
+      return this.renderTagCondition(tag, index);
+    });
+
+    query += conditions.join(' ');
+    query += (conditions.length > 0 ? ' AND ' : '') + '$timeFilter';
+
+    var groupBySection = "";
+    for (i = 0; i < this.groupByParts.length; i++) {
+      var part = this.groupByParts[i];
+      if (i > 0) {
+        // for some reason fill has no seperator
+        groupBySection += part.def.type === 'fill' ? ' ' : ', ';
+      }
+      groupBySection += part.render('');
+    }
+
+    if (groupBySection.length) {
+      query += ' GROUP BY ' + groupBySection;
+    }
+
+    if (target.fill) {
+      query += ' fill(' + target.fill + ')';
+    }
+
+    target.query = query;
+
+    return query;
+  }
+}
+
+export = InfluxQuery;

+ 17 - 68
public/app/plugins/datasource/influxdb/partials/query.editor.html

@@ -1,4 +1,4 @@
-<div class="tight-form-container-no-item-borders">
+<div class="">
 	<div  class="tight-form">
 		<ul class="tight-form-list pull-right">
 			<li ng-show="parserError" class="tight-form-item">
@@ -48,98 +48,47 @@
 			<li>
 				<metric-segment segment="measurementSegment" get-options="getMeasurements()" on-change="measurementChanged()"></metric-segment>
 			</li>
+			<li class="tight-form-item query-keyword" style="padding-left: 15px; padding-right: 15px;">
+				WHERE
+			</li>
+			<li ng-repeat="segment in tagSegments">
+				<metric-segment segment="segment" get-options="getTagsOrValues(segment, $index)" on-change="tagSegmentUpdated(segment, $index)"></metric-segment>
+			</li>
 		</ul>
 		<div class="clearfix"></div>
 
 		<div style="padding: 10px" ng-if="target.rawQuery">
 			<textarea ng-model="target.query" rows="8" spellcheck="false" style="width: 100%; box-sizing: border-box;" ng-blur="get_data()"></textarea>
 		</div>
+
 	</div>
 
 	<div ng-hide="target.rawQuery">
-		<div class="tight-form">
-			<ul class="tight-form-list">
-				<li class="tight-form-item query-keyword tight-form-align" style="width: 75px;">
-					WHERE
-				</li>
-				<li ng-repeat="segment in tagSegments">
-					<metric-segment segment="segment" get-options="getTagsOrValues(segment, $index)" on-change="tagSegmentUpdated(segment, $index)"></metric-segment>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
 
-		<div class="tight-form" ng-repeat="field in target.fields">
+		<div class="tight-form" ng-repeat="selectParts in queryModel.selectModels">
 			<ul class="tight-form-list">
 				<li class="tight-form-item query-keyword tight-form-align" style="width: 75px;">
 					<span ng-show="$index === 0">SELECT</span>
 				</li>
-				<li>
-					<metric-segment-model property="field.func" get-options="getFunctions()" on-change="get_data()" css-class="tight-form-item-xlarge"></metric-segment>
-				</li>
-				<li>
-					<metric-segment-model property="field.name" get-options="getFields()" on-change="get_data()" css-class="tight-form-item-large"></metric-segment>
-				</li>
-				<li>
-					<input type="text" class="tight-form-clear-input text-center" style="width: 70px;" ng-model="field.mathExpr" spellcheck='false' placeholder="math expr" ng-blur="get_data()">
+				<li ng-repeat="part in selectParts">
+					<influx-query-part-editor part="part" class="tight-form-item tight-form-func" remove-action="removeSelectPart(selectParts, part)" part-updated="selectPartUpdated(selectParts, part)" get-options="getPartOptions(part)"></influx-query-part-editor>
 				</li>
-				<li class="tight-form-item query-keyword">
-					AS
-				</li>
-				<li>
-					<input type="text" class="tight-form-clear-input" style="width: 180px;" ng-model="field.asExpr" spellcheck='false' placeholder="as expr" ng-blur="get_data()">
-				</li>
-			</ul>
-
-			<ul class="tight-form-list pull-right">
-				<li class="tight-form-item last" ng-show="$index === 0">
-					<a class="pointer" ng-click="addSelect()"><i class="fa fa-plus"></i></a>
-				</li>
-				<li class="tight-form-item last" ng-show="target.fields.length > 1">
-					<a class="pointer" ng-click="removeSelect($index)"><i class="fa fa-minus"></i></a>
+				<li class="dropdown" dropdown-typeahead="selectMenu" dropdown-typeahead-on-select="addSelectPart(selectParts, $item, $subItem)">
 				</li>
 			</ul>
 			<div class="clearfix"></div>
 		</div>
 
-		<div class="tight-form" ng-repeat="groupBy in target.groupBy">
+		<div class="tight-form">
 			<ul class="tight-form-list">
 				<li class="tight-form-item query-keyword tight-form-align" style="width: 75px;">
 					<span ng-show="$index === 0">GROUP BY</span>
 				</li>
-				<li ng-if="groupBy.type === 'time'">
-					<span class="tight-form-item">time</span>
-					<metric-segment-model property="groupBy.interval" get-options="getGroupByTimeIntervals()" on-change="get_data()">
-					</metric-segment>
+				<li ng-repeat="part in queryModel.groupByParts">
+					<influx-query-part-editor part="part" class="tight-form-item tight-form-func" remove-action="removeGroupByPart(part, $index)" part-updated="get_data();" get-options="getPartOptions(part)"></influx-query-part-editor>
 				</li>
-				<li class="dropdown" ng-if="groupBy.type === 'time'">
-					<a class="tight-form-item pointer" data-toggle="dropdown" bs-tooltip="'Insert missing values, important when stacking'" data-placement="right">
-						<span ng-show="target.fill">
-							fill ({{target.fill}})
-						</span>
-						<span ng-show="!target.fill">
-							no fill
-						</span>
-					</a>
-					<ul class="dropdown-menu">
-						<li><a ng-click="setFill('')">no fill</a></li>
-						<li><a ng-click="setFill('0')">fill (0)</a></li>
-						<li><a ng-click="setFill('null')">fill (null)</a></li>
-						<li><a ng-click="setFill('none')">fill (none)</a></li>
-						<li><a ng-click="setFill('previous')">fill (previous)</a></li>
-					</ul>
-				</li>
-				<li ng-if="groupBy.type === 'tag'">
-					<metric-segment-model property="groupBy.key" get-options="getTagOptions()" on-change="get_data()"></metric-segment>
-				</li>
-			</ul>
-
-			<ul class="tight-form-list pull-right">
-				<li class="tight-form-item last" ng-show="$index === 0">
-					<a class="pointer" ng-click="addGroupBy()"><i class="fa fa-plus"></i></a>
-				</li>
-				<li class="tight-form-item last" ng-show="$index > 0">
-					<a class="pointer" ng-click="removeGroupBy($index)"><i class="fa fa-minus"></i></a>
+				<li>
+					<metric-segment segment="groupBySegment" get-options="getGroupByOptions()" on-change="groupByAction(part, $index)"></metric-segment>
 				</li>
 			</ul>
 			<div class="clearfix"></div>

+ 5 - 0
public/app/plugins/datasource/influxdb/partials/query_part.html

@@ -0,0 +1,5 @@
+<div class="tight-form-func-controls">
+	<span class="pointer fa fa-remove" ng-click="removeActionInternal()" ></span>
+</div>
+
+<a ng-click="toggleControls()">{{part.def.type}}</a><span>(</span><span class="query-part-parameters"></span><span>)</span>

+ 2 - 73
public/app/plugins/datasource/influxdb/query_builder.js

@@ -4,8 +4,9 @@ define([
 function (_) {
   'use strict';
 
-  function InfluxQueryBuilder(target) {
+  function InfluxQueryBuilder(target, queryModel) {
     this.target = target;
+    this.model = queryModel;
 
     if (target.groupByTags) {
       target.groupBy = [{type: 'time', interval: 'auto'}];
@@ -92,77 +93,5 @@ function (_) {
     return query;
   };
 
-  p._getGroupByTimeInterval = function(interval) {
-    if (interval === 'auto') {
-      return '$interval';
-    }
-    return interval;
-  };
-
-  p._buildQuery = function() {
-    var target = this.target;
-
-    if (!target.measurement) {
-      throw "Metric measurement is missing";
-    }
-
-    if (!target.fields) {
-      target.fields = [{name: 'value', func: target.function || 'mean'}];
-    }
-
-    var query = 'SELECT ';
-    var i;
-    for (i = 0; i < target.fields.length; i++) {
-      var field = target.fields[i];
-      if (i > 0) {
-        query += ', ';
-      }
-      query += field.func + '("' + field.name + '")';
-      if (field.mathExpr) {
-        query += field.mathExpr;
-      }
-      if (field.asExpr) {
-        query += ' AS "' + field.asExpr + '"';
-      } else {
-        query += ' AS "' + field.name + '"';
-      }
-    }
-
-    var measurement = target.measurement;
-    if (!measurement.match('^/.*/') && !measurement.match(/^merge\(.*\)/)) {
-      measurement = '"' + measurement+ '"';
-    }
-
-    query += ' FROM ' + measurement + ' WHERE ';
-    var conditions = _.map(target.tags, function(tag, index) {
-      return renderTagCondition(tag, index);
-    });
-
-    query += conditions.join(' ');
-    query += (conditions.length > 0 ? ' AND ' : '') + '$timeFilter';
-
-    query += ' GROUP BY';
-    for (i = 0; i < target.groupBy.length; i++) {
-      var group = target.groupBy[i];
-      if (group.type === 'time') {
-        query += ' time(' + this._getGroupByTimeInterval(group.interval) + ')';
-      } else {
-        query += ', "' + group.key + '"';
-      }
-    }
-
-    if (target.fill) {
-      query += ' fill(' + target.fill + ')';
-    }
-
-    target.query = query;
-
-    return query;
-  };
-
-  p._modifyRawQuery = function () {
-    return this.target.query.replace(";", "");
-  };
-
   return InfluxQueryBuilder;
 });

+ 79 - 67
public/app/plugins/datasource/influxdb/query_ctrl.js

@@ -2,32 +2,33 @@ define([
   'angular',
   'lodash',
   './query_builder',
+  './influx_query',
+  './query_part',
+  './query_part_editor',
 ],
-function (angular, _, InfluxQueryBuilder) {
+function (angular, _, InfluxQueryBuilder, InfluxQuery, queryPart) {
   'use strict';
 
   var module = angular.module('grafana.controllers');
 
-  module.controller('InfluxQueryCtrl', function($scope, $timeout, $sce, templateSrv, $q, uiSegmentSrv) {
+  module.controller('InfluxQueryCtrl', function($scope, templateSrv, $q, uiSegmentSrv) {
 
     $scope.init = function() {
       if (!$scope.target) { return; }
 
-      var target = $scope.target;
-      target.tags = target.tags || [];
-      target.groupBy = target.groupBy || [{type: 'time', interval: 'auto'}];
-      target.fields = target.fields || [{name: 'value', func: target.function || 'mean'}];
+      $scope.target = $scope.target;
+      $scope.queryModel = new InfluxQuery($scope.target);
+      $scope.queryBuilder = new InfluxQueryBuilder($scope.target);
+      $scope.groupBySegment = uiSegmentSrv.newPlusButton();
 
-      $scope.queryBuilder = new InfluxQueryBuilder(target);
-
-      if (!target.measurement) {
+      if (!$scope.target.measurement) {
         $scope.measurementSegment = uiSegmentSrv.newSelectMeasurement();
       } else {
-        $scope.measurementSegment = uiSegmentSrv.newSegment(target.measurement);
+        $scope.measurementSegment = uiSegmentSrv.newSegment($scope.target.measurement);
       }
 
       $scope.tagSegments = [];
-      _.each(target.tags, function(tag) {
+      _.each($scope.target.tags, function(tag) {
         if (!tag.operator) {
           if (/^\/.*\/$/.test(tag.value)) {
             tag.operator = "=~";
@@ -46,50 +47,81 @@ function (angular, _, InfluxQueryBuilder) {
       });
 
       $scope.fixTagSegments();
+      $scope.buildSelectMenu();
       $scope.removeTagFilterSegment = uiSegmentSrv.newSegment({fake: true, value: '-- remove tag filter --'});
     };
 
-    $scope.fixTagSegments = function() {
-      var count = $scope.tagSegments.length;
-      var lastSegment = $scope.tagSegments[Math.max(count-1, 0)];
+    $scope.buildSelectMenu = function() {
+      var categories = queryPart.getCategories();
+      $scope.selectMenu = _.reduce(categories, function(memo, cat, key) {
+        var menu = {text: key};
+        menu.submenu = _.map(cat, function(item) {
+          return {text: item.type, value: item.type};
+        });
+        memo.push(menu);
+        return memo;
+      }, []);
+    };
 
-      if (!lastSegment || lastSegment.type !== 'plus-button') {
-        $scope.tagSegments.push(uiSegmentSrv.newPlusButton());
-      }
+    $scope.getGroupByOptions = function() {
+      var query = $scope.queryBuilder.buildExploreQuery('TAG_KEYS');
+
+      return $scope.datasource.metricFindQuery(query)
+      .then(function(tags) {
+        var options = [];
+        if (!$scope.queryModel.hasFill()) {
+          options.push(uiSegmentSrv.newSegment({value: 'fill(null)'}));
+        }
+        if (!$scope.queryModel.hasGroupByTime()) {
+          options.push(uiSegmentSrv.newSegment({value: 'time($interval)'}));
+        }
+        _.each(tags, function(tag) {
+          options.push(uiSegmentSrv.newSegment({value: 'tag(' + tag.text + ')'}));
+        });
+        return options;
+      })
+      .then(null, $scope.handleQueryError);
     };
 
-    $scope.addGroupBy = function() {
-      $scope.target.groupBy.push({type: 'tag', key: "select tag"});
+    $scope.groupByAction = function() {
+      $scope.queryModel.addGroupBy($scope.groupBySegment.value);
+      var plusButton = uiSegmentSrv.newPlusButton();
+      $scope.groupBySegment.value  = plusButton.value;
+      $scope.groupBySegment.html  = plusButton.html;
+      $scope.get_data();
     };
 
-    $scope.removeGroupBy = function(index) {
-      $scope.target.groupBy.splice(index, 1);
+    $scope.removeGroupByPart = function(part, index) {
+      $scope.queryModel.removeGroupByPart(part, index);
       $scope.get_data();
     };
 
-    $scope.addSelect = function() {
-      $scope.target.fields.push({name: "select field", func: 'mean'});
+    $scope.addSelectPart = function(selectParts, cat, subitem) {
+      $scope.queryModel.addSelectPart(selectParts, subitem.value);
+      $scope.get_data();
     };
 
-    $scope.removeSelect = function(index) {
-      $scope.target.fields.splice(index, 1);
+    $scope.removeSelectPart = function(selectParts, part) {
+      $scope.queryModel.removeSelectPart(selectParts, part);
       $scope.get_data();
     };
 
-    $scope.changeFunction = function(func) {
-      $scope.target.function = func;
-      $scope.$parent.get_data();
+    $scope.selectPartUpdated = function() {
+      $scope.get_data();
     };
 
-    $scope.measurementChanged = function() {
-      $scope.target.measurement = $scope.measurementSegment.value;
-      $scope.$parent.get_data();
+    $scope.fixTagSegments = function() {
+      var count = $scope.tagSegments.length;
+      var lastSegment = $scope.tagSegments[Math.max(count-1, 0)];
+
+      if (!lastSegment || lastSegment.type !== 'plus-button') {
+        $scope.tagSegments.push(uiSegmentSrv.newPlusButton());
+      }
     };
 
-    $scope.getFields = function() {
-      var fieldsQuery = $scope.queryBuilder.buildExploreQuery('FIELDS');
-      return $scope.datasource.metricFindQuery(fieldsQuery)
-      .then($scope.transformToSegments(false), $scope.handleQueryError);
+    $scope.measurementChanged = function() {
+      $scope.target.measurement = $scope.measurementSegment.value;
+      $scope.get_data();
     };
 
     $scope.toggleQueryMode = function () {
@@ -102,20 +134,17 @@ function (angular, _, InfluxQueryBuilder) {
       .then($scope.transformToSegments(true), $scope.handleQueryError);
     };
 
-    $scope.getFunctions = function () {
-      var functionList = ['count', 'mean', 'sum', 'min', 'max', 'mode', 'distinct', 'median',
-        'stddev', 'first', 'last'
-      ];
-      return $q.when(_.map(functionList, function(func) {
-        return uiSegmentSrv.newSegment(func);
-      }));
-    };
-
-    $scope.getGroupByTimeIntervals = function () {
-      var times = ['auto', '1s', '10s', '1m', '2m', '5m', '10m', '30m', '1h', '1d'];
-      return $q.when(_.map(times, function(func) {
-        return uiSegmentSrv.newSegment(func);
-      }));
+    $scope.getPartOptions = function(part) {
+      if (part.def.type === 'field') {
+        var fieldsQuery = $scope.queryBuilder.buildExploreQuery('FIELDS');
+        return $scope.datasource.metricFindQuery(fieldsQuery)
+        .then($scope.transformToSegments(true), $scope.handleQueryError);
+      }
+      if (part.def.type === 'tag') {
+        var tagsQuery = $scope.queryBuilder.buildExploreQuery('TAG_KEYS');
+        return $scope.datasource.metricFindQuery(tagsQuery)
+        .then($scope.transformToSegments(true), $scope.handleQueryError);
+      }
     };
 
     $scope.handleQueryError = function(err) {
@@ -179,25 +208,8 @@ function (angular, _, InfluxQueryBuilder) {
       .then(null, $scope.handleQueryError);
     };
 
-    $scope.addField = function() {
-      $scope.target.fields.push({name: $scope.addFieldSegment.value, func: 'mean'});
-      _.extend($scope.addFieldSegment, uiSegmentSrv.newPlusButton());
-    };
-
-    $scope.fieldChanged = function(field) {
-      if (field.name === '-- remove from select --') {
-        $scope.target.fields = _.without($scope.target.fields, field);
-      }
-      $scope.get_data();
-    };
-
     $scope.getTagOptions = function() {
-      var query = $scope.queryBuilder.buildExploreQuery('TAG_KEYS');
-
-      return $scope.datasource.metricFindQuery(query)
-      .then($scope.transformToSegments(false))
-      .then(null, $scope.handleQueryError);
-    };
+   };
 
     $scope.setFill = function(fill) {
       $scope.target.fill = fill;

+ 432 - 0
public/app/plugins/datasource/influxdb/query_part.ts

@@ -0,0 +1,432 @@
+///<reference path="../../../headers/common.d.ts" />
+
+import _ = require('lodash');
+
+var index = [];
+var categories = {
+  Aggregations: [],
+  Selectors: [],
+  Transformations: [],
+  Math: [],
+  Aliasing: [],
+  Fields: [],
+};
+
+var groupByTimeFunctions = [];
+
+class QueryPartDef {
+  type: string;
+  params: any[];
+  defaultParams: any[];
+  renderer: any;
+  category: any;
+  addStrategy: any;
+
+  constructor(options: any) {
+    this.type = options.type;
+    this.params = options.params;
+    this.defaultParams = options.defaultParams;
+    this.renderer = options.renderer;
+    this.category = options.category;
+    this.addStrategy = options.addStrategy;
+  }
+
+  static register(options: any) {
+    index[options.type] = new QueryPartDef(options);
+    options.category.push(index[options.type]);
+  }
+}
+
+function functionRenderer(part, innerExpr) {
+  var str = part.def.type + '(';
+  var parameters = _.map(part.params, (value, index) => {
+    var paramType = part.def.params[index];
+    if (paramType.type === 'time') {
+      if (value === 'auto') {
+        value = '$interval';
+      }
+    }
+    if (paramType.quote === 'single') {
+      return "'" + value + "'";
+    } else if (paramType.quote === 'double') {
+      return '"' + value + '"';
+    }
+
+    return value;
+  });
+
+  if (innerExpr) {
+    parameters.unshift(innerExpr);
+  }
+  return str + parameters.join(', ') + ')';
+}
+
+function aliasRenderer(part, innerExpr) {
+  return innerExpr + ' AS ' + '"' + part.params[0] + '"';
+}
+
+function suffixRenderer(part, innerExpr) {
+  return innerExpr + ' ' + part.params[0];
+}
+
+function identityRenderer(part, innerExpr) {
+  return part.params[0];
+}
+
+function quotedIdentityRenderer(part, innerExpr) {
+  return '"' + part.params[0] + '"';
+}
+
+function fieldRenderer(part, innerExpr) {
+  if (part.params[0] === '*')  {
+    return '*';
+  }
+  return '"' + part.params[0] + '"';
+}
+
+function replaceAggregationAddStrategy(selectParts, partModel) {
+  // look for existing aggregation
+  for (var i = 0; i < selectParts.length; i++) {
+    var part = selectParts[i];
+    if (part.def.category === categories.Aggregations) {
+      selectParts[i] = partModel;
+      return;
+    }
+    if (part.def.category === categories.Selectors) {
+      selectParts[i] = partModel;
+      return;
+    }
+  }
+
+  selectParts.splice(1, 0, partModel);
+}
+
+function addTransformationStrategy(selectParts, partModel) {
+  var i;
+  // look for index to add transformation
+  for (i = 0; i < selectParts.length; i++) {
+    var part = selectParts[i];
+    if (part.def.category === categories.Math  || part.def.category === categories.Aliasing) {
+      break;
+    }
+  }
+
+  selectParts.splice(i, 0, partModel);
+}
+
+function addMathStrategy(selectParts, partModel) {
+  var partCount = selectParts.length;
+  if (partCount > 0) {
+    // if last is math, replace it
+    if (selectParts[partCount-1].def.type === 'math') {
+      selectParts[partCount-1] = partModel;
+      return;
+    }
+    // if next to last is math, replace it
+    if (selectParts[partCount-2].def.type === 'math') {
+      selectParts[partCount-2] = partModel;
+      return;
+    }
+    // if last is alias add it before
+    else if (selectParts[partCount-1].def.type === 'alias') {
+      selectParts.splice(partCount-1, 0, partModel);
+      return;
+    }
+  }
+  selectParts.push(partModel);
+}
+
+function addAliasStrategy(selectParts, partModel) {
+  var partCount = selectParts.length;
+  if (partCount > 0) {
+    // if last is alias, replace it
+    if (selectParts[partCount-1].def.type === 'alias') {
+      selectParts[partCount-1] = partModel;
+      return;
+    }
+  }
+  selectParts.push(partModel);
+}
+
+function addFieldStrategy(selectParts, partModel, query) {
+  // copy all parts
+  var parts = _.map(selectParts, function(part: any) {
+    return new QueryPart({type: part.def.type, params: _.clone(part.params)});
+  });
+
+  query.selectModels.push(parts);
+}
+
+QueryPartDef.register({
+  type: 'field',
+  addStrategy: addFieldStrategy,
+  category: categories.Fields,
+  params: [{type: 'field', dynamicLookup: true}],
+  defaultParams: ['value'],
+  renderer: fieldRenderer,
+});
+
+// Aggregations
+QueryPartDef.register({
+  type: 'count',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'distinct',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'integral',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'mean',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'median',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'sum',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Aggregations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+// transformations
+
+QueryPartDef.register({
+  type: 'derivative',
+  addStrategy: addTransformationStrategy,
+  category: categories.Transformations,
+  params: [{ name: "duration", type: "interval", options: ['1s', '10s', '1m', '5min', '10m', '15m', '1h']}],
+  defaultParams: ['10s'],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'non_negative_derivative',
+  addStrategy: addTransformationStrategy,
+  category: categories.Transformations,
+  params: [{ name: "duration", type: "interval", options: ['1s', '10s', '1m', '5min', '10m', '15m', '1h']}],
+  defaultParams: ['10s'],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'stddev',
+  addStrategy: addTransformationStrategy,
+  category: categories.Transformations,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'time',
+  category: groupByTimeFunctions,
+  params: [{ name: "interval", type: "time", options: ['auto', '1s', '10s', '1m', '5m', '10m', '15m', '1h'] }],
+  defaultParams: ['auto'],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'fill',
+  category: groupByTimeFunctions,
+  params: [{ name: "fill", type: "string", options: ['none', 'null', '0', 'previous'] }],
+  defaultParams: ['null'],
+  renderer: functionRenderer,
+});
+
+// Selectors
+QueryPartDef.register({
+  type: 'bottom',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [{name: 'count', type: 'int'}],
+  defaultParams: [3],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'first',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'last',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'max',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'min',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [],
+  defaultParams: [],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'percentile',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [{name: 'nth', type: 'int'}],
+  defaultParams: [95],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'top',
+  addStrategy: replaceAggregationAddStrategy,
+  category: categories.Selectors,
+  params: [{name: 'count', type: 'int'}],
+  defaultParams: [3],
+  renderer: functionRenderer,
+});
+
+QueryPartDef.register({
+  type: 'tag',
+  category: groupByTimeFunctions,
+  params: [{name: 'tag', type: 'string', dynamicLookup: true}],
+  defaultParams: ['tag'],
+  renderer: fieldRenderer,
+});
+
+QueryPartDef.register({
+  type: 'math',
+  addStrategy: addMathStrategy,
+  category: categories.Math,
+  params: [{ name: "expr", type: "string"}],
+  defaultParams: [' / 100'],
+  renderer: suffixRenderer,
+});
+
+QueryPartDef.register({
+  type: 'alias',
+  addStrategy: addAliasStrategy,
+  category: categories.Aliasing,
+  params: [{ name: "name", type: "string", quote: 'double'}],
+  defaultParams: ['alias'],
+  renderMode: 'suffix',
+  renderer: aliasRenderer,
+});
+
+class QueryPart {
+  part: any;
+  def: QueryPartDef;
+  params: any[];
+  text: string;
+
+  constructor(part: any) {
+    this.part = part;
+    this.def = index[part.type];
+    if (!this.def) {
+      throw {message: 'Could not find query part ' + part.type};
+    }
+
+    part.params = part.params || _.clone(this.def.defaultParams);
+    this.params = part.params;
+    this.updateText();
+  }
+
+  render(innerExpr: string) {
+    return this.def.renderer(this, innerExpr);
+  }
+
+  hasMultipleParamsInString (strValue, index) {
+    if (strValue.indexOf(',') === -1) {
+      return false;
+    }
+
+    return this.def.params[index + 1] && this.def.params[index + 1].optional;
+  }
+
+  updateParam (strValue, index) {
+    // handle optional parameters
+    // if string contains ',' and next param is optional, split and update both
+    if (this.hasMultipleParamsInString(strValue, index)) {
+      _.each(strValue.split(','), function(partVal: string, idx) {
+        this.updateParam(partVal.trim(), idx);
+      }, this);
+      return;
+    }
+
+    if (strValue === '' && this.def.params[index].optional) {
+      this.params.splice(index, 1);
+    }
+    else {
+      this.params[index] = strValue;
+    }
+
+    this.part.params = this.params;
+    this.updateText();
+  }
+
+  updateText() {
+    if (this.params.length === 0) {
+      this.text = this.def.type + '()';
+      return;
+    }
+
+    var text = this.def.type + '(';
+    text += this.params.join(', ');
+    text += ')';
+    this.text = text;
+  }
+}
+
+export = {
+  create: function(part): any {
+    return new QueryPart(part);
+  },
+
+  getCategories: function() {
+    return categories;
+  }
+};

+ 178 - 0
public/app/plugins/datasource/influxdb/query_part_editor.js

@@ -0,0 +1,178 @@
+define([
+  'angular',
+  'lodash',
+  'jquery',
+],
+function (angular, _, $) {
+  'use strict';
+
+  angular
+    .module('grafana.directives')
+    .directive('influxQueryPartEditor', function($compile, templateSrv) {
+
+      var paramTemplate = '<input type="text" style="display:none"' +
+                          ' class="input-mini tight-form-func-param"></input>';
+      return {
+        restrict: 'E',
+        templateUrl: 'app/plugins/datasource/influxdb/partials/query_part.html',
+        scope: {
+          part: "=",
+          removeAction: "&",
+          partUpdated: "&",
+          getOptions: "&",
+        },
+        link: function postLink($scope, elem) {
+          var part = $scope.part;
+          var partDef = part.def;
+          var $paramsContainer = elem.find('.query-part-parameters');
+          var $controlsContainer = elem.find('.tight-form-func-controls');
+
+          function clickFuncParam(paramIndex) {
+            /*jshint validthis:true */
+            var $link = $(this);
+            var $input = $link.next();
+
+            $input.val(part.params[paramIndex]);
+            $input.css('width', ($link.width() + 16) + 'px');
+
+            $link.hide();
+            $input.show();
+            $input.focus();
+            $input.select();
+
+            var typeahead = $input.data('typeahead');
+            if (typeahead) {
+              $input.val('');
+              typeahead.lookup();
+            }
+          }
+
+          function inputBlur(paramIndex) {
+            /*jshint validthis:true */
+            var $input = $(this);
+            var $link = $input.prev();
+            var newValue = $input.val();
+
+            if (newValue !== '' || part.def.params[paramIndex].optional) {
+              $link.html(templateSrv.highlightVariablesAsHtml(newValue));
+
+              part.updateParam($input.val(), paramIndex);
+              $scope.$apply($scope.partUpdated);
+            }
+
+            $input.hide();
+            $link.show();
+          }
+
+          function inputKeyPress(paramIndex, e) {
+            /*jshint validthis:true */
+            if(e.which === 13) {
+              inputBlur.call(this, paramIndex);
+            }
+          }
+
+          function inputKeyDown() {
+            /*jshint validthis:true */
+            this.style.width = (3 + this.value.length) * 8 + 'px';
+          }
+
+          function addTypeahead($input, param, paramIndex) {
+            if (!param.options && !param.dynamicLookup) {
+              return;
+            }
+
+            var typeaheadSource = function (query, callback) {
+              if (param.options) { return param.options; }
+
+              $scope.$apply(function() {
+                $scope.getOptions().then(function(result) {
+                  var dynamicOptions = _.map(result, function(op) { return op.value; });
+                  callback(dynamicOptions);
+                });
+              });
+            };
+
+            $input.attr('data-provide', 'typeahead');
+            var options = param.options;
+            if (param.type === 'int') {
+              options = _.map(options, function(val) { return val.toString(); });
+            }
+
+            $input.typeahead({
+              source: typeaheadSource,
+              minLength: 0,
+              items: 1000,
+              updater: function (value) {
+                setTimeout(function() {
+                  inputBlur.call($input[0], paramIndex);
+                }, 0);
+                return value;
+              }
+            });
+
+            var typeahead = $input.data('typeahead');
+            typeahead.lookup = function () {
+              this.query = this.$element.val() || '';
+              var items = this.source(this.query, $.proxy(this.process, this));
+              return items ? this.process(items) : items;
+            };
+          }
+
+          $scope.toggleControls = function() {
+            var targetDiv = elem.closest('.tight-form');
+
+            if (elem.hasClass('show-function-controls')) {
+              elem.removeClass('show-function-controls');
+              targetDiv.removeClass('has-open-function');
+              $controlsContainer.hide();
+              return;
+            }
+
+            elem.addClass('show-function-controls');
+            targetDiv.addClass('has-open-function');
+            $controlsContainer.show();
+          };
+
+          $scope.removeActionInternal = function() {
+            $scope.toggleControls();
+            $scope.removeAction();
+          };
+
+          function addElementsAndCompile() {
+            _.each(partDef.params, function(param, index) {
+              if (param.optional && part.params.length <= index) {
+                return;
+              }
+
+              if (index > 0) {
+                $('<span>, </span>').appendTo($paramsContainer);
+              }
+
+              var paramValue = templateSrv.highlightVariablesAsHtml(part.params[index]);
+              var $paramLink = $('<a class="graphite-func-param-link pointer">' + paramValue + '</a>');
+              var $input = $(paramTemplate);
+
+              $paramLink.appendTo($paramsContainer);
+              $input.appendTo($paramsContainer);
+
+              $input.blur(_.partial(inputBlur, index));
+              $input.keyup(inputKeyDown);
+              $input.keypress(_.partial(inputKeyPress, index));
+              $paramLink.click(_.partial(clickFuncParam, index));
+
+              addTypeahead($input, param, index);
+            });
+          }
+
+          function relink() {
+            $paramsContainer.empty();
+            addElementsAndCompile();
+          }
+
+          relink();
+        }
+      };
+
+    });
+
+});

+ 216 - 0
public/app/plugins/datasource/influxdb/specs/influx_query_specs.ts

@@ -0,0 +1,216 @@
+import {describe, beforeEach, it, sinon, expect} from 'test/lib/common';
+
+import InfluxQuery = require('../influx_query');
+
+describe('InfluxQuery', function() {
+
+  describe('render series with mesurement only', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE $timeFilter GROUP BY time($interval) fill(null)');
+    });
+  });
+
+  describe('render series with math and alias', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [
+          [
+            {type: 'field', params: ['value']},
+            {type: 'mean', params: []},
+            {type: 'math', params: ['/100']},
+            {type: 'alias', params: ['text']},
+          ]
+        ]
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") /100 AS "text" FROM "cpu" WHERE $timeFilter GROUP BY time($interval) fill(null)');
+    });
+  });
+
+  describe('series with single tag only', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: [{type: 'time', params: ['auto']}],
+        tags: [{key: 'hostname', value: 'server1'}]
+      });
+
+      var queryText = query.render();
+
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server1\' AND $timeFilter'
+                          + ' GROUP BY time($interval)');
+    });
+
+    it('should switch regex operator with tag value is regex', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: [{type: 'time', params: ['auto']}],
+        tags: [{key: 'app', value: '/e.*/'}]
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "app" =~ /e.*/ AND $timeFilter GROUP BY time($interval)');
+    });
+  });
+
+  describe('series with multiple tags only', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: [{type: 'time', params: ['auto']}],
+        tags: [{key: 'hostname', value: 'server1'}, {key: 'app', value: 'email', condition: "AND"}]
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server1\' AND "app" = \'email\' AND ' +
+                          '$timeFilter GROUP BY time($interval)');
+    });
+  });
+
+  describe('series with tags OR condition', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: [{type: 'time', params: ['auto']}],
+        tags: [{key: 'hostname', value: 'server1'}, {key: 'hostname', value: 'server2', condition: "OR"}]
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE "hostname" = \'server1\' OR "hostname" = \'server2\' AND ' +
+                          '$timeFilter GROUP BY time($interval)');
+    });
+  });
+
+  describe('series with groupByTag', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        tags: [],
+        groupBy: [{type: 'time', interval: 'auto'}, {type: 'tag', params: ['host']}],
+      });
+
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT mean("value") FROM "cpu" WHERE $timeFilter ' +
+                          'GROUP BY time($interval), "host"');
+    });
+  });
+
+  describe('render series without group by', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}]],
+        groupBy: [],
+      });
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT "value" FROM "cpu" WHERE $timeFilter');
+    });
+  });
+
+  describe('render series without group by and fill', function() {
+    it('should generate correct query', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}]],
+        groupBy: [{type: 'time'}, {type: 'fill', params: ['0']}],
+      });
+      var queryText = query.render();
+      expect(queryText).to.be('SELECT "value" FROM "cpu" WHERE $timeFilter GROUP BY time($interval) fill(0)');
+    });
+  });
+
+  describe('when adding group by part', function() {
+
+    it('should add tag before fill', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: [{type: 'time'}, {type: 'fill'}]
+      });
+
+      query.addGroupBy('tag(host)');
+      expect(query.target.groupBy.length).to.be(3);
+      expect(query.target.groupBy[1].type).to.be('tag');
+      expect(query.target.groupBy[1].params[0]).to.be('host');
+      expect(query.target.groupBy[2].type).to.be('fill');
+    });
+
+    it('should add tag last if no fill', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        groupBy: []
+      });
+
+      query.addGroupBy('tag(host)');
+      expect(query.target.groupBy.length).to.be(1);
+      expect(query.target.groupBy[0].type).to.be('tag');
+    });
+
+  });
+
+  describe('when adding select part', function() {
+
+    it('should add mean after after field', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}]]
+      });
+
+      query.addSelectPart(query.selectModels[0], 'mean');
+      expect(query.target.select[0].length).to.be(2);
+      expect(query.target.select[0][1].type).to.be('mean');
+    });
+
+    it('should replace sum by mean', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}, {type: 'mean'}]]
+      });
+
+      query.addSelectPart(query.selectModels[0], 'sum');
+      expect(query.target.select[0].length).to.be(2);
+      expect(query.target.select[0][1].type).to.be('sum');
+    });
+
+    it('should add math before alias', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}, {type: 'mean'}, {type: 'alias'}]]
+      });
+
+      query.addSelectPart(query.selectModels[0], 'math');
+      expect(query.target.select[0].length).to.be(4);
+      expect(query.target.select[0][2].type).to.be('math');
+    });
+
+    it('should add math last', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}, {type: 'mean'}]]
+      });
+
+      query.addSelectPart(query.selectModels[0], 'math');
+      expect(query.target.select[0].length).to.be(3);
+      expect(query.target.select[0][2].type).to.be('math');
+    });
+
+    it('should replace math', function() {
+      var query = new InfluxQuery({
+        measurement: 'cpu',
+        select: [[{type: 'field', params: ['value']}, {type: 'mean'}, {type: 'math'}]]
+      });
+
+      query.addSelectPart(query.selectModels[0], 'math');
+      expect(query.target.select[0].length).to.be(3);
+      expect(query.target.select[0][2].type).to.be('math');
+    });
+
+  });
+
+});

+ 5 - 113
public/app/plugins/datasource/influxdb/specs/query_builder_specs.ts

@@ -6,116 +6,6 @@ declare var InfluxQueryBuilder: any;
 
 describe('InfluxQueryBuilder', function() {
 
-  describe('series with mesurement only', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      groupBy: [{type: 'time', interval: 'auto'}]
-      });
-
-      var query = builder.build();
-
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE $timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with math expr and as expr', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      fields: [{name: 'test', func: 'max', mathExpr: '*2', asExpr: 'new_name'}],
-      groupBy: [{type: 'time', interval: 'auto'}]
-      });
-
-      var query = builder.build();
-
-      expect(query).to.be('SELECT max("test")*2 AS "new_name" FROM "cpu" WHERE $timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with single tag only', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      groupBy: [{type: 'time', interval: 'auto'}],
-      tags: [{key: 'hostname', value: 'server1'}]
-      });
-
-      var query = builder.build();
-
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE "hostname" = \'server1\' AND $timeFilter'
-          + ' GROUP BY time($interval)');
-    });
-
-    it('should switch regex operator with tag value is regex', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      groupBy: [{type: 'time', interval: 'auto'}],
-      tags: [{key: 'app', value: '/e.*/'}]
-      });
-
-      var query = builder.build();
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE "app" =~ /e.*/ AND $timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with multiple fields', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      tags: [],
-      groupBy: [{type: 'time', interval: 'auto'}],
-      fields: [{ name: 'tx_in', func: 'sum' }, { name: 'tx_out', func: 'mean' }]
-      });
-
-      var query = builder.build();
-      expect(query).to.be('SELECT sum("tx_in") AS "tx_in", mean("tx_out") AS "tx_out" ' +
-          'FROM "cpu" WHERE $timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with multiple tags only', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      groupBy: [{type: 'time', interval: 'auto'}],
-      tags: [{key: 'hostname', value: 'server1'}, {key: 'app', value: 'email', condition: "AND"}]
-      });
-
-      var query = builder.build();
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE "hostname" = \'server1\' AND "app" = \'email\' AND ' +
-          '$timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with tags OR condition', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      groupBy: [{type: 'time', interval: 'auto'}],
-      tags: [{key: 'hostname', value: 'server1'}, {key: 'hostname', value: 'server2', condition: "OR"}]
-      });
-
-      var query = builder.build();
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE "hostname" = \'server1\' OR "hostname" = \'server2\' AND ' +
-          '$timeFilter GROUP BY time($interval)');
-    });
-  });
-
-  describe('series with groupByTag', function() {
-    it('should generate correct query', function() {
-      var builder = new InfluxQueryBuilder({
-      measurement: 'cpu',
-      tags: [],
-      groupBy: [{type: 'time', interval: 'auto'}, {type: 'tag', key: 'host'}],
-      });
-
-      var query = builder.build();
-      expect(query).to.be('SELECT mean("value") AS "value" FROM "cpu" WHERE $timeFilter ' +
-          'GROUP BY time($interval), "host"');
-    });
-  });
-
   describe('when building explore queries', function() {
 
     it('should only have measurement condition in tag keys query given query with measurement', function() {
@@ -126,8 +16,7 @@ describe('InfluxQueryBuilder', function() {
 
     it('should handle regex measurement in tag keys query', function() {
       var builder = new InfluxQueryBuilder({
-      measurement: '/.*/',
-      tags: []
+        measurement: '/.*/', tags: []
       });
       var query = builder.buildExploreQuery('TAG_KEYS');
       expect(query).to.be('SHOW TAG KEYS FROM /.*/');
@@ -170,7 +59,10 @@ describe('InfluxQueryBuilder', function() {
     });
 
     it('should switch to regex operator in tag condition', function() {
-      var builder = new InfluxQueryBuilder({measurement: 'cpu', tags: [{key: 'host', value: '/server.*/'}]});
+      var builder = new InfluxQueryBuilder({
+        measurement: 'cpu',
+        tags: [{key: 'host', value: '/server.*/'}]
+      });
       var query = builder.buildExploreQuery('TAG_VALUES', 'app');
       expect(query).to.be('SHOW TAG VALUES FROM "cpu" WITH KEY = "app" WHERE "host" =~ /server.*/');
     });

+ 41 - 0
public/app/plugins/datasource/influxdb/specs/query_part_specs.ts

@@ -0,0 +1,41 @@
+
+import {describe, beforeEach, it, sinon, expect} from 'test/lib/common';
+
+import queryPart = require('../query_part');
+
+describe('InfluxQueryPart', () => {
+
+  describe('series with mesurement only', () => {
+    it('should handle nested function parts', () => {
+      var part = queryPart.create({
+        type: 'derivative',
+        params: ['10s'],
+      });
+
+      expect(part.text).to.be('derivative(10s)');
+      expect(part.render('mean(value)')).to.be('derivative(mean(value), 10s)');
+    });
+
+    it('should handle suffirx parts', () => {
+      var part = queryPart.create({
+        type: 'math',
+        params: ['/ 100'],
+      });
+
+      expect(part.text).to.be('math(/ 100)');
+      expect(part.render('mean(value)')).to.be('mean(value) / 100');
+    });
+
+    it('should handle alias parts', () => {
+      var part = queryPart.create({
+        type: 'alias',
+        params: ['test'],
+      });
+
+      expect(part.text).to.be('alias(test)');
+      expect(part.render('mean(value)')).to.be('mean(value) AS "test"');
+    });
+
+  });
+
+});

+ 86 - 1
public/test/specs/dashboardSrv-specs.js

@@ -204,7 +204,7 @@ define([
       });
 
       it('dashboard schema version should be set to latest', function() {
-        expect(model.schemaVersion).to.be(7);
+        expect(model.schemaVersion).to.be(8);
       });
 
     });
@@ -248,5 +248,90 @@ define([
         expect(clone.meta).to.be(undefined);
       });
     });
+
+    describe('when loading dashboard with old influxdb query schema', function() {
+      var model;
+      var target;
+
+      beforeEach(function() {
+        model = _dashboardSrv.create({
+          rows: [{
+            panels: [{
+              type: 'graph',
+              targets: [{
+                "alias": "$tag_datacenter $tag_source $col",
+                "column": "value",
+                "measurement": "logins.count",
+                "fields": [
+                  {
+                    "func": "mean",
+                    "name": "value",
+                    "mathExpr": "*2",
+                    "asExpr": "value"
+                  },
+                  {
+                    "name": "one-minute",
+                    "func": "mean",
+                    "mathExpr": "*3",
+                    "asExpr": "one-minute"
+                  }
+                ],
+                "tags": [],
+                "fill": "previous",
+                "function": "mean",
+                "groupBy": [
+                  {
+                    "interval": "auto",
+                    "type": "time"
+                  },
+                  {
+                    "key": "source",
+                    "type": "tag"
+                  },
+                  {
+                    "type": "tag",
+                    "key": "datacenter"
+                  }
+                ],
+              }]
+            }]
+          }]
+        });
+
+        target = model.rows[0].panels[0].targets[0];
+      });
+
+      it('should update query schema', function() {
+        expect(target.fields).to.be(undefined);
+        expect(target.select.length).to.be(2);
+        expect(target.select[0].length).to.be(4);
+        expect(target.select[0][0].type).to.be('field');
+        expect(target.select[0][1].type).to.be('mean');
+        expect(target.select[0][2].type).to.be('math');
+        expect(target.select[0][3].type).to.be('alias');
+      });
+
+    });
+
+    describe('when creating dashboard model with missing list for annoations or templating', function() {
+      var model;
+
+      beforeEach(function() {
+        model = _dashboardSrv.create({
+          annotations: {
+            enable: true,
+          },
+          templating: {
+            enable: true
+          }
+        });
+      });
+
+      it('should add empty list', function() {
+        expect(model.annotations.list.length).to.be(0);
+        expect(model.templating.list.length).to.be(0);
+      });
+    });
+
   });
 });