diff --git a/public/app/plugins/datasource/prometheus/datasource.js b/public/app/plugins/datasource/prometheus/datasource.js new file mode 100644 index 00000000000..e6eaa71a392 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/datasource.js @@ -0,0 +1,256 @@ +define([ + 'angular', + 'lodash', + 'kbn', + 'moment', + 'app/core/utils/datemath', + './directives', + './queryCtrl', +], +function (angular, _, kbn, dateMath) { + 'use strict'; + + var module = angular.module('grafana.services'); + + module.factory('PrometheusDatasource', function($q, backendSrv, templateSrv) { + + function PrometheusDatasource(datasource) { + this.type = 'prometheus'; + this.editorSrc = 'app/features/prometheus/partials/query.editor.html'; + this.name = datasource.name; + this.supportMetrics = true; + + var url = datasource.url; + if (url[url.length-1] === '/') { + // remove trailing slash + url = url.substr(0, url.length - 1); + } + this.url = url; + this.basicAuth = datasource.basicAuth; + this.lastErrors = {}; + } + + PrometheusDatasource.prototype._request = function(method, url) { + var options = { + url: this.url + url, + method: method + }; + + if (this.basicAuth) { + options.withCredentials = true; + options.headers = { + "Authorization": this.basicAuth + }; + } + + return backendSrv.datasourceRequest(options); + }; + + // Called once per panel (graph) + PrometheusDatasource.prototype.query = function(options) { + var start = getPrometheusTime(options.range.from, false); + var end = getPrometheusTime(options.range.to, true); + + var queries = []; + _.each(options.targets, _.bind(function(target) { + if (!target.expr || target.hide) { + return; + } + + var query = {}; + query.expr = templateSrv.replace(target.expr, options.scopedVars); + + var interval = target.interval || options.interval; + var intervalFactor = target.intervalFactor || 1; + query.step = this.calculateInterval(interval, intervalFactor); + + queries.push(query); + }, this)); + + // 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 allQueryPromise = _.map(queries, _.bind(function(query) { + return this.performTimeSeriesQuery(query, start, end); + }, this)); + + var self = this; + return $q.all(allQueryPromise) + .then(function(allResponse) { + var result = []; + + _.each(allResponse, function(response, index) { + if (response.status === 'error') { + self.lastErrors.query = response.error; + throw response.error; + } + delete self.lastErrors.query; + + _.each(response.data.data.result, function(metricData) { + result.push(transformMetricData(metricData, options.targets[index])); + }); + }); + + return { data: result }; + }); + }; + + PrometheusDatasource.prototype.performTimeSeriesQuery = function(query, start, end) { + var url = '/api/v1/query_range?query=' + encodeURIComponent(query.expr) + '&start=' + start + '&end=' + end; + + var step = query.step; + var range = Math.floor(end - start); + // Prometheus drop query if range/step > 11000 + // calibrate step if it is too big + if (step !== 0 && range / step > 11000) { + step = Math.floor(range / 11000); + } + url += '&step=' + step; + + return this._request('GET', url); + }; + + PrometheusDatasource.prototype.performSuggestQuery = function(query) { + var url = '/api/v1/label/__name__/values'; + + return this._request('GET', url).then(function(result) { + var suggestData = _.filter(result.data.data, function(metricName) { + return metricName.indexOf(query) !== 1; + }); + + return suggestData; + }); + }; + + PrometheusDatasource.prototype.metricFindQuery = function(query) { + var url; + + var metricsQuery = query.match(/^[a-zA-Z_:*][a-zA-Z0-9_:*]*/); + var labelValuesQuery = query.match(/^label_values\((.+)\)/); + + if (labelValuesQuery) { + // return label values + url = '/api/v1/label/' + labelValuesQuery[1] + '/values'; + + return this._request('GET', url).then(function(result) { + return _.map(result.data.data, function(value) { + return {text: value}; + }); + }); + } else if (metricsQuery != null && metricsQuery[0].indexOf('*') >= 0) { + // if query has wildcard character, return metric name list + url = '/api/v1/label/__name__/values'; + + return this._request('GET', url) + .then(function(result) { + return _.chain(result.data.data) + .filter(function(metricName) { + var r = new RegExp(metricsQuery[0].replace(/\*/g, '.*')); + return r.test(metricName); + }) + .map(function(matchedMetricName) { + return { + text: matchedMetricName, + expandable: true + }; + }) + .value(); + }); + } else { + // if query contains full metric name, return metric name and label list + url = '/api/v1/query?query=' + encodeURIComponent(query); + + return this._request('GET', url) + .then(function(result) { + return _.map(result.data.result, function(metricData) { + return { + text: getOriginalMetricName(metricData.metric), + expandable: true + }; + }); + }); + } + }; + + PrometheusDatasource.prototype.testDatasource = function() { + return this.metricFindQuery('*').then(function() { + return { status: 'success', message: 'Data source is working', title: 'Success' }; + }); + }; + + PrometheusDatasource.prototype.calculateInterval = function(interval, intervalFactor) { + var sec = kbn.interval_to_seconds(interval); + + if (sec < 1) { + sec = 1; + } + + return sec * intervalFactor; + }; + + function transformMetricData(md, options) { + var dps = [], + metricLabel = null; + + metricLabel = createMetricLabel(md.metric, options); + + dps = _.map(md.values, function(value) { + return [parseFloat(value[1]), value[0] * 1000]; + }); + + return { target: metricLabel, datapoints: dps }; + } + + function createMetricLabel(labelData, options) { + if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) { + return getOriginalMetricName(labelData); + } + + var originalSettings = _.templateSettings; + _.templateSettings = { + interpolate: /\{\{(.+?)\}\}/g + }; + + var template = _.template(templateSrv.replace(options.legendFormat)); + var metricName; + try { + metricName = template(labelData); + } catch (e) { + metricName = '{}'; + } + + _.templateSettings = originalSettings; + + return metricName; + } + + function getOriginalMetricName(labelData) { + var metricName = labelData.__name__ || ''; + delete labelData.__name__; + var labelPart = _.map(_.pairs(labelData), function(label) { + return label[0] + '="' + label[1] + '"'; + }).join(','); + return metricName + '{' + labelPart + '}'; + } + + function getPrometheusTime(date, roundUp) { + if (_.isString(date)) { + if (date === 'now') { + return 'now()'; + } + if (date.indexOf('now-') >= 0 && date.indexOf('/') === -1) { + return date.replace('now', 'now()').replace('-', ' - '); + } + date = dateMath.parse(date, roundUp); + } + return (date.valueOf() / 1000).toFixed(0); + } + + return PrometheusDatasource; + }); + +}); diff --git a/public/app/plugins/datasource/prometheus/directives.js b/public/app/plugins/datasource/prometheus/directives.js new file mode 100644 index 00000000000..2ceed8bffdb --- /dev/null +++ b/public/app/plugins/datasource/prometheus/directives.js @@ -0,0 +1,13 @@ +define([ + 'angular', +], +function (angular) { + 'use strict'; + + var module = angular.module('grafana.directives'); + + module.directive('metricQueryEditorPrometheus', function() { + return {controller: 'PrometheusQueryCtrl', templateUrl: 'app/plugins/datasource/prometheus/partials/query.editor.html'}; + }); + +}); diff --git a/public/app/plugins/datasource/prometheus/partials/config.html b/public/app/plugins/datasource/prometheus/partials/config.html new file mode 100644 index 00000000000..bb5bdda1e20 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/partials/config.html @@ -0,0 +1,4 @@ +
+ +
+ diff --git a/public/app/plugins/datasource/prometheus/partials/query.editor.html b/public/app/plugins/datasource/prometheus/partials/query.editor.html new file mode 100644 index 00000000000..29cb2acd3d7 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/partials/query.editor.html @@ -0,0 +1,143 @@ +
+ + + + + + +
+
+ +
+ + +
+
+ +
+ + +
+
diff --git a/public/app/plugins/datasource/prometheus/plugin.json b/public/app/plugins/datasource/prometheus/plugin.json new file mode 100644 index 00000000000..5c97866101d --- /dev/null +++ b/public/app/plugins/datasource/prometheus/plugin.json @@ -0,0 +1,15 @@ +{ + "pluginType": "datasource", + "name": "Prometheus", + + "type": "prometheus", + "serviceName": "PrometheusDatasource", + + "module": "app/plugins/datasource/prometheus/datasource", + + "partials": { + "config": "app/plugins/datasource/prometheus/partials/config.html" + }, + + "metrics": true +} diff --git a/public/app/plugins/datasource/prometheus/queryCtrl.js b/public/app/plugins/datasource/prometheus/queryCtrl.js new file mode 100644 index 00000000000..88257c824f5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/queryCtrl.js @@ -0,0 +1,133 @@ +define([ + 'angular', + 'lodash', + 'kbn', + 'app/core/utils/datemath', +], +function (angular, _, kbn, dateMath) { + 'use strict'; + + var module = angular.module('grafana.controllers'); + + module.controller('PrometheusQueryCtrl', function($scope) { + + $scope.init = function() { + $scope.target.errors = validateTarget(); + $scope.target.datasourceErrors = {}; + + if (!$scope.target.expr) { + $scope.target.expr = ''; + } + $scope.target.metric = ''; + + $scope.resolutions = [ + { factor: 1, }, + { factor: 2, }, + { factor: 3, }, + { factor: 5, }, + { factor: 10, }, + ]; + $scope.resolutions = _.map($scope.resolutions, function(r) { + r.label = '1/' + r.factor; + return r; + }); + if (!$scope.target.intervalFactor) { + $scope.target.intervalFactor = 2; // default resolution is 1/2 + } + + $scope.calculateInterval(); + $scope.$on('render', function() { + $scope.calculateInterval(); // re-calculate interval when time range is updated + }); + $scope.target.prometheusLink = $scope.linkToPrometheus(); + + $scope.$on('typeahead-updated', function() { + $scope.$apply($scope.inputMetric); + $scope.refreshMetricData(); + }); + + $scope.datasource.lastErrors = {}; + $scope.$watch('datasource.lastErrors', function() { + $scope.target.datasourceErrors = $scope.datasource.lastErrors; + }, true); + }; + + $scope.refreshMetricData = function() { + $scope.target.errors = validateTarget($scope.target); + $scope.calculateInterval(); + $scope.target.prometheusLink = $scope.linkToPrometheus(); + + // this does not work so good + if (!_.isEqual($scope.oldTarget, $scope.target) && _.isEmpty($scope.target.errors)) { + $scope.oldTarget = angular.copy($scope.target); + $scope.get_data(); + } + }; + + $scope.inputMetric = function() { + $scope.target.expr += $scope.target.metric; + $scope.target.metric = ''; + }; + + $scope.moveMetricQuery = function(fromIndex, toIndex) { + _.move($scope.panel.targets, fromIndex, toIndex); + }; + + $scope.suggestMetrics = function(query, callback) { + $scope.datasource + .performSuggestQuery(query) + .then(callback); + }; + + $scope.linkToPrometheus = function() { + var from = dateMath.parse($scope.dashboard.time.from, false); + var to = dateMath.parse($scope.dashboard.time.to, true); + + if ($scope.panel.timeFrom) { + from = dateMath.parseDateMath('-' + $scope.panel.timeFrom, to, false); + } + if ($scope.panel.timeShift) { + from = dateMath.parseDateMath('-' + $scope.panel.timeShift, from, false); + to = dateMath.parseDateMath('-' + $scope.panel.timeShift, to, true); + } + + var range = Math.ceil((to.valueOf()- from.valueOf()) / 1000); + + var endTime = to.format('YYYY-MM-DD HH:MM'); + + var step = kbn.interval_to_seconds(this.target.calculatedInterval); + if (step !== 0 && range / step > 11000) { + step = Math.floor(range / 11000); + } + + var expr = { + expr: $scope.target.expr, + range_input: range + 's', + end_input: endTime, + //step_input: step, + step_input: '', + stacked: $scope.panel.stack, + tab: 0 + }; + + var hash = encodeURIComponent(JSON.stringify([expr])); + return $scope.datasource.url + '/graph#' + hash; + }; + + $scope.calculateInterval = function() { + var interval = $scope.target.interval || $scope.interval; + var calculatedInterval = $scope.datasource.calculateInterval(interval, $scope.target.intervalFactor); + $scope.target.calculatedInterval = kbn.secondsToHms(calculatedInterval); + }; + + // TODO: validate target + function validateTarget() { + var errs = {}; + + return errs; + } + + $scope.init(); + }); + +}); diff --git a/public/test/specs/prometheus-datasource-specs.js b/public/test/specs/prometheus-datasource-specs.js new file mode 100644 index 00000000000..c331b82385c --- /dev/null +++ b/public/test/specs/prometheus-datasource-specs.js @@ -0,0 +1,61 @@ +define([ + './helpers', + 'moment', + 'app/plugins/datasource/prometheus/datasource', + 'app/services/backendSrv', + 'app/services/alertSrv' +], function(helpers, moment) { + 'use strict'; + + describe('PrometheusDatasource', function() { + var ctx = new helpers.ServiceTestContext(); + + beforeEach(module('grafana.services')); + beforeEach(ctx.providePhase(['templateSrv'])); + beforeEach(ctx.createService('PrometheusDatasource')); + beforeEach(function() { + ctx.ds = new ctx.service({ url: '', user: 'test', password: 'mupp' }); + }); + + describe('When querying prometheus with one target using query editor target spec', function() { + var results; + var urlExpected = '/api/v1/query_range?query=' + + encodeURIComponent('test{job="testjob"}') + + '&start=1443438675&end=1443460275&step=60'; + var query = { + range: { from: moment(1443438674760), to: moment(1443460274760) }, + targets: [{ expr: 'test{job="testjob"}' }], + interval: '60s' + }; + + var response = { + "status":"success", + "data":{ + "resultType":"matrix", + "result":[{ + "metric":{"__name__":"test", "job":"testjob"}, + "values":[[1443454528,"3846"]] + }] + } + }; + + beforeEach(function() { + ctx.$httpBackend.expect('GET', urlExpected).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{job="testjob"}'); + }); + + }); + + }); +}); + diff --git a/tasks/options/requirejs.js b/tasks/options/requirejs.js index a1636af98e5..a21ebcc8580 100644 --- a/tasks/options/requirejs.js +++ b/tasks/options/requirejs.js @@ -63,6 +63,7 @@ module.exports = function(config,grunt) { 'app/plugins/datasource/grafana/datasource', 'app/plugins/datasource/graphite/datasource', 'app/plugins/datasource/influxdb/datasource', + 'app/plugins/datasource/prometheus/datasource', ] }, ];