├── .gitignore ├── README.md ├── datasources ├── atlas │ ├── atlas.js │ ├── atlasTargetCtrl.js │ ├── editor.html │ └── specs │ │ └── atlasDatasource-specs.js └── kairosdb │ ├── kairosdb.Datasource.js │ ├── kairosdb.TargetCtrl.js │ └── kairosdb.editor.html └── features └── druid ├── README.md ├── datasource.js ├── partials └── query.editor.html └── queryCtrl.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | 27 | #Mac OS X 28 | .DS_Store 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Note: The Druid plugin is in [features/druid](features/druid) 2 | 3 | grafana-plugins 4 | =============== 5 | 6 | Extensions, custom & experimental panels 7 | 8 | Examples 9 | =========== 10 | 11 | ### Adding Custom Panels 12 | 13 | ~~~ 14 | plugins: { 15 | // list of plugin panels 16 | panels: { 17 | 'custom panel name': { path: '../plugins/panels/custom.panel.example' } 18 | }, 19 | // requirejs modules in plugins folder that should be loaded 20 | // for example custom datasources 21 | dependencies: [], 22 | } 23 | ~~~ 24 | 25 | ### Adding custom data sources 26 | 27 | ~~~ 28 | datasources: { 29 | custom: { 30 | type: 'CustomDatasource', 31 | hello: 'some property' 32 | }, 33 | }, 34 | 35 | plugins: { 36 | dependencies: ['datasource.example'] 37 | }, 38 | ~~~ 39 | 40 | -------------------------------------------------------------------------------- /datasources/atlas/atlas.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'angular', 3 | 'lodash', 4 | 'kbn' 5 | ], 6 | function (angular, _, kbn) { 7 | 'use strict'; 8 | 9 | var module = angular.module('grafana.services'); 10 | 11 | module.factory('AtlasDatasource', function($q, $http) { 12 | function AtlasDatasource(datasource) { 13 | this.name = datasource.name; 14 | this.type = 'AtlasDatasource'; 15 | this.url = datasource.url; 16 | 17 | this.supportMetrics = true; 18 | this.partials = datasource.partials || 'plugins/grafana-plugins/datasources/atlas'; 19 | this.editorSrc = this.partials + '/editor.html'; 20 | 21 | this.minimumInterval = datasource.minimumInterval || 1000; 22 | } 23 | 24 | AtlasDatasource.prototype.query = function(options) { 25 | // Atlas can take multiple concatenated stack queries 26 | var fullQuery = _(options.targets).reject('hide').pluck('query').value().join(','); 27 | 28 | var interval = options.interval; 29 | 30 | if (kbn.interval_to_ms(interval) < this.minimumInterval) { 31 | interval = kbn.secondsToHms(this.minimumInterval / 1000); 32 | } 33 | 34 | var params = { 35 | q: fullQuery, 36 | step: interval, 37 | s: options.range.from, 38 | e: options.range.to, 39 | format: options.format || 'json' 40 | }; 41 | 42 | var httpOptions = { 43 | method: 'GET', 44 | url: this.url + '/api/v1/graph', 45 | params: params, 46 | inspect: { type: 'atlas' } 47 | }; 48 | 49 | // Note: while Atlas supports PNGs, Grafana can only provide graphite-specific dimension params 50 | // See https://github.com/grafana/grafana/issues/1273 for status 51 | if (options.format === "png") { 52 | var encodedParams = _.map(httpOptions.params, function (v, k) { 53 | return [k, encodeURIComponent(v)].join("="); 54 | }); 55 | 56 | return $q.when(httpOptions.url + "?" + encodedParams.join("&")); 57 | } else { 58 | var deferred = $q.defer(); 59 | $http(httpOptions) 60 | .success(function (response) { 61 | deferred.resolve(convertToTimeseries(response)) 62 | }) 63 | .error(function (data, status, headers, config) { 64 | var error = new Error(data.message); 65 | error.config = config; 66 | error.data = JSON.stringify(data); 67 | deferred.reject(error); 68 | }); 69 | return deferred.promise; 70 | } 71 | }; 72 | 73 | function convertToTimeseries (result) { 74 | var timeseriesData = _.map(result.legend, function (legend, index) { 75 | var series = {target: legend, datapoints: []}; 76 | var values = _.pluck(result.values, index); 77 | 78 | for (var i = 0; i < values.length; i++) { 79 | var value = values[i]; 80 | var timestamp = result.start + (i * result.step); 81 | series.datapoints.push([value, timestamp]); 82 | } 83 | 84 | return series; 85 | }); 86 | 87 | return {data: timeseriesData}; 88 | } 89 | 90 | return AtlasDatasource; 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /datasources/atlas/atlasTargetCtrl.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'angular', 3 | 'lodash' 4 | ], 5 | function (angular, _) { 6 | 'use strict'; 7 | 8 | var module = angular.module('grafana.controllers'); 9 | 10 | var seriesList = null; 11 | 12 | module.controller('AtlasTargetCtrl', function($scope) { 13 | 14 | $scope.init = function() { 15 | }; 16 | 17 | $scope.moveMetricQuery = function(fromIndex, toIndex) { 18 | _.move($scope.panel.targets, fromIndex, toIndex); 19 | $scope.get_data(); 20 | }; 21 | 22 | $scope.duplicate = function() { 23 | var clone = angular.copy($scope.target); 24 | $scope.panel.targets.push(clone); 25 | $scope.get_data(); 26 | }; 27 | 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /datasources/atlas/editor.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
8 | 9 |
10 |
11 | 30 | 31 | 38 | 39 |
    40 |
  • 41 | 51 |
  • 52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 | 60 |
61 |
62 |
63 | 80 |
81 |
82 |
83 |
-------------------------------------------------------------------------------- /datasources/atlas/specs/atlasDatasource-specs.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'helpers', 3 | '../datasources/atlas' 4 | ], function(helpers) { 5 | 'use strict'; 6 | 7 | describe('AtlasDatasource', function() { 8 | var ctx = new helpers.ServiceTestContext(); 9 | 10 | beforeEach(module('grafana.services')); 11 | beforeEach(ctx.providePhase(['templateSrv'])); 12 | beforeEach(ctx.createService('AtlasDatasource')); 13 | beforeEach(function() { 14 | ctx.ds = new ctx.service({ url: [''] }); 15 | }); 16 | 17 | describe('When querying atlas with multiple stack query targets', function() { 18 | var results; 19 | var urlExpected = "/api/v1/graph?e=now&format=json&q=name,sps,:eq,:sum,name,ssCpuUser,:eq,:sum&s=now-1h&step=10m"; 20 | var query = { 21 | range: { from: 'now-1h', to: 'now' }, 22 | targets: [ 23 | { query: 'name,sps,:eq,:sum' }, 24 | { query: 'name,ssCpuUser,:eq,:sum' } 25 | ], 26 | interval: '10m' 27 | }; 28 | 29 | var response = { 30 | start: 1419312000000, 31 | step: 600000, 32 | legend: [ 33 | "atlas.legacy=epic, name=sps, nf.app=nccp", 34 | "atlas.legacy=epic, name=ssCpuUser, nf.app=alerttest, nf.asg=alerttest-v042, nf.cluster=alerttest, nf.node=alert1" 35 | ], 36 | metrics: [{}], 37 | values:[ 38 | [604930.164776, 18.537333], 39 | [593086.155587, 20.939233], 40 | [605539.831071, 20.855982] 41 | ], 42 | notices: [] 43 | }; 44 | 45 | beforeEach(function() { 46 | ctx.$httpBackend.expect('GET', urlExpected).respond(response); 47 | ctx.ds.query(query).then(function(data) { results = data; }); 48 | ctx.$httpBackend.flush(); 49 | }); 50 | 51 | it('should generate the correct query', function() { 52 | ctx.$httpBackend.verifyNoOutstandingExpectation(); 53 | }); 54 | 55 | it('should return series list', function() { 56 | expect(results.data.length).to.be(2); 57 | expect(results.data[0].target).to.be('atlas.legacy=epic, name=sps, nf.app=nccp'); 58 | expect(results.data[0].datapoints[0]).to.eql([604930.164776, 1419312000000]); 59 | expect(results.data[0].datapoints[1]).to.eql([593086.155587, 1419312000000 + 600000]); 60 | }); 61 | }); 62 | 63 | describe('when querying for a png', function () { 64 | var query = { 65 | range: { from: 'now-1h', to: 'now' }, 66 | targets: [ 67 | { query: 'name,sps,:eq,:sum' }, 68 | ], 69 | interval: '10m', 70 | format: 'png' 71 | }; 72 | 73 | it('should generate the correct query', function () { 74 | var promise = ctx.ds.query(query); 75 | promise.then(function(urlReturned) { 76 | expect(urlReturned).to.match(/format=png/); 77 | }); 78 | ctx.$rootScope.$digest(); 79 | }); 80 | }) 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /datasources/kairosdb/kairosdb.Datasource.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'angular', 3 | 'lodash', 4 | 'kbn' 5 | ], 6 | function (angular, _, kbn) { 7 | 'use strict'; 8 | 9 | var module = angular.module('grafana.services'); 10 | var tagList = null; 11 | 12 | module.factory('KairosDBDatasource', function($q, $http) { 13 | 14 | function KairosDBDatasource(datasource) { 15 | this.type = datasource.type; 16 | this.editorSrc = 'plugins/datasources/kairosdb/kairosdb.editor.html'; 17 | this.url = datasource.url; 18 | this.name = datasource.name; 19 | this.supportMetrics = true; 20 | this.grafanaDB = datasource.grafanaDB; 21 | } 22 | 23 | // Called once per panel (graph) 24 | KairosDBDatasource.prototype.query = function(options) { 25 | var start = options.range.from; 26 | var end = options.range.to; 27 | 28 | var queries = _.compact(_.map(options.targets, _.partial(convertTargetToQuery, options))); 29 | var plotParams = _.compact(_.map(options.targets, function(target){ 30 | var alias = target.alias; 31 | if (typeof target.alias == 'undefined' || target.alias == "") 32 | alias = target.metric; 33 | return !target.hide 34 | ? {alias: alias, 35 | exouter: target.exOuter} 36 | : null; 37 | })); 38 | var handleKairosDBQueryResponseAlias = _.partial(handleKairosDBQueryResponse, plotParams); 39 | // No valid targets, return the empty result to save a round trip. 40 | if (_.isEmpty(queries)) { 41 | var d = $q.defer(); 42 | d.resolve({ data: [] }); 43 | return d.promise; 44 | } 45 | return this.performTimeSeriesQuery(queries, start, end).then(handleKairosDBQueryResponseAlias,handleQueryError); 46 | }; 47 | 48 | /////////////////////////////////////////////////////////////////////// 49 | /// Query methods 50 | /////////////////////////////////////////////////////////////////////// 51 | 52 | KairosDBDatasource.prototype.performTimeSeriesQuery = function(queries, start, end) { 53 | var reqBody = { 54 | metrics: queries 55 | }; 56 | reqBody.cache_time=0; 57 | convertToKairosTime(start,reqBody,'start'); 58 | convertToKairosTime(end,reqBody,'end'); 59 | var options = { 60 | method: 'POST', 61 | url: '/api/v1/datapoints/query', 62 | data: reqBody 63 | }; 64 | 65 | options.url = this.url + options.url; 66 | return $http(options); 67 | }; 68 | 69 | /** 70 | * Gets the list of metrics 71 | * @returns {*|Promise} 72 | */ 73 | KairosDBDatasource.prototype.performMetricSuggestQuery = function() { 74 | var options = { 75 | url : this.url + '/api/v1/metricnames', 76 | method : 'GET' 77 | }; 78 | return $http(options).then(function(results) { 79 | if (!results.data) { 80 | return []; 81 | } 82 | return results.data.results; 83 | }); 84 | 85 | }; 86 | 87 | KairosDBDatasource.prototype.performTagSuggestQuery = function(metricname,range,type,keyValue) { 88 | if(tagList && (metricname === tagList.metricName) && (range.from === tagList.range.from) && 89 | (range.to === tagList.range.to)) { 90 | return getTagListFromResponse(tagList.results,type,keyValue); 91 | } 92 | tagList = { 93 | metricName:metricname, 94 | range:range 95 | }; 96 | var body = { 97 | metrics : [{name : metricname}] 98 | }; 99 | convertToKairosTime(range.from,body,'start'); 100 | convertToKairosTime(range.to,body,'end'); 101 | var options = { 102 | url : this.url + '/api/v1/datapoints/query/tags', 103 | method : 'POST', 104 | data : body 105 | }; 106 | return $http(options).then(function(results) { 107 | tagList.results = results; 108 | return getTagListFromResponse(results,type,keyValue); 109 | }); 110 | 111 | }; 112 | 113 | ///////////////////////////////////////////////////////////////////////// 114 | /// Formatting methods 115 | //////////////////////////////////////////////////////////////////////// 116 | 117 | function getTagListFromResponse(results,type,keyValue) { 118 | if (!results.data) { 119 | return []; 120 | } 121 | if(type==="key") { 122 | return _.keys(results.data.queries[0].results[0].tags); 123 | } 124 | else if(type==="value" && _.has(results.data.queries[0].results[0].tags,keyValue)) { 125 | return results.data.queries[0].results[0].tags[keyValue]; 126 | } 127 | return []; 128 | } 129 | 130 | /** 131 | * Requires a verion of KairosDB with every CORS defects fixed 132 | * @param results 133 | * @returns {*} 134 | */ 135 | function handleQueryError(results) { 136 | if(results.data.errors && !_.isEmpty(results.data.errors)) { 137 | var errors = { 138 | message: results.data.errors[0] 139 | }; 140 | return $q.reject(errors); 141 | } 142 | else{ 143 | return $q.reject(results); 144 | } 145 | } 146 | 147 | function handleKairosDBQueryResponse(plotParams, results) { 148 | var output = []; 149 | var index = 0; 150 | _.each(results.data.queries, function (series) { 151 | var sample_size = series.sample_size; 152 | console.log("sample_size:" + sample_size + " samples"); 153 | 154 | _.each(series.results, function (result) { 155 | 156 | //var target = result.name; 157 | var target = plotParams[index].alias; 158 | var details = " ( "; 159 | _.each(result.group_by,function(element) { 160 | if(element.name==="tag") { 161 | _.each(element.group,function(value, key) { 162 | details+= key+"="+value+" "; 163 | }); 164 | } 165 | else if(element.name==="value") { 166 | details+= 'value_group='+element.group.group_number+" "; 167 | } 168 | else if(element.name==="time") { 169 | details+= 'time_group='+element.group.group_number+" "; 170 | } 171 | }); 172 | details+= ") "; 173 | if (details != " ( ) ") 174 | target += details; 175 | var datapoints = []; 176 | 177 | for (var i = 0; i < result.values.length; i++) { 178 | var t = Math.floor(result.values[i][0]); 179 | var v = result.values[i][1]; 180 | datapoints[i] = [v, t]; 181 | } 182 | if (plotParams[index].exouter) 183 | datapoints = PeakFilter(datapoints, 10); 184 | output.push({ target: target, datapoints: datapoints }); 185 | }); 186 | index ++; 187 | }); 188 | var output2 = { data: _.flatten(output) }; 189 | 190 | return output2; 191 | } 192 | 193 | function convertTargetToQuery(options,target) { 194 | if (!target.metric || target.hide) { 195 | return null; 196 | } 197 | 198 | var query = { 199 | name: target.metric 200 | }; 201 | 202 | query.aggregators = []; 203 | if(target.downsampling!=='(NONE)') { 204 | query.aggregators.push({ 205 | name: target.downsampling, 206 | align_sampling: true, 207 | align_start_time: true, 208 | sampling: KairosDBDatasource.prototype.convertToKairosInterval(target.sampling || options.interval) 209 | }); 210 | } 211 | if(target.horizontalAggregators) { 212 | _.each(target.horizontalAggregators,function(chosenAggregator) { 213 | var returnedAggregator = { 214 | name:chosenAggregator.name 215 | }; 216 | if(chosenAggregator.sampling_rate) { 217 | returnedAggregator.sampling = KairosDBDatasource.prototype.convertToKairosInterval(chosenAggregator.sampling_rate); 218 | returnedAggregator.align_sampling = true; 219 | returnedAggregator.align_start_time=true; 220 | } 221 | if(chosenAggregator.unit) { 222 | returnedAggregator.unit = chosenAggregator.unit+'s'; 223 | } 224 | if(chosenAggregator.factor && chosenAggregator.name==='div') { 225 | returnedAggregator.divisor = chosenAggregator.factor; 226 | } 227 | else if(chosenAggregator.factor && chosenAggregator.name==='scale') { 228 | returnedAggregator.factor = chosenAggregator.factor; 229 | } 230 | if(chosenAggregator.percentile) { 231 | returnedAggregator.percentile = chosenAggregator.percentile; 232 | } 233 | query.aggregators.push(returnedAggregator); 234 | }); 235 | } 236 | if(_.isEmpty(query.aggregators)) { 237 | delete query.aggregators; 238 | } 239 | 240 | if(target.tags) { 241 | query.tags = angular.copy(target.tags); 242 | } 243 | 244 | if(target.groupByTags || target.nonTagGroupBys) { 245 | query.group_by = []; 246 | if(target.groupByTags) {query.group_by.push({name: "tag", tags: angular.copy(target.groupByTags)});} 247 | if(target.nonTagGroupBys) { 248 | _.each(target.nonTagGroupBys,function(rawGroupBy) { 249 | var formattedGroupBy = angular.copy(rawGroupBy); 250 | if(formattedGroupBy.name==='time') { 251 | formattedGroupBy.range_size=KairosDBDatasource.prototype.convertToKairosInterval(formattedGroupBy.range_size); 252 | } 253 | query.group_by.push(formattedGroupBy); 254 | }); 255 | } 256 | } 257 | return query; 258 | } 259 | 260 | /////////////////////////////////////////////////////////////////////// 261 | /// Time conversion functions specifics to KairosDB 262 | ////////////////////////////////////////////////////////////////////// 263 | 264 | KairosDBDatasource.prototype.convertToKairosInterval = function(intervalString) { 265 | var interval_regex = /(\d+(?:\.\d+)?)([Mwdhmsy])/; 266 | var interval_regex_ms = /(\d+(?:\.\d+)?)(ms)/; 267 | var matches = intervalString.match(interval_regex_ms); 268 | if(!matches) { 269 | matches = intervalString.match(interval_regex); 270 | } 271 | if (!matches) { 272 | throw new Error('Invalid interval string, expecting a number followed by one of "y M w d h m s ms"'); 273 | } 274 | 275 | var value = matches[1]; 276 | var unit = matches[2]; 277 | if (value%1!==0) { 278 | if(unit==='ms') {throw new Error('Invalid interval value, cannot be smaller than the millisecond');} 279 | value = Math.round(kbn.intervals_in_seconds[unit]*value*1000); 280 | unit = 'ms'; 281 | 282 | } 283 | switch(unit) { 284 | case 'ms': 285 | unit = 'milliseconds'; 286 | break; 287 | case 's': 288 | unit = 'seconds'; 289 | break; 290 | case 'm': 291 | unit = 'minutes'; 292 | break; 293 | case 'h': 294 | unit = 'hours'; 295 | break; 296 | case 'd': 297 | unit = 'days'; 298 | break; 299 | case 'w': 300 | unit = 'weeks'; 301 | break; 302 | case 'M': 303 | unit = 'months'; 304 | break; 305 | case 'y': 306 | unit = 'years'; 307 | break; 308 | default: 309 | console.log("Unknown interval ", intervalString); 310 | break; 311 | } 312 | 313 | return { 314 | "value": value, 315 | "unit": unit 316 | }; 317 | 318 | }; 319 | 320 | function convertToKairosTime(date, response_obj, start_stop_name) { 321 | var name; 322 | if (_.isString(date)) { 323 | if (date === 'now') { 324 | return; 325 | } 326 | else if (date.indexOf('now-') >= 0) { 327 | 328 | name = start_stop_name + "_relative"; 329 | 330 | date = date.substring(4); 331 | var re_date = /(\d+)\s*(\D+)/; 332 | var result = re_date.exec(date); 333 | if (result) { 334 | var value = result[1]; 335 | var unit = result[2]; 336 | switch(unit) { 337 | case 'ms': 338 | unit = 'milliseconds'; 339 | break; 340 | case 's': 341 | unit = 'seconds'; 342 | break; 343 | case 'm': 344 | unit = 'minutes'; 345 | break; 346 | case 'h': 347 | unit = 'hours'; 348 | break; 349 | case 'd': 350 | unit = 'days'; 351 | break; 352 | case 'w': 353 | unit = 'weeks'; 354 | break; 355 | case 'M': 356 | unit = 'months'; 357 | break; 358 | case 'y': 359 | unit = 'years'; 360 | break; 361 | default: 362 | console.log("Unknown date ", date); 363 | break; 364 | } 365 | response_obj[name] = { 366 | "value": value, 367 | "unit": unit 368 | }; 369 | return; 370 | } 371 | console.log("Unparseable date", date); 372 | return; 373 | } 374 | date = kbn.parseDate(date); 375 | } 376 | 377 | if(_.isDate(date)) { 378 | name = start_stop_name + "_absolute"; 379 | response_obj[name] = date.getTime(); 380 | return; 381 | } 382 | 383 | console.log("Date is neither string nor date"); 384 | } 385 | 386 | function PeakFilter(dataIn, limit) { 387 | var datapoints = dataIn; 388 | var arrLength = datapoints.length; 389 | if (arrLength <= 3) 390 | return datapoints; 391 | var LastIndx = arrLength - 1; 392 | 393 | // Check first point 394 | var prvDelta = Math.abs((datapoints[1][0] - datapoints[0][0]) / datapoints[0][0]); 395 | var nxtDelta = Math.abs((datapoints[1][0] - datapoints[2][0]) / datapoints[2][0]); 396 | if (prvDelta >= limit && nxtDelta < limit) 397 | datapoints[0][0] = datapoints[1][0]; 398 | 399 | // Check last point 400 | prvDelta = Math.abs((datapoints[LastIndx - 1][0] - datapoints[LastIndx - 2][0]) / datapoints[LastIndx - 2][0]); 401 | nxtDelta = Math.abs((datapoints[LastIndx - 1][0] - datapoints[LastIndx][0]) / datapoints[LastIndx][0]); 402 | if (prvDelta >= limit && nxtDelta < limit) 403 | datapoints[LastIndx][0] = datapoints[LastIndx - 1][0]; 404 | 405 | for (var i = 1; i < arrLength - 1; i++){ 406 | prvDelta = Math.abs((datapoints[i][0] - datapoints[i - 1][0]) / datapoints[i - 1][0]); 407 | nxtDelta = Math.abs((datapoints[i][0] - datapoints[i + 1][0]) / datapoints[i + 1][0]); 408 | if (prvDelta >= limit && nxtDelta >= limit) 409 | datapoints[i][0] = (datapoints[i-1][0] + datapoints[i+1][0]) / 2; 410 | } 411 | 412 | return datapoints; 413 | } 414 | 415 | //////////////////////////////////////////////////////////////////////// 416 | return KairosDBDatasource; 417 | }); 418 | 419 | }); 420 | -------------------------------------------------------------------------------- /datasources/kairosdb/kairosdb.TargetCtrl.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'angular', 3 | 'lodash' 4 | ], 5 | function (angular, _) { 6 | 'use strict'; 7 | 8 | var module = angular.module('grafana.controllers'); 9 | var MetricStruct = {}; 10 | var metricList = null; 11 | var targetLetters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O']; 12 | 13 | module.controller('KairosDBTargetCtrl', function($scope) { 14 | 15 | $scope.init = function() { 16 | $scope.metric = { 17 | list: ["Loading..."], 18 | value: "Loading..." 19 | }; 20 | $scope.panel.stack = false; 21 | if (!$scope.panel.downsampling) { 22 | $scope.panel.downsampling = 'avg'; 23 | } 24 | if (!$scope.target.downsampling) { 25 | $scope.target.downsampling = $scope.panel.downsampling; 26 | $scope.target.sampling = $scope.panel.sampling; 27 | } 28 | $scope.targetLetters = targetLetters; 29 | $scope.updateMetricList(); 30 | $scope.target.errors = validateTarget($scope.target); 31 | }; 32 | 33 | $scope.targetBlur = function() { 34 | $scope.target.metric = $scope.metric.value; 35 | $scope.target.errors = validateTarget($scope.target); 36 | if (!_.isEqual($scope.oldTarget, $scope.target) && _.isEmpty($scope.target.errors)) { 37 | $scope.oldTarget = angular.copy($scope.target); 38 | $scope.get_data(); 39 | } 40 | }; 41 | $scope.panelBlur = function() { 42 | _.each($scope.panel.targets, function(target) { 43 | target.downsampling = $scope.panel.downsampling; 44 | target.sampling = $scope.panel.sampling; 45 | }); 46 | $scope.get_data(); 47 | }; 48 | 49 | $scope.duplicate = function() { 50 | var clone = angular.copy($scope.target); 51 | $scope.panel.targets.push(clone); 52 | }; 53 | $scope.moveMetricQuery = function(fromIndex, toIndex) { 54 | _.move($scope.panel.targets, fromIndex, toIndex); 55 | }; 56 | 57 | ////////////////////////////// 58 | // SUGGESTION QUERIES 59 | ////////////////////////////// 60 | 61 | $scope.updateMetricList = function() { 62 | $scope.metricListLoading = true; 63 | metricList = []; 64 | $scope.datasource.performMetricSuggestQuery().then(function(series) { 65 | metricList = series; 66 | $scope.metric.list = series; 67 | if ($scope.target.metric) 68 | $scope.metric.value = $scope.target.metric; 69 | else 70 | $scope.metric.value = ""; 71 | $scope.metricListLoading = false; 72 | return metricList; 73 | }); 74 | }; 75 | 76 | $scope.suggestTagKeys = function(query, callback) { 77 | $scope.updateTimeRange(); 78 | callback($scope.datasource 79 | .performTagSuggestQuery($scope.target.metric,$scope.rangeUnparsed, 'key','')); 80 | 81 | }; 82 | 83 | $scope.suggestTagValues = function(query, callback) { 84 | callback($scope.datasource 85 | .performTagSuggestQuery($scope.target.metric,$scope.rangeUnparsed, 'value',$scope.target.currentTagKey)); 86 | }; 87 | 88 | ////////////////////////////// 89 | // FILTER by TAG 90 | ////////////////////////////// 91 | 92 | $scope.addFilterTag = function() { 93 | if (!$scope.addFilterTagMode) { 94 | $scope.addFilterTagMode = true; 95 | $scope.validateFilterTag(); 96 | return; 97 | } 98 | 99 | if (!$scope.target.tags) { 100 | $scope.target.tags = {}; 101 | } 102 | 103 | $scope.validateFilterTag(); 104 | if (!$scope.target.errors.tags) { 105 | if(!_.has($scope.target.tags,$scope.target.currentTagKey)) { 106 | $scope.target.tags[$scope.target.currentTagKey] = []; 107 | } 108 | $scope.target.tags[$scope.target.currentTagKey].push($scope.target.currentTagValue); 109 | $scope.target.currentTagKey = ''; 110 | $scope.target.currentTagValue = ''; 111 | $scope.targetBlur(); 112 | } 113 | 114 | $scope.addFilterTagMode = false; 115 | }; 116 | 117 | $scope.removeFilterTag = function(key) { 118 | delete $scope.target.tags[key]; 119 | if(_.size($scope.target.tags)===0) { 120 | $scope.target.tags = null; 121 | } 122 | $scope.targetBlur(); 123 | }; 124 | 125 | $scope.validateFilterTag = function() { 126 | $scope.target.errors.tags = null; 127 | if(!$scope.target.currentTagKey || !$scope.target.currentTagValue) { 128 | $scope.target.errors.tags = "You must specify a tag name and value."; 129 | } 130 | }; 131 | 132 | ////////////////////////////// 133 | // GROUP BY 134 | ////////////////////////////// 135 | 136 | $scope.addGroupBy = function() { 137 | if (!$scope.addGroupByMode) { 138 | $scope.addGroupByMode = true; 139 | $scope.target.currentGroupByType = 'tag'; 140 | $scope.isTagGroupBy = true; 141 | $scope.validateGroupBy(); 142 | return; 143 | } 144 | $scope.validateGroupBy(); 145 | // nb: if error is found, means that user clicked on cross : cancels input 146 | if (_.isEmpty($scope.target.errors.groupBy)) { 147 | if($scope.isTagGroupBy) { 148 | if (!$scope.target.groupByTags) { 149 | $scope.target.groupByTags = []; 150 | } 151 | console.log($scope.target.groupBy.tagKey); 152 | if (!_.contains($scope.target.groupByTags, $scope.target.groupBy.tagKey)) { 153 | $scope.target.groupByTags.push($scope.target.groupBy.tagKey); 154 | $scope.targetBlur(); 155 | } 156 | $scope.target.groupBy.tagKey = ''; 157 | } 158 | else { 159 | if (!$scope.target.nonTagGroupBys) { 160 | $scope.target.nonTagGroupBys = []; 161 | } 162 | var groupBy = { 163 | name: $scope.target.currentGroupByType 164 | }; 165 | if($scope.isValueGroupBy) {groupBy.range_size = $scope.target.groupBy.valueRange;} 166 | else if($scope.isTimeGroupBy) { 167 | groupBy.range_size = $scope.target.groupBy.timeInterval; 168 | groupBy.group_count = $scope.target.groupBy.groupCount; 169 | } 170 | $scope.target.nonTagGroupBys.push(groupBy); 171 | } 172 | $scope.targetBlur(); 173 | } 174 | $scope.isTagGroupBy = false; 175 | $scope.isValueGroupBy = false; 176 | $scope.isTimeGroupBy = false; 177 | $scope.addGroupByMode = false; 178 | }; 179 | 180 | $scope.removeGroupByTag = function(index) { 181 | $scope.target.groupByTags.splice(index, 1); 182 | if(_.size($scope.target.groupByTags)===0) { 183 | $scope.target.groupByTags = null; 184 | } 185 | $scope.targetBlur(); 186 | }; 187 | 188 | $scope.removeNonTagGroupBy = function(index) { 189 | $scope.target.nonTagGroupBys.splice(index, 1); 190 | if(_.size($scope.target.nonTagGroupBys)===0) { 191 | $scope.target.nonTagGroupBys = null; 192 | } 193 | $scope.targetBlur(); 194 | }; 195 | 196 | $scope.changeGroupByInput = function() { 197 | $scope.isTagGroupBy = $scope.target.currentGroupByType==='tag'; 198 | $scope.isValueGroupBy = $scope.target.currentGroupByType==='value'; 199 | $scope.isTimeGroupBy = $scope.target.currentGroupByType==='time'; 200 | $scope.validateGroupBy(); 201 | }; 202 | 203 | $scope.validateGroupBy = function() { 204 | delete $scope.target.errors.groupBy; 205 | var errors = {}; 206 | $scope.isGroupByValid = true; 207 | if($scope.isTagGroupBy) { 208 | if(!$scope.target.groupBy.tagKey) { 209 | $scope.isGroupByValid = false; 210 | errors.tagKey = 'You must supply a tag name'; 211 | } 212 | } 213 | if($scope.isValueGroupBy) { 214 | if(!$scope.target.groupBy.valueRange || !isInt($scope.target.groupBy.valueRange)) { 215 | errors.valueRange = "Range must be an integer"; 216 | $scope.isGroupByValid = false; 217 | } 218 | } 219 | if($scope.isTimeGroupBy) { 220 | try { 221 | $scope.datasource.convertToKairosInterval($scope.target.groupBy.timeInterval); 222 | } catch(err) { 223 | errors.timeInterval = err.message; 224 | $scope.isGroupByValid = false; 225 | } 226 | if(!$scope.target.groupBy.groupCount || !isInt($scope.target.groupBy.groupCount)) { 227 | errors.groupCount = "Group count must be an integer"; 228 | $scope.isGroupByValid = false; 229 | } 230 | } 231 | 232 | if(!_.isEmpty(errors)) { 233 | $scope.target.errors.groupBy = errors; 234 | } 235 | }; 236 | 237 | function isInt(n) { 238 | return parseInt(n) % 1 === 0; 239 | } 240 | 241 | ////////////////////////////// 242 | // HORIZONTAL AGGREGATION 243 | ////////////////////////////// 244 | 245 | $scope.addHorizontalAggregator = function() { 246 | if (!$scope.addHorizontalAggregatorMode) { 247 | $scope.addHorizontalAggregatorMode = true; 248 | $scope.target.currentHorizontalAggregatorName = 'avg'; 249 | $scope.hasSamplingRate = true; 250 | $scope.validateHorizontalAggregator(); 251 | return; 252 | } 253 | 254 | $scope.validateHorizontalAggregator(); 255 | // nb: if error is found, means that user clicked on cross : cancels input 256 | if(_.isEmpty($scope.target.errors.horAggregator)) { 257 | if (!$scope.target.horizontalAggregators) { 258 | $scope.target.horizontalAggregators = []; 259 | } 260 | var aggregator = { 261 | name:$scope.target.currentHorizontalAggregatorName 262 | }; 263 | if($scope.hasSamplingRate) {aggregator.sampling_rate = $scope.target.horAggregator.samplingRate;} 264 | if($scope.hasUnit) {aggregator.unit = $scope.target.horAggregator.unit;} 265 | if($scope.hasFactor) {aggregator.factor = $scope.target.horAggregator.factor;} 266 | if($scope.hasPercentile) {aggregator.percentile = $scope.target.horAggregator.percentile;} 267 | $scope.target.horizontalAggregators.push(aggregator); 268 | $scope.targetBlur(); 269 | } 270 | 271 | $scope.addHorizontalAggregatorMode = false; 272 | $scope.hasSamplingRate = false; 273 | $scope.hasUnit = false; 274 | $scope.hasFactor = false; 275 | $scope.hasPercentile = false; 276 | 277 | }; 278 | 279 | $scope.removeHorizontalAggregator = function(index) { 280 | $scope.target.horizontalAggregators.splice(index, 1); 281 | if(_.size($scope.target.horizontalAggregators)===0) { 282 | $scope.target.horizontalAggregators = null; 283 | } 284 | 285 | $scope.targetBlur(); 286 | }; 287 | 288 | $scope.changeHorAggregationInput = function() { 289 | $scope.hasSamplingRate = _.contains(['avg','dev','max','min','sum','least_squares','count','percentile'], 290 | $scope.target.currentHorizontalAggregatorName); 291 | $scope.hasUnit = _.contains(['sampler','rate'], $scope.target.currentHorizontalAggregatorName); 292 | $scope.hasFactor = _.contains(['div','scale'], $scope.target.currentHorizontalAggregatorName); 293 | $scope.hasPercentile = 'percentile'===$scope.target.currentHorizontalAggregatorName; 294 | $scope.validateHorizontalAggregator(); 295 | }; 296 | 297 | $scope.validateHorizontalAggregator = function() { 298 | delete $scope.target.errors.horAggregator; 299 | var errors = {}; 300 | $scope.isAggregatorValid = true; 301 | if($scope.hasSamplingRate) { 302 | try { 303 | $scope.datasource.convertToKairosInterval($scope.target.horAggregator.samplingRate); 304 | } catch(err) { 305 | errors.samplingRate = err.message; 306 | $scope.isAggregatorValid = false; 307 | } 308 | } 309 | if($scope.hasFactor) { 310 | if(!$scope.target.horAggregator.factor) { 311 | errors.factor = 'You must supply a numeric value for this aggregator'; 312 | $scope.isAggregatorValid = false; 313 | } 314 | else if(parseInt($scope.target.horAggregator.factor)===0 && $scope.target.currentHorizontalAggregatorName==='div') { 315 | errors.factor = 'Cannot divide by 0'; 316 | $scope.isAggregatorValid = false; 317 | } 318 | } 319 | if($scope.hasPercentile) { 320 | if(!$scope.target.horAggregator.percentile || 321 | $scope.target.horAggregator.percentile<=0 || 322 | $scope.target.horAggregator.percentile>1) { 323 | errors.percentile = 'Percentile must be between 0 and 1'; 324 | $scope.isAggregatorValid = false; 325 | } 326 | } 327 | 328 | if(!_.isEmpty(errors)) { 329 | $scope.target.errors.horAggregator = errors; 330 | } 331 | }; 332 | 333 | $scope.alert = function(message) { 334 | alert(message); 335 | }; 336 | 337 | ////////////////////////////// 338 | // VALIDATION 339 | ////////////////////////////// 340 | 341 | function MetricListToObject(MetricList) { 342 | var result = {}; 343 | var Metric; 344 | var MetricArray = []; 345 | var MetricCnt = 0; 346 | for (var i =0;i < MetricList.length; i++) { 347 | Metric = MetricList[i]; 348 | MetricArray = Metric.split('.'); 349 | if(!result.hasOwnProperty(MetricArray[0])) { 350 | result[MetricArray[0]] = {}; 351 | } 352 | if(!result[MetricArray[0]].hasOwnProperty(MetricArray[1])) { 353 | result[MetricArray[0]][MetricArray[1]] = []; 354 | } 355 | result[MetricArray[0]][MetricArray[1]].push(MetricArray[2]); 356 | } 357 | return result; 358 | } 359 | 360 | function validateTarget(target) { 361 | var errs = {}; 362 | 363 | if (!target.metric) { 364 | errs.metric = "You must supply a metric name."; 365 | } 366 | 367 | try { 368 | if (target.sampling) { 369 | $scope.datasource.convertToKairosInterval(target.sampling); 370 | } 371 | } catch(err) { 372 | errs.sampling = err.message; 373 | } 374 | 375 | return errs; 376 | } 377 | 378 | }); 379 | 380 | }); -------------------------------------------------------------------------------- /datasources/kairosdb/kairosdb.editor.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
8 | 9 |
10 | 50 | 51 |
    52 |
  • 53 | {{targetLetters[$index]}} 54 |
  • 55 |
  • 56 | 59 | 60 | 61 |
  • 62 |
63 | 64 | 65 | 66 | 126 | 127 |
128 | 129 |
130 | 131 | 132 | 133 |
134 | 394 |
395 |
396 | 397 | 398 |
399 |
400 |
401 |
402 |
403 |
    404 |
  • 405 | Downsampling with 406 |
  • 407 |
  • 408 | 409 |
  • 410 | 411 | 412 |
  • 413 | every 414 |
  • 415 |
  • 416 | 424 | 427 | 428 | 429 |
  • 430 |
431 |
432 |
433 |
434 |
435 | -------------------------------------------------------------------------------- /features/druid/README.md: -------------------------------------------------------------------------------- 1 | Grafana plugin for [Druid](http://druid.io/) real-time OLAP database. 2 | 3 | ![Screenshot](https://raw.githubusercontent.com/Quantiply/grafana-plugins/screenshot/features/druid/Uniques.png) 4 | 5 | ## Status 6 | 7 | This plugin is experimental. It's usable but still needs TLC. In particular, auto-completion for dimension names would really help. It supports timeseries, group by, and topN queries. For the filters, it supports a list of filters (AND) and negation (NOT) on a single expression. OR filters are not yet supported. To completely support all filters, the editor will need to let you build a tree. 8 | 9 | This plugin works with Grafana 1.9.x and has been tested against 1.9.1. Note that from 1.8.x to 1.9.x, the timestamp format for Grafana was changed from seconds to milliseconds. 10 | 11 | The code in Grafana master branch was recently reorganized so that all the files for each datasource live in a subdirectory under the [features](https://github.com/grafana/grafana/tree/master/src/app/features) directory. We've followed that convention for this plugin. 12 | 13 | ## Installation 14 | 15 | It must be installed in plugins/features/druid. You can do this by creating a symbolic link in the Grafana plugins directory so that the plugin/features directory maps to the features directory of this repo. 16 | 17 | Add this to your Grafana configuration file. 18 | 19 | ``` 20 | datasources: { 21 | druid: { 22 | type: 'DruidDatasource', 23 | url: '' 24 | } 25 | }, 26 | 27 | plugins: { 28 | panels: [], 29 | dependencies: ['features/druid/datasource'], 30 | } 31 | ``` 32 | 33 | An example configuration and dashboard is [here](https://github.com/Quantiply/grafana-druid-wikipedia/). -------------------------------------------------------------------------------- /features/druid/datasource.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014-2015 Quantiply Corporation. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | define([ 17 | 'angular', 18 | 'lodash', 19 | 'kbn', 20 | 'moment', 21 | './queryCtrl', 22 | ], 23 | function (angular, _, kbn, moment) { 24 | 'use strict'; 25 | 26 | var module = angular.module('grafana.services'); 27 | 28 | module.factory('DruidDatasource', function($q, $http, templateSrv, $timeout, $log) { 29 | 30 | function replaceTemplateValues(obj, attrList) { 31 | var substitutedVals = attrList.map(function (attr) { 32 | return templateSrv.replace(obj[attr]); 33 | }); 34 | return _.assign(_.clone(obj, true), _.zipObject(attrList, substitutedVals)); 35 | } 36 | 37 | var GRANULARITIES = [ 38 | ['minute', moment.duration(1, 'minute')], 39 | ['fifteen_minute', moment.duration(15, 'minute')], 40 | ['thirty_minute', moment.duration(30, 'minute')], 41 | ['hour', moment.duration(1, 'hour')], 42 | ['day', moment.duration(1, 'day')] 43 | ]; 44 | 45 | var filterTemplateExpanders = { 46 | "selector": _.partialRight(replaceTemplateValues, ['value']), 47 | "regex": _.partialRight(replaceTemplateValues, ['pattern']), 48 | }; 49 | 50 | function DruidDatasource(datasource) { 51 | this.type = 'druid'; 52 | this.editorSrc = 'plugins/features/druid/partials/query.editor.html'; 53 | this.url = datasource.url; 54 | this.name = datasource.name; 55 | this.supportMetrics = true; 56 | } 57 | 58 | //Get list of available datasources 59 | DruidDatasource.prototype.getDataSources = function() { 60 | return $http({method: 'GET', url: this.url + '/datasources'}).then(function (response) { 61 | return response.data; 62 | }); 63 | } 64 | 65 | /* Returns a promise which returns 66 | {"dimensions":["page_url","ip_netspeed", ...],"metrics":["count", ...]} 67 | */ 68 | DruidDatasource.prototype.getDimensionsAndMetrics = function (target, range) { 69 | var datasource = target.datasource; 70 | return $http({method: 'GET', url: this.url + '/datasources/' + datasource}).then(function (response) { 71 | return response.data; 72 | }); 73 | } 74 | 75 | // Called once per panel (graph) 76 | DruidDatasource.prototype.query = function(options) { 77 | var dataSource = this; 78 | var from = dateToMoment(options.range.from); 79 | var to = dateToMoment(options.range.to); 80 | 81 | $log.debug(options); 82 | 83 | var promises = options.targets.map(function (target) { 84 | var maxDataPointsByResolution = options.maxDataPoints; 85 | var maxDataPointsByConfig = target.maxDataPoints? target.maxDataPoints : Number.MAX_VALUE; 86 | var maxDataPoints = Math.min(maxDataPointsByResolution, maxDataPointsByConfig); 87 | var granularity = target.shouldOverrideGranularity? target.customGranularity : computeGranularity(from, to, maxDataPoints); 88 | //Round up to start of an interval 89 | //Width of bar chars in Grafana is determined by size of the smallest interval 90 | var roundedFrom = roundUpStartTime(from, granularity); 91 | return dataSource._doQuery(roundedFrom, to, granularity, target); 92 | }); 93 | 94 | return $q.all(promises).then(function(results) { 95 | return { data: _.flatten(results) }; 96 | }); 97 | }; 98 | 99 | DruidDatasource.prototype._doQuery = function (from, to, granularity, target) { 100 | var datasource = target.datasource; 101 | var filters = target.filters; 102 | var aggregators = getAggregations(target.aggregators); 103 | var postAggregators = target.postAggregators; 104 | var groupBy = target.groupBy; 105 | var limitSpec = null; 106 | var metricNames = getMetricNames(aggregators, postAggregators); 107 | var intervals = getQueryIntervals(from, to); 108 | var promise = null; 109 | 110 | if (target.queryType === 'topN') { 111 | var threshold = target.limit; 112 | var metric = target.metric; 113 | var dimension = target.dimension; 114 | promise = this._topNQuery(datasource, intervals, granularity, filters, aggregators, postAggregators, threshold, metric, dimension) 115 | .then(function(response) { 116 | return convertTopNData(response.data, dimension, metric); 117 | }); 118 | } 119 | else if (target.queryType === 'groupBy') { 120 | if (target.hasLimit) { 121 | limitSpec = getLimitSpec(target.limit, target.orderBy); 122 | } 123 | promise = this._groupByQuery(datasource, intervals, granularity, filters, aggregators, postAggregators, groupBy, limitSpec) 124 | .then(function(response) { 125 | return convertGroupByData(response.data, groupBy, metricNames); 126 | }); 127 | } 128 | else { 129 | promise = this._timeSeriesQuery(datasource, intervals, granularity, filters, aggregators, postAggregators) 130 | .then(function(response) { 131 | return convertTimeSeriesData(response.data, metricNames); 132 | }); 133 | } 134 | /* 135 | At this point the promise will return an list of time series of this form 136 | [ 137 | { 138 | target: , 139 | datapoints: [ 140 | [, ], 141 | ... 142 | ] 143 | }, 144 | ... 145 | ] 146 | 147 | Druid calculates metrics based on the intervals specified in the query but returns a timestamp rounded down. 148 | We need to adjust the first timestamp in each time series 149 | */ 150 | return promise.then(function (metrics) { 151 | var fromMs = formatTimestamp(from); 152 | metrics.forEach(function (metric) { 153 | if (metric.datapoints[0][1] < fromMs) { 154 | metric.datapoints[0][1] = fromMs; 155 | } 156 | }); 157 | return metrics; 158 | }); 159 | }; 160 | 161 | DruidDatasource.prototype._timeSeriesQuery = function (datasource, intervals, granularity, filters, aggregators, postAggregators) { 162 | var query = { 163 | "queryType": "timeseries", 164 | "dataSource": datasource, 165 | "granularity": granularity, 166 | "aggregations": aggregators, 167 | "postAggregations": postAggregators, 168 | "intervals": intervals 169 | }; 170 | 171 | if (filters && filters.length > 0) { 172 | query.filter = buildFilterTree(filters); 173 | } 174 | 175 | return this._druidQuery(query); 176 | }; 177 | 178 | DruidDatasource.prototype._topNQuery = function (datasource, intervals, granularity, filters, aggregators, postAggregators, threshold, metric, dimension) { 179 | var query = { 180 | "queryType": "topN", 181 | "dataSource": datasource, 182 | "granularity": granularity, 183 | "threshold": threshold, 184 | "dimension": dimension, 185 | "metric": metric, 186 | // "metric": {type: "inverted", metric: metric}, 187 | "aggregations": aggregators, 188 | "postAggregations": postAggregators, 189 | "intervals": intervals 190 | }; 191 | 192 | if (filters && filters.length > 0) { 193 | query.filter = buildFilterTree(filters); 194 | } 195 | 196 | return this._druidQuery(query); 197 | }; 198 | 199 | DruidDatasource.prototype._groupByQuery = function (datasource, intervals, granularity, filters, aggregators, postAggregators, groupBy, limitSpec) { 200 | var query = { 201 | "queryType": "groupBy", 202 | "dataSource": datasource, 203 | "granularity": granularity, 204 | "dimensions": groupBy, 205 | "aggregations": aggregators, 206 | "postAggregations": postAggregators, 207 | "intervals": intervals, 208 | "limitSpec": limitSpec 209 | }; 210 | 211 | if (filters && filters.length > 0) { 212 | query.filter = buildFilterTree(filters); 213 | } 214 | 215 | return this._druidQuery(query); 216 | }; 217 | 218 | DruidDatasource.prototype._druidQuery = function (query) { 219 | var options = { 220 | method: 'POST', 221 | url: this.url, 222 | data: query 223 | }; 224 | $log.debug(query); 225 | return $http(options); 226 | }; 227 | 228 | function getAggregations(aggregations) { 229 | return _.map(aggregations, function (agg) { 230 | if (agg.type === 'doubleMax' || agg.type === 'longMax') { 231 | //This is called max in Duid 0.7, doubleMax/longMax in 0.8 232 | return _.assign({}, agg, {type: 'max'}); 233 | } 234 | if (agg.type === 'doubleMin' || agg.type === 'longMin') { 235 | //This is called min in Duid 0.7, doubleMin/longMin in 0.8 236 | return _.assign({}, agg, {type: 'min'}); 237 | } 238 | return agg; 239 | }); 240 | } 241 | 242 | function getLimitSpec(limitNum, orderBy) { 243 | return { 244 | "type": "default", 245 | "limit": limitNum, 246 | "columns": orderBy.map(function (col) { 247 | return {"dimension": col, "direction": "DESCENDING"}; 248 | }) 249 | }; 250 | } 251 | 252 | function buildFilterTree(filters) { 253 | //Do template variable replacement 254 | var replacedFilters = filters.map(function (filter) { 255 | return filterTemplateExpanders[filter.type](filter); 256 | }) 257 | .map(function (filter) { 258 | var finalFilter = _.omit(filter, 'negate'); 259 | if (filter.negate) { 260 | return { "type": "not", "field": finalFilter }; 261 | } 262 | return finalFilter; 263 | }); 264 | if (replacedFilters) { 265 | if (replacedFilters.length === 1) { 266 | return replacedFilters[0]; 267 | } 268 | return { 269 | "type": "and", 270 | "fields": replacedFilters 271 | }; 272 | } 273 | return null; 274 | } 275 | 276 | function getQueryIntervals(from, to) { 277 | return [from.toISOString() + '/' + to.toISOString()]; 278 | } 279 | 280 | function getMetricNames(aggregators, postAggregators) { 281 | var displayAggs = _.filter(aggregators, function (agg) { 282 | return agg.type !== 'approxHistogramFold'; 283 | }); 284 | return _.union(_.pluck(displayAggs, 'name'), _.pluck(postAggregators, 'name')); 285 | } 286 | 287 | function formatTimestamp(ts) { 288 | return moment(ts).format('X')*1000 289 | } 290 | 291 | function convertTimeSeriesData(md, metrics) { 292 | return metrics.map(function (metric) { 293 | return { 294 | target: metric, 295 | datapoints: md.map(function (item) { 296 | return [ 297 | item.result[metric], 298 | formatTimestamp(item.timestamp) 299 | ]; 300 | }) 301 | }; 302 | }); 303 | } 304 | 305 | function getGroupName(groupBy, metric) { 306 | return groupBy.map(function (dim) { 307 | return metric.event[dim]; 308 | }) 309 | .join("-"); 310 | } 311 | 312 | function convertTopNData(md, dimension, metric) { 313 | /* 314 | Druid topN results look like this: 315 | [ 316 | { 317 | "timestamp": "ts1", 318 | "result": [ 319 | {"": d1, "": mv1}, 320 | {"": d2, "": mv2} 321 | ] 322 | }, 323 | { 324 | "timestamp": "ts2", 325 | "result": [ 326 | {"": d1, "": mv3}, 327 | {"": d2, "": mv4} 328 | ] 329 | }, 330 | ... 331 | ] 332 | */ 333 | 334 | /* 335 | First, we need make sure that the result for each 336 | timestamp contains entries for all distinct dimension values 337 | in the entire list of results. 338 | 339 | Otherwise, if we do a stacked bar chart, Grafana doesn't sum 340 | the metrics correctly. 341 | */ 342 | 343 | //Get the list of all distinct dimension values for the entire result set 344 | var dVals = md.reduce(function (dValsSoFar, tsItem) { 345 | var dValsForTs = _.pluck(tsItem.result, dimension); 346 | return _.union(dValsSoFar, dValsForTs); 347 | }, {}); 348 | 349 | //Add null for the metric for any missing dimension values per timestamp result 350 | md.forEach(function (tsItem) { 351 | var dValsPresent = _.pluck(tsItem.result, dimension); 352 | var dValsMissing = _.difference(dVals, dValsPresent); 353 | dValsMissing.forEach(function (dVal) { 354 | var nullPoint = {}; 355 | nullPoint[dimension] = dVal; 356 | nullPoint[metric] = null; 357 | tsItem.result.push(nullPoint); 358 | }); 359 | return tsItem; 360 | }); 361 | 362 | //Re-index the results by dimension value instead of time interval 363 | var mergedData = md.map(function (item) { 364 | /* 365 | This first map() transforms this into a list of objects 366 | where the keys are dimension values 367 | and the values are [metricValue, unixTime] so that we get this: 368 | [ 369 | { 370 | "d1": [mv1, ts1], 371 | "d2": [mv2, ts1] 372 | }, 373 | { 374 | "d1": [mv3, ts2], 375 | "d2": [mv4, ts2] 376 | }, 377 | ... 378 | ] 379 | */ 380 | var timestamp = formatTimestamp(item.timestamp); 381 | var keys = _.pluck(item.result, dimension); 382 | var vals = _.pluck(item.result, metric).map(function (val) { return [val, timestamp]}); 383 | return _.zipObject(keys, vals); 384 | }) 385 | .reduce(function (prev, curr) { 386 | /* 387 | Reduce() collapses all of the mapped objects into a single 388 | object. The keys are dimension values 389 | and the values are arrays of all the values for the same key. 390 | The _.assign() function merges objects together and it's callback 391 | gets invoked for every key,value pair in the source (2nd argument). 392 | Since our initial value for reduce() is an empty object, 393 | the _.assign() callback will get called for every new val 394 | that we add to the final object. 395 | */ 396 | return _.assign(prev, curr, function (pVal, cVal) { 397 | if (pVal) { 398 | pVal.push(cVal); 399 | return pVal; 400 | } 401 | return [cVal]; 402 | }); 403 | }, {}); 404 | 405 | //Convert object keyed by dimension values into an array 406 | //of objects {target: , datapoints: } 407 | return _.map(mergedData, function (vals, key) { 408 | return { 409 | target: key, 410 | datapoints: vals 411 | }; 412 | }); 413 | } 414 | 415 | function convertGroupByData(md, groupBy, metrics) { 416 | var mergedData = md.map(function (item) { 417 | /* 418 | The first map() transforms the list Druid events into a list of objects 419 | with keys of the form ":" and values 420 | of the form [metricValue, unixTime] 421 | */ 422 | var groupName = getGroupName(groupBy, item); 423 | var keys = metrics.map(function (metric) { 424 | return groupName + ":" + metric; 425 | }); 426 | var vals = metrics.map(function (metric) { 427 | return [ 428 | item.event[metric], 429 | formatTimestamp(item.timestamp) 430 | ]; 431 | }); 432 | return _.zipObject(keys, vals); 433 | }) 434 | .reduce(function (prev, curr) { 435 | /* 436 | Reduce() collapses all of the mapped objects into a single 437 | object. The keys are still of the form ":" 438 | and the values are arrays of all the values for the same key. 439 | The _.assign() function merges objects together and it's callback 440 | gets invoked for every key,value pair in the source (2nd argument). 441 | Since our initial value for reduce() is an empty object, 442 | the _.assign() callback will get called for every new val 443 | that we add to the final object. 444 | */ 445 | return _.assign(prev, curr, function (pVal, cVal) { 446 | if (pVal) { 447 | pVal.push(cVal); 448 | return pVal; 449 | } 450 | return [cVal]; 451 | }); 452 | }, {}); 453 | 454 | return _.map(mergedData, function (vals, key) { 455 | /* 456 | Second map converts the aggregated object into an array 457 | */ 458 | return { 459 | target: key, 460 | datapoints: vals 461 | }; 462 | }); 463 | } 464 | 465 | function dateToMoment(date) { 466 | if (date === 'now') { 467 | return moment(); 468 | } 469 | return moment(kbn.parseDate(date)); 470 | } 471 | 472 | function computeGranularity(from, to, maxDataPoints) { 473 | var intervalSecs = to.unix() - from.unix(); 474 | /* 475 | Find the smallest granularity for which there 476 | will be fewer than maxDataPoints 477 | */ 478 | var granularityEntry = _.find(GRANULARITIES, function(gEntry) { 479 | return Math.ceil(intervalSecs/gEntry[1].asSeconds()) <= maxDataPoints; 480 | }); 481 | 482 | $log.debug("Calculated \"" + granularityEntry[0] + "\" granularity [" + Math.ceil(intervalSecs/granularityEntry[1].asSeconds()) + " pts]" + " for " + (intervalSecs/60).toFixed(0) + " minutes and max of " + maxDataPoints + " data points"); 483 | return granularityEntry[0]; 484 | } 485 | 486 | function roundUpStartTime(from, granularity) { 487 | var duration = _.find(GRANULARITIES, function (gEntry) { 488 | return gEntry[0] === granularity; 489 | })[1]; 490 | var rounded = moment(Math.ceil((+from)/(+duration)) * (+duration)); 491 | $log.debug("Rounding up start time from " + from.format() + " to " + rounded.format() + " for granularity [" + granularity + "]"); 492 | return rounded; 493 | } 494 | 495 | return DruidDatasource; 496 | }); 497 | 498 | }); 499 | -------------------------------------------------------------------------------- /features/druid/partials/query.editor.html: -------------------------------------------------------------------------------- 1 | 16 |
17 |
22 | 23 |
24 |
25 | 47 | 48 | 57 | 58 | 236 |
237 |
238 | 239 |
240 | 328 |
329 |
330 | 331 |
332 | 567 |
568 |
569 | 570 |
571 | 661 |
662 |
663 | 664 |
665 |
666 |
667 | -------------------------------------------------------------------------------- /features/druid/queryCtrl.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2014-2015 Quantiply Corporation. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | define([ 17 | 'angular', 18 | 'lodash' 19 | ], 20 | function (angular, _) { 21 | 'use strict'; 22 | 23 | var module = angular.module('grafana.controllers'); 24 | 25 | module.controller('DruidTargetCtrl', function($scope, $q, $timeout, $log) { 26 | 27 | var 28 | validateMaxDataPoints = function (target, errs) { 29 | if (target.maxDataPoints) { 30 | var intMax = parseInt(target.maxDataPoints); 31 | if (isNaN(intMax) || intMax <= 0) { 32 | errs.maxDataPoints = "Must be a positive integer"; 33 | return false; 34 | } 35 | target.maxDataPoints = intMax; 36 | } 37 | return true; 38 | }, 39 | validateLimit = function (target, errs) { 40 | if (!target.limit) { 41 | errs.limit = "Must specify a limit"; 42 | return false; 43 | } 44 | var intLimit = parseInt(target.limit); 45 | if (isNaN(intLimit)) { 46 | errs.limit = "Limit must be a integer"; 47 | return false; 48 | } 49 | target.limit = intLimit; 50 | if (target.orderBy && !Array.isArray(target.orderBy)) { 51 | target.orderBy = target.orderBy.split(","); 52 | } 53 | if (!target.orderBy) { 54 | errs.orderBy = "Must list columns to order by."; 55 | return false; 56 | } 57 | return true; 58 | }, 59 | validateGroupByQuery = function(target, errs) { 60 | if (target.groupBy && !Array.isArray(target.groupBy)) { 61 | target.groupBy = target.groupBy.split(","); 62 | } 63 | if (!target.groupBy) { 64 | errs.groupBy = "Must list dimensions to group by."; 65 | return false; 66 | } 67 | if (target.hasLimit) { 68 | if (!validateLimit(target, errs)) { 69 | return false; 70 | } 71 | } 72 | return true; 73 | }, 74 | validateTopNQuery = function(target, errs) { 75 | if (!validateLimit(target, errs)) { 76 | return false; 77 | } 78 | if (!target.metric) { 79 | errs.metric = "Must specify a metric"; 80 | return false; 81 | } 82 | if (!target.dimension) { 83 | errs.dimension = "Must specify a dimension"; 84 | return false; 85 | } 86 | return true; 87 | }, 88 | validateSelectorFilter = function(target) { 89 | if (!target.currentFilter.dimension) { 90 | return "Must provide dimension name for selector filter."; 91 | } 92 | if (!target.currentFilter.value) { 93 | //Empty string is how you match null or empty in Druid 94 | target.currentFilter.value = ""; 95 | } 96 | return null; 97 | }, 98 | validateRegexFilter = function(target) { 99 | if (!target.currentFilter.dimension) { 100 | return "Must provide dimension name for regex filter."; 101 | } 102 | if (!target.currentFilter.pattern) { 103 | return "Must provide pattern for regex filter."; 104 | } 105 | return null; 106 | }, 107 | validateCountAggregator = function(target) { 108 | if (!target.currentAggregator.name) { 109 | return "Must provide an output name for count aggregator."; 110 | } 111 | return null; 112 | }, 113 | validateSimpleAggregator = function(type, target) { 114 | if (!target.currentAggregator.name) { 115 | return "Must provide an output name for " + type + " aggregator."; 116 | } 117 | if (!target.currentAggregator.fieldName) { 118 | return "Must provide a metric name for " + type + " aggregator."; 119 | } 120 | //TODO - check that fieldName is a valid metric (exists and of correct type) 121 | return null; 122 | }, 123 | validateApproxHistogramFoldAggregator = function(target) { 124 | var err = validateSimpleAggregator('approxHistogramFold', target); 125 | if (err) { return err; } 126 | //TODO - check that resolution and numBuckets are ints (if given) 127 | //TODO - check that lowerLimit and upperLimit are flots (if given) 128 | return null; 129 | }, 130 | validateSimplePostAggregator = function(type, target) { 131 | if (!target.currentPostAggregator.name) { 132 | return "Must provide an output name for " + type + " post aggregator."; 133 | } 134 | if (!target.currentPostAggregator.fieldName) { 135 | return "Must provide an aggregator name for " + type + " post aggregator."; 136 | } 137 | //TODO - check that fieldName is a valid aggregation (exists and of correct type) 138 | return null; 139 | }, 140 | validateQuantilePostAggregator = function (target) { 141 | var err = validateSimplePostAggregator('quantile', target); 142 | if (err) { return err; } 143 | if (!target.currentPostAggregator.probability) { 144 | return "Must provide a probability for the quantile post aggregator."; 145 | } 146 | return null; 147 | }, 148 | validateArithmeticPostAggregator = function(target) { 149 | if (!target.currentPostAggregator.name) { 150 | return "Must provide an output name for arithmetic post aggregator."; 151 | } 152 | if (!target.currentPostAggregator.fn) { 153 | return "Must provide a function for arithmetic post aggregator."; 154 | } 155 | if (!isValidArithmeticPostAggregatorFn(target.currentPostAggregator.fn)) { 156 | return "Invalid arithmetic function"; 157 | } 158 | if (!target.currentPostAggregator.fields) { 159 | return "Must provide a list of fields for arithmetic post aggregator."; 160 | } 161 | else { 162 | if (!Array.isArray(target.currentPostAggregator.fields)) { 163 | target.currentPostAggregator.fields = target.currentPostAggregator.fields 164 | .split(",") 165 | .map(function (f) { return f.trim(); }) 166 | .map(function (f) { return {type: "fieldAccess", fieldName: f}; }); 167 | } 168 | if (target.currentPostAggregator.fields.length < 2) { 169 | return "Must provide at least two fields for arithmetic post aggregator."; 170 | } 171 | } 172 | return null; 173 | }, 174 | queryTypeValidators = { 175 | "timeseries": _.noop, 176 | "groupBy": validateGroupByQuery, 177 | "topN": validateTopNQuery 178 | }, 179 | filterValidators = { 180 | "selector": validateSelectorFilter, 181 | "regex": validateRegexFilter 182 | }, 183 | aggregatorValidators = { 184 | "count": validateCountAggregator, 185 | "longSum": _.partial(validateSimpleAggregator, 'longSum'), 186 | "doubleSum": _.partial(validateSimpleAggregator, 'doubleSum'), 187 | "doubleMax": _.partial(validateSimpleAggregator, 'doubleMax'), 188 | "doubleMin": _.partial(validateSimpleAggregator, 'doubleMin'), 189 | "longMax": _.partial(validateSimpleAggregator, 'longMax'), 190 | "longMin": _.partial(validateSimpleAggregator, 'longMin'), 191 | "approxHistogramFold": validateApproxHistogramFoldAggregator, 192 | "hyperUnique": _.partial(validateSimpleAggregator, 'hyperUnique') 193 | }, 194 | postAggregatorValidators = { 195 | "arithmetic": validateArithmeticPostAggregator, 196 | "quantile": validateQuantilePostAggregator 197 | }, 198 | arithmeticPostAggregatorFns = {'+': null, '-': null, '*': null, '/': null}, 199 | defaultQueryType = "timeseries", 200 | defaultFilterType = "selector", 201 | defaultAggregatorType = "count", 202 | defaultPostAggregator = {type: 'arithmetic', 'fn': '+'}, 203 | customGranularities = ['minute', 'fifteen_minute', 'thirty_minute', 'hour', 'day', 'all'], 204 | defaultCustomGranularity = 'minute', 205 | defaultLimit = 5; 206 | 207 | $scope.init = function() { 208 | $scope.target.errors = validateTarget($scope.target); 209 | $scope.queryTypes = _.keys(queryTypeValidators); 210 | $scope.filterTypes = _.keys(filterValidators); 211 | $scope.aggregatorTypes = _.keys(aggregatorValidators); 212 | $scope.postAggregatorTypes = _.keys(postAggregatorValidators); 213 | $scope.arithmeticPostAggregatorFns = _.keys(arithmeticPostAggregatorFns); 214 | $scope.customGranularities = customGranularities; 215 | 216 | if (!$scope.target.queryType) { 217 | $scope.target.queryType = defaultQueryType; 218 | } 219 | 220 | if (!$scope.target.currentFilter) { 221 | clearCurrentFilter(); 222 | } 223 | 224 | if (!$scope.target.currentAggregator) { 225 | clearCurrentAggregator(); 226 | } 227 | 228 | if (!$scope.target.currentPostAggregator) { 229 | clearCurrentPostAggregator(); 230 | } 231 | 232 | if (!$scope.target.customGranularity) { 233 | $scope.target.customGranularity = defaultCustomGranularity; 234 | } 235 | 236 | if (!$scope.target.limit) { 237 | $scope.target.limit = defaultLimit; 238 | } 239 | 240 | $scope.$on('typeahead-updated', function() { 241 | $timeout($scope.targetBlur); 242 | }); 243 | }; 244 | 245 | /* 246 | rhoover: copied this function from OpenTSDB. 247 | I don't know what the comment below refers to 248 | */ 249 | $scope.targetBlur = function() { 250 | $scope.target.errors = validateTarget($scope.target); 251 | 252 | // this does not work so good 253 | if (!_.isEqual($scope.oldTarget, $scope.target) && _.isEmpty($scope.target.errors)) { 254 | $scope.oldTarget = angular.copy($scope.target); 255 | $scope.get_data(); 256 | } 257 | }; 258 | 259 | $scope.duplicate = function() { 260 | var clone = angular.copy($scope.target); 261 | $scope.panel.targets.push(clone); 262 | }; 263 | 264 | $scope.listDataSources = function(query, callback) { 265 | $log.debug("Datasource type-ahead query"); 266 | var ioFn = _.bind($scope.datasource.getDataSources, $scope.datasource); 267 | return cachedAndCoalesced(ioFn, $scope, 'dataSourceList').then(function(sources) { 268 | callback(sources); 269 | }); 270 | }; 271 | 272 | $scope.getDimensions = function(query, callback) { 273 | $log.debug("Dimension type-ahead query"); 274 | return $scope.getDimensionsAndMetrics(query).then(function (dimsAndMetrics) { 275 | callback(dimsAndMetrics.dimensions); 276 | }); 277 | }; 278 | 279 | $scope.getMetrics = function(query, callback) { 280 | $log.debug("Metric type-ahead query"); 281 | return $scope.getDimensionsAndMetrics(query).then(function (dimsAndMetrics) { 282 | callback(dimsAndMetrics.metrics); 283 | }); 284 | }; 285 | 286 | $scope.getDimensionsAndMetrics = function(query) { 287 | var ioFn = _.bind($scope.datasource.getDimensionsAndMetrics, $scope.datasource, $scope.target, $scope.range); 288 | return cachedAndCoalesced(ioFn, $scope, 'dimensionsAndMetrics'); 289 | }; 290 | 291 | $scope.addFilter = function() { 292 | if (!$scope.addFilterMode) { 293 | //Enabling this mode will display the filter inputs 294 | $scope.addFilterMode = true; 295 | return; 296 | } 297 | 298 | if (!$scope.target.filters) { 299 | $scope.target.filters = []; 300 | } 301 | 302 | $scope.target.errors = validateTarget($scope.target); 303 | if (!$scope.target.errors.currentFilter) { 304 | //Add new filter to the list 305 | $scope.target.filters.push($scope.target.currentFilter); 306 | clearCurrentFilter(); 307 | $scope.addFilterMode = false; 308 | } 309 | 310 | $scope.targetBlur(); 311 | }; 312 | 313 | $scope.removeFilter = function(index) { 314 | $scope.target.filters.splice(index, 1); 315 | $scope.targetBlur(); 316 | }; 317 | 318 | $scope.clearCurrentFilter = function() { 319 | clearCurrentFilter(); 320 | $scope.addFilterMode = false; 321 | $scope.targetBlur(); 322 | }; 323 | 324 | $scope.addAggregator = function() { 325 | if (!$scope.addAggregatorMode) { 326 | $scope.addAggregatorMode = true; 327 | return; 328 | } 329 | 330 | if (!$scope.target.aggregators) { 331 | $scope.target.aggregators = []; 332 | } 333 | 334 | $scope.target.errors = validateTarget($scope.target); 335 | if (!$scope.target.errors.currentAggregator) { 336 | //Add new aggregator to the list 337 | $scope.target.aggregators.push($scope.target.currentAggregator); 338 | clearCurrentAggregator(); 339 | $scope.addAggregatorMode = false; 340 | } 341 | 342 | $scope.targetBlur(); 343 | }; 344 | 345 | $scope.removeAggregator = function(index) { 346 | $scope.target.aggregators.splice(index, 1); 347 | $scope.targetBlur(); 348 | }; 349 | 350 | $scope.clearCurrentAggregator = function() { 351 | clearCurrentAggregator(); 352 | $scope.addAggregatorMode = false; 353 | $scope.targetBlur(); 354 | }; 355 | 356 | $scope.addPostAggregator = function() { 357 | if (!$scope.addPostAggregatorMode) { 358 | $scope.addPostAggregatorMode = true; 359 | return; 360 | } 361 | 362 | if (!$scope.target.postAggregators) { 363 | $scope.target.postAggregators = []; 364 | } 365 | 366 | $scope.target.errors = validateTarget($scope.target); 367 | if (!$scope.target.errors.currentPostAggregator) { 368 | //Add new post aggregator to the list 369 | $scope.target.postAggregators.push($scope.target.currentPostAggregator); 370 | clearCurrentPostAggregator(); 371 | $scope.addPostAggregatorMode = false; 372 | } 373 | 374 | $scope.targetBlur(); 375 | }; 376 | 377 | $scope.removePostAggregator = function(index) { 378 | $scope.target.postAggregators.splice(index, 1); 379 | $scope.targetBlur(); 380 | }; 381 | 382 | $scope.clearCurrentPostAggregator = function() { 383 | clearCurrentPostAggregator(); 384 | $scope.addPostAggregatorMode = false; 385 | $scope.targetBlur(); 386 | }; 387 | 388 | function cachedAndCoalesced(ioFn, $scope, cacheName) { 389 | var promiseName = cacheName + "Promise"; 390 | if (!$scope[cacheName]) { 391 | $log.debug(cacheName + ": no cached value to use"); 392 | if (!$scope[promiseName]) { 393 | $log.debug(cacheName + ": making async call"); 394 | $scope[promiseName] = ioFn() 395 | .then(function(result) { 396 | $scope[promiseName] = null; 397 | $scope[cacheName] = result; 398 | return $scope[cacheName]; 399 | }); 400 | } 401 | else { 402 | $log.debug(cacheName + ": async call already in progress...returning same promise"); 403 | } 404 | return $scope[promiseName]; 405 | } 406 | else { 407 | $log.debug(cacheName + ": using cached value"); 408 | var deferred = $q.defer(); 409 | deferred.resolve($scope[cacheName]); 410 | return deferred.promise; 411 | } 412 | }; 413 | 414 | function isValidFilterType(type) { 415 | return _.has(filterValidators, type); 416 | } 417 | 418 | function isValidAggregatorType(type) { 419 | return _.has(aggregatorValidators, type); 420 | } 421 | 422 | function isValidPostAggregatorType(type) { 423 | return _.has(postAggregatorValidators, type); 424 | } 425 | 426 | function isValidQueryType(type) { 427 | return _.has(queryTypeValidators, type); 428 | } 429 | 430 | function isValidArithmeticPostAggregatorFn(fn) { 431 | return _.has(arithmeticPostAggregatorFns, fn); 432 | } 433 | 434 | function clearCurrentFilter() { 435 | $scope.target.currentFilter = {type: defaultFilterType}; 436 | } 437 | 438 | function clearCurrentAggregator() { 439 | $scope.target.currentAggregator = {type: defaultAggregatorType}; 440 | } 441 | 442 | function clearCurrentPostAggregator() { 443 | $scope.target.currentPostAggregator = _.clone(defaultPostAggregator); 444 | } 445 | 446 | function validateTarget(target) { 447 | var validatorOut, errs = {}; 448 | 449 | if (!target.datasource) { 450 | errs.datasource = "You must supply a datasource name."; 451 | } 452 | 453 | if (!target.queryType) { 454 | errs.queryType = "You must supply a query type."; 455 | } 456 | else if (!isValidQueryType(target.queryType)) { 457 | errs.queryType = "Unknown query type: " + target.queryType + "."; 458 | } 459 | else { 460 | queryTypeValidators[target.queryType](target, errs); 461 | } 462 | 463 | if (target.shouldOverrideGranularity) { 464 | if (target.customGranularity) { 465 | if (!_.contains(customGranularities, target.customGranularity)) { 466 | errs.customGranularity = "Invalid granularity."; 467 | } 468 | } 469 | else { 470 | errs.customGranularity = "You must choose a granularity."; 471 | } 472 | } 473 | else { 474 | validateMaxDataPoints(target, errs); 475 | } 476 | 477 | if ($scope.addFilterMode) { 478 | if (!isValidFilterType(target.currentFilter.type)) { 479 | errs.currentFilter = "Invalid filter type: " + target.currentFilter.type + "."; 480 | } 481 | else { 482 | validatorOut = filterValidators[target.currentFilter.type](target); 483 | if (validatorOut) { 484 | errs.currentFilter = validatorOut; 485 | } 486 | } 487 | } 488 | 489 | if ($scope.addAggregatorMode) { 490 | if (!isValidAggregatorType(target.currentAggregator.type)) { 491 | errs.currentAggregator = "Invalid aggregator type: " + target.currentAggregator.type + "."; 492 | } 493 | else { 494 | validatorOut = aggregatorValidators[target.currentAggregator.type](target); 495 | if (validatorOut) { 496 | errs.currentAggregator = validatorOut; 497 | } 498 | } 499 | } 500 | 501 | if (!$scope.target.aggregators) { 502 | errs.aggregators = "You must supply at least one aggregator"; 503 | } 504 | 505 | if ($scope.addPostAggregatorMode) { 506 | if (!isValidPostAggregatorType(target.currentPostAggregator.type)) { 507 | errs.currentPostAggregator = "Invalid post aggregator type: " + target.currentPostAggregator.type + "."; 508 | } 509 | else { 510 | validatorOut = postAggregatorValidators[target.currentPostAggregator.type](target); 511 | if (validatorOut) { 512 | errs.currentPostAggregator = validatorOut; 513 | } 514 | } 515 | } 516 | 517 | return errs; 518 | } 519 | 520 | }); 521 | 522 | }); 523 | --------------------------------------------------------------------------------