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

Testing kariosdb datasource, hm.. needs a lot of work

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

+ 3 - 3
pkg/api/frontendsettings.go

@@ -1,11 +1,10 @@
 package api
 
 import (
-	"errors"
-	"fmt"
 	"strconv"
 
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
@@ -45,7 +44,8 @@ func getFrontendSettingsMap(c *middleware.Context) (map[string]interface{}, erro
 
 		meta, exists := plugins.DataSources[ds.Type]
 		if !exists {
-			return nil, errors.New(fmt.Sprintf("Could not find plugin definition for data source: %v", ds.Type))
+			log.Error(3, "Could not find plugin definition for data source: %v", ds.Type)
+			continue
 		}
 
 		dsMap["meta"] = meta

+ 420 - 0
src/app/plugins/datasource/kairosdb/datasource.js

@@ -0,0 +1,420 @@
+define([
+  'angular',
+  'lodash',
+  'kbn',
+  './queryCtrl',
+],
+function (angular, _, kbn) {
+  'use strict';
+
+  var module = angular.module('grafana.services');
+  var tagList = null;
+
+  module.factory('KairosDBDatasource', function($q, $http) {
+
+    function KairosDBDatasource(datasource) {
+      this.type = datasource.type;
+      this.editorSrc = 'plugins/datasources/kairosdb/kairosdb.editor.html';
+      this.url = datasource.url;
+      this.name = datasource.name;
+      this.supportMetrics = true;
+      this.grafanaDB = datasource.grafanaDB;
+    }
+
+    // Called once per panel (graph)
+    KairosDBDatasource.prototype.query = function(options) {
+      var start = options.range.from;
+      var end = options.range.to;
+
+      var queries = _.compact(_.map(options.targets, _.partial(convertTargetToQuery, options)));
+      var plotParams = _.compact(_.map(options.targets, function(target){
+        var alias = target.alias;
+        if (typeof target.alias == 'undefined' || target.alias == "")
+          alias = target.metric;
+        return !target.hide
+            ?  {alias: alias,
+                exouter: target.exOuter}
+            : null;
+      }));
+      var handleKairosDBQueryResponseAlias = _.partial(handleKairosDBQueryResponse, plotParams);
+      // No valid targets, return the empty result to save a round trip.
+      if (_.isEmpty(queries)) {
+        var d = $q.defer();
+        d.resolve({ data: [] });
+        return d.promise;
+      }
+      return this.performTimeSeriesQuery(queries, start, end).then(handleKairosDBQueryResponseAlias,handleQueryError);
+    };
+
+    ///////////////////////////////////////////////////////////////////////
+    /// Query methods
+    ///////////////////////////////////////////////////////////////////////
+
+    KairosDBDatasource.prototype.performTimeSeriesQuery = function(queries, start, end) {
+      var reqBody = {
+        metrics: queries
+      };
+      reqBody.cache_time=0;
+      convertToKairosTime(start,reqBody,'start');
+      convertToKairosTime(end,reqBody,'end');
+      var options = {
+        method: 'POST',
+        url: '/api/v1/datapoints/query',
+        data: reqBody
+      };
+
+      options.url = this.url + options.url;
+      return $http(options);
+    };
+
+    /**
+     * Gets the list of metrics
+     * @returns {*|Promise}
+     */
+    KairosDBDatasource.prototype.performMetricSuggestQuery = function() {
+      var options = {
+        url : this.url + '/api/v1/metricnames',
+        method : 'GET'
+      };
+      return $http(options).then(function(results) {
+        if (!results.data) {
+          return [];
+        }
+        return results.data.results;
+      });
+
+    };
+
+    KairosDBDatasource.prototype.performTagSuggestQuery = function(metricname,range,type,keyValue) {
+      if(tagList && (metricname === tagList.metricName) && (range.from === tagList.range.from) &&
+        (range.to === tagList.range.to)) {
+        return getTagListFromResponse(tagList.results,type,keyValue);
+      }
+      tagList = {
+        metricName:metricname,
+        range:range
+      };
+      var body = {
+        metrics : [{name : metricname}]
+      };
+      convertToKairosTime(range.from,body,'start');
+      convertToKairosTime(range.to,body,'end');
+      var options = {
+        url : this.url + '/api/v1/datapoints/query/tags',
+        method : 'POST',
+        data : body
+      };
+      return $http(options).then(function(results) {
+        tagList.results = results;
+        return getTagListFromResponse(results,type,keyValue);
+      });
+
+    };
+
+    /////////////////////////////////////////////////////////////////////////
+    /// Formatting methods
+    ////////////////////////////////////////////////////////////////////////
+
+    function getTagListFromResponse(results,type,keyValue) {
+      if (!results.data) {
+        return [];
+      }
+      if(type==="key") {
+        return _.keys(results.data.queries[0].results[0].tags);
+      }
+      else if(type==="value" && _.has(results.data.queries[0].results[0].tags,keyValue)) {
+        return results.data.queries[0].results[0].tags[keyValue];
+      }
+      return [];
+    }
+
+    /**
+     * Requires a verion of KairosDB with every CORS defects fixed
+     * @param results
+     * @returns {*}
+     */
+    function handleQueryError(results) {
+      if(results.data.errors && !_.isEmpty(results.data.errors)) {
+        var errors = {
+          message: results.data.errors[0]
+        };
+        return $q.reject(errors);
+      }
+      else{
+        return $q.reject(results);
+      }
+    }
+
+    function handleKairosDBQueryResponse(plotParams, results) {
+      var output = [];
+      var index = 0;
+      _.each(results.data.queries, function (series) {
+        var sample_size = series.sample_size;
+        console.log("sample_size:" + sample_size + " samples");
+
+        _.each(series.results, function (result) {
+
+          //var target = result.name;
+          var target = plotParams[index].alias;
+          var details = " ( ";
+          _.each(result.group_by,function(element) {
+            if(element.name==="tag") {
+              _.each(element.group,function(value, key) {
+                details+= key+"="+value+" ";
+              });
+            }
+            else if(element.name==="value") {
+              details+= 'value_group='+element.group.group_number+" ";
+            }
+            else if(element.name==="time") {
+              details+= 'time_group='+element.group.group_number+" ";
+            }
+          });
+          details+= ") ";
+          if (details != " ( ) ")
+            target += details;
+          var datapoints = [];
+
+          for (var i = 0; i < result.values.length; i++) {
+            var t = Math.floor(result.values[i][0]);
+            var v = result.values[i][1];
+            datapoints[i] = [v, t];
+          }
+          if (plotParams[index].exouter)
+            datapoints = PeakFilter(datapoints, 10);
+          output.push({ target: target, datapoints: datapoints });
+        });
+        index ++;
+      });
+      var output2 = { data: _.flatten(output) };
+
+      return output2;
+    }
+
+    function convertTargetToQuery(options,target) {
+      if (!target.metric || target.hide) {
+        return null;
+      }
+
+      var query = {
+        name: target.metric
+      };
+
+      query.aggregators = [];
+      if(target.downsampling!=='(NONE)') {
+        query.aggregators.push({
+          name: target.downsampling,
+          align_sampling: true,
+          align_start_time: true,
+          sampling: KairosDBDatasource.prototype.convertToKairosInterval(target.sampling || options.interval)
+        });
+      }
+      if(target.horizontalAggregators) {
+        _.each(target.horizontalAggregators,function(chosenAggregator) {
+          var returnedAggregator = {
+            name:chosenAggregator.name
+          };
+          if(chosenAggregator.sampling_rate) {
+            returnedAggregator.sampling = KairosDBDatasource.prototype.convertToKairosInterval(chosenAggregator.sampling_rate);
+            returnedAggregator.align_sampling = true;
+            returnedAggregator.align_start_time=true;
+          }
+          if(chosenAggregator.unit) {
+            returnedAggregator.unit = chosenAggregator.unit+'s';
+          }
+          if(chosenAggregator.factor && chosenAggregator.name==='div') {
+            returnedAggregator.divisor = chosenAggregator.factor;
+          }
+          else if(chosenAggregator.factor && chosenAggregator.name==='scale') {
+            returnedAggregator.factor  = chosenAggregator.factor;
+          }
+          if(chosenAggregator.percentile) {
+            returnedAggregator.percentile = chosenAggregator.percentile;
+          }
+          query.aggregators.push(returnedAggregator);
+        });
+      }
+      if(_.isEmpty(query.aggregators)) {
+        delete query.aggregators;
+      }
+
+      if(target.tags) {
+        query.tags = angular.copy(target.tags);
+      }
+
+      if(target.groupByTags || target.nonTagGroupBys) {
+        query.group_by = [];
+        if(target.groupByTags) {query.group_by.push({name: "tag", tags: angular.copy(target.groupByTags)});}
+        if(target.nonTagGroupBys) {
+          _.each(target.nonTagGroupBys,function(rawGroupBy) {
+            var formattedGroupBy = angular.copy(rawGroupBy);
+            if(formattedGroupBy.name==='time') {
+              formattedGroupBy.range_size=KairosDBDatasource.prototype.convertToKairosInterval(formattedGroupBy.range_size);
+            }
+            query.group_by.push(formattedGroupBy);
+          });
+        }
+      }
+      return query;
+    }
+
+    ///////////////////////////////////////////////////////////////////////
+    /// Time conversion functions specifics to KairosDB
+    //////////////////////////////////////////////////////////////////////
+
+    KairosDBDatasource.prototype.convertToKairosInterval = function(intervalString) {
+      var interval_regex = /(\d+(?:\.\d+)?)([Mwdhmsy])/;
+      var interval_regex_ms = /(\d+(?:\.\d+)?)(ms)/;
+      var matches = intervalString.match(interval_regex_ms);
+      if(!matches) {
+        matches = intervalString.match(interval_regex);
+      }
+      if (!matches) {
+        throw new Error('Invalid interval string, expecting a number followed by one of "y M w d h m s ms"');
+      }
+
+      var value = matches[1];
+      var unit = matches[2];
+      if (value%1!==0) {
+        if(unit==='ms') {throw new Error('Invalid interval value, cannot be smaller than the millisecond');}
+        value = Math.round(kbn.intervals_in_seconds[unit]*value*1000);
+        unit = 'ms';
+
+      }
+      switch(unit) {
+        case 'ms':
+          unit = 'milliseconds';
+          break;
+        case 's':
+          unit = 'seconds';
+          break;
+        case 'm':
+          unit = 'minutes';
+          break;
+        case 'h':
+          unit = 'hours';
+          break;
+        case 'd':
+          unit = 'days';
+          break;
+        case 'w':
+          unit = 'weeks';
+          break;
+        case 'M':
+          unit = 'months';
+          break;
+        case 'y':
+          unit = 'years';
+          break;
+        default:
+          console.log("Unknown interval ", intervalString);
+          break;
+      }
+
+      return {
+        "value": value,
+        "unit": unit
+      };
+
+    };
+
+    function convertToKairosTime(date, response_obj, start_stop_name) {
+      var name;
+      if (_.isString(date)) {
+        if (date === 'now') {
+          return;
+        }
+        else if (date.indexOf('now-') >= 0) {
+
+          name = start_stop_name + "_relative";
+
+          date = date.substring(4);
+          var re_date = /(\d+)\s*(\D+)/;
+          var result = re_date.exec(date);
+          if (result) {
+            var value = result[1];
+            var unit = result[2];
+            switch(unit) {
+              case 'ms':
+                unit = 'milliseconds';
+                break;
+              case 's':
+                unit = 'seconds';
+                break;
+              case 'm':
+                unit = 'minutes';
+                break;
+              case 'h':
+                unit = 'hours';
+                break;
+              case 'd':
+                unit = 'days';
+                break;
+              case 'w':
+                unit = 'weeks';
+                break;
+              case 'M':
+                unit = 'months';
+                break;
+              case 'y':
+                unit = 'years';
+                break;
+              default:
+                console.log("Unknown date ", date);
+                break;
+            }
+            response_obj[name] = {
+              "value": value,
+              "unit": unit
+            };
+            return;
+          }
+          console.log("Unparseable date", date);
+          return;
+        }
+        date = kbn.parseDate(date);
+      }
+
+      if(_.isDate(date)) {
+        name = start_stop_name + "_absolute";
+        response_obj[name] = date.getTime();
+        return;
+      }
+
+      console.log("Date is neither string nor date");
+    }
+
+    function PeakFilter(dataIn, limit) {
+      var datapoints = dataIn;
+      var arrLength = datapoints.length;
+      if (arrLength <= 3)
+        return datapoints;
+      var LastIndx = arrLength - 1;
+
+      // Check first point
+      var prvDelta = Math.abs((datapoints[1][0] - datapoints[0][0]) / datapoints[0][0]);
+      var nxtDelta = Math.abs((datapoints[1][0] - datapoints[2][0]) / datapoints[2][0]);
+      if (prvDelta >= limit && nxtDelta < limit)
+        datapoints[0][0] = datapoints[1][0];
+
+      // Check last point
+      prvDelta = Math.abs((datapoints[LastIndx - 1][0] - datapoints[LastIndx - 2][0]) / datapoints[LastIndx - 2][0]);
+      nxtDelta = Math.abs((datapoints[LastIndx - 1][0] - datapoints[LastIndx][0]) / datapoints[LastIndx][0]);
+      if (prvDelta >= limit && nxtDelta < limit)
+        datapoints[LastIndx][0] = datapoints[LastIndx - 1][0];
+
+      for (var i = 1; i < arrLength - 1; i++){
+        prvDelta = Math.abs((datapoints[i][0] - datapoints[i - 1][0]) / datapoints[i - 1][0]);
+        nxtDelta = Math.abs((datapoints[i][0] - datapoints[i + 1][0]) / datapoints[i + 1][0]);
+        if (prvDelta >= limit && nxtDelta >= limit)
+          datapoints[i][0] = (datapoints[i-1][0] + datapoints[i+1][0]) / 2;
+      }
+
+      return datapoints;
+    }
+
+    ////////////////////////////////////////////////////////////////////////
+    return KairosDBDatasource;
+  });
+
+});

+ 1 - 0
src/app/plugins/datasource/kairosdb/partials/config.html

@@ -0,0 +1 @@
+<div ng-include="httpConfigPartialSrc"></div>

+ 384 - 0
src/app/plugins/datasource/kairosdb/partials/query.editor.html

@@ -0,0 +1,384 @@
+<div class="editor-row">
+	<div ng-repeat="target in panel.targets"
+		class="tight-form-container"
+		ng-class="{'tight-form-disabled': target.hide}"
+		ng-controller="KairosDBTargetCtrl"
+		ng-init="init()">
+
+		<div class="tight-form">
+			<ul class="tight-form-list pull-right">
+				<li>
+					<a bs-tooltip="'Group by\'s are always executed before aggregations!'"
+						ng-click="alert('Group by\'s are always executed before aggregations!')">
+						<i class="icon-info"></i>
+					</a>
+				</li>
+				<li class="dropdown">
+					<a  class="pointer dropdown-toggle"
+						data-toggle="dropdown"
+						tabindex="1">
+						<i class="fa fa-cog"></i>
+					</a>
+					<ul class="dropdown-menu pull-right" role="menu">
+						<li role="menuitem">
+							<a  tabindex="1"
+								ng-click="duplicate()">
+								Duplicate
+							</a>
+						</li>
+						<li role="menuitem">
+							<a  tabindex="1"
+								ng-click="moveMetricQuery($index, $index-1)">
+								Move up
+							</a>
+						</li>
+						<li role="menuitem">
+							<a  tabindex="1"
+								ng-click="moveMetricQuery($index, $index+1)">
+								Move down
+							</a>
+						</li>
+					</ul>
+				</li>
+				<li>
+					<a class="pointer" tabindex="1" ng-click="removeDataQuery(target)">
+						<i class="fa fa-remove"></i>
+					</a>
+				</li>
+			</ul>
+
+			<ul class="tight-form-list">
+				<li class="tight-form-item" style="min-width: 15px; text-align: center">
+					{{targetLetters[$index]}}
+				</li>
+				<li>
+					<a  class="tight-form-item" ng-click="target.hide = !target.hide; targetBlur();" role="menuitem">
+						<i class="fa fa-fw fa-eye"></i>
+					</a>
+				</li>
+				<li>
+					<input type="text" class="input-medium tight-form-input" ng-model="target.alias"
+					spellcheck='false' placeholder="alias" ng-blur="targetBlur()">
+				</li>
+				<li>
+					<select style="width: 20em"
+						class="input-medium tight-form-input"
+						ng-change="targetBlur()"
+						ng-model="metric.value"
+						bs-tooltip="metricValue.length > 40 ? metricValue : ''"
+						ng-options="f for f in metric.list" >
+						<option value="">--select metric--</option>
+					</select>
+					<a bs-tooltip="target.errors.metric"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.metric">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+
+				<li  class="tight-form-item">
+					&nbsp;Peak filter
+					<input class="input-medium" type="checkbox" ng-model="target.exOuter" ng-change="targetBlur()">
+				</li>
+			</ul>
+
+			<div class="clearfix"></div>
+		</div>
+
+		<div class="tight-form">
+			<ul class="tight-form-list" role="menu">
+				<li class="tight-form-item" style="min-width: 15px; text-align: center">
+				</li>
+				<li>
+					<i class="fa fa-fw fa-eye invisible"></i>
+				</li>
+				<li class="tight-form-item">
+					Filter by Tag:
+				</li>
+				<li ng-repeat="(key, value) in target.tags track by $index" class="tight-form-item">
+					{{key}}&nbsp;=&nbsp;{{value}}
+					<a ng-click="removeFilterTag(key)">
+						<i class="icon-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-hide="addFilterTagMode">
+					<a ng-click="addFilterTag()">
+						<i class="icon-plus-sign"></i>
+					</a>
+				</li>
+
+				<li ng-show="addFilterTagMode">
+					<input type="text"
+					class="input-small tight-form-input"
+					spellcheck='false'
+					bs-typeahead="suggestTagKeys"
+					ng-change="validateFilterTag()"
+					data-min-length=0 data-items=100
+					ng-model="target.currentTagKey"
+					placeholder="key">
+				</li>
+				<li ng-show="addFilterTagMode">
+					<input type="text"
+					class="input-small tight-form-input"
+					spellcheck='false'
+					bs-typeahead="suggestTagValues"
+					ng-change="validateFilterTag()"
+					data-min-length=0 data-items=100
+					ng-model="target.currentTagValue"
+					placeholder="value">
+					<a bs-tooltip="target.errors.tags"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.tags">
+						<i class="icon-warning-sign"></i>
+					</a>
+					<li class="tight-form-item" ng-show="addFilterTagMode">
+						<a ng-click="addFilterTag()">
+							<i ng-show="target.errors.tags" class="icon-remove"></i>
+							<i ng-hide="target.errors.tags" class="icon-plus-sign"></i>
+						</a>
+					</li>
+				</li>
+
+				<!-- TAGS  GROUP BYS -->
+				<li class="tight-form-item">
+					Group by
+				</li>
+
+				<li class="tight-form-item" ng-show="target.groupByTags">
+					tags:
+				</li>
+
+				<li ng-repeat="key in target.groupByTags track by $index" class="tight-form-item">
+					{{key}}
+					<a ng-click="removeGroupByTag($index)">
+						<i class="icon-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-show="target.groupByTags && target.nonTagGroupBys">
+					and by:
+				</li>
+
+				<li ng-repeat="groupByObject in target.nonTagGroupBys track by $index" class="tight-form-item">
+					{{_.values(groupByObject)}}
+					<a ng-click="removeNonTagGroupBy($index)">
+						<i class="icon-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-hide="addGroupByMode">
+					<a ng-click="addGroupBy()">
+						<i class="fa fa-fw fa-plus"></i>
+					</a>
+				</li>
+				<li ng-show="addGroupByMode">
+					<select class="input-small tight-form-input"
+						ng-change="changeGroupByInput()"
+						ng-model="target.currentGroupByType"
+						ng-options="f for f in ['tag','value','time']"></select>
+				</li>
+				<li ng-show="isTagGroupBy">
+					<input type="text"
+					class="input-small tight-form-input"
+					spellcheck='false'
+					bs-typeahead="suggestTagKeys"
+					ng-change = "validateGroupBy()"
+					data-min-length=0 data-items=100
+					ng-model="target.groupBy.tagKey"
+					placeholder="key">
+					<a bs-tooltip="target.errors.groupBy.tagKey"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.groupBy.tagKey">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+				<li ng-show="isValueGroupBy">
+					<input type="text"
+					class="input-mini tight-form-input"
+					spellcheck='false'
+					ng-model="target.groupBy.valueRange"
+					placeholder="range"
+					bs-tooltip="'Range on which values are considered in the same group'"
+					ng-change = "validateGroupBy()" >
+					<a bs-tooltip="target.errors.groupBy.valueRange"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.groupBy.valueRange">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+				<li ng-show="isTimeGroupBy">
+					<input type="text"
+					class="input-mini tight-form-input"
+					ng-model="target.groupBy.timeInterval"
+					ng-init="target.groupBy.timeInterval='1s'"
+					placeholder="interval"
+					bs-tooltip="'Duration of time groups'"
+					spellcheck='false'
+					ng-change="validateGroupBy()">
+					<a bs-tooltip="target.errors.groupBy.timeInterval"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.groupBy.timeInterval">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+				<li ng-show="isTimeGroupBy">
+					<input type="text"
+					class="input-mini tight-form-input"
+					ng-model="target.groupBy.groupCount"
+					placeholder="Count"
+					bs-tooltip="'Number of time groups to be formed'"
+					spellcheck='false'
+					ng-change="validateGroupBy()">
+					<a bs-tooltip="target.errors.groupBy.groupCount"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.groupBy.groupCount">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+				<li class="tight-form-item" ng-show="addGroupByMode">
+					<a ng-click="addGroupBy()">
+						<i ng-hide="isGroupByValid" class="icon-remove"></i>
+						<i ng-show="isGroupByValid" class="icon-plus-sign"></i>
+					</a>
+				</li>
+
+				<!-- HORIZONTAL AGGREGATION -->
+				<li class="tight-form-item">
+					Aggregation:
+				</li>
+				<li ng-repeat="aggregatorObject in target.horizontalAggregators track by $index" class="tight-form-item">
+					{{aggregatorObject.name}}&#40;
+					<span ng-repeat="aggKey in _.keys(_.omit(aggregatorObject,'name'))" bs-tooltip="aggKey">
+						{{$last?aggregatorObject[aggKey]:aggregatorObject[aggKey]+","}}
+					</span>
+					&#41;
+					<a ng-click="removeHorizontalAggregator($index)">
+						<i class="fa fa-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-hide="addHorizontalAggregatorMode">
+					<a ng-click="addHorizontalAggregator()">
+						<i class="fa fa-plus"></i>
+					</a>
+				</li>
+
+				<li ng-show="addHorizontalAggregatorMode">
+					<select class="input-medium tight-form-input"
+						ng-change="changeHorAggregationInput()"
+						ng-model="target.currentHorizontalAggregatorName"
+						ng-options="f for f in ['avg','dev','max','min','rate','sampler','count','sum','least_squares','percentile','scale','div']"></select>
+				</li>
+
+				<!-- Different parameters -->
+				<li ng-show="hasSamplingRate" class="tight-form-item">
+					every
+				</li>
+				<li ng-show="hasSamplingRate">
+					<input type="text"
+					class="input-mini tight-form-input"
+					ng-model="target.horAggregator.samplingRate"
+					ng-init="target.horAggregator.samplingRate='1s'"
+					spellcheck='false'
+					ng-change="validateHorizontalAggregator()" >
+					<a bs-tooltip="target.errors.horAggregator.samplingRate"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.horAggregator.samplingRate">
+						<i class="icon-warning-sign"></i>
+					</a>
+				</li>
+
+				<li ng-show="hasUnit" class="tight-form-item">
+					every
+				</li>
+				<li ng-show="hasUnit">
+					<select class="input-medium tight-form-input"
+						ng-model="target.horAggregator.unit"
+						ng-init="target.horAggregator.unit='millisecond'"
+						ng-options="f for f in ['millisecond','second','minute','hour','day','week','month','year']"></select>
+				</li>
+
+				<li ng-show="hasFactor" class="tight-form-item">
+					by
+				</li>
+				<li ng-show="hasFactor">
+					<input type="text"
+					class="input-mini tight-form-input"
+					ng-model="target.horAggregator.factor"
+					ng-init="target.horAggregator.factor='1'"
+					spellcheck='false'
+					ng-change="validateHorizontalAggregator()" >
+					<a bs-tooltip="target.errors.horAggregator.factor"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.horAggregator.factor">
+						<i class="fa fa-warning"></i>
+					</a>
+				</li>
+
+				<li ng-show="hasPercentile" class="tight-form-item">
+					percentile
+				</li>
+				<li ng-show="hasPercentile">
+					<input type="text"
+					class="input-mini tight-form-input"
+					ng-model="target.horAggregator.percentile"
+					ng-init="target.horAggregator.percentile='0.75'"
+					spellcheck='false'
+					ng-change="validateHorizontalAggregator()" >
+					<a bs-tooltip="target.errors.horAggregator.percentile"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.horAggregator.percentile">
+						<i class="fa fa-warning"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-show="addHorizontalAggregatorMode">
+					<a ng-click="addHorizontalAggregator()">
+						<i ng-hide="isAggregatorValid" class="fa fa-remove"></i>
+						<i ng-show="isAggregatorValid" class="fa fa-plus"></i>
+					</a>
+				</li>
+			</ul>
+			<div class="clearfix"></div>
+		</div>
+	</div>
+</div>
+
+<section class="grafana-metric-options" ng-controller="KairosDBTargetCtrl">
+	<div class="tight-form">
+		<ul class="tight-form-list">
+			<li class="tight-form-item tight-form-item-icon">
+				<i class="fa fa-wrench"></i>
+			</li>
+
+			<li class="tight-form-item">
+				Downsampling with
+			</li>
+			<li>
+				<select class="input-medium tight-form-input" ng-change="panelBlur()" ng-model="panel.downsampling" ng-options="f for f in ['(NONE)','avg', 'sum', 'min', 'max', 'dev']" ></select>
+			</li>
+
+			<!-- SAMPLING RATE -->
+			<li ng-hide="panel.downsampling=='(NONE)'" class="tight-form-item">
+				every
+			</li>
+			<li>
+				<input type="text"
+				ng-hide="panel.downsampling=='(NONE)'"
+				class="input-mini tight-form-input"
+				ng-model="panel.sampling"
+				placeholder="{{interval}}"
+				bs-tooltip="'Leave blank for auto handling based on time range and panel width'"
+				spellcheck='false'
+				ng-blur="panelBlur()" >
+				<a bs-tooltip="target.errors.sampling"
+					style="color: rgb(229, 189, 28)"
+					ng-show="target.errors.sampling">
+					<i class="icon-warning-sign"></i>
+				</a>
+			</li>
+		</ul>
+		<div class="clearfix"></div>
+	</div>
+</section>

+ 17 - 0
src/app/plugins/datasource/kairosdb/plugin.json

@@ -0,0 +1,17 @@
+{
+  "pluginType": "datasource",
+  "name": "KairosDB",
+
+  "type": "kairosdb",
+  "serviceName": "KairosDBDatasource",
+
+  "module": "plugins/datasource/kairosdb/datasource",
+
+  "partials": {
+    "config": "app/plugins/datasource/kairosdb/partials/config.html",
+    "query": "app/plugins/datasource/kairosdb/partials/query.editor.html"
+  },
+
+  "metrics": true,
+  "annotations": false
+}

+ 379 - 0
src/app/plugins/datasource/kairosdb/queryCtrl.js

@@ -0,0 +1,379 @@
+define([
+  'angular',
+  'lodash'
+],
+function (angular, _) {
+  'use strict';
+
+  var module = angular.module('grafana.controllers');
+  var metricList = null;
+  var targetLetters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O'];
+
+  module.controller('KairosDBTargetCtrl', function($scope) {
+
+    $scope.init = function() {
+      $scope.metric = {
+        list: ["Loading..."],
+        value: "Loading..."
+      };
+      $scope.panel.stack = false;
+      if (!$scope.panel.downsampling) {
+        $scope.panel.downsampling = 'avg';
+      }
+      if (!$scope.target.downsampling) {
+        $scope.target.downsampling = $scope.panel.downsampling;
+        $scope.target.sampling = $scope.panel.sampling;
+      }
+      $scope.targetLetters = targetLetters;
+      $scope.updateMetricList();
+      $scope.target.errors = validateTarget($scope.target);
+    };
+
+    $scope.targetBlur = function() {
+      $scope.target.metric = $scope.metric.value;
+      $scope.target.errors = validateTarget($scope.target);
+      if (!_.isEqual($scope.oldTarget, $scope.target) && _.isEmpty($scope.target.errors)) {
+        $scope.oldTarget = angular.copy($scope.target);
+        $scope.get_data();
+      }
+    };
+    $scope.panelBlur = function() {
+      _.each($scope.panel.targets, function(target) {
+        target.downsampling = $scope.panel.downsampling;
+        target.sampling = $scope.panel.sampling;
+      });
+      $scope.get_data();
+    };
+
+    $scope.duplicate = function() {
+      var clone = angular.copy($scope.target);
+      $scope.panel.targets.push(clone);
+    };
+    $scope.moveMetricQuery = function(fromIndex, toIndex) {
+      _.move($scope.panel.targets, fromIndex, toIndex);
+    };
+
+    //////////////////////////////
+    // SUGGESTION QUERIES
+    //////////////////////////////
+
+    $scope.updateMetricList = function() {
+      $scope.metricListLoading = true;
+      metricList = [];
+      $scope.datasource.performMetricSuggestQuery().then(function(series) {
+        metricList = series;
+        $scope.metric.list = series;
+        if ($scope.target.metric)
+          $scope.metric.value = $scope.target.metric;
+        else
+          $scope.metric.value = "";
+        $scope.metricListLoading = false;
+        return metricList;
+      });
+    };
+
+    $scope.suggestTagKeys = function(query, callback) {
+      $scope.updateTimeRange();
+      callback($scope.datasource
+        .performTagSuggestQuery($scope.target.metric,$scope.rangeUnparsed, 'key',''));
+
+    };
+
+    $scope.suggestTagValues = function(query, callback) {
+      callback($scope.datasource
+        .performTagSuggestQuery($scope.target.metric,$scope.rangeUnparsed, 'value',$scope.target.currentTagKey));
+    };
+
+    //////////////////////////////
+    // FILTER by TAG
+    //////////////////////////////
+
+    $scope.addFilterTag = function() {
+      if (!$scope.addFilterTagMode) {
+        $scope.addFilterTagMode = true;
+        $scope.validateFilterTag();
+        return;
+      }
+
+      if (!$scope.target.tags) {
+        $scope.target.tags = {};
+      }
+
+      $scope.validateFilterTag();
+      if (!$scope.target.errors.tags) {
+        if(!_.has($scope.target.tags,$scope.target.currentTagKey)) {
+          $scope.target.tags[$scope.target.currentTagKey] = [];
+        }
+        $scope.target.tags[$scope.target.currentTagKey].push($scope.target.currentTagValue);
+        $scope.target.currentTagKey = '';
+        $scope.target.currentTagValue = '';
+        $scope.targetBlur();
+      }
+
+      $scope.addFilterTagMode = false;
+    };
+
+    $scope.removeFilterTag = function(key) {
+      delete $scope.target.tags[key];
+      if(_.size($scope.target.tags)===0) {
+        $scope.target.tags = null;
+      }
+      $scope.targetBlur();
+    };
+
+    $scope.validateFilterTag = function() {
+      $scope.target.errors.tags = null;
+      if(!$scope.target.currentTagKey || !$scope.target.currentTagValue) {
+        $scope.target.errors.tags = "You must specify a tag name and value.";
+      }
+    };
+
+    //////////////////////////////
+    // GROUP BY
+    //////////////////////////////
+
+    $scope.addGroupBy = function() {
+      if (!$scope.addGroupByMode) {
+        $scope.addGroupByMode = true;
+        $scope.target.currentGroupByType = 'tag';
+        $scope.isTagGroupBy = true;
+        $scope.validateGroupBy();
+        return;
+      }
+      $scope.validateGroupBy();
+      // nb: if error is found, means that user clicked on cross : cancels input
+      if (_.isEmpty($scope.target.errors.groupBy)) {
+        if($scope.isTagGroupBy) {
+          if (!$scope.target.groupByTags) {
+            $scope.target.groupByTags = [];
+          }
+          console.log($scope.target.groupBy.tagKey);
+          if (!_.contains($scope.target.groupByTags, $scope.target.groupBy.tagKey)) {
+            $scope.target.groupByTags.push($scope.target.groupBy.tagKey);
+            $scope.targetBlur();
+          }
+          $scope.target.groupBy.tagKey = '';
+        }
+        else  {
+          if (!$scope.target.nonTagGroupBys) {
+            $scope.target.nonTagGroupBys = [];
+          }
+          var groupBy = {
+            name: $scope.target.currentGroupByType
+          };
+          if($scope.isValueGroupBy) {groupBy.range_size = $scope.target.groupBy.valueRange;}
+          else if($scope.isTimeGroupBy) {
+            groupBy.range_size = $scope.target.groupBy.timeInterval;
+            groupBy.group_count = $scope.target.groupBy.groupCount;
+          }
+          $scope.target.nonTagGroupBys.push(groupBy);
+        }
+        $scope.targetBlur();
+      }
+      $scope.isTagGroupBy = false;
+      $scope.isValueGroupBy = false;
+      $scope.isTimeGroupBy = false;
+      $scope.addGroupByMode = false;
+    };
+
+    $scope.removeGroupByTag = function(index) {
+      $scope.target.groupByTags.splice(index, 1);
+      if(_.size($scope.target.groupByTags)===0) {
+        $scope.target.groupByTags = null;
+      }
+      $scope.targetBlur();
+    };
+
+    $scope.removeNonTagGroupBy = function(index) {
+      $scope.target.nonTagGroupBys.splice(index, 1);
+      if(_.size($scope.target.nonTagGroupBys)===0) {
+        $scope.target.nonTagGroupBys = null;
+      }
+      $scope.targetBlur();
+    };
+
+    $scope.changeGroupByInput = function() {
+      $scope.isTagGroupBy = $scope.target.currentGroupByType==='tag';
+      $scope.isValueGroupBy = $scope.target.currentGroupByType==='value';
+      $scope.isTimeGroupBy = $scope.target.currentGroupByType==='time';
+      $scope.validateGroupBy();
+    };
+
+    $scope.validateGroupBy = function() {
+      delete $scope.target.errors.groupBy;
+      var errors = {};
+      $scope.isGroupByValid = true;
+      if($scope.isTagGroupBy) {
+        if(!$scope.target.groupBy.tagKey) {
+          $scope.isGroupByValid = false;
+          errors.tagKey = 'You must supply a tag name';
+        }
+      }
+      if($scope.isValueGroupBy) {
+        if(!$scope.target.groupBy.valueRange || !isInt($scope.target.groupBy.valueRange)) {
+          errors.valueRange = "Range must be an integer";
+          $scope.isGroupByValid = false;
+        }
+      }
+      if($scope.isTimeGroupBy) {
+        try {
+          $scope.datasource.convertToKairosInterval($scope.target.groupBy.timeInterval);
+        } catch(err) {
+          errors.timeInterval = err.message;
+          $scope.isGroupByValid = false;
+        }
+        if(!$scope.target.groupBy.groupCount || !isInt($scope.target.groupBy.groupCount)) {
+          errors.groupCount = "Group count must be an integer";
+          $scope.isGroupByValid = false;
+        }
+      }
+
+      if(!_.isEmpty(errors)) {
+        $scope.target.errors.groupBy = errors;
+      }
+    };
+
+    function isInt(n) {
+      return parseInt(n) % 1 === 0;
+    }
+
+    //////////////////////////////
+    // HORIZONTAL AGGREGATION
+    //////////////////////////////
+
+    $scope.addHorizontalAggregator = function() {
+      if (!$scope.addHorizontalAggregatorMode) {
+        $scope.addHorizontalAggregatorMode = true;
+        $scope.target.currentHorizontalAggregatorName = 'avg';
+        $scope.hasSamplingRate = true;
+        $scope.validateHorizontalAggregator();
+        return;
+      }
+
+      $scope.validateHorizontalAggregator();
+      // nb: if error is found, means that user clicked on cross : cancels input
+      if(_.isEmpty($scope.target.errors.horAggregator)) {
+        if (!$scope.target.horizontalAggregators) {
+          $scope.target.horizontalAggregators = [];
+        }
+        var aggregator = {
+          name:$scope.target.currentHorizontalAggregatorName
+        };
+        if($scope.hasSamplingRate) {aggregator.sampling_rate = $scope.target.horAggregator.samplingRate;}
+        if($scope.hasUnit) {aggregator.unit = $scope.target.horAggregator.unit;}
+        if($scope.hasFactor) {aggregator.factor = $scope.target.horAggregator.factor;}
+        if($scope.hasPercentile) {aggregator.percentile = $scope.target.horAggregator.percentile;}
+        $scope.target.horizontalAggregators.push(aggregator);
+        $scope.targetBlur();
+      }
+
+      $scope.addHorizontalAggregatorMode = false;
+      $scope.hasSamplingRate = false;
+      $scope.hasUnit = false;
+      $scope.hasFactor = false;
+      $scope.hasPercentile = false;
+
+    };
+
+    $scope.removeHorizontalAggregator = function(index) {
+      $scope.target.horizontalAggregators.splice(index, 1);
+      if(_.size($scope.target.horizontalAggregators)===0) {
+        $scope.target.horizontalAggregators = null;
+      }
+
+      $scope.targetBlur();
+    };
+
+    $scope.changeHorAggregationInput = function() {
+      $scope.hasSamplingRate = _.contains(['avg','dev','max','min','sum','least_squares','count','percentile'],
+        $scope.target.currentHorizontalAggregatorName);
+      $scope.hasUnit = _.contains(['sampler','rate'], $scope.target.currentHorizontalAggregatorName);
+      $scope.hasFactor = _.contains(['div','scale'], $scope.target.currentHorizontalAggregatorName);
+      $scope.hasPercentile = 'percentile'===$scope.target.currentHorizontalAggregatorName;
+      $scope.validateHorizontalAggregator();
+    };
+
+    $scope.validateHorizontalAggregator = function() {
+      delete $scope.target.errors.horAggregator;
+      var errors = {};
+      $scope.isAggregatorValid = true;
+      if($scope.hasSamplingRate) {
+        try {
+          $scope.datasource.convertToKairosInterval($scope.target.horAggregator.samplingRate);
+        } catch(err) {
+          errors.samplingRate = err.message;
+          $scope.isAggregatorValid = false;
+        }
+      }
+      if($scope.hasFactor) {
+        if(!$scope.target.horAggregator.factor) {
+          errors.factor = 'You must supply a numeric value for this aggregator';
+          $scope.isAggregatorValid = false;
+        }
+        else if(parseInt($scope.target.horAggregator.factor)===0 && $scope.target.currentHorizontalAggregatorName==='div') {
+          errors.factor = 'Cannot divide by 0';
+          $scope.isAggregatorValid = false;
+        }
+      }
+      if($scope.hasPercentile) {
+        if(!$scope.target.horAggregator.percentile ||
+          $scope.target.horAggregator.percentile<=0 ||
+          $scope.target.horAggregator.percentile>1) {
+          errors.percentile = 'Percentile must be between 0 and 1';
+          $scope.isAggregatorValid = false;
+        }
+      }
+
+      if(!_.isEmpty(errors)) {
+        $scope.target.errors.horAggregator = errors;
+      }
+    };
+
+    $scope.alert = function(message) {
+      alert(message);
+    };
+
+    //////////////////////////////
+    // VALIDATION
+    //////////////////////////////
+
+    function MetricListToObject(MetricList) {
+      var result = {};
+      var Metric;
+      var MetricArray = [];
+      var MetricCnt = 0;
+      for (var i =0;i < MetricList.length; i++) {
+        Metric = MetricList[i];
+        MetricArray = Metric.split('.');
+        if(!result.hasOwnProperty(MetricArray[0])) {
+          result[MetricArray[0]] = {};
+        }
+        if(!result[MetricArray[0]].hasOwnProperty(MetricArray[1])) {
+          result[MetricArray[0]][MetricArray[1]] = [];
+        }
+        result[MetricArray[0]][MetricArray[1]].push(MetricArray[2]);
+      }
+      return result;
+    }
+
+    function validateTarget(target) {
+      var errs = {};
+
+      if (!target.metric) {
+        errs.metric = "You must supply a metric name.";
+      }
+
+      try {
+        if (target.sampling) {
+          $scope.datasource.convertToKairosInterval(target.sampling);
+        }
+      } catch(err) {
+        errs.sampling = err.message;
+      }
+
+      return errs;
+    }
+
+  });
+
+});