From 30b62e172df25bf27bc1d1e68c4ee21a64d3a1df Mon Sep 17 00:00:00 2001
From: Matt Page <mpage@brkt.com>
Date: Fri, 14 Mar 2014 12:14:16 -0700
Subject: [PATCH] Add an OpenTSDB datasource.

This adds support for querying OpenTSDB for metric data.
---
 src/app/controllers/all.js                    |   3 +-
 src/app/controllers/influxTargetCtrl.js       |   2 +-
 src/app/controllers/opentsdbTargetCtrl.js     | 107 ++++++++++
 src/app/partials/opentsdb/editor.html         | 191 ++++++++++++++++++
 src/app/services/datasourceSrv.js             |   9 +-
 .../services/opentsdb/opentsdbDatasource.js   | 155 ++++++++++++++
 6 files changed, 461 insertions(+), 6 deletions(-)
 create mode 100644 src/app/controllers/opentsdbTargetCtrl.js
 create mode 100644 src/app/partials/opentsdb/editor.html
 create mode 100644 src/app/services/opentsdb/opentsdbDatasource.js

diff --git a/src/app/controllers/all.js b/src/app/controllers/all.js
index 2626137657c..b3348bb0a58 100644
--- a/src/app/controllers/all.js
+++ b/src/app/controllers/all.js
@@ -10,4 +10,5 @@ define([
   './graphiteImport',
   './influxTargetCtrl',
   './playlistCtrl',
-], function () {});
\ No newline at end of file
+  './opentsdbTargetCtrl',
+], function () {});
diff --git a/src/app/controllers/influxTargetCtrl.js b/src/app/controllers/influxTargetCtrl.js
index 66439c5e757..467b135a961 100644
--- a/src/app/controllers/influxTargetCtrl.js
+++ b/src/app/controllers/influxTargetCtrl.js
@@ -64,4 +64,4 @@ function (angular) {
 
   });
 
-});
\ No newline at end of file
+});
diff --git a/src/app/controllers/opentsdbTargetCtrl.js b/src/app/controllers/opentsdbTargetCtrl.js
new file mode 100644
index 00000000000..ba66986532d
--- /dev/null
+++ b/src/app/controllers/opentsdbTargetCtrl.js
@@ -0,0 +1,107 @@
+define([
+  'angular',
+  'underscore',
+  'kbn'
+],
+function (angular, _, kbn) {
+  'use strict';
+
+  var module = angular.module('kibana.controllers');
+
+  module.controller('OpenTSDBTargetCtrl', function($scope) {
+
+    $scope.init = function() {
+      $scope.target.errors = validateTarget($scope.target);
+      $scope.aggregators = ['avg', 'sum', 'min', 'max', 'dev', 'zimsum', 'mimmin', 'mimmax'];
+    };
+
+    $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.duplicate = function() {
+      var clone = angular.copy($scope.target);
+      $scope.panel.targets.push(clone);
+    };
+
+    $scope.suggestMetrics = function(query, callback) {
+      $scope.datasource
+        .performSuggestQuery(query, 'metrics')
+        .then(callback);
+    };
+
+    $scope.suggestTagKeys = function(query, callback) {
+      $scope.datasource
+        .performSuggestQuery(query, 'tagk')
+        .then(callback);
+    };
+
+    $scope.suggestTagValues = function(query, callback) {
+      $scope.datasource
+        .performSuggestQuery(query, 'tagv')
+        .then(callback);
+    };
+
+    $scope.addTag = function() {
+      if (!$scope.target.tags) {
+        $scope.target.tags = {};
+      }
+
+      $scope.target.errors = validateTarget($scope.target);
+
+      if (!$scope.target.errors.tags) {
+        $scope.target.tags[$scope.target.currentTagKey] = $scope.target.currentTagValue;
+        $scope.target.currentTagKey = '';
+        $scope.target.currentTagValue = '';
+        $scope.targetBlur();
+      }
+    };
+
+    $scope.removeTag = function(key) {
+      delete $scope.target.tags[key];
+      $scope.targetBlur();
+    };
+
+    function validateTarget(target) {
+      var errs = {};
+
+      if (!target.metric) {
+        errs.metric = "You must supply a metric name.";
+      }
+
+      if (!target.aggregator) {
+        errs.aggregator = "You must choose an aggregation function.";
+      }
+
+      if (target.shouldDownsample) {
+        try {
+          if (target.downsampleInterval) {
+            kbn.describe_interval(target.downsampleInterval);
+          } else {
+            errs.downsampleInterval = "You must supply a downsample interval (e.g. '1m' or '1h').";
+          }
+        } catch(err) {
+          errs.downsampleInterval = err.message;
+        }
+
+        if (!target.downsampleAggregator) {
+          errs.downsampleAggregator = "You must choose an aggregation function for downsampling.";
+        }
+      }
+
+      if (target.tags && _.has(target.tags, target.currentTagKey)) {
+        errs.tags = "Duplicate tag key '" + target.currentTagKey + "'.";
+      }
+
+      return errs;
+    }
+
+
+  });
+
+});
diff --git a/src/app/partials/opentsdb/editor.html b/src/app/partials/opentsdb/editor.html
new file mode 100644
index 00000000000..ebc0c6190b6
--- /dev/null
+++ b/src/app/partials/opentsdb/editor.html
@@ -0,0 +1,191 @@
+<div class="editor-row" style="margin-top: 10px;">
+  <div  ng-repeat="target in panel.targets"
+        class="grafana-target"
+        ng-class="{'grafana-target-hidden': target.hide}"
+        ng-controller="OpenTSDBTargetCtrl"
+        ng-init="init()">
+
+    <div class="grafana-target-inner-wrapper">
+      <div class="grafana-target-inner">
+        <ul class="grafana-target-controls">
+          <li class="dropdown">
+            <a  class="pointer dropdown-toggle"
+                data-toggle="dropdown"
+                tabindex="1">
+              <i class="icon-cog"></i>
+            </a>
+            <ul class="dropdown-menu pull-right" role="menu">
+              <li role="menuitem">
+                <a  tabindex="1"
+                    ng-click="duplicate()">
+                  Duplicate
+                </a>
+              </li>
+            </ul>
+          </li>
+          <li>
+            <a class="pointer" tabindex="1" ng-click="removeTarget(target)">
+              <i class="icon-remove"></i>
+            </a>
+          </li>
+        </ul>
+
+        <ul class="grafana-target-controls-left">
+          <li>
+            <a  class="grafana-target-segment"
+                ng-click="target.hide = !target.hide; get_data();"
+                role="menuitem">
+              <i class="icon-eye-open"></i>
+            </a>
+          </li>
+        </ul>
+
+        <ul class="grafana-segment-list" role="menu">
+          <li class="grafana-target-segment">
+            Metric:
+            <input type="text"
+                   class="input-large grafana-target-segment-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="icon-warning-sign"></i>
+            </a>
+          </li>
+
+          <li class="grafana-target-segment">
+            Aggregator:
+            <select ng-model="target.aggregator"
+                    class="grafana-target-segment-input input-small"
+                    ng-options="agg for agg in aggregators"
+                    ng-change="targetBlur()"
+                    >
+              <option value="">Pick one</option>
+            </select>
+            <a bs-tooltip="target.errors.aggregator"
+               style="color: rgb(229, 189, 28)"
+               ng-show="target.errors.aggregator">
+              <i class="icon-warning-sign"></i>
+            </a>
+          </li>
+
+          <li class="grafana-target-segment">
+              Compute Rate:
+              <input type="checkbox"
+                     ng-model="target.shouldComputeRate"
+                     ng-change="targetBlur()"
+                     >
+              <div ng-hide="!target.shouldComputeRate">
+                Counter:
+                <input type="checkbox"
+                       ng-disabled="!target.shouldComputeRate"
+                       ng-model="target.isCounter"
+                       ng-change="targetBlur()"
+                       >
+              </div>
+          </li>
+
+          <li class="grafana-target-segment">
+            Downsample:
+            <input type="checkbox"
+                   ng-model="target.shouldDownsample"
+                   ng-change="targetBlur(target)"
+                   >
+            <div ng-hide="!target.shouldDownsample">
+              <table>
+                <tr>
+                  <td>
+                    Interval:
+                  </td>
+                  <td>
+                    <input type="text"
+                           class="input-small"
+                           ng-disabled="!target.shouldDownsample"
+                           ng-model="target.downsampleInterval"
+                           ng-change="targetBlur()">
+                  </td>
+                  <td>
+                    <a bs-tooltip="target.errors.downsampleInterval"
+                       style="color: rgb(229, 189, 28)"
+                       ng-show="target.errors.downsampleInterval">
+                      <i class="icon-warning-sign"></i>
+                    </a>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Aggregator:</td>
+                  <td>
+                    <select ng-model="target.downsampleAggregator"
+                            class="grafana-target-segment-input input-small"
+                            ng-options="agg for agg in aggregators"
+                            ng-change="targetBlur()"
+                            >
+                      <option value="">Pick one</option>
+                    </select>
+                  </td>
+                  <td>
+                    <a bs-tooltip="target.errors.downsampleAggregator"
+                       style="color: rgb(229, 189, 28)"
+                       ng-show="target.errors.downsampleAggregator">
+                      <i class="icon-warning-sign"></i>
+                    </a>
+                  </td>
+                </tr>
+              </table>
+            </div>
+          </li>
+
+          <li class="grafana-target-segment">
+            <div>
+              Tags:
+              <input type="text"
+                     class="input-small grafana-target-segment-input"
+                     spellcheck='false'
+                     bs-typeahead="suggestTagKeys"
+                     data-min-length=0 data-items=100
+                     ng-model="target.currentTagKey"
+                     placeholder="key">
+              <input type="text"
+                     class="input-small grafana-target-segment-input"
+                     spellcheck='false'
+                     bs-typeahead="suggestTagValues"
+                     data-min-length=0 data-items=100
+                     ng-model="target.currentTagValue"
+                     placeholder="value">
+              <a ng-click="addTag()">
+                <i class="icon-plus-sign"></i>
+              </a>
+              <a bs-tooltip="target.errors.tags"
+                 style="color: rgb(229, 189, 28)"
+                 ng-show="target.errors.tags">
+                <i class="icon-warning-sign"></i>
+              </a>
+            </div>
+            <table ng-hide="_.isEmpty(target.tags)">
+              <tr>
+                <th>Key</th>
+                <th>Value</td>
+              <tr ng-repeat="(key, value) in target.tags track by $index">
+                <td>{{ key }}</td>
+                <td>{{ value }}</td>
+                <td>
+                  <a ng-click="removeTag(key)">
+                    <i class="icon-remove"></i>
+                  </a>
+                </td>
+              </tr>
+            </table>
+          </li>
+        </ul>
+
+        <div class="clearfix"></div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src/app/services/datasourceSrv.js b/src/app/services/datasourceSrv.js
index d05bd11e278..7ad122539e9 100644
--- a/src/app/services/datasourceSrv.js
+++ b/src/app/services/datasourceSrv.js
@@ -4,19 +4,18 @@ define([
   'config',
   './graphite/graphiteDatasource',
   './influxdb/influxdbDatasource',
+  './opentsdb/opentsdbDatasource',
 ],
 function (angular, _, config) {
   'use strict';
 
   var module = angular.module('kibana.services');
 
-  module.service('datasourceSrv', function($q, filterSrv, $http, GraphiteDatasource, InfluxDatasource) {
+  module.service('datasourceSrv', function($q, filterSrv, $http, GraphiteDatasource, InfluxDatasource, OpenTSDBDatasource) {
 
     this.init = function() {
-
       var defaultDatasource = _.findWhere(_.values(config.datasources), { default: true } );
       this.default = this.datasourceFactory(defaultDatasource);
-
     };
 
     this.datasourceFactory = function(ds) {
@@ -25,6 +24,8 @@ function (angular, _, config) {
         return new GraphiteDatasource(ds);
       case 'influxdb':
         return new InfluxDatasource(ds);
+      case 'opentsdb':
+        return new OpenTSDBDatasource(ds);
       }
     };
 
@@ -50,4 +51,4 @@ function (angular, _, config) {
 
     this.init();
   });
-});
\ No newline at end of file
+});
diff --git a/src/app/services/opentsdb/opentsdbDatasource.js b/src/app/services/opentsdb/opentsdbDatasource.js
new file mode 100644
index 00000000000..8766c224982
--- /dev/null
+++ b/src/app/services/opentsdb/opentsdbDatasource.js
@@ -0,0 +1,155 @@
+define([
+  'angular',
+  'underscore',
+  'kbn'
+],
+function (angular, _, kbn) {
+  'use strict';
+
+  var module = angular.module('kibana.services');
+
+  module.factory('OpenTSDBDatasource', function($q, $http) {
+
+    function OpenTSDBDatasource(datasource) {
+      this.type = 'opentsdb';
+      this.editorSrc = 'app/partials/opentsdb/editor.html';
+      this.url = datasource.url;
+      this.name = datasource.name;
+    }
+
+    // Called once per panel (graph)
+    OpenTSDBDatasource.prototype.query = function(options) {
+      var start = convertToTSDBTime(options.range.from);
+      var end = convertToTSDBTime(options.range.to);
+      var queries = _.compact(_.map(options.targets, convertTargetToQuery));
+
+      // 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;
+      }
+
+      var groupByTags = {};
+      _.each(queries, function(query) {
+        _.each(query.tags, function(val, key) {
+          if (val === "*") {
+            groupByTags[key] = true;
+          }
+        });
+      });
+
+      return this.performTimeSeriesQuery(queries, start, end)
+        .then(function(response) {
+          var result = _.map(response.data, function(metricData) {
+            return transformMetricData(metricData, groupByTags);
+          });
+          return { data: result };
+        });
+    };
+
+    OpenTSDBDatasource.prototype.performTimeSeriesQuery = function(queries, start, end) {
+      var reqBody = {
+        start: start,
+        queries: queries
+      };
+
+      // Relative queries (e.g. last hour) don't include an end time
+      if (end) {
+        reqBody.end = end;
+      }
+
+      var options = {
+        method: 'POST',
+        url: this.url + '/api/query',
+        data: reqBody
+      };
+
+      return $http(options);
+    };
+
+    OpenTSDBDatasource.prototype.performSuggestQuery = function(query, type) {
+      var options = {
+        method: 'GET',
+        url: this.url + '/api/suggest',
+        params: {
+          type: type,
+          q: query
+        }
+      };
+      return $http(options).then(function(result) {
+        return result.data;
+      });
+    };
+
+    function transformMetricData(md, groupByTags) {
+      var dps = [];
+
+      // TSDB returns datapoints has a hash of ts => value.
+      // Can't use _.pairs(invert()) because it stringifies keys/values
+      _.each(md.dps, function (v, k) {
+        dps.push([v, k]);
+      });
+
+      var target = md.metric;
+      if (!_.isEmpty(md.tags)) {
+        var tagData = [];
+
+        _.each(_.pairs(md.tags), function(tag) {
+          if (_.has(groupByTags, tag[0])) {
+            tagData.push(tag[0] + "=" + tag[1]);
+          }
+        });
+
+        if (!_.isEmpty(tagData)) {
+          target = target + "{" + tagData.join(", ") + "}";
+        }
+      }
+
+      return { target: target, datapoints: dps };
+    }
+
+    function convertTargetToQuery(target) {
+      if (!target.metric) {
+        return null;
+      }
+
+      var query = {
+        metric: target.metric,
+        aggregator: "avg"
+      };
+
+      if (target.aggregator) {
+        query.aggregator = target.aggregator;
+      }
+
+      if (target.shouldComputeRate) {
+        query.rate = true;
+        query.rateOptions = {
+          counter: !!target.isCounter
+        };
+      }
+
+      if (target.shouldDownsample) {
+        query.downsample = target.downsampleInterval + "-" + target.downsampleAggregator;
+      }
+
+      query.tags = angular.copy(target.tags);
+
+      return query;
+    }
+
+    function convertToTSDBTime(date) {
+      if (date === 'now') {
+        return null;
+      }
+
+      date = kbn.parseDate(date);
+
+      return date.getTime();
+    }
+
+    return OpenTSDBDatasource;
+  });
+
+});