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

Merge remote-tracking branch 'origin/kariosdb'

Conflicts:
	public/test/test-main.js
Torkel Ödegaard 10 лет назад
Родитель
Сommit
9266d3789b

+ 1 - 0
docs/mkdocs.yml

@@ -60,6 +60,7 @@ pages:
 - ['datasources/graphite.md', 'Data Sources', 'Graphite']
 - ['datasources/influxdb.md', 'Data Sources', 'InfluxDB']
 - ['datasources/opentsdb.md', 'Data Sources', 'OpenTSDB']
+- ['datasources/kairosdb.md', 'Data Sources', 'KairosDB']
 
 - ['project/building_from_source.md', 'Project', 'Building from source']
 - ['project/cla.md', 'Project', 'Contributor License Agreement']

+ 47 - 0
docs/sources/datasources/kairosdb.md

@@ -0,0 +1,47 @@
+---
+page_title: KairosDB Guide
+page_description: KairosDB guide for Grafana
+page_keywords: grafana, kairosdb, documentation
+---
+
+# KairosDB Guide
+
+## Adding the data source to Grafana
+Open the side menu by clicking the the Grafana icon in the top header. In the side menu under the `Dashboards` link you
+should find a link named `Data Sources`. If this link is missing in the side menu it means that your current
+user does not have the `Admin` role for the current organization.
+
+<!-- ![](/img/v2/add_datasource_kairosdb.png) -->
+
+Now click the `Add new` link in the top header.
+
+Name | Description
+------------ | -------------
+Name | The data source name, important that this is the same as in Grafana v1.x if you plan to import old dashboards.
+Default | Default data source means that it will be pre-selected for new panels.
+Url | The http protocol, ip and port of your kairosdb server (default port is usually 8080)
+Access | Proxy = access via Grafana backend, Direct = access directory from browser.
+
+## Query editor
+Open a graph in edit mode by click the title.
+
+<!-- ![](/img/v2/kairosdb_query_editor.png) -->
+
+For details on KairosDB metric queries checkout the offical.
+
+- [Query Metrics - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/QueryMetrics.html).
+
+## Templated queries
+KairosDB Datasource Plugin provides following functions in `Variables values query` field in Templating Editor to query `metric names`, `tag names`, and `tag values` to kairosdb server.
+
+Name | Description
+---- | ----
+`metrics(query)` | Returns a list of metric names. If nothing is given, returns a list of all metric names.
+`tag_names(query)` | Returns a list of tag names. If nothing is given, returns a list of all tag names.
+`tag_values(query)` | Returns a list of tag values. If nothing is given, returns a list of all tag values.
+
+For details of `metric names`, `tag names`, and `tag values`, please refer to the KairosDB documentations.
+
+- [List Metric Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListMetricNames.html)
+- [List Tag Names - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagNames.html)
+- [List Tag Values - KairosDB 0.9.4 documentation](http://kairosdb.github.io/kairosdocs/restapi/ListTagValues.html)

+ 1 - 1
docs/sources/index.md

@@ -10,7 +10,7 @@ It provides a powerful and elegant way to create, share, and explore data and da
 
 Grafana is most commonly used for Internet infrastructure and application analytics, but many use it in other domains including industrial sensors, home automation, weather, and process control.
 
-Grafana features pluggable panels and data sources allowing easy extensibility. There is currently rich support for [Graphite](http://graphite.readthedocs.org/en/latest/), [InfluxDB](http://influxdb.org) and [OpenTSDB](http://opentsdb.net). There is also experimental support for KairosDB, and SQL is on the roadmap. Grafana has a variety of panels, including a fully featured graph panel with rich visualization options.
+Grafana features pluggable panels and data sources allowing easy extensibility. There is currently rich support for [Graphite](http://graphite.readthedocs.org/en/latest/), [InfluxDB](http://influxdb.org) and [OpenTSDB](http://opentsdb.net). There is also experimental support for [KairosDB](https://github.com/kairosdb/kairosdb), and SQL is on the roadmap. Grafana has a variety of panels, including a fully featured graph panel with rich visualization options.
 
 Version 2.0 was released in April 2015: Grafana now ships with its own backend server that brings [many changes and features](../guides/whats-new-in-v2/).
 

+ 475 - 0
public/app/plugins/datasource/kairosdb/datasource.js

@@ -0,0 +1,475 @@
+define([
+  'angular',
+  'lodash',
+  'kbn',
+  './queryCtrl',
+],
+function (angular, _, kbn) {
+  'use strict';
+
+  var module = angular.module('grafana.services');
+
+  module.factory('KairosDBDatasource', function($q, $http, templateSrv) {
+
+    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;
+    }
+
+    // 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;
+        }
+
+        if (!target.hide) {
+          return { alias: alias, exouter: target.exOuter };
+        }
+        else {
+          return 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,
+        cache_time: 0
+      };
+
+      convertToKairosTime(start, reqBody, 'start');
+      convertToKairosTime(end, reqBody, 'end');
+
+      var options = {
+        method: 'POST',
+        url: this.url + '/api/v1/datapoints/query',
+        data: reqBody
+      };
+
+      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(response) {
+        if (!response.data) {
+          return [];
+        }
+        return response.data.results;
+      });
+    };
+
+    KairosDBDatasource.prototype.performListTagNames = function() {
+      var options = {
+        url : this.url + '/api/v1/tagnames',
+        method : 'GET'
+      };
+
+      return $http(options).then(function(response) {
+        if (!response.data) {
+          return [];
+        }
+        return response.data.results;
+      });
+    };
+
+    KairosDBDatasource.prototype.performListTagValues = function() {
+      var options = {
+        url : this.url + '/api/v1/tagvalues',
+        method : 'GET'
+      };
+
+      return $http(options).then(function(response) {
+        if (!response.data) {
+          return [];
+        }
+        return response.data.results;
+      });
+    };
+
+    KairosDBDatasource.prototype.performTagSuggestQuery = function(metricname) {
+      var options = {
+        url : this.url + '/api/v1/datapoints/query/tags',
+        method : 'POST',
+        data : {
+          metrics : [{ name : metricname }],
+          cache_time : 0,
+          start_absolute: 0
+        }
+      };
+
+      return $http(options).then(function(response) {
+        if (!response.data) {
+          return [];
+        }
+        else {
+          return response.data.queries[0].results[0];
+        }
+      });
+    };
+
+    KairosDBDatasource.prototype.metricFindQuery = function(query) {
+      function format(results, query) {
+        return _.chain(results)
+          .filter(function(result) {
+            return result.indexOf(query) >= 0;
+          })
+          .map(function(result) {
+            return {
+              text: result,
+              expandable: true
+            };
+          })
+          .value();
+      }
+
+      var interpolated;
+      try {
+        interpolated = templateSrv.replace(query);
+      }
+      catch (err) {
+        return $q.reject(err);
+      }
+
+      var metrics_regex = /metrics\((.*)\)/;
+      var tag_names_regex = /tag_names\((.*)\)/;
+      var tag_values_regex = /tag_values\((.*)\)/;
+
+      var metrics_query = interpolated.match(metrics_regex);
+      if (metrics_query) {
+        return this.performMetricSuggestQuery().then(function(metrics) {
+          return format(metrics, metrics_query[1]);
+        });
+      }
+
+      var tag_names_query = interpolated.match(tag_names_regex);
+      if (tag_names_query) {
+        return this.performListTagNames().then(function(tag_names) {
+          return format(tag_names, tag_names_query[1]);
+        });
+      }
+
+      var tag_values_query = interpolated.match(tag_values_regex);
+      if (tag_values_query) {
+        return this.performListTagValues().then(function(tag_values) {
+          return format(tag_values, tag_values_query[1]);
+        });
+      }
+    };
+
+    /////////////////////////////////////////////////////////////////////////
+    /// Formatting methods
+    ////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 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) {
+        _.each(series.results, function(result) {
+          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 = new PeakFilter(datapoints, 10);
+          }
+          output.push({ target: target, datapoints: datapoints });
+        });
+
+        index++;
+      });
+
+      return { data: _.flatten(output) };
+    }
+
+    function convertTargetToQuery(options, target) {
+      if (!target.metric || target.hide) {
+        return null;
+      }
+
+      var query = {
+        name: templateSrv.replace(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);
+        _.forOwn(query.tags, function(value, key) {
+          query.tags[key] = _.map(value, function(tag) { return templateSrv.replace(tag); });
+        });
+      }
+
+      if (target.groupByTags || target.nonTagGroupBys) {
+        query.group_by = [];
+        if (target.groupByTags) {
+          query.group_by.push({
+            name: "tag",
+            tags: _.map(angular.copy(target.groupByTags), function(tag) { return templateSrv.replace(tag); })
+          });
+        }
+
+        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) {
+      intervalString = templateSrv.replace(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';
+      }
+
+      return {
+        value: value,
+        unit: convertToKairosDBTimeUnit(unit)
+      };
+    };
+
+    function convertToKairosTime(date, response_obj, start_stop_name) {
+      var name;
+
+      if (_.isString(date)) {
+        if (date === 'now') {
+          return;
+        }
+        else if (date.indexOf('now-') >= 0) {
+          date = date.substring(4);
+          name = start_stop_name + "_relative";
+          var re_date = /(\d+)\s*(\D+)/;
+          var result = re_date.exec(date);
+
+          if (result) {
+            var value = result[1];
+            var unit = result[2];
+
+            response_obj[name] = {
+              value: value,
+              unit: convertToKairosDBTimeUnit(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 convertToKairosDBTimeUnit(unit) {
+      switch (unit) {
+      case 'ms':
+        return 'milliseconds';
+      case 's':
+        return 'seconds';
+      case 'm':
+        return 'minutes';
+      case 'h':
+        return 'hours';
+      case 'd':
+        return 'days';
+      case 'w':
+        return 'weeks';
+      case 'M':
+        return 'months';
+      case 'y':
+        return 'years';
+      default:
+        console.log("Unknown unit ", unit);
+        return '';
+      }
+    }
+
+    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
public/app/plugins/datasource/kairosdb/partials/config.html

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

+ 384 - 0
public/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="KairosDBQueryCtrl"
+		ng-init="init()">
+
+		<div class="tight-form">
+			<ul class="tight-form-list pull-right">
+				<li class="tight-form-item">
+					<div class="dropdown">
+						<a  class="pointer dropdown-toggle"
+							data-toggle="dropdown"
+							tabindex="1">
+							<i class="fa fa-bars"></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>
+					</div>
+				</li>
+				<li class="tight-form-item last">
+					<a class="pointer" tabindex="1" ng-click="removeDataQuery(target)">
+						<i class="fa fa-remove"></i>
+					</a>
+				</li>
+			</ul>
+
+			<ul class="tight-form-list">
+				<li>
+					<a  class="tight-form-item" ng-click="target.hide = !target.hide; targetBlur();" role="menuitem">
+						<i class="fa fa-eye"></i>
+					</a>
+				</li>
+				<li class="tight-form-item">
+					Metric
+				</li>
+				<li>
+					<input type="text"
+						   class="input-large tight-form-input"
+						   ng-model="target.metric"
+						   spellcheck="false"
+						   bs-typeahead="suggestMetrics"
+						   placeholder="metric name"
+						   data-min-length=0 data-items=100
+						   ng-blur="targetBlur()"
+						   >
+					<a bs-tooltip="target.errors.metric"
+						style="color: rgb(229, 189, 28)"
+						ng-show="target.errors.metric">
+						<i class="fa fa-warning"></i>
+					</a>
+				</li>
+				<li class="tight-form-item">
+					Alias
+				</li>
+				<li>
+					<input type="text" class="input-medium tight-form-input" ng-model="target.alias"
+						   spellcheck='false' placeholder="alias" ng-blur="targetBlur()">
+				</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>
+
+		<!-- TAGS -->
+		<div class="tight-form">
+			<ul class="tight-form-list" role="menu">
+				<li class="tight-form-item">
+					<i class="fa fa-eye invisible"></i>
+				</li>
+				<li class="tight-form-item">
+					Tags
+				</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="fa fa-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-hide="addFilterTagMode">
+					<a ng-click="addFilterTag()">
+						<i class="fa fa-plus"></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="fa fa-warning"></i>
+					</a>
+					<li class="tight-form-item" ng-show="addFilterTagMode">
+						<a ng-click="addFilterTag()">
+							<i ng-show="target.errors.tags" class="fa fa-remove"></i>
+							<i ng-hide="target.errors.tags" class="fa fa-plus-circle"></i>
+						</a>
+					</li>
+				</li>
+			</ul>
+			<div class="clearfix"></div>
+		</div>
+
+		<!-- GROUP BY -->
+		<div class="tight-form">
+			<ul class="tight-form-list" role="menu">
+				<li class="tight-form-item">
+					<i class="fa fa-eye invisible"></i>
+				</li>
+
+				<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="fa fa-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="fa fa-remove"></i>
+					</a>
+				</li>
+
+				<li class="tight-form-item" ng-hide="addGroupByMode">
+					<a ng-click="addGroupBy()">
+						<i class="fa 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="fa fa-warning"></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="fa fa-warning"></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="fa fa-warning"></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="fa fa-warning"></i>
+					</a>
+				</li>
+				<li class="tight-form-item" ng-show="addGroupByMode">
+					<a ng-click="addGroupBy()">
+						<i ng-hide="isGroupByValid" class="fa fa-remove"></i>
+						<i ng-show="isGroupByValid" class="fa fa-plus-circle"></i>
+					</a>
+				</li>
+			<div class="clearfix"></div>
+		</div>
+
+		<!-- HORIZONTAL AGGREGATION -->
+		<div class="tight-form">
+			<ul class="tight-form-list" role="menu">
+				<li class="tight-form-item">
+					<i class="fa fa-eye invisible"></i>
+				</li>
+
+				<li class="tight-form-item">
+					Aggregators
+				</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="fa fa-warning"></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-circle"></i>
+					</a>
+				</li>
+			</ul>
+			<div class="clearfix"></div>
+		</div>
+	</div>
+</div>
+
+<section class="grafana-metric-options" ng-controller="KairosDBQueryCtrl">
+	<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="fa fa-warning"></i>
+				</a>
+			</li>
+		</ul>
+		<div class="clearfix"></div>
+	</div>
+</section>

+ 17 - 0
public/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
+}

+ 367 - 0
public/app/plugins/datasource/kairosdb/queryCtrl.js

@@ -0,0 +1,367 @@
+define([
+  'angular',
+  'lodash'
+],
+function (angular, _) {
+  'use strict';
+
+  var module = angular.module('grafana.controllers');
+  var metricList = [];
+  var tagList = [];
+
+  module.controller('KairosDBQueryCtrl', function($scope) {
+
+    $scope.init = function() {
+      $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.target.errors = validateTarget($scope.target);
+    };
+
+    $scope.targetBlur = function() {
+      $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);
+    };
+
+    $scope.suggestMetrics = function(query, callback) {
+      if (!_.isEmpty(metricList)) {
+        return metricList;
+      }
+      else {
+        $scope.datasource.performMetricSuggestQuery().then(function(result) {
+          metricList = result;
+          callback(metricList);
+        });
+      }
+    };
+
+    $scope.suggestTagKeys = function(query, callback) {
+      if (!_.isEmpty(tagList)) {
+        var result = _.find(tagList, { name : $scope.target.metric });
+
+        if (!_.isEmpty(result)) {
+          return _.keys(result.tags);
+        }
+      }
+
+      $scope.datasource.performTagSuggestQuery($scope.target.metric).then(function(result) {
+        if (!_.isEmpty(result)) {
+          tagList.push(result);
+          callback(_.keys(result.tags));
+        }
+      });
+    };
+
+    $scope.suggestTagValues = function(query, callback) {
+      if (!_.isEmpty(tagList)) {
+        var result = _.find(tagList, { name : $scope.target.metric });
+
+        if (!_.isEmpty(result)) {
+          return result.tags[$scope.target.currentTagKey];
+        }
+      }
+
+      $scope.datasource.performTagSuggestQuery($scope.target.metric).then(function(result) {
+        if (!_.isEmpty(result)) {
+          tagList.push(result);
+          callback(result.tags[$scope.target.currentTagKey]);
+        }
+      });
+    };
+
+    // Filter metric 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 = [];
+          }
+          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 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;
+    }
+
+  });
+
+});

+ 63 - 0
public/test/specs/kairosdb-datasource-specs.js

@@ -0,0 +1,63 @@
+define([
+  'helpers',
+  'plugins/datasource/kairosdb/datasource'
+], function(helpers) {
+  'use strict';
+
+  describe('KairosDBDatasource', function() {
+    var ctx = new helpers.ServiceTestContext();
+
+    beforeEach(module('grafana.services'));
+    beforeEach(ctx.providePhase(['templateSrv']));
+    beforeEach(ctx.createService('KairosDBDatasource'));
+    beforeEach(function() {
+      ctx.ds = new ctx.service({ url: ''});
+    });
+
+    describe('When querying kairosdb with one target using query editor target spec', function() {
+      var results;
+      var urlExpected = "/api/v1/datapoints/query";
+      var bodyExpected = {
+        metrics: [{ name: "test" }],
+        cache_time: 0,
+        start_relative: {
+          value: "1",
+          unit: "hours"
+        }
+      };
+
+      var query = {
+        range: { from: 'now-1h', to: 'now' },
+        targets: [{ metric: 'test', downsampling: '(NONE)'}]
+      };
+
+      var response = {
+        queries: [{
+          sample_size: 60,
+          results: [{
+            name: "test",
+            values: [[1420070400000, 1]]
+          }]
+        }]
+      };
+
+      beforeEach(function() {
+        ctx.$httpBackend.expect('POST', urlExpected, bodyExpected).respond(response);
+        ctx.ds.query(query).then(function(data) { results = data; });
+        ctx.$httpBackend.flush();
+      });
+
+      it('should generate the correct query', function() {
+        ctx.$httpBackend.verifyNoOutstandingExpectation();
+      });
+
+      it('should return series list', function() {
+        expect(results.data.length).to.be(1);
+        expect(results.data[0].target).to.be('test');
+      });
+
+    });
+
+  });
+
+});

+ 1 - 1
public/test/test-main.js

@@ -130,6 +130,7 @@ require([
     'specs/influx09-querybuilder-specs',
     'specs/influxdb-datasource-specs',
     'specs/influxdbQueryCtrl-specs',
+    'specs/kairosdb-datasource-specs',
     'specs/graph-ctrl-specs',
     'specs/graph-specs',
     'specs/graph-tooltip-specs',
@@ -155,4 +156,3 @@ require([
     window.__karma__.start();
   });
 });
-