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; + }); + +});