Browse Source

feat(templating): good progress on new variable update code, #6048

Torkel Ödegaard 9 years ago
parent
commit
7e8b279895

+ 1 - 1
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',

+ 13 - 0
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,
+}

+ 48 - 0
public/app/features/templating/interval_variable.ts

@@ -0,0 +1,48 @@
+///<reference path="../../headers/common.d.ts" />
+
+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;

+ 125 - 0
public/app/features/templating/query_variable.ts

@@ -0,0 +1,125 @@
+///<reference path="../../headers/common.d.ts" />
+
+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;

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

+ 0 - 3
public/app/features/templating/templateSrv.js

@@ -1,9 +1,6 @@
 define([
   'angular',
   'lodash',
-  './editorCtrl',
-  './variable_srv',
-  './templateValuesSrv',
 ],
 function (angular, _) {
   'use strict';

+ 5 - 0
public/app/features/templating/variable.ts

@@ -0,0 +1,5 @@
+
+export interface Variable {
+  setValue(option);
+}
+

+ 97 - 70
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 = [];
 
-  }
+      dashboard.templating.list.map(this.addVariable.bind(this));
+      this.templateSrv.init(this.variables);
 
-  init(dashboard) {
-    this.variableLock = {};
-    this.dashboard = dashboard;
+      return this.$q.when();
+    }
 
-    this.variables = dashboard.templating.list.map(item => {
-      return new QueryVariable(item, this);
-    });
+    addVariable(model) {
+      var ctor = variableConstructorMap[model.type];
+      if (!ctor) {
+        throw "Unable to find variable constructor for " + model.type;
+      }
 
-    this.templateSrv.init(this.variables);
-    return this.$q.when();
-  }
+      var variable = this.$injector.instantiate(ctor, {model: model});
+      this.variables.push(variable);
+      this.dashboard.templating.list.push(model);
 
-  updateOptions(variable) {
-    return variable.updateOptions();
-  }
+      return variable;
+    }
 
-  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();
+    updateOptions(variable) {
+      return variable.updateOptions();
     }
 
-    var promises = _.map(this.variables, otherVariable => {
-      if (otherVariable === variable) {
-        return;
+    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();
       }
 
-      if (this.templateSrv.containsVariable(otherVariable.regex, variable.name) ||
-          this.templateSrv.containsVariable(otherVariable.query, variable.name) ||
-          this.templateSrv.containsVariable(otherVariable.datasource, variable.name)) {
+      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);