From 7e8b279895ed4070ab90f14f9d87838557b56928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 16 Sep 2016 10:48:03 +0200 Subject: [PATCH] feat(templating): good progress on new variable update code, #6048 --- public/app/features/all.js | 2 +- public/app/features/templating/all.ts | 13 + .../features/templating/interval_variable.ts | 48 +++ .../app/features/templating/query_variable.ts | 125 ++++++++ .../templating/specs/variable_srv_specs.ts | 277 +++++++++++++++++- public/app/features/templating/templateSrv.js | 3 - public/app/features/templating/variable.ts | 5 + .../app/features/templating/variable_srv.ts | 171 ++++++----- 8 files changed, 567 insertions(+), 77 deletions(-) create mode 100644 public/app/features/templating/all.ts create mode 100644 public/app/features/templating/interval_variable.ts create mode 100644 public/app/features/templating/query_variable.ts create mode 100644 public/app/features/templating/variable.ts diff --git a/public/app/features/all.js b/public/app/features/all.js index 43d9f33f190..cd7adb49de6 100644 --- a/public/app/features/all.js +++ b/public/app/features/all.js @@ -2,7 +2,7 @@ define([ './panellinks/module', './dashlinks/module', './annotations/annotations_srv', - './templating/templateSrv', + './templating/all', './dashboard/all', './playlist/all', './snapshot/all', diff --git a/public/app/features/templating/all.ts b/public/app/features/templating/all.ts new file mode 100644 index 00000000000..208212c4034 --- /dev/null +++ b/public/app/features/templating/all.ts @@ -0,0 +1,13 @@ +import './templateSrv'; +import './templateValuesSrv'; +import './editorCtrl'; + +import {VariableSrv} from './variable_srv'; +import {IntervalVariable} from './interval_variable'; +import {QueryVariable} from './query_variable'; + +export { + VariableSrv, + IntervalVariable, + QueryVariable, +} diff --git a/public/app/features/templating/interval_variable.ts b/public/app/features/templating/interval_variable.ts new file mode 100644 index 00000000000..e7177982e07 --- /dev/null +++ b/public/app/features/templating/interval_variable.ts @@ -0,0 +1,48 @@ +/// + +import _ from 'lodash'; +import kbn from 'app/core/utils/kbn'; +import {Variable} from './variable'; +import {VariableSrv, variableConstructorMap} from './variable_srv'; + +export class IntervalVariable implements Variable { + auto_count: number; + auto_min: number; + options: any; + auto: boolean; + query: string; + + /** @ngInject */ + constructor(private model, private timeSrv, private templateSrv) { + _.extend(this, model); + } + + setValue(option) { + if (this.auto) { + this.updateAutoValue(); + } + } + + updateAutoValue() { + // add auto option if missing + if (this.options.length && this.options[0].text !== 'auto') { + this.options.unshift({ text: 'auto', value: '$__auto_interval' }); + } + + var interval = kbn.calculateInterval(this.timeSrv.timeRange(), this.auto_count, (this.auto_min ? ">"+this.auto_min : null)); + this.templateSrv.setGrafanaVariable('$__auto_interval', interval); + } + + updateOptions() { + // extract options in comma separated string + this.options = _.map(this.query.split(/[,]+/), function(text) { + return {text: text.trim(), value: text.trim()}; + }); + + if (this.auto) { + this.updateAutoValue(); + } + } +} + +variableConstructorMap['interval'] = IntervalVariable; diff --git a/public/app/features/templating/query_variable.ts b/public/app/features/templating/query_variable.ts new file mode 100644 index 00000000000..2bcd89460d3 --- /dev/null +++ b/public/app/features/templating/query_variable.ts @@ -0,0 +1,125 @@ +/// + +import _ from 'lodash'; +import kbn from 'app/core/utils/kbn'; +import {Variable} from './variable'; +import {VariableSrv, variableConstructorMap} from './variable_srv'; + +function getNoneOption() { + return { text: 'None', value: '', isNone: true }; +} + +export class QueryVariable implements Variable { + datasource: any; + query: any; + regex: any; + sort: any; + options: any; + current: any; + includeAll: boolean; + + constructor(private model, private datasourceSrv, private templateSrv, private variableSrv) { + _.extend(this, model); + } + + setValue(option){ + this.current = _.cloneDeep(option); + + if (_.isArray(this.current.text)) { + this.current.text = this.current.text.join(' + '); + } + + this.variableSrv.selectOptionsForCurrentValue(this); + return this.variableSrv.variableUpdated(this); + } + + updateOptions() { + return this.datasourceSrv.get(this.datasource) + .then(this.updateOptionsFromMetricFindQuery.bind(this)) + .then(() => { + this.variableSrv.validateVariableSelectionState(this); + }); + } + + updateOptionsFromMetricFindQuery(datasource) { + return datasource.metricFindQuery(this.query).then(results => { + this.options = this.metricNamesToVariableValues(results); + if (this.includeAll) { + this.addAllOption(); + } + if (!this.options.length) { + this.options.push(getNoneOption()); + } + return datasource; + }); + } + + addAllOption() { + this.options.unshift({text: 'All', value: "$__all"}); + } + + metricNamesToVariableValues(metricNames) { + var regex, options, i, matches; + options = []; + + if (this.model.regex) { + regex = kbn.stringToJsRegex(this.templateSrv.replace(this.regex)); + } + + for (i = 0; i < metricNames.length; i++) { + var item = metricNames[i]; + var value = item.value || item.text; + var text = item.text || item.value; + + if (_.isNumber(value)) { + value = value.toString(); + } + + if (_.isNumber(text)) { + text = text.toString(); + } + + if (regex) { + matches = regex.exec(value); + if (!matches) { continue; } + if (matches.length > 1) { + value = matches[1]; + text = value; + } + } + + options.push({text: text, value: value}); + } + + options = _.uniq(options, 'value'); + return this.sortVariableValues(options, this.sort); + } + + sortVariableValues(options, sortOrder) { + if (sortOrder === 0) { + return options; + } + + var sortType = Math.ceil(sortOrder / 2); + var reverseSort = (sortOrder % 2 === 0); + if (sortType === 1) { + options = _.sortBy(options, 'text'); + } else if (sortType === 2) { + options = _.sortBy(options, function(opt) { + var matches = opt.text.match(new RegExp(".*?(\d+).*")); + if (!matches) { + return 0; + } else { + return parseInt(matches[1], 10); + } + }); + } + if (reverseSort) { + options = options.reverse(); + } + + return options; + } +} + +variableConstructorMap['query'] = QueryVariable; diff --git a/public/app/features/templating/specs/variable_srv_specs.ts b/public/app/features/templating/specs/variable_srv_specs.ts index 4d0c88e2e39..c5a1ea6f536 100644 --- a/public/app/features/templating/specs/variable_srv_specs.ts +++ b/public/app/features/templating/specs/variable_srv_specs.ts @@ -1,4 +1,279 @@ import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common'; -describe('VariableSrv', function() { +import moment from 'moment'; +import helpers from 'test/specs/helpers'; +import '../all'; + +describe.only('VariableSrv', function() { + var ctx = new helpers.ControllerTestContext(); + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.controllers')); + beforeEach(angularMocks.module('grafana.services')); + + beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location'])); + beforeEach(angularMocks.inject(($rootScope, $q, $location, $injector) => { + ctx.$q = $q; + ctx.$rootScope = $rootScope; + ctx.variableSrv = $injector.get('variableSrv'); + ctx.variableSrv.init({templating: {list: []}}); + ctx.$rootScope.$digest(); + })); + + function describeUpdateVariable(desc, fn) { + describe(desc, function() { + var scenario: any = {}; + scenario.setup = function(setupFn) { + scenario.setupFn = setupFn; + }; + + beforeEach(function() { + scenario.setupFn(); + var ds: any = {}; + ds.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult)); + ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ds)); + + scenario.variable = ctx.variableSrv.addVariable(scenario.variableModel); + ctx.variableSrv.updateOptions(scenario.variable); + ctx.$rootScope.$digest(); + }); + + fn(scenario); + }); + } + + describeUpdateVariable('interval variable without auto', scenario => { + scenario.setup(() => { + scenario.variableModel = {type: 'interval', query: '1s,2h,5h,1d', name: 'test'}; + }); + + it('should update options array', () => { + expect(scenario.variable.options.length).to.be(4); + expect(scenario.variable.options[0].text).to.be('1s'); + expect(scenario.variable.options[0].value).to.be('1s'); + }); + }); + + // + // Interval variable update + // + describeUpdateVariable('interval variable with auto', scenario => { + scenario.setup(() => { + scenario.variableModel = {type: 'interval', query: '1s,2h,5h,1d', name: 'test', auto: true, auto_count: 10 }; + + var range = { + from: moment(new Date()).subtract(7, 'days').toDate(), + to: new Date() + }; + + ctx.timeSrv.timeRange = sinon.stub().returns(range); + ctx.templateSrv.setGrafanaVariable = sinon.spy(); + }); + + it('should update options array', function() { + expect(scenario.variable.options.length).to.be(5); + expect(scenario.variable.options[0].text).to.be('auto'); + expect(scenario.variable.options[0].value).to.be('$__auto_interval'); + }); + + it('should set $__auto_interval', function() { + var call = ctx.templateSrv.setGrafanaVariable.getCall(0); + expect(call.args[0]).to.be('$__auto_interval'); + expect(call.args[1]).to.be('12h'); + }); + }); + + // + // Query variable update + // + describeUpdateVariable('query variable with empty current object and refresh', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: '', name: 'test', current: {}}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should set current value to first option', function() { + expect(scenario.variable.options.length).to.be(2); + expect(scenario.variable.current.value).to.be('backend1'); + }); + }); + + describeUpdateVariable('query variable with multi select and new options does not contain some selected values', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + current: { + value: ['val1', 'val2', 'val3'], + text: 'val1 + val2 + val3' + } + }; + scenario.queryResult = [{text: 'val2'}, {text: 'val3'}]; + }); + + it('should update current value', function() { + expect(scenario.variable.current.value).to.eql(['val2', 'val3']); + expect(scenario.variable.current.text).to.eql('val2 + val3'); + }); + }); + + describeUpdateVariable('query variable with multi select and new options does not contain any selected values', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + current: { + value: ['val1', 'val2', 'val3'], + text: 'val1 + val2 + val3' + } + }; + scenario.queryResult = [{text: 'val5'}, {text: 'val6'}]; + }); + + it('should update current value with first one', function() { + expect(scenario.variable.current.value).to.eql('val5'); + expect(scenario.variable.current.text).to.eql('val5'); + }); + }); + + describeUpdateVariable('query variable with multi select and $__all selected', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { + type: 'query', + query: '', + name: 'test', + includeAll: true, + current: { + value: ['$__all'], + text: 'All' + } + }; + scenario.queryResult = [{text: 'val5'}, {text: 'val6'}]; + }); + + it('should keep current All value', function() { + expect(scenario.variable.current.value).to.eql(['$__all']); + expect(scenario.variable.current.text).to.eql('All'); + }); + }); + + describeUpdateVariable('query variable with numeric results', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { type: 'query', query: '', name: 'test', current: {} }; + scenario.queryResult = [{text: 12, value: 12}]; + }); + + it('should set current value to first option', function() { + expect(scenario.variable.current.value).to.be('12'); + expect(scenario.variable.options[0].value).to.be('12'); + expect(scenario.variable.options[0].text).to.be('12'); + }); + }); + + describeUpdateVariable('basic query variable', function(scenario) { + scenario.setup(function() { + scenario.variableModel = { type: 'query', query: 'apps.*', name: 'test' }; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should update options array', function() { + expect(scenario.variable.options.length).to.be(2); + expect(scenario.variable.options[0].text).to.be('backend1'); + expect(scenario.variable.options[0].value).to.be('backend1'); + expect(scenario.variable.options[1].value).to.be('backend2'); + }); + + it('should select first option as value', function() { + expect(scenario.variable.current.value).to.be('backend1'); + }); + }); + + describeUpdateVariable('and existing value still exists in options', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.current = { value: 'backend2', text: 'backend2'}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}]; + }); + + it('should keep variable value', function() { + expect(scenario.variable.current.text).to.be('backend2'); + }); + }); + + describeUpdateVariable('and regex pattern exists', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = '/apps.*(backend_[0-9]+)/'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should extract and use match group', function() { + expect(scenario.variable.options[0].value).to.be('backend_01'); + }); + }); + + describeUpdateVariable('and regex pattern exists and no match', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = '/apps.*(backendasd[0-9]+)/'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should not add non matching items, None option should be added instead', function() { + expect(scenario.variable.options.length).to.be(1); + expect(scenario.variable.options[0].isNone).to.be(true); + }); + }); + + describeUpdateVariable('regex pattern without slashes', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = 'backend_01'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_02.counters.req'}]; + }); + + it('should return matches options', function() { + expect(scenario.variable.options.length).to.be(1); + }); + }); + + describeUpdateVariable('regex pattern remove duplicates', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test'}; + scenario.variableModel.regex = 'backend_01'; + scenario.queryResult = [{text: 'apps.backend.backend_01.counters.req'}, {text: 'apps.backend.backend_01.counters.req'}]; + }); + + it('should return matches options', function() { + expect(scenario.variable.options.length).to.be(1); + }); + }); + + describeUpdateVariable('with include All', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', includeAll: true}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}, { text: 'backend3'}]; + }); + + it('should add All option', function() { + expect(scenario.variable.options[0].text).to.be('All'); + expect(scenario.variable.options[0].value).to.be('$__all'); + }); + }); + + describeUpdateVariable('with include all and custom value', function(scenario) { + scenario.setup(function() { + scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', includeAll: true, allValue: '*'}; + scenario.queryResult = [{text: 'backend1'}, {text: 'backend2'}, { text: 'backend3'}]; + }); + + it('should add All option with custom value', function() { + expect(scenario.variable.options[0].value).to.be('$__all'); + }); + }); + }); + + diff --git a/public/app/features/templating/templateSrv.js b/public/app/features/templating/templateSrv.js index 130e2fed246..d6ed0b65877 100644 --- a/public/app/features/templating/templateSrv.js +++ b/public/app/features/templating/templateSrv.js @@ -1,9 +1,6 @@ define([ 'angular', 'lodash', - './editorCtrl', - './variable_srv', - './templateValuesSrv', ], function (angular, _) { 'use strict'; diff --git a/public/app/features/templating/variable.ts b/public/app/features/templating/variable.ts new file mode 100644 index 00000000000..fb8b08112d6 --- /dev/null +++ b/public/app/features/templating/variable.ts @@ -0,0 +1,5 @@ + +export interface Variable { + setValue(option); +} + diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index d03559537dc..e7470bdfa98 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -3,49 +3,13 @@ import angular from 'angular'; import _ from 'lodash'; import $ from 'jquery'; +import kbn from 'app/core/utils/kbn'; import coreModule from 'app/core/core_module'; import appEvents from 'app/core/app_events'; +import {IntervalVariable} from './interval_variable'; +import {Variable} from './variable'; -interface Variable { -} - -class ConstantVariable implements Variable { - constructor(private model) { - } -} - -class CustomVariable implements Variable { - constructor(private model) { - } -} - -class IntervalVariable implements Variable { - constructor(private model) { - } -} - - -class QueryVariable implements Variable { - - constructor(private model, - private variableSrv: VariableSrv, - private datasourceSrv) { - _.extend(this, model); - } - - updateOptions() { - return this.datasourceSrv.get(this.datasource) - .then(_.partial(this.updateOptionsFromMetricFindQuery, variable)) - .then(_.partial(this.updateTags, variable)) - .then(_.partial(this.validateVariableSelectionState, variable)); - } -} - -class DatasourceVariable implements Variable { - constructor(private model) { - } -} - +export var variableConstructorMap: any = {}; export class VariableSrv { dashboard: any; @@ -55,53 +19,116 @@ export class VariableSrv { /** @ngInject */ constructor( - private $q, private $rootScope, - private datasourceSrv, + private $q, private $location, - private templateSrv, - private timeSrv) { + private $injector, + private templateSrv) { + } - } + init(dashboard) { + this.variableLock = {}; + this.dashboard = dashboard; + this.variables = []; - init(dashboard) { - this.variableLock = {}; - this.dashboard = dashboard; + dashboard.templating.list.map(this.addVariable.bind(this)); + this.templateSrv.init(this.variables); - this.variables = dashboard.templating.list.map(item => { - return new QueryVariable(item, this); - }); - - this.templateSrv.init(this.variables); - return this.$q.when(); - } - - updateOptions(variable) { - return variable.updateOptions(); - } - - variableUpdated(variable) { - // if there is a variable lock ignore cascading update because we are in a boot up scenario - if (this.variableLock[variable.name]) { return this.$q.when(); } - var promises = _.map(this.variables, otherVariable => { - if (otherVariable === variable) { - return; + addVariable(model) { + var ctor = variableConstructorMap[model.type]; + if (!ctor) { + throw "Unable to find variable constructor for " + model.type; } - if (this.templateSrv.containsVariable(otherVariable.regex, variable.name) || - this.templateSrv.containsVariable(otherVariable.query, variable.name) || - this.templateSrv.containsVariable(otherVariable.datasource, variable.name)) { + var variable = this.$injector.instantiate(ctor, {model: model}); + this.variables.push(variable); + this.dashboard.templating.list.push(model); + + return variable; + } + + updateOptions(variable) { + return variable.updateOptions(); + } + + variableUpdated(variable) { + // if there is a variable lock ignore cascading update because we are in a boot up scenario + if (this.variableLock[variable.name]) { + return this.$q.when(); + } + + var promises = _.map(this.variables, otherVariable => { + if (otherVariable === variable) { + return; + } + + if (this.templateSrv.containsVariable(otherVariable.regex, variable.name) || + this.templateSrv.containsVariable(otherVariable.query, variable.name) || + this.templateSrv.containsVariable(otherVariable.datasource, variable.name)) { return this.updateOptions(otherVariable); } - }); + }); - return this.$q.all(promises); - } + return this.$q.all(promises); + } + selectOptionsForCurrentValue(variable) { + var i, y, value, option; + var selected: any = []; + for (i = 0; i < variable.options.length; i++) { + option = variable.options[i]; + option.selected = false; + if (_.isArray(variable.current.value)) { + for (y = 0; y < variable.current.value.length; y++) { + value = variable.current.value[y]; + if (option.value === value) { + option.selected = true; + selected.push(option); + } + } + } else if (option.value === variable.current.value) { + option.selected = true; + selected.push(option); + } + } + + return selected; + } + + validateVariableSelectionState(variable) { + if (!variable.current) { + if (!variable.options.length) { return Promise.resolve(); } + return variable.setValue(variable.options[0]); + } + + if (_.isArray(variable.current.value)) { + var selected = this.selectOptionsForCurrentValue(variable); + + // if none pick first + if (selected.length === 0) { + selected = variable.options[0]; + } else { + selected = { + value: _.map(selected, function(val) {return val.value;}), + text: _.map(selected, function(val) {return val.text;}).join(' + '), + }; + } + + return variable.setValue(selected); + } else { + var currentOption = _.find(variable.options, {text: variable.current.text}); + if (currentOption) { + return variable.setValue(currentOption); + } else { + if (!variable.options.length) { return Promise.resolve(); } + return variable.setValue(variable.options[0]); + } + } + } } coreModule.service('variableSrv', VariableSrv);