瀏覽代碼

Merge branch 'master' into gnet-oauth

Conflicts:
	pkg/api/login_oauth.go
Dan Cech 9 年之前
父節點
當前提交
b387c1291d
共有 72 個文件被更改,包括 3878 次插入2450 次删除
  1. 1 1
      .github/CONTRIBUTING.md
  2. 1 0
      CHANGELOG.md
  3. 4 1
      Gruntfile.js
  4. 1 1
      README.md
  5. 38 4
      build.go
  6. 1 1
      conf/defaults.ini
  7. 3 4
      pkg/api/login_oauth.go
  8. 0 2
      pkg/cmd/grafana-cli/services/services.go
  9. 8 2
      public/app/core/controllers/login_ctrl.js
  10. 1 0
      public/app/core/services/context_srv.ts
  11. 4 0
      public/app/core/utils/kbn.js
  12. 1 1
      public/app/features/all.js
  13. 1 1
      public/app/features/annotations/annotations_srv.ts
  14. 171 0
      public/app/features/dashboard/ad_hoc_filters.ts
  15. 2 1
      public/app/features/dashboard/all.js
  16. 0 552
      public/app/features/dashboard/dashboardSrv.js
  17. 2 3
      public/app/features/dashboard/dashboard_ctrl.ts
  18. 590 0
      public/app/features/dashboard/dashboard_srv.ts
  19. 14 2
      public/app/features/dashboard/export/exporter.ts
  20. 379 0
      public/app/features/dashboard/specs/dashboard_srv_specs.ts
  21. 2 1
      public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts
  22. 31 8
      public/app/features/dashboard/specs/exporter_specs.ts
  23. 22 23
      public/app/features/dashboard/submenu/submenu.html
  24. 5 6
      public/app/features/dashboard/submenu/submenu.ts
  25. 1 21
      public/app/features/dashboard/viewStateSrv.js
  26. 5 3
      public/app/features/dashlinks/module.js
  27. 6 3
      public/app/features/styleguide/styleguide.ts
  28. 74 0
      public/app/features/templating/adhoc_variable.ts
  29. 20 0
      public/app/features/templating/all.ts
  30. 59 0
      public/app/features/templating/constant_variable.ts
  31. 80 0
      public/app/features/templating/custom_variable.ts
  32. 87 0
      public/app/features/templating/datasource_variable.ts
  33. 0 198
      public/app/features/templating/editorCtrl.js
  34. 155 0
      public/app/features/templating/editor_ctrl.ts
  35. 89 0
      public/app/features/templating/interval_variable.ts
  36. 58 54
      public/app/features/templating/partials/editor.html
  37. 167 0
      public/app/features/templating/query_variable.ts
  38. 40 0
      public/app/features/templating/specs/adhoc_variable_specs.ts
  39. 39 0
      public/app/features/templating/specs/query_variable_specs.ts
  40. 237 0
      public/app/features/templating/specs/template_srv_specs.ts
  41. 59 0
      public/app/features/templating/specs/variable_specs.ts
  42. 142 0
      public/app/features/templating/specs/variable_srv_init_specs.ts
  43. 395 0
      public/app/features/templating/specs/variable_srv_specs.ts
  44. 24 29
      public/app/features/templating/templateSrv.js
  45. 8 3
      public/app/features/templating/templateValuesSrv.js
  46. 40 0
      public/app/features/templating/variable.ts
  47. 233 0
      public/app/features/templating/variable_srv.ts
  48. 2 2
      public/app/partials/valueSelectDropdown.html
  49. 32 0
      public/app/plugins/datasource/cloudwatch/datasource.js
  50. 32 0
      public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts
  51. 17 2
      public/app/plugins/datasource/elasticsearch/datasource.js
  52. 19 1
      public/app/plugins/datasource/elasticsearch/query_builder.js
  53. 12 0
      public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts
  54. 38 16
      public/app/plugins/datasource/influxdb/datasource.ts
  55. 24 2
      public/app/plugins/datasource/influxdb/influx_query.ts
  56. 13 0
      public/app/plugins/datasource/influxdb/specs/influx_query_specs.ts
  57. 3 3
      public/app/plugins/datasource/prometheus/datasource.ts
  58. 2 1
      public/app/plugins/panel/graph/graph.js
  59. 47 0
      public/app/plugins/panel/graph/specs/graph_specs.ts
  60. 120 202
      public/app/plugins/panel/singlestat/editor.html
  61. 123 157
      public/app/plugins/panel/table/editor.html
  62. 2 1
      public/app/plugins/panel/table/module.ts
  63. 1 1
      public/sass/components/_gf-form.scss
  64. 3 17
      public/sass/components/_submenu.scss
  65. 16 0
      public/test/core/utils/emitter_specs.ts
  66. 0 388
      public/test/specs/dashboardSrv-specs.js
  67. 1 0
      public/test/specs/helpers.js
  68. 0 267
      public/test/specs/templateSrv-specs.js
  69. 32 428
      public/test/specs/templateValuesSrv-specs.js
  70. 2 1
      public/test/specs/unsavedChangesSrv-specs.js
  71. 4 2
      public/vendor/flot/jquery.flot.js
  72. 33 34
      tasks/options/phantomjs.js

+ 1 - 1
.github/CONTRIBUTING.md

@@ -12,7 +12,7 @@ grunt karma:dev
 
 ### Run tests for backend assets before commit
 ```
-test -z "$(gofmt -s -l . | grep -v vendor/src/ | tee /dev/stderr)"
+test -z "$(gofmt -s -l . | grep -v -E 'vendor/(github.com|golang.org|gopkg.in)' | tee /dev/stderr)"
 ```
 
 ### Run tests for frontend assets before commit

+ 1 - 0
CHANGELOG.md

@@ -12,6 +12,7 @@
 * **Graphite**: Add support for groupByNode, closes [#5613](https://github.com/grafana/grafana/pull/5613)
 * **Influxdb**: Add support for elapsed(), closes [#5827](https://github.com/grafana/grafana/pull/5827)
 * **OAuth**: Add support for generic oauth, closes [#4718](https://github.com/grafana/grafana/pull/4718)
+* **Cloudwatch**: Add support to expand multi select template variable, closes [#5003](https://github.com/grafana/grafana/pull/5003)
 
 ### Breaking changes
 * **SystemD**: Change systemd description, closes [#5971](https://github.com/grafana/grafana/pull/5971)

+ 4 - 1
Gruntfile.js

@@ -9,7 +9,6 @@ module.exports = function (grunt) {
     genDir: 'public_gen',
     destDir: 'dist',
     tempDir: 'tmp',
-    arch: os.arch(),
     platform: process.platform.replace('win32', 'windows'),
   };
 
@@ -17,6 +16,10 @@ module.exports = function (grunt) {
     config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86';
   }
 
+  config.arch = grunt.option('arch') || os.arch();
+
+  config.phjs = grunt.option('phjsToRelease');
+
   config.pkg.version = grunt.option('pkgVer') || config.pkg.version;
   console.log('Version', config.pkg.version);
 

+ 1 - 1
README.md

@@ -96,7 +96,7 @@ easily the grafana repository you want to build.
 ```bash
 go get github.com/*your_account*/grafana
 mkdir $GOPATH/src/github.com/grafana
-ln -s  github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana
+ln -s  $GOPATH/src/github.com/*your_account*/grafana $GOPATH/src/github.com/grafana/grafana
 ```
 
 ### Building the backend

+ 38 - 4
build.go

@@ -25,11 +25,16 @@ var (
 	versionRe = regexp.MustCompile(`-[0-9]{1,3}-g[0-9a-f]{5,10}`)
 	goarch    string
 	goos      string
+	gocc      string
+	gocxx     string
+	cgo       string
+	pkgArch   string
 	version   string = "v1"
 	// deb & rpm does not support semver so have to handle their version a little differently
 	linuxPackageVersion   string = "v1"
 	linuxPackageIteration string = ""
 	race                  bool
+	phjsToRelease         string
 	workingDir            string
 	binaries              []string = []string{"grafana-server", "grafana-cli"}
 )
@@ -47,6 +52,11 @@ func main() {
 
 	flag.StringVar(&goarch, "goarch", runtime.GOARCH, "GOARCH")
 	flag.StringVar(&goos, "goos", runtime.GOOS, "GOOS")
+	flag.StringVar(&gocc, "cc", "", "CC")
+	flag.StringVar(&gocxx, "cxx", "", "CXX")
+	flag.StringVar(&cgo, "cgo-enabled", "", "CGO_ENABLED")
+	flag.StringVar(&pkgArch, "pkg-arch", "", "PKG ARCH")
+	flag.StringVar(&phjsToRelease, "phjs", "", "PhantomJS binary")
 	flag.BoolVar(&race, "race", race, "Use race detector")
 	flag.Parse()
 
@@ -73,15 +83,15 @@ func main() {
 			grunt("test")
 
 		case "package":
-			grunt("release", fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration))
+			grunt(gruntBuildArg("release")...)
 			createLinuxPackages()
 
 		case "pkg-rpm":
-			grunt("release")
+			grunt(gruntBuildArg("release")...)
 			createRpmPackages()
 
 		case "pkg-deb":
-			grunt("release")
+			grunt(gruntBuildArg("release")...)
 			createDebPackages()
 
 		case "latest":
@@ -258,6 +268,10 @@ func createPackage(options linuxPackageOptions) {
 		"-p", "./dist",
 	}
 
+	if pkgArch != "" {
+		args = append(args, "-a", pkgArch)
+	}
+
 	if linuxPackageIteration != "" {
 		args = append(args, "--iteration", linuxPackageIteration)
 	}
@@ -307,9 +321,20 @@ func grunt(params ...string) {
 	runPrint("./node_modules/.bin/grunt", params...)
 }
 
+func gruntBuildArg(task string) []string {
+	args := []string{task, fmt.Sprintf("--pkgVer=%v-%v", linuxPackageVersion, linuxPackageIteration)}
+	if pkgArch != "" {
+		args = append(args, fmt.Sprintf("--arch=%v", pkgArch))
+	}
+	if phjsToRelease != "" {
+		args = append(args, fmt.Sprintf("--phjsToRelease=%v", phjsToRelease))
+	}
+	return args
+}
+
 func setup() {
 	runPrint("go", "get", "-v", "github.com/kardianos/govendor")
-  runPrint("go", "get", "-v", "github.com/blang/semver")
+	runPrint("go", "get", "-v", "github.com/blang/semver")
 	runPrint("go", "get", "-v", "github.com/mattn/go-sqlite3")
 	runPrint("go", "install", "-v", "github.com/mattn/go-sqlite3")
 }
@@ -382,6 +407,15 @@ func setBuildEnv() {
 	if goarch == "386" {
 		os.Setenv("GO386", "387")
 	}
+	if cgo != "" {
+		os.Setenv("CGO_ENABLED", cgo)
+	}
+	if gocc != "" {
+		os.Setenv("CC", gocc)
+	}
+	if gocxx != "" {
+		os.Setenv("CXX", gocxx)
+	}
 }
 
 func getGitSha() string {

+ 1 - 1
conf/defaults.ini

@@ -413,7 +413,7 @@ url = https://grafana.net
 
 #################################### External Image Storage ##############
 [external_image_storage]
-# You can choose between (s3, webdav or internal)
+# You can choose between (s3, webdav)
 provider = s3
 
 [external_image_storage.s3]

+ 3 - 4
pkg/api/login_oauth.go

@@ -3,7 +3,6 @@ package api
 import (
 	"errors"
 	"fmt"
-	"net/url"
 
 	"golang.org/x/oauth2"
 
@@ -46,9 +45,9 @@ func OAuthLogin(ctx *middleware.Context) {
 	userInfo, err := connect.UserInfo(token)
 	if err != nil {
 		if err == social.ErrMissingTeamMembership {
-			ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required team membership not fulfilled"))
+			ctx.Redirect(setting.AppSubUrl + "/login?failCode=1000")
 		} else if err == social.ErrMissingOrganizationMembership {
-			ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required organization membership not fulfilled"))
+			ctx.Redirect(setting.AppSubUrl + "/login?failCode=1001")
 		} else {
 			ctx.Handle(500, fmt.Sprintf("login.OAuthLogin(get info from %s)", name), err)
 		}
@@ -60,7 +59,7 @@ func OAuthLogin(ctx *middleware.Context) {
 	// validate that the email is allowed to login to grafana
 	if !connect.IsEmailAllowed(userInfo.Email) {
 		ctx.Logger.Info("OAuth login attempt with unallowed email", "email", userInfo.Email)
-		ctx.Redirect(setting.AppSubUrl + "/login?failedMsg=" + url.QueryEscape("Required email domain not fulfilled"))
+		ctx.Redirect(setting.AppSubUrl + "/login?failCode=1002")
 		return
 	}
 

+ 0 - 2
pkg/cmd/grafana-cli/services/services.go

@@ -141,8 +141,6 @@ func createRequest(repoUrl string, subPaths ...string) ([]byte, error) {
 
 	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
 
-	logger.Info("grafanaVersion ", grafanaVersion)
-
 	req.Header.Set("grafana-version", grafanaVersion)
 	req.Header.Set("User-Agent", "grafana "+grafanaVersion)
 

+ 8 - 2
public/app/core/controllers/login_ctrl.js

@@ -6,6 +6,12 @@ define([
 function (angular, coreModule, config) {
   'use strict';
 
+  var failCodes = {
+    "1000": "Required Github team membership not fulfilled",
+    "1001": "Required Github organization membership not fulfilled",
+    "1002": "Required email domain not fulfilled",
+  };
+
   coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {
     $scope.formModel = {
       user: '',
@@ -37,8 +43,8 @@ function (angular, coreModule, config) {
       $scope.$watch("loginMode", $scope.loginModeChanged);
 
       var params = $location.search();
-      if (params.failedMsg) {
-        $scope.appEvent('alert-warning', ['Login Failed', params.failedMsg]);
+      if (params.failCode) {
+        $scope.appEvent('alert-warning', ['Login Failed', failCodes[params.failCode]]);
         delete params.failedMsg;
         $location.search(params);
       }

+ 1 - 0
public/app/core/services/context_srv.ts

@@ -9,6 +9,7 @@ export class User {
   isGrafanaAdmin: any;
   isSignedIn: any;
   orgRole: any;
+  timezone: string;
 
   constructor() {
     if (config.bootData.user) {

+ 4 - 0
public/app/core/utils/kbn.js

@@ -9,6 +9,10 @@ function($, _, moment) {
   var kbn = {};
   kbn.valueFormats = {};
 
+  kbn.regexEscape = function(value) {
+    return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&');
+  };
+
   ///// HELPER FUNCTIONS /////
 
   kbn.round_interval = function(interval) {

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

+ 1 - 1
public/app/features/annotations/annotations_srv.ts

@@ -29,7 +29,7 @@ export class AnnotationsSrv {
       this.getGlobalAnnotations(options),
       this.getPanelAnnotations(options)
     ]).then(allResults => {
-      return _.flatten(allResults);
+      return _.flattenDeep(allResults);
     }).catch(err => {
       this.$rootScope.appEvent('alert-error', ['Annotations failed', (err.message || err)]);
     });

+ 171 - 0
public/app/features/dashboard/ad_hoc_filters.ts

@@ -0,0 +1,171 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import angular from 'angular';
+import coreModule from 'app/core/core_module';
+
+export class AdHocFiltersCtrl {
+  segments: any;
+  variable: any;
+  removeTagFilterSegment: any;
+
+  /** @ngInject */
+  constructor(private uiSegmentSrv, private datasourceSrv, private $q, private templateSrv, private $rootScope) {
+    this.removeTagFilterSegment = uiSegmentSrv.newSegment({fake: true, value: '-- remove filter --'});
+    this.buildSegmentModel();
+  }
+
+  buildSegmentModel() {
+    this.segments = [];
+
+    if (this.variable.value && !_.isArray(this.variable.value)) {
+    }
+
+    for (let tag of this.variable.filters) {
+      if (this.segments.length > 0) {
+        this.segments.push(this.uiSegmentSrv.newCondition('AND'));
+      }
+
+      if (tag.key !== undefined && tag.value !== undefined) {
+        this.segments.push(this.uiSegmentSrv.newKey(tag.key));
+        this.segments.push(this.uiSegmentSrv.newOperator(tag.operator));
+        this.segments.push(this.uiSegmentSrv.newKeyValue(tag.value));
+      }
+    }
+
+    this.segments.push(this.uiSegmentSrv.newPlusButton());
+  }
+
+  getOptions(segment, index) {
+    if (segment.type === 'operator') {
+      return this.$q.when(this.uiSegmentSrv.newOperators(['=', '!=', '<', '>', '=~', '!~']));
+    }
+
+    if (segment.type === 'condition') {
+      return this.$q.when([this.uiSegmentSrv.newSegment('AND')]);
+    }
+
+    return this.datasourceSrv.get(this.variable.datasource).then(ds => {
+      var options: any = {};
+      var promise = null;
+
+      if (segment.type !== 'value') {
+        promise = ds.getTagKeys();
+      } else {
+        options.key = this.segments[index-2].value;
+        promise = ds.getTagValues(options);
+      }
+
+      return promise.then(results => {
+        results = _.map(results, segment => {
+          return this.uiSegmentSrv.newSegment({value: segment.text});
+        });
+
+        // add remove option for keys
+        if (segment.type === 'key') {
+          results.splice(0, 0, angular.copy(this.removeTagFilterSegment));
+        }
+        return results;
+      });
+    });
+  }
+
+  segmentChanged(segment, index) {
+    this.segments[index] = segment;
+
+    // handle remove tag condition
+    if (segment.value === this.removeTagFilterSegment.value) {
+      this.segments.splice(index, 3);
+      if (this.segments.length === 0) {
+        this.segments.push(this.uiSegmentSrv.newPlusButton());
+      } else if (this.segments.length > 2) {
+        this.segments.splice(Math.max(index-1, 0), 1);
+        if (this.segments[this.segments.length-1].type !== 'plus-button') {
+          this.segments.push(this.uiSegmentSrv.newPlusButton());
+        }
+      }
+    } else {
+      if (segment.type === 'plus-button') {
+        if (index > 2) {
+          this.segments.splice(index, 0, this.uiSegmentSrv.newCondition('AND'));
+        }
+        this.segments.push(this.uiSegmentSrv.newOperator('='));
+        this.segments.push(this.uiSegmentSrv.newFake('select tag value', 'value', 'query-segment-value'));
+        segment.type = 'key';
+        segment.cssClass = 'query-segment-key';
+      }
+
+      if ((index+1) === this.segments.length) {
+        this.segments.push(this.uiSegmentSrv.newPlusButton());
+      }
+    }
+
+    this.updateVariableModel();
+  }
+
+  updateVariableModel() {
+    var filters = [];
+    var filterIndex = -1;
+    var operator = "";
+    var hasFakes = false;
+
+    this.segments.forEach(segment => {
+      if (segment.type === 'value' && segment.fake) {
+        hasFakes = true;
+        return;
+      }
+
+      switch (segment.type) {
+        case 'key': {
+          filters.push({key: segment.value});
+          filterIndex += 1;
+          break;
+        }
+        case 'value': {
+          filters[filterIndex].value = segment.value;
+          break;
+        }
+        case 'operator': {
+          filters[filterIndex].operator = segment.value;
+          break;
+        }
+        case 'condition': {
+          filters[filterIndex].condition = segment.value;
+          break;
+        }
+      }
+    });
+
+    if (hasFakes) {
+      return;
+    }
+
+    this.variable.setFilters(filters);
+    this.$rootScope.$emit('template-variable-value-updated');
+    this.$rootScope.$broadcast('refresh');
+  }
+}
+
+var template = `
+<div class="gf-form-inline">
+  <div class="gf-form" ng-repeat="segment in ctrl.segments">
+    <metric-segment segment="segment" get-options="ctrl.getOptions(segment, $index)"
+                    on-change="ctrl.segmentChanged(segment, $index)"></metric-segment>
+  </div>
+</div>
+`;
+
+export function adHocFiltersComponent() {
+  return {
+    restrict: 'E',
+    template: template,
+    controller: AdHocFiltersCtrl,
+    bindToController: true,
+    controllerAs: 'ctrl',
+    scope: {
+      variable: "="
+    }
+  };
+}
+
+coreModule.directive('adHocFilters', adHocFiltersComponent);

+ 2 - 1
public/app/features/dashboard/all.js

@@ -7,7 +7,7 @@ define([
   './rowCtrl',
   './shareModalCtrl',
   './shareSnapshotCtrl',
-  './dashboardSrv',
+  './dashboard_srv',
   './keybindings',
   './viewStateSrv',
   './timeSrv',
@@ -20,4 +20,5 @@ define([
   './import/dash_import',
   './export/export_modal',
   './dash_list_ctrl',
+  './ad_hoc_filters',
 ], function () {});

+ 0 - 552
public/app/features/dashboard/dashboardSrv.js

@@ -1,552 +0,0 @@
-define([
-  'angular',
-  'jquery',
-  'lodash',
-  'moment',
-],
-function (angular, $, _, moment) {
-  'use strict';
-
-  var module = angular.module('grafana.services');
-
-  module.factory('dashboardSrv', function(contextSrv)  {
-
-    function DashboardModel (data, meta) {
-      if (!data) {
-        data = {};
-      }
-
-      this.id = data.id || null;
-      this.title = data.title || 'No Title';
-      this.autoUpdate = data.autoUpdate;
-      this.description = data.description;
-      this.tags = data.tags || [];
-      this.style = data.style || "dark";
-      this.timezone = data.timezone || '';
-      this.editable = data.editable !== false;
-      this.hideControls = data.hideControls || false;
-      this.sharedCrosshair = data.sharedCrosshair || false;
-      this.rows = data.rows || [];
-      this.time = data.time || { from: 'now-6h', to: 'now' };
-      this.timepicker = data.timepicker || {};
-      this.templating = this._ensureListExist(data.templating);
-      this.annotations = this._ensureListExist(data.annotations);
-      this.refresh = data.refresh;
-      this.snapshot = data.snapshot;
-      this.schemaVersion = data.schemaVersion || 0;
-      this.version = data.version || 0;
-      this.links = data.links || [];
-      this.gnetId = data.gnetId || null;
-      this._updateSchema(data);
-      this._initMeta(meta);
-    }
-
-    var p = DashboardModel.prototype;
-
-    p._initMeta = function(meta) {
-      meta = meta || {};
-
-      meta.canShare = meta.canShare !== false;
-      meta.canSave = meta.canSave !== false;
-      meta.canStar = meta.canStar !== false;
-      meta.canEdit = meta.canEdit !== false;
-
-      if (!this.editable) {
-        meta.canEdit = false;
-        meta.canDelete = false;
-        meta.canSave = false;
-        this.hideControls = true;
-      }
-
-      this.meta = meta;
-    };
-
-    // cleans meta data and other non peristent state
-    p.getSaveModelClone = function() {
-      var copy = $.extend(true, {}, this);
-      delete copy.meta;
-      return copy;
-    };
-
-    p._ensureListExist = function (data) {
-      if (!data) { data = {}; }
-      if (!data.list) { data.list = []; }
-      return data;
-    };
-
-    p.getNextPanelId = function() {
-      var i, j, row, panel, max = 0;
-      for (i = 0; i < this.rows.length; i++) {
-        row = this.rows[i];
-        for (j = 0; j < row.panels.length; j++) {
-          panel = row.panels[j];
-          if (panel.id > max) { max = panel.id; }
-        }
-      }
-      return max + 1;
-    };
-
-    p.forEachPanel = function(callback) {
-      var i, j, row;
-      for (i = 0; i < this.rows.length; i++) {
-        row = this.rows[i];
-        for (j = 0; j < row.panels.length; j++) {
-          callback(row.panels[j], j, row, i);
-        }
-      }
-    };
-
-    p.getPanelById = function(id) {
-      for (var i = 0; i < this.rows.length; i++) {
-        var row = this.rows[i];
-        for (var j = 0; j < row.panels.length; j++) {
-          var panel = row.panels[j];
-          if (panel.id === id) {
-            return panel;
-          }
-        }
-      }
-      return null;
-    };
-
-    p.rowSpan = function(row) {
-      return _.reduce(row.panels, function(p,v) {
-        return p + v.span;
-      },0);
-    };
-
-    p.addPanel = function(panel, row) {
-      var rowSpan = this.rowSpan(row);
-      var panelCount = row.panels.length;
-      var space = (12 - rowSpan) - panel.span;
-      panel.id = this.getNextPanelId();
-
-      // try to make room of there is no space left
-      if (space <= 0) {
-        if (panelCount === 1) {
-          row.panels[0].span = 6;
-          panel.span = 6;
-        }
-        else if (panelCount === 2) {
-          row.panels[0].span = 4;
-          row.panels[1].span = 4;
-          panel.span = 4;
-        }
-      }
-
-      row.panels.push(panel);
-    };
-
-    p.isSubmenuFeaturesEnabled = function() {
-      var visableTemplates = _.filter(this.templating.list, function(template) {
-        return template.hideVariable === undefined || template.hideVariable === false;
-      });
-
-      return visableTemplates.length > 0 || this.annotations.list.length > 0 || this.links.length > 0;
-    };
-
-    p.getPanelInfoById = function(panelId) {
-      var result = {};
-      _.each(this.rows, function(row) {
-        _.each(row.panels, function(panel, index) {
-          if (panel.id === panelId) {
-            result.panel = panel;
-            result.row = row;
-            result.index = index;
-          }
-        });
-      });
-
-      if (!result.panel) {
-        return null;
-      }
-
-      return result;
-    };
-
-    p.duplicatePanel = function(panel, row) {
-      var rowIndex = _.indexOf(this.rows, row);
-      var newPanel = angular.copy(panel);
-      newPanel.id = this.getNextPanelId();
-
-      delete newPanel.repeat;
-      delete newPanel.repeatIteration;
-      delete newPanel.repeatPanelId;
-      delete newPanel.scopedVars;
-
-      var currentRow = this.rows[rowIndex];
-      currentRow.panels.push(newPanel);
-      return newPanel;
-    };
-
-    p.formatDate = function(date, format) {
-      date = moment.isMoment(date) ? date : moment(date);
-      format = format || 'YYYY-MM-DD HH:mm:ss';
-      this.timezone = this.getTimezone();
-
-      return this.timezone === 'browser' ?
-        moment(date).format(format) :
-        moment.utc(date).format(format);
-    };
-
-    p.getRelativeTime = function(date) {
-      date = moment.isMoment(date) ? date : moment(date);
-
-      return this.timezone === 'browser' ?
-        moment(date).fromNow() :
-        moment.utc(date).fromNow();
-    };
-
-    p.getNextQueryLetter = function(panel) {
-      var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
-
-      return _.find(letters, function(refId) {
-        return _.every(panel.targets, function(other) {
-          return other.refId !== refId;
-        });
-      });
-    };
-
-    p.isTimezoneUtc = function() {
-      return this.getTimezone() === 'utc';
-    };
-
-    p.getTimezone = function() {
-      return this.timezone ? this.timezone : contextSrv.user.timezone;
-    };
-
-    p._updateSchema = function(old) {
-      var i, j, k;
-      var oldVersion = this.schemaVersion;
-      var panelUpgrades = [];
-      this.schemaVersion = 13;
-
-      if (oldVersion === this.schemaVersion) {
-        return;
-      }
-
-      // version 2 schema changes
-      if (oldVersion < 2) {
-
-        if (old.services) {
-          if (old.services.filter) {
-            this.time = old.services.filter.time;
-            this.templating.list = old.services.filter.list || [];
-          }
-          delete this.services;
-        }
-
-        panelUpgrades.push(function(panel) {
-          // rename panel type
-          if (panel.type === 'graphite') {
-            panel.type = 'graph';
-          }
-
-          if (panel.type !== 'graph') {
-            return;
-          }
-
-          if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; }
-
-          if (panel.grid) {
-            if (panel.grid.min) {
-              panel.grid.leftMin = panel.grid.min;
-              delete panel.grid.min;
-            }
-
-            if (panel.grid.max) {
-              panel.grid.leftMax = panel.grid.max;
-              delete panel.grid.max;
-            }
-          }
-
-          if (panel.y_format) {
-            panel.y_formats[0] = panel.y_format;
-            delete panel.y_format;
-          }
-
-          if (panel.y2_format) {
-            panel.y_formats[1] = panel.y2_format;
-            delete panel.y2_format;
-          }
-        });
-      }
-
-      // schema version 3 changes
-      if (oldVersion < 3) {
-        // ensure panel ids
-        var maxId = this.getNextPanelId();
-        panelUpgrades.push(function(panel) {
-          if (!panel.id) {
-            panel.id = maxId;
-            maxId += 1;
-          }
-        });
-      }
-
-      // schema version 4 changes
-      if (oldVersion < 4) {
-        // move aliasYAxis changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'graph') { return; }
-          _.each(panel.aliasYAxis, function(value, key) {
-            panel.seriesOverrides = [{ alias: key, yaxis: value }];
-          });
-          delete panel.aliasYAxis;
-        });
-      }
-
-      if (oldVersion < 6) {
-        // move pulldowns to new schema
-        var annotations = _.find(old.pulldowns, { type: 'annotations' });
-
-        if (annotations) {
-          this.annotations = {
-            list: annotations.annotations || [],
-          };
-        }
-
-        // update template variables
-        for (i = 0 ; i < this.templating.list.length; i++) {
-          var variable = this.templating.list[i];
-          if (variable.datasource === void 0) { variable.datasource = null; }
-          if (variable.type === 'filter') { variable.type = 'query'; }
-          if (variable.type === void 0) { variable.type = 'query'; }
-          if (variable.allFormat === void 0) { variable.allFormat = 'glob'; }
-        }
-      }
-
-      if (oldVersion < 7) {
-        if (old.nav && old.nav.length) {
-          this.timepicker = old.nav[0];
-          delete this.nav;
-        }
-
-        // ensure query refIds
-        panelUpgrades.push(function(panel) {
-          _.each(panel.targets, function(target) {
-            if (!target.refId) {
-              target.refId = this.getNextQueryLetter(panel);
-            }
-          }.bind(this));
-        });
-      }
-
-      if (oldVersion < 8) {
-        panelUpgrades.push(function(panel) {
-          _.each(panel.targets, function(target) {
-            // update old influxdb query schema
-            if (target.fields && target.tags && target.groupBy) {
-              if (target.rawQuery) {
-                delete target.fields;
-                delete target.fill;
-              } else {
-                target.select = _.map(target.fields, function(field) {
-                  var parts = [];
-                  parts.push({type: 'field', params: [field.name]});
-                  parts.push({type: field.func, params: []});
-                  if (field.mathExpr) {
-                    parts.push({type: 'math', params: [field.mathExpr]});
-                  }
-                  if (field.asExpr) {
-                    parts.push({type: 'alias', params: [field.asExpr]});
-                  }
-                  return parts;
-                });
-                delete target.fields;
-                _.each(target.groupBy, function(part) {
-                  if (part.type === 'time' && part.interval)  {
-                    part.params = [part.interval];
-                    delete part.interval;
-                  }
-                  if (part.type === 'tag' && part.key) {
-                    part.params = [part.key];
-                    delete part.key;
-                  }
-                });
-
-                if (target.fill) {
-                  target.groupBy.push({type: 'fill', params: [target.fill]});
-                  delete target.fill;
-                }
-              }
-            }
-          });
-        });
-      }
-
-      // schema version 9 changes
-      if (oldVersion < 9) {
-        // move aliasYAxis changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; }
-
-          if (panel.thresholds) {
-            var k = panel.thresholds.split(",");
-
-            if (k.length >= 3) {
-              k.shift();
-              panel.thresholds = k.join(",");
-            }
-          }
-        });
-      }
-
-      // schema version 10 changes
-      if (oldVersion < 10) {
-        // move aliasYAxis changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'table') { return; }
-
-          _.each(panel.styles, function(style) {
-            if (style.thresholds && style.thresholds.length >= 3) {
-              var k = style.thresholds;
-              k.shift();
-              style.thresholds = k;
-            }
-          });
-        });
-      }
-
-      if (oldVersion < 12) {
-        // update template variables
-        _.each(this.templating.list, function(templateVariable) {
-          if (templateVariable.refresh) { templateVariable.refresh = 1; }
-          if (!templateVariable.refresh) { templateVariable.refresh = 0; }
-          if (templateVariable.hideVariable) {
-            templateVariable.hide = 2;
-          } else if (templateVariable.hideLabel) {
-            templateVariable.hide = 1;
-          } else {
-            templateVariable.hide = 0;
-          }
-        });
-      }
-
-      if (oldVersion < 12) {
-        // update graph yaxes changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'graph') { return; }
-          if (!panel.grid) { return; }
-
-          if (!panel.yaxes) {
-            panel.yaxes = [
-              {
-                show: panel['y-axis'],
-                min: panel.grid.leftMin,
-                max: panel.grid.leftMax,
-                logBase: panel.grid.leftLogBase,
-                format: panel.y_formats[0],
-                label: panel.leftYAxisLabel,
-              },
-              {
-                show: panel['y-axis'],
-                min: panel.grid.rightMin,
-                max: panel.grid.rightMax,
-                logBase: panel.grid.rightLogBase,
-                format: panel.y_formats[1],
-                label: panel.rightYAxisLabel,
-              }
-            ];
-
-            panel.xaxis = {
-              show: panel['x-axis'],
-            };
-
-            delete panel.grid.leftMin;
-            delete panel.grid.leftMax;
-            delete panel.grid.leftLogBase;
-            delete panel.grid.rightMin;
-            delete panel.grid.rightMax;
-            delete panel.grid.rightLogBase;
-            delete panel.y_formats;
-            delete panel.leftYAxisLabel;
-            delete panel.rightYAxisLabel;
-            delete panel['y-axis'];
-            delete panel['x-axis'];
-          }
-        });
-      }
-
-      if (oldVersion < 13) {
-        // update graph yaxes changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'graph') { return; }
-
-          panel.thresholds = [];
-          var t1 = {}, t2 = {};
-
-          if (panel.grid.threshold1 !== null) {
-            t1.value = panel.grid.threshold1;
-            if (panel.grid.thresholdLine) {
-              t1.line = true;
-              t1.lineColor = panel.grid.threshold1Color;
-            } else {
-              t1.fill = true;
-              t1.fillColor = panel.grid.threshold1Color;
-            }
-          }
-
-          if (panel.grid.threshold2 !== null) {
-            t2.value = panel.grid.threshold2;
-            if (panel.grid.thresholdLine) {
-              t2.line = true;
-              t2.lineColor = panel.grid.threshold2Color;
-            } else {
-              t2.fill = true;
-              t2.fillColor = panel.grid.threshold2Color;
-            }
-          }
-
-          if (_.isNumber(t1.value)) {
-            if (_.isNumber(t2.value)) {
-              if (t1.value > t2.value) {
-                t1.op = t2.op = '<';
-                panel.thresholds.push(t2);
-                panel.thresholds.push(t1);
-              } else {
-                t1.op = t2.op = '>';
-                panel.thresholds.push(t2);
-                panel.thresholds.push(t1);
-              }
-            } else {
-              t1.op = '>';
-              panel.thresholds.push(t1);
-            }
-          }
-
-          delete panel.grid.threshold1;
-          delete panel.grid.threshold1Color;
-          delete panel.grid.threshold2;
-          delete panel.grid.threshold2Color;
-          delete panel.grid.thresholdLine;
-        });
-      }
-
-      if (panelUpgrades.length === 0) {
-        return;
-      }
-
-      for (i = 0; i < this.rows.length; i++) {
-        var row = this.rows[i];
-        for (j = 0; j < row.panels.length; j++) {
-          for (k = 0; k < panelUpgrades.length; k++) {
-            panelUpgrades[k].call(this, row.panels[j]);
-          }
-        }
-      }
-    };
-
-    return {
-      create: function(dashboard, meta) {
-        return new DashboardModel(dashboard, meta);
-      },
-      setCurrent: function(dashboard) {
-        this.currentDashboard = dashboard;
-      },
-      getCurrent: function() {
-        return this.currentDashboard;
-      },
-    };
-  });
-});

+ 2 - 3
public/app/features/dashboard/dashboard_ctrl.ts

@@ -15,7 +15,7 @@ export class DashboardCtrl {
     private $rootScope,
     dashboardKeybindings,
     timeSrv,
-    templateValuesSrv,
+    variableSrv,
     dashboardSrv,
     unsavedChangesSrv,
     dynamicDashboardSrv,
@@ -46,7 +46,7 @@ export class DashboardCtrl {
 
         // template values service needs to initialize completely before
         // the rest of the dashboard can load
-        templateValuesSrv.init(dashboard)
+        variableSrv.init(dashboard)
         // template values failes are non fatal
         .catch($scope.onInitFailed.bind(this, 'Templating init failed', false))
         // continue
@@ -87,7 +87,6 @@ export class DashboardCtrl {
       };
 
       $scope.templateVariableUpdated = function() {
-        console.log('dynamic update');
         dynamicDashboardSrv.update($scope.dashboard);
       };
 

+ 590 - 0
public/app/features/dashboard/dashboard_srv.ts

@@ -0,0 +1,590 @@
+///<reference path="../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import angular from 'angular';
+import moment from 'moment';
+import _ from 'lodash';
+import $ from 'jquery';
+
+import {Emitter} from 'app/core/core';
+import {contextSrv} from 'app/core/services/context_srv';
+import coreModule from 'app/core/core_module';
+
+export class DashboardModel {
+  id: any;
+  title: any;
+  autoUpdate: any;
+  description: any;
+  tags: any;
+  style: any;
+  timezone: any;
+  editable: any;
+  hideControls: any;
+  sharedCrosshair: any;
+  rows: any;
+  time: any;
+  timepicker: any;
+  templating: any;
+  annotations: any;
+  refresh: any;
+  snapshot: any;
+  schemaVersion: number;
+  version: number;
+  links: any;
+  gnetId: any;
+  meta: any;
+  events: any;
+
+  constructor(data, meta) {
+    if (!data) {
+      data = {};
+    }
+
+    this.events = new Emitter();
+    this.id = data.id || null;
+    this.title = data.title || 'No Title';
+    this.autoUpdate = data.autoUpdate;
+    this.description = data.description;
+    this.tags = data.tags || [];
+    this.style = data.style || "dark";
+    this.timezone = data.timezone || '';
+    this.editable = data.editable !== false;
+    this.hideControls = data.hideControls || false;
+    this.sharedCrosshair = data.sharedCrosshair || false;
+    this.rows = data.rows || [];
+    this.time = data.time || { from: 'now-6h', to: 'now' };
+    this.timepicker = data.timepicker || {};
+    this.templating = this.ensureListExist(data.templating);
+    this.annotations = this.ensureListExist(data.annotations);
+    this.refresh = data.refresh;
+    this.snapshot = data.snapshot;
+    this.schemaVersion = data.schemaVersion || 0;
+    this.version = data.version || 0;
+    this.links = data.links || [];
+    this.gnetId = data.gnetId || null;
+
+    this.updateSchema(data);
+    this.initMeta(meta);
+  }
+
+  private initMeta(meta) {
+    meta = meta || {};
+
+    meta.canShare = meta.canShare !== false;
+    meta.canSave = meta.canSave !== false;
+    meta.canStar = meta.canStar !== false;
+    meta.canEdit = meta.canEdit !== false;
+
+    if (!this.editable) {
+      meta.canEdit = false;
+      meta.canDelete = false;
+      meta.canSave = false;
+      this.hideControls = true;
+    }
+
+    this.meta = meta;
+  }
+
+  // cleans meta data and other non peristent state
+  getSaveModelClone() {
+    // temp remove stuff
+    var events = this.events;
+    var meta = this.meta;
+    delete this.events;
+    delete this.meta;
+
+    events.emit('prepare-save-model');
+    var copy = $.extend(true, {}, this);
+
+    // restore properties
+    this.events = events;
+    this.meta = meta;
+    return copy;
+  }
+
+  private ensureListExist(data) {
+    if (!data) { data = {}; }
+    if (!data.list) { data.list = []; }
+    return data;
+  }
+
+  getNextPanelId() {
+    var i, j, row, panel, max = 0;
+    for (i = 0; i < this.rows.length; i++) {
+      row = this.rows[i];
+      for (j = 0; j < row.panels.length; j++) {
+        panel = row.panels[j];
+        if (panel.id > max) { max = panel.id; }
+      }
+    }
+    return max + 1;
+  }
+
+  forEachPanel(callback) {
+    var i, j, row;
+    for (i = 0; i < this.rows.length; i++) {
+      row = this.rows[i];
+      for (j = 0; j < row.panels.length; j++) {
+        callback(row.panels[j], j, row, i);
+      }
+    }
+  }
+
+  getPanelById(id) {
+    for (var i = 0; i < this.rows.length; i++) {
+      var row = this.rows[i];
+      for (var j = 0; j < row.panels.length; j++) {
+        var panel = row.panels[j];
+        if (panel.id === id) {
+          return panel;
+        }
+      }
+    }
+    return null;
+  }
+
+  rowSpan(row) {
+    return _.reduce(row.panels, function(p,v) {
+      return p + v.span;
+    },0);
+  };
+
+  addPanel(panel, row) {
+    var rowSpan = this.rowSpan(row);
+    var panelCount = row.panels.length;
+    var space = (12 - rowSpan) - panel.span;
+    panel.id = this.getNextPanelId();
+
+    // try to make room of there is no space left
+    if (space <= 0) {
+      if (panelCount === 1) {
+        row.panels[0].span = 6;
+        panel.span = 6;
+      } else if (panelCount === 2) {
+        row.panels[0].span = 4;
+        row.panels[1].span = 4;
+        panel.span = 4;
+      }
+    }
+
+    row.panels.push(panel);
+  }
+
+  isSubmenuFeaturesEnabled() {
+    var visableTemplates = _.filter(this.templating.list, function(template) {
+      return template.hideVariable === undefined || template.hideVariable === false;
+    });
+
+    return visableTemplates.length > 0 || this.annotations.list.length > 0 || this.links.length > 0;
+  }
+
+  getPanelInfoById(panelId) {
+    var result: any = {};
+    _.each(this.rows, function(row) {
+      _.each(row.panels, function(panel, index) {
+        if (panel.id === panelId) {
+          result.panel = panel;
+          result.row = row;
+          result.index = index;
+        }
+      });
+    });
+
+    if (!result.panel) {
+      return null;
+    }
+
+    return result;
+  }
+
+  duplicatePanel(panel, row) {
+    var rowIndex = _.indexOf(this.rows, row);
+    var newPanel = angular.copy(panel);
+    newPanel.id = this.getNextPanelId();
+
+    delete newPanel.repeat;
+    delete newPanel.repeatIteration;
+    delete newPanel.repeatPanelId;
+    delete newPanel.scopedVars;
+
+    var currentRow = this.rows[rowIndex];
+    currentRow.panels.push(newPanel);
+    return newPanel;
+  }
+
+  formatDate(date, format) {
+    date = moment.isMoment(date) ? date : moment(date);
+    format = format || 'YYYY-MM-DD HH:mm:ss';
+    this.timezone = this.getTimezone();
+
+    return this.timezone === 'browser' ?
+      moment(date).format(format) :
+      moment.utc(date).format(format);
+  }
+
+  getRelativeTime(date) {
+    date = moment.isMoment(date) ? date : moment(date);
+
+    return this.timezone === 'browser' ?
+      moment(date).fromNow() :
+      moment.utc(date).fromNow();
+  }
+
+  getNextQueryLetter(panel) {
+    var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+    return _.find(letters, function(refId) {
+      return _.every(panel.targets, function(other) {
+        return other.refId !== refId;
+      });
+    });
+  }
+
+  isTimezoneUtc() {
+    return this.getTimezone() === 'utc';
+  }
+
+  getTimezone() {
+    return this.timezone ? this.timezone : contextSrv.user.timezone;
+  }
+
+  private updateSchema(old) {
+    var i, j, k;
+    var oldVersion = this.schemaVersion;
+    var panelUpgrades = [];
+    this.schemaVersion = 13;
+
+    if (oldVersion === this.schemaVersion) {
+      return;
+    }
+
+    // version 2 schema changes
+    if (oldVersion < 2) {
+
+      if (old.services) {
+        if (old.services.filter) {
+          this.time = old.services.filter.time;
+          this.templating.list = old.services.filter.list || [];
+        }
+      }
+
+      panelUpgrades.push(function(panel) {
+        // rename panel type
+        if (panel.type === 'graphite') {
+          panel.type = 'graph';
+        }
+
+        if (panel.type !== 'graph') {
+          return;
+        }
+
+        if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; }
+
+        if (panel.grid) {
+          if (panel.grid.min) {
+            panel.grid.leftMin = panel.grid.min;
+            delete panel.grid.min;
+          }
+
+          if (panel.grid.max) {
+            panel.grid.leftMax = panel.grid.max;
+            delete panel.grid.max;
+          }
+        }
+
+        if (panel.y_format) {
+          panel.y_formats[0] = panel.y_format;
+          delete panel.y_format;
+        }
+
+        if (panel.y2_format) {
+          panel.y_formats[1] = panel.y2_format;
+          delete panel.y2_format;
+        }
+      });
+    }
+
+    // schema version 3 changes
+    if (oldVersion < 3) {
+      // ensure panel ids
+      var maxId = this.getNextPanelId();
+      panelUpgrades.push(function(panel) {
+        if (!panel.id) {
+          panel.id = maxId;
+          maxId += 1;
+        }
+      });
+    }
+
+    // schema version 4 changes
+    if (oldVersion < 4) {
+      // move aliasYAxis changes
+      panelUpgrades.push(function(panel) {
+        if (panel.type !== 'graph') { return; }
+        _.each(panel.aliasYAxis, function(value, key) {
+          panel.seriesOverrides = [{ alias: key, yaxis: value }];
+        });
+        delete panel.aliasYAxis;
+      });
+    }
+
+    if (oldVersion < 6) {
+      // move pulldowns to new schema
+      var annotations = _.find(old.pulldowns, { type: 'annotations' });
+
+      if (annotations) {
+        this.annotations = {
+          list: annotations.annotations || [],
+        };
+      }
+
+      // update template variables
+      for (i = 0 ; i < this.templating.list.length; i++) {
+        var variable = this.templating.list[i];
+        if (variable.datasource === void 0) { variable.datasource = null; }
+        if (variable.type === 'filter') { variable.type = 'query'; }
+        if (variable.type === void 0) { variable.type = 'query'; }
+        if (variable.allFormat === void 0) { variable.allFormat = 'glob'; }
+      }
+    }
+
+    if (oldVersion < 7) {
+      if (old.nav && old.nav.length) {
+        this.timepicker = old.nav[0];
+      }
+
+      // ensure query refIds
+      panelUpgrades.push(function(panel) {
+        _.each(panel.targets, function(target) {
+          if (!target.refId) {
+            target.refId = this.getNextQueryLetter(panel);
+            }
+          }.bind(this));
+        });
+      }
+
+      if (oldVersion < 8) {
+        panelUpgrades.push(function(panel) {
+          _.each(panel.targets, function(target) {
+            // update old influxdb query schema
+            if (target.fields && target.tags && target.groupBy) {
+              if (target.rawQuery) {
+                delete target.fields;
+                delete target.fill;
+              } else {
+                target.select = _.map(target.fields, function(field) {
+                  var parts = [];
+                  parts.push({type: 'field', params: [field.name]});
+                  parts.push({type: field.func, params: []});
+                  if (field.mathExpr) {
+                    parts.push({type: 'math', params: [field.mathExpr]});
+                  }
+                  if (field.asExpr) {
+                    parts.push({type: 'alias', params: [field.asExpr]});
+                  }
+                  return parts;
+                });
+                delete target.fields;
+                _.each(target.groupBy, function(part) {
+                  if (part.type === 'time' && part.interval)  {
+                    part.params = [part.interval];
+                    delete part.interval;
+                  }
+                  if (part.type === 'tag' && part.key) {
+                    part.params = [part.key];
+                    delete part.key;
+                  }
+                });
+
+                if (target.fill) {
+                  target.groupBy.push({type: 'fill', params: [target.fill]});
+                  delete target.fill;
+                }
+              }
+            }
+          });
+        });
+      }
+
+      // schema version 9 changes
+      if (oldVersion < 9) {
+        // move aliasYAxis changes
+        panelUpgrades.push(function(panel) {
+          if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; }
+
+          if (panel.thresholds) {
+            var k = panel.thresholds.split(",");
+
+            if (k.length >= 3) {
+              k.shift();
+              panel.thresholds = k.join(",");
+            }
+          }
+        });
+      }
+
+      // schema version 10 changes
+      if (oldVersion < 10) {
+        // move aliasYAxis changes
+        panelUpgrades.push(function(panel) {
+          if (panel.type !== 'table') { return; }
+
+          _.each(panel.styles, function(style) {
+            if (style.thresholds && style.thresholds.length >= 3) {
+              var k = style.thresholds;
+              k.shift();
+              style.thresholds = k;
+            }
+          });
+        });
+      }
+
+      if (oldVersion < 12) {
+        // update template variables
+        _.each(this.templating.list, function(templateVariable) {
+          if (templateVariable.refresh) { templateVariable.refresh = 1; }
+          if (!templateVariable.refresh) { templateVariable.refresh = 0; }
+          if (templateVariable.hideVariable) {
+            templateVariable.hide = 2;
+          } else if (templateVariable.hideLabel) {
+            templateVariable.hide = 1;
+          } else {
+            templateVariable.hide = 0;
+          }
+        });
+      }
+
+      if (oldVersion < 12) {
+        // update graph yaxes changes
+        panelUpgrades.push(function(panel) {
+          if (panel.type !== 'graph') { return; }
+          if (!panel.grid) { return; }
+
+          if (!panel.yaxes) {
+            panel.yaxes = [
+              {
+                show: panel['y-axis'],
+                min: panel.grid.leftMin,
+                max: panel.grid.leftMax,
+                logBase: panel.grid.leftLogBase,
+                format: panel.y_formats[0],
+                label: panel.leftYAxisLabel,
+              },
+              {
+                show: panel['y-axis'],
+                min: panel.grid.rightMin,
+                max: panel.grid.rightMax,
+                logBase: panel.grid.rightLogBase,
+                format: panel.y_formats[1],
+                label: panel.rightYAxisLabel,
+              }
+            ];
+
+            panel.xaxis = {
+              show: panel['x-axis'],
+            };
+
+            delete panel.grid.leftMin;
+            delete panel.grid.leftMax;
+            delete panel.grid.leftLogBase;
+            delete panel.grid.rightMin;
+            delete panel.grid.rightMax;
+            delete panel.grid.rightLogBase;
+            delete panel.y_formats;
+            delete panel.leftYAxisLabel;
+            delete panel.rightYAxisLabel;
+            delete panel['y-axis'];
+            delete panel['x-axis'];
+          }
+        });
+      }
+
+      if (oldVersion < 13) {
+        // update graph yaxes changes
+        panelUpgrades.push(function(panel) {
+          if (panel.type !== 'graph') { return; }
+
+          panel.thresholds = [];
+          var t1: any = {}, t2: any = {};
+
+          if (panel.grid.threshold1 !== null) {
+            t1.value = panel.grid.threshold1;
+            if (panel.grid.thresholdLine) {
+              t1.line = true;
+              t1.lineColor = panel.grid.threshold1Color;
+            } else {
+              t1.fill = true;
+              t1.fillColor = panel.grid.threshold1Color;
+            }
+          }
+
+          if (panel.grid.threshold2 !== null) {
+            t2.value = panel.grid.threshold2;
+            if (panel.grid.thresholdLine) {
+              t2.line = true;
+              t2.lineColor = panel.grid.threshold2Color;
+            } else {
+              t2.fill = true;
+              t2.fillColor = panel.grid.threshold2Color;
+            }
+          }
+
+          if (_.isNumber(t1.value)) {
+            if (_.isNumber(t2.value)) {
+              if (t1.value > t2.value) {
+                t1.op = t2.op = '<';
+                panel.thresholds.push(t2);
+                panel.thresholds.push(t1);
+              } else {
+                t1.op = t2.op = '>';
+                panel.thresholds.push(t2);
+                panel.thresholds.push(t1);
+              }
+            } else {
+              t1.op = '>';
+              panel.thresholds.push(t1);
+            }
+          }
+
+          delete panel.grid.threshold1;
+          delete panel.grid.threshold1Color;
+          delete panel.grid.threshold2;
+          delete panel.grid.threshold2Color;
+          delete panel.grid.thresholdLine;
+        });
+      }
+
+      if (panelUpgrades.length === 0) {
+        return;
+      }
+
+      for (i = 0; i < this.rows.length; i++) {
+        var row = this.rows[i];
+        for (j = 0; j < row.panels.length; j++) {
+          for (k = 0; k < panelUpgrades.length; k++) {
+            panelUpgrades[k].call(this, row.panels[j]);
+          }
+        }
+      }
+    }
+}
+
+
+export class DashboardSrv {
+  currentDashboard: any;
+
+  create(dashboard, meta) {
+    return new DashboardModel(dashboard, meta);
+  }
+
+  setCurrent(dashboard) {
+    this.currentDashboard = dashboard;
+  }
+
+  getCurrent() {
+    return this.currentDashboard;
+  }
+}
+
+coreModule.service('dashboardSrv', DashboardSrv);
+

+ 14 - 2
public/app/features/dashboard/export/exporter.ts

@@ -24,6 +24,10 @@ export class DashboardExporter {
 
     var templateizeDatasourceUsage = obj => {
       promises.push(this.datasourceSrv.get(obj.datasource).then(ds => {
+        if (ds.meta.builtIn) {
+          return;
+        }
+
         var refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
         datasources[refName] = {
           name: refName,
@@ -46,11 +50,19 @@ export class DashboardExporter {
 
     // check up panel data sources
     for (let row of dash.rows) {
-      _.each(row.panels, (panel) => {
+      for (let panel of row.panels) {
         if (panel.datasource !== undefined) {
           templateizeDatasourceUsage(panel);
         }
 
+        if (panel.targets) {
+          for (let target of panel.targets) {
+            if (target.datasource !== undefined) {
+              templateizeDatasourceUsage(target);
+            }
+          }
+        }
+
         var panelDef = config.panels[panel.type];
         if (panelDef) {
           requires['panel' + panelDef.id] = {
@@ -60,7 +72,7 @@ export class DashboardExporter {
             version: panelDef.info.version,
           };
         }
-      });
+      }
     }
 
     // templatize template vars

+ 379 - 0
public/app/features/dashboard/specs/dashboard_srv_specs.ts

@@ -0,0 +1,379 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import {DashboardSrv} from '../dashboard_srv';
+
+describe('dashboardSrv', function() {
+  var _dashboardSrv;
+
+  beforeEach(() => {
+    _dashboardSrv = new DashboardSrv();
+  });
+
+  describe('when creating new dashboard with defaults only', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({}, {});
+    });
+
+    it('should have title', function() {
+      expect(model.title).to.be('No Title');
+    });
+
+    it('should have meta', function() {
+      expect(model.meta.canSave).to.be(true);
+      expect(model.meta.canShare).to.be(true);
+    });
+
+    it('should have default properties', function() {
+      expect(model.rows.length).to.be(0);
+    });
+  });
+
+  describe('when getting next panel id', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        rows: [{ panels: [{ id: 5 }]}]
+      });
+    });
+
+    it('should return max id + 1', function() {
+      expect(model.getNextPanelId()).to.be(6);
+    });
+  });
+
+  describe('row and panel manipulation', function() {
+    var dashboard;
+
+    beforeEach(function() {
+      dashboard = _dashboardSrv.create({});
+    });
+
+    it('row span should sum spans', function() {
+      var spanLeft = dashboard.rowSpan({ panels: [{ span: 2 }, { span: 3 }] });
+      expect(spanLeft).to.be(5);
+    });
+
+    it('adding default should split span in half', function() {
+      dashboard.rows = [{ panels: [{ span: 12, id: 7 }] }];
+      dashboard.addPanel({span: 4}, dashboard.rows[0]);
+
+      expect(dashboard.rows[0].panels[0].span).to.be(6);
+      expect(dashboard.rows[0].panels[1].span).to.be(6);
+      expect(dashboard.rows[0].panels[1].id).to.be(8);
+    });
+
+    it('duplicate panel should try to add it to same row', function() {
+      var panel = { span: 4, attr: '123', id: 10 };
+      dashboard.rows = [{ panels: [panel] }];
+      dashboard.duplicatePanel(panel, dashboard.rows[0]);
+
+      expect(dashboard.rows[0].panels[0].span).to.be(4);
+      expect(dashboard.rows[0].panels[1].span).to.be(4);
+      expect(dashboard.rows[0].panels[1].attr).to.be('123');
+      expect(dashboard.rows[0].panels[1].id).to.be(11);
+    });
+
+    it('duplicate panel should remove repeat data', function() {
+      var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }};
+      dashboard.rows = [{ panels: [panel] }];
+      dashboard.duplicatePanel(panel, dashboard.rows[0]);
+
+      expect(dashboard.rows[0].panels[1].repeat).to.be(undefined);
+      expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined);
+    });
+
+  });
+
+  describe('when creating dashboard with editable false', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        editable: false
+      });
+    });
+
+    it('should set editable false', function() {
+      expect(model.editable).to.be(false);
+    });
+
+  });
+
+  describe('when creating dashboard with old schema', function() {
+    var model;
+    var graph;
+    var singlestat;
+    var table;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        services: { filter: { time: { from: 'now-1d', to: 'now'}, list: [{}] }},
+        pulldowns: [
+          {type: 'filtering', enable: true},
+          {type: 'annotations', enable: true, annotations: [{name: 'old'}]}
+        ],
+        rows: [
+          {
+            panels: [
+              {
+                type: 'graph', legend: true, aliasYAxis: { test: 2 },
+                y_formats: ['kbyte', 'ms'],
+                grid: {
+                  min: 1,
+                  max: 10,
+                  rightMin: 5,
+                  rightMax: 15,
+                  leftLogBase: 1,
+                  rightLogBase: 2,
+                  threshold1: 200,
+                  threshold2: 400,
+                  threshold1Color: 'yellow',
+                  threshold2Color: 'red',
+                },
+                leftYAxisLabel: 'left label',
+                targets: [{refId: 'A'}, {}],
+              },
+              {
+                type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 },
+                targets: [{refId: 'A'}, {}],
+              },
+              {
+                type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}],
+                targets: [{refId: 'A'}, {}],
+              }
+            ]
+          }
+        ]
+      });
+
+      graph = model.rows[0].panels[0];
+      singlestat = model.rows[0].panels[1];
+      table = model.rows[0].panels[2];
+    });
+
+    it('should have title', function() {
+      expect(model.title).to.be('No Title');
+    });
+
+    it('should have panel id', function() {
+      expect(graph.id).to.be(1);
+    });
+
+    it('should move time and filtering list', function() {
+      expect(model.time.from).to.be('now-1d');
+      expect(model.templating.list[0].allFormat).to.be('glob');
+    });
+
+    it('graphite panel should change name too graph', function() {
+      expect(graph.type).to.be('graph');
+    });
+
+    it('single stat panel should have two thresholds', function() {
+      expect(singlestat.thresholds).to.be('20,30');
+    });
+
+    it('queries without refId should get it', function() {
+      expect(graph.targets[1].refId).to.be('B');
+    });
+
+    it('update legend setting', function() {
+      expect(graph.legend.show).to.be(true);
+    });
+
+    it('move aliasYAxis to series override', function() {
+      expect(graph.seriesOverrides[0].alias).to.be("test");
+      expect(graph.seriesOverrides[0].yaxis).to.be(2);
+    });
+
+    it('should move pulldowns to new schema', function() {
+      expect(model.annotations.list[0].name).to.be('old');
+    });
+
+    it('table panel should only have two thresholds values', function() {
+      expect(table.styles[0].thresholds[0]).to.be("20");
+      expect(table.styles[0].thresholds[1]).to.be("30");
+      expect(table.styles[1].thresholds[0]).to.be("200");
+      expect(table.styles[1].thresholds[1]).to.be("300");
+    });
+
+    it('graph grid to yaxes options', function() {
+      expect(graph.yaxes[0].min).to.be(1);
+      expect(graph.yaxes[0].max).to.be(10);
+      expect(graph.yaxes[0].format).to.be('kbyte');
+      expect(graph.yaxes[0].label).to.be('left label');
+      expect(graph.yaxes[0].logBase).to.be(1);
+      expect(graph.yaxes[1].min).to.be(5);
+      expect(graph.yaxes[1].max).to.be(15);
+      expect(graph.yaxes[1].format).to.be('ms');
+      expect(graph.yaxes[1].logBase).to.be(2);
+
+      expect(graph.grid.rightMax).to.be(undefined);
+      expect(graph.grid.rightLogBase).to.be(undefined);
+      expect(graph.y_formats).to.be(undefined);
+    });
+
+    it('dashboard schema version should be set to latest', function() {
+      expect(model.schemaVersion).to.be(13);
+    });
+
+    it('graph thresholds should be migrated', function() {
+      expect(graph.thresholds.length).to.be(2);
+      expect(graph.thresholds[0].op).to.be('>');
+      expect(graph.thresholds[0].value).to.be(400);
+      expect(graph.thresholds[0].fillColor).to.be('red');
+      expect(graph.thresholds[1].value).to.be(200);
+      expect(graph.thresholds[1].fillColor).to.be('yellow');
+    });
+  });
+
+  describe('when creating dashboard model with missing list for annoations or templating', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        annotations: {
+          enable: true,
+        },
+        templating: {
+          enable: true
+        }
+      });
+    });
+
+    it('should add empty list', function() {
+      expect(model.annotations.list.length).to.be(0);
+      expect(model.templating.list.length).to.be(0);
+    });
+  });
+
+  describe('Given editable false dashboard', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        editable:  false,
+      });
+    });
+
+    it('Should set meta canEdit and canSave to false', function() {
+      expect(model.meta.canSave).to.be(false);
+      expect(model.meta.canEdit).to.be(false);
+    });
+
+    it('getSaveModelClone should remove meta', function() {
+      var clone = model.getSaveModelClone();
+      expect(clone.meta).to.be(undefined);
+    });
+  });
+
+  describe('when loading dashboard with old influxdb query schema', function() {
+    var model;
+    var target;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        rows: [{
+          panels: [{
+            type: 'graph',
+            grid: {},
+            yaxes: [{}, {}],
+            targets: [{
+              "alias": "$tag_datacenter $tag_source $col",
+              "column": "value",
+              "measurement": "logins.count",
+              "fields": [
+                {
+                  "func": "mean",
+                  "name": "value",
+                  "mathExpr": "*2",
+                  "asExpr": "value"
+                },
+                {
+                  "name": "one-minute",
+                  "func": "mean",
+                  "mathExpr": "*3",
+                  "asExpr": "one-minute"
+                }
+              ],
+              "tags": [],
+              "fill": "previous",
+              "function": "mean",
+              "groupBy": [
+                {
+                  "interval": "auto",
+                  "type": "time"
+                },
+                {
+                  "key": "source",
+                  "type": "tag"
+                },
+                {
+                  "type": "tag",
+                  "key": "datacenter"
+                }
+              ],
+            }]
+          }]
+        }]
+      });
+
+      target = model.rows[0].panels[0].targets[0];
+    });
+
+    it('should update query schema', function() {
+      expect(target.fields).to.be(undefined);
+      expect(target.select.length).to.be(2);
+      expect(target.select[0].length).to.be(4);
+      expect(target.select[0][0].type).to.be('field');
+      expect(target.select[0][1].type).to.be('mean');
+      expect(target.select[0][2].type).to.be('math');
+      expect(target.select[0][3].type).to.be('alias');
+    });
+
+  });
+
+  describe('when creating dashboard model with missing list for annoations or templating', function() {
+    var model;
+
+    beforeEach(function() {
+      model = _dashboardSrv.create({
+        annotations: {
+          enable: true,
+        },
+        templating: {
+          enable: true
+        }
+      });
+    });
+
+    it('should add empty list', function() {
+      expect(model.annotations.list.length).to.be(0);
+      expect(model.templating.list.length).to.be(0);
+    });
+  });
+
+  describe('Formatting epoch timestamp when timezone is set as utc', function() {
+    var dashboard;
+
+    beforeEach(function() {
+      dashboard = _dashboardSrv.create({
+        timezone: 'utc',
+      });
+    });
+
+    it('Should format timestamp with second resolution by default', function() {
+      expect(dashboard.formatDate(1234567890000)).to.be('2009-02-13 23:31:30');
+    });
+
+    it('Should format timestamp with second resolution even if second format is passed as parameter', function() {
+      expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss')).to.be('2009-02-13 23:31:30');
+    });
+
+    it('Should format timestamp with millisecond resolution if format is passed as parameter', function() {
+      expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss.SSS')).to.be('2009-02-13 23:31:30.007');
+    });
+  });
+});

+ 2 - 1
public/app/features/dashboard/specs/dynamic_dashboard_srv_specs.ts

@@ -1,6 +1,6 @@
 import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
 
-import 'app/features/dashboard/dashboardSrv';
+import {DashboardSrv} from '../dashboard_srv';
 import {DynamicDashboardSrv} from '../dynamic_dashboard_srv';
 
 function dynamicDashScenario(desc, func)  {
@@ -10,6 +10,7 @@ function dynamicDashScenario(desc, func)  {
 
     ctx.setup = function (setupFunc) {
 
+      beforeEach(angularMocks.module('grafana.core'));
       beforeEach(angularMocks.module('grafana.services'));
       beforeEach(angularMocks.module(function($provide) {
         $provide.value('contextSrv', {

+ 31 - 8
public/app/features/dashboard/specs/exporter_specs.ts

@@ -42,21 +42,34 @@ describe('given dashboard with repeated panels', function() {
       repeat: 'test',
       panels: [
         {id: 2, repeat: 'apps', datasource: 'gfdb', type: 'graph'},
-        {id: 2, repeat: null, repeatPanelId: 2},
+        {id: 3, repeat: null, repeatPanelId: 2},
+        {
+          id: 4,
+          datasource: '-- Mixed --',
+          targets: [{datasource: 'other'}],
+        },
       ]
     });
+
     dash.rows.push({
       repeat: null,
       repeatRowId: 1,
       panels: [],
     });
 
-    var datasourceSrvStub = {
-      get: sinon.stub().returns(Promise.resolve({
-        name: 'gfdb',
-        meta: {id: "testdb", info: {version: "1.2.1"}, name: "TestDB"}
-      }))
-    };
+    var datasourceSrvStub = {get: sinon.stub()};
+    datasourceSrvStub.get.withArgs('gfdb').returns(Promise.resolve({
+      name: 'gfdb',
+      meta: {id: "testdb", info: {version: "1.2.1"}, name: "TestDB"}
+    }));
+    datasourceSrvStub.get.withArgs('other').returns(Promise.resolve({
+      name: 'other',
+      meta: {id: "other", info: {version: "1.2.1"}, name: "OtherDB"}
+    }));
+    datasourceSrvStub.get.withArgs('-- Mixed --').returns(Promise.resolve({
+      name: 'mixed',
+      meta: {id: "mixed", info: {version: "1.2.1"}, name: "Mixed", builtIn: true}
+    }));
 
     config.panels['graph'] = {
       id: "graph",
@@ -72,7 +85,7 @@ describe('given dashboard with repeated panels', function() {
   });
 
   it('exported dashboard should not contain repeated panels', function() {
-    expect(exported.rows[0].panels.length).to.be(1);
+    expect(exported.rows[0].panels.length).to.be(2);
   });
 
   it('exported dashboard should not contain repeated rows', function() {
@@ -109,6 +122,16 @@ describe('given dashboard with repeated panels', function() {
     expect(require.version).to.be("1.2.1");
   });
 
+  it('should not add built in datasources to required', function() {
+    var require = _.find(exported.__requires, {name: 'Mixed'});
+    expect(require).to.be(undefined);
+  });
+
+  it('should add datasources used in mixed mode', function() {
+    var require = _.find(exported.__requires, {name: 'OtherDB'});
+    expect(require).to.not.be(undefined);
+  });
+
   it('should add panel to required', function() {
     var require = _.find(exported.__requires, {name: 'Graph'});
     expect(require.name).to.be("Graph");

+ 22 - 23
public/app/features/dashboard/submenu/submenu.html

@@ -1,27 +1,26 @@
-<div class="submenu-controls">
-	<ul ng-if="ctrl.dashboard.templating.list.length > 0">
-		<li ng-repeat="variable in ctrl.variables" ng-hide="variable.hide === 2" class="submenu-item">
-			<span class="submenu-item-label template-variable " ng-hide="variable.hide === 1">
-				{{variable.label || variable.name}}:
-			</span>
-			<value-select-dropdown variable="variable" on-updated="ctrl.variableUpdated(variable)" get-values-for-tag="ctrl.getValuesForTag(variable, tagKey)"></value-select-dropdown>
-		</li>
-	</ul>
+<div class="submenu-controls gf-form-query">
+  <div ng-repeat="variable in ctrl.variables" ng-hide="variable.hide === 2" class="submenu-item gf-form-inline">
+    <div class="gf-form">
+      <label class="gf-form-label template-variable" ng-hide="variable.hide === 1">
+        {{variable.label || variable.name}}:
+      </label>
+      <value-select-dropdown ng-if="variable.type !== 'adhoc'" variable="variable" on-updated="ctrl.variableUpdated(variable)" get-values-for-tag="ctrl.getValuesForTag(variable, tagKey)"></value-select-dropdown>
+    </div>
+    <ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
+  </div>
 
-	<ul ng-if="ctrl.dashboard.annotations.list.length > 0">
-		<li ng-repeat="annotation in ctrl.dashboard.annotations.list" class="submenu-item annotation-segment" ng-class="{'annotation-disabled': !annotation.enable}">
-			<a ng-click="ctrl.disableAnnotation(annotation)">
-				<i class="fa fa-bolt" style="color:{{annotation.iconColor}}"></i>
-				{{annotation.name}}
-				<input class="cr1" id="hideYAxis" type="checkbox" ng-model="annotation.enable" ng-checked="annotation.enable">
-				<label for="hideYAxis" class="cr1"></label>
-			</a>
-		</li>
-	</ul>
+  <div ng-if="ctrl.dashboard.annotations.list.length > 0">
+    <div ng-repeat="annotation in ctrl.dashboard.annotations.list" class="submenu-item" ng-class="{'annotation-disabled': !annotation.enable}">
+      <gf-form-switch class="gf-form" label="{{annotation.name}}" checked="annotation.enable" on-change="ctrl.annotationStateChanged()"></gf-form-switch>
+    </div>
+  </div>
 
-	<ul class="pull-right" ng-if="ctrl.dashboard.links.length > 0">
-		<dash-links-container links="ctrl.dashboard.links"></dash-links-container>
-	</ul>
+  <div class="gf-form gf-form--grow">
+  </div>
 
-	<div class="clearfix"></div>
+  <div ng-if="ctrl.dashboard.links.length > 0" >
+    <dash-links-container links="ctrl.dashboard.links" class="gf-form-inline"></dash-links-container>
+  </div>
+
+  <div class="clearfix"></div>
 </div>

+ 5 - 6
public/app/features/dashboard/submenu/submenu.ts

@@ -10,24 +10,23 @@ export class SubmenuCtrl {
 
   /** @ngInject */
   constructor(private $rootScope,
-              private templateValuesSrv,
+              private variableSrv,
               private templateSrv,
               private $location) {
     this.annotations = this.dashboard.templating.list;
-    this.variables = this.dashboard.templating.list;
+    this.variables = this.variableSrv.variables;
   }
 
-  disableAnnotation(annotation) {
-    annotation.enable = !annotation.enable;
+  annotationStateChanged() {
     this.$rootScope.$broadcast('refresh');
   }
 
   getValuesForTag(variable, tagKey) {
-    return this.templateValuesSrv.getValuesForTag(variable, tagKey);
+    return this.variableSrv.getValuesForTag(variable, tagKey);
   }
 
   variableUpdated(variable) {
-    this.templateValuesSrv.variableUpdated(variable).then(() => {
+    this.variableSrv.variableUpdated(variable).then(() => {
       this.$rootScope.$emit('template-variable-value-updated');
       this.$rootScope.$broadcast('refresh');
     });

+ 1 - 21
public/app/features/dashboard/viewStateSrv.js

@@ -34,10 +34,6 @@ function (angular, _, $) {
         $location.search(urlParams);
       });
 
-      $scope.onAppEvent('template-variable-value-updated', function() {
-        self.updateUrlParamsWithCurrentVariables();
-      });
-
       $scope.onAppEvent('$routeUpdate', function() {
         var urlState = self.getQueryStringState();
         if (self.needsSync(urlState)) {
@@ -57,22 +53,6 @@ function (angular, _, $) {
       this.expandRowForPanel();
     }
 
-    DashboardViewState.prototype.updateUrlParamsWithCurrentVariables = function() {
-      // update url
-      var params = $location.search();
-      // remove variable params
-      _.each(params, function(value, key) {
-        if (key.indexOf('var-') === 0) {
-          delete params[key];
-        }
-      });
-
-      // add new values
-      templateSrv.fillVariableValuesForUrl(params);
-      // update url
-      $location.search(params);
-    };
-
     DashboardViewState.prototype.expandRowForPanel = function() {
       if (!this.state.panelId) { return; }
 
@@ -185,7 +165,7 @@ function (angular, _, $) {
     DashboardViewState.prototype.enterFullscreen = function(panelScope) {
       var ctrl = panelScope.ctrl;
 
-      ctrl.editMode = this.state.edit && this.$scope.dashboardMeta.canEdit;
+      ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit;
       ctrl.fullscreen = true;
 
       this.oldTimeRange = ctrl.range;

+ 5 - 3
public/app/features/dashlinks/module.js

@@ -44,17 +44,19 @@ function (angular, _) {
       restrict: 'E',
       link: function(scope, elem) {
         var link = scope.link;
-        var template = '<div class="submenu-item dropdown">' +
-          '<a class="pointer dash-nav-link" data-placement="bottom"' +
+        var template = '<div class="gf-form">' +
+          '<a class="pointer gf-form-label" data-placement="bottom"' +
           (link.asDropdown ? ' ng-click="fillDropdown(link)" data-toggle="dropdown"'  : "") + '>' +
           '<i></i> <span></span></a>';
 
         if (link.asDropdown) {
           template += '<ul class="dropdown-menu" role="menu">' +
-            '<li ng-repeat="dash in link.searchHits"><a href="{{dash.url}}"><i class="fa fa-th-large"></i> {{dash.title}}</a></li>' +
+            '<li ng-repeat="dash in link.searchHits"><a href="{{dash.url}}">{{dash.title}}</a></li>' +
             '</ul>';
         }
 
+        template += '</div>';
+
         elem.html(template);
         $compile(elem.contents())(scope);
 

+ 6 - 3
public/app/features/styleguide/styleguide.ts

@@ -13,7 +13,7 @@ class StyleGuideCtrl {
   pages = ['colors', 'buttons'];
 
   /** @ngInject **/
-  constructor(private $http, $routeParams) {
+  constructor(private $http, private $routeParams, private $location) {
     this.theme = config.bootData.user.lightTheme ? 'light': 'dark';
     this.page = {};
 
@@ -37,8 +37,11 @@ class StyleGuideCtrl {
   }
 
   switchTheme() {
-    var other = this.theme === 'dark' ? 'light' : 'dark';
-    window.location.href = window.location.href + '?theme=' + other;
+    this.$routeParams.theme = this.theme === 'dark' ? 'light' : 'dark';
+    this.$location.search(this.$routeParams);
+    setTimeout(() => {
+      window.location.href = window.location.href;
+    });
   }
 
 }

+ 74 - 0
public/app/features/templating/adhoc_variable.ts

@@ -0,0 +1,74 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {Variable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} from './variable_srv';
+
+export class AdhocVariable implements Variable {
+  filters: any[];
+
+  defaults = {
+    type: 'adhoc',
+    name: '',
+    label: '',
+    hide: 0,
+    datasource: null,
+    filters: [],
+  };
+
+  /** @ngInject **/
+  constructor(private model) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  setValue(option) {
+    return Promise.resolve();
+  }
+
+  getModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  updateOptions() {
+    return Promise.resolve();
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    if (!_.isArray(urlValue)) {
+      urlValue = [urlValue];
+    }
+
+    this.filters = urlValue.map(item => {
+      var values = item.split('|');
+      return {
+        key: values[0],
+        operator: values[1],
+        value: values[2],
+      };
+    });
+
+    return Promise.resolve();
+  }
+
+  getValueForUrl() {
+    return this.filters.map(filter => {
+      return filter.key + '|' + filter.operator + '|' + filter.value;
+    });
+  }
+
+  setFilters(filters: any[]) {
+    this.filters = filters;
+  }
+}
+
+variableTypes['adhoc'] = {
+  name: 'Ad hoc filters',
+  ctor: AdhocVariable,
+  description: 'Add key/value filters on the fly',
+};

+ 20 - 0
public/app/features/templating/all.ts

@@ -0,0 +1,20 @@
+import './templateSrv';
+import './editor_ctrl';
+
+import {VariableSrv} from './variable_srv';
+import {IntervalVariable} from './interval_variable';
+import {QueryVariable} from './query_variable';
+import {DatasourceVariable} from './datasource_variable';
+import {CustomVariable} from './custom_variable';
+import {ConstantVariable} from './constant_variable';
+import {AdhocVariable} from './adhoc_variable';
+
+export {
+  VariableSrv,
+  IntervalVariable,
+  QueryVariable,
+  DatasourceVariable,
+  CustomVariable,
+  ConstantVariable,
+  AdhocVariable,
+}

+ 59 - 0
public/app/features/templating/constant_variable.ts

@@ -0,0 +1,59 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import {Variable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} from './variable_srv';
+
+export class ConstantVariable implements Variable {
+  query: string;
+  options: any[];
+  current: any;
+
+  defaults = {
+    type: 'constant',
+    name: '',
+    hide: 2,
+    label: '',
+    query: '',
+    current: {},
+  };
+
+  /** @ngInject */
+  constructor(private model, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  getModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option) {
+    this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  updateOptions() {
+    this.options = [{text: this.query.trim(), value: this.query.trim()}];
+    this.setValue(this.options[0]);
+    return Promise.resolve();
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    return this.current.value;
+  }
+
+}
+
+variableTypes['constant'] = {
+  name: 'Constant',
+  ctor: ConstantVariable,
+  description: 'Define a hidden constant variable, useful for metric prefixes in dashboards you want to share' ,
+};

+ 80 - 0
public/app/features/templating/custom_variable.ts

@@ -0,0 +1,80 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {Variable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} from './variable_srv';
+
+export class CustomVariable implements Variable {
+  query: string;
+  options: any;
+  includeAll: boolean;
+  multi: boolean;
+  current: any;
+
+  defaults = {
+    type: 'custom',
+    name: '',
+    label: '',
+    hide: 0,
+    options: [],
+    current: {},
+    query: '',
+    includeAll: false,
+    multi: false,
+    allValue: null,
+  };
+
+  /** @ngInject **/
+  constructor(private model, private timeSrv, private templateSrv, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  setValue(option) {
+    return this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  getModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  updateOptions() {
+    // extract options in comma separated string
+    this.options = _.map(this.query.split(/[,]+/), function(text) {
+      return { text: text.trim(), value: text.trim() };
+    });
+
+    if (this.includeAll) {
+      this.addAllOption();
+    }
+
+    return this.variableSrv.validateVariableSelectionState(this);
+  }
+
+  addAllOption() {
+    this.options.unshift({text: 'All', value: "$__all"});
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    if (this.current.text === 'All') {
+      return 'All';
+    }
+    return this.current.value;
+  }
+}
+
+variableTypes['custom'] = {
+  name: 'Custom',
+  ctor: CustomVariable,
+  description: 'Define variable values manually' ,
+  supportsMulti: true,
+};

+ 87 - 0
public/app/features/templating/datasource_variable.ts

@@ -0,0 +1,87 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {Variable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} from './variable_srv';
+
+export class DatasourceVariable implements Variable {
+  regex: any;
+  query: string;
+  options: any;
+  current: any;
+
+ defaults = {
+    type: 'datasource',
+    name: '',
+    hide: 0,
+    label: '',
+    current: {},
+    regex: '',
+    options: [],
+    query: '',
+  };
+
+  /** @ngInject */
+  constructor(private model, private datasourceSrv, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  getModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option) {
+    return this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  updateOptions() {
+    var options = [];
+    var sources = this.datasourceSrv.getMetricSources({skipVariables: true});
+    var regex;
+
+    if (this.regex) {
+      regex = kbn.stringToJsRegex(this.regex);
+    }
+
+    for (var i = 0; i < sources.length; i++) {
+      var source = sources[i];
+      // must match on type
+      if (source.meta.id !== this.query) {
+        continue;
+      }
+
+      if (regex && !regex.exec(source.name)) {
+        continue;
+      }
+
+      options.push({text: source.name, value: source.name});
+    }
+
+    if (options.length === 0) {
+      options.push({text: 'No data sources found', value: ''});
+    }
+
+    this.options = options;
+    return this.variableSrv.validateVariableSelectionState(this);
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    return this.current.value;
+  }
+}
+
+variableTypes['datasource'] = {
+  name: 'Datasource',
+  ctor: DatasourceVariable,
+  description: 'Enabled you to dynamically switch the datasource for multiple panels',
+};

+ 0 - 198
public/app/features/templating/editorCtrl.js

@@ -1,198 +0,0 @@
-define([
-  'angular',
-  'lodash',
-],
-function (angular, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('TemplateEditorCtrl', function($scope, datasourceSrv, templateSrv, templateValuesSrv) {
-
-    var replacementDefaults = {
-      type: 'query',
-      datasource: null,
-      refresh: 0,
-      sort: 1,
-      name: '',
-      hide: 0,
-      options: [],
-      includeAll: false,
-      multi: false,
-    };
-
-    $scope.variableTypes = [
-      {value: "query",      text: "Query"},
-      {value: "interval",   text: "Interval"},
-      {value: "datasource", text: "Data source"},
-      {value: "custom",     text: "Custom"},
-      {value: "constant",   text: "Constant"},
-    ];
-
-    $scope.refreshOptions = [
-      {value: 0, text: "Never"},
-      {value: 1, text: "On Dashboard Load"},
-      {value: 2, text: "On Time Range Change"},
-    ];
-
-    $scope.sortOptions = [
-      {value: 0, text: "Without Sort"},
-      {value: 1, text: "Alphabetical (asc)"},
-      {value: 2, text: "Alphabetical (desc)"},
-      {value: 3, text: "Numerical (asc)"},
-      {value: 4, text: "Numerical (desc)"},
-    ];
-
-    $scope.hideOptions = [
-      {value: 0, text: ""},
-      {value: 1, text: "Label"},
-      {value: 2, text: "Variable"},
-    ];
-
-    $scope.init = function() {
-      $scope.mode = 'list';
-
-      $scope.datasourceTypes = {};
-      $scope.datasources = _.filter(datasourceSrv.getMetricSources(), function(ds) {
-        $scope.datasourceTypes[ds.meta.id] = {text: ds.meta.name, value: ds.meta.id};
-        return !ds.meta.builtIn;
-      });
-
-      $scope.datasourceTypes = _.map($scope.datasourceTypes, function(value) {
-        return value;
-      });
-
-      $scope.variables = templateSrv.variables;
-      $scope.reset();
-
-      $scope.$watch('mode', function(val) {
-        if (val === 'new') {
-          $scope.reset();
-        }
-      });
-
-      $scope.$watch('current.datasource', function(val) {
-        if ($scope.mode === 'new') {
-          datasourceSrv.get(val).then(function(ds) {
-            if (ds.meta.defaultMatchFormat) {
-              $scope.current.allFormat = ds.meta.defaultMatchFormat;
-              $scope.current.multiFormat = ds.meta.defaultMatchFormat;
-            }
-          });
-        }
-      });
-    };
-
-    $scope.add = function() {
-      if ($scope.isValid()) {
-        $scope.variables.push($scope.current);
-        $scope.update();
-        $scope.updateSubmenuVisibility();
-      }
-    };
-
-    $scope.isValid = function() {
-      if (!$scope.current.name) {
-        $scope.appEvent('alert-warning', ['Validation', 'Template variable requires a name']);
-        return false;
-      }
-
-      if (!$scope.current.name.match(/^\w+$/)) {
-        $scope.appEvent('alert-warning', ['Validation', 'Only word and digit characters are allowed in variable names']);
-        return false;
-      }
-
-      var sameName = _.find($scope.variables, { name: $scope.current.name });
-      if (sameName && sameName !== $scope.current) {
-        $scope.appEvent('alert-warning', ['Validation', 'Variable with the same name already exists']);
-        return false;
-      }
-
-      return true;
-    };
-
-    $scope.runQuery = function() {
-      return templateValuesSrv.updateOptions($scope.current).then(null, function(err) {
-        if (err.data && err.data.message) { err.message = err.data.message; }
-        $scope.appEvent("alert-error", ['Templating', 'Template variables could not be initialized: ' + err.message]);
-      });
-    };
-
-    $scope.edit = function(variable) {
-      $scope.current = variable;
-      $scope.currentIsNew = false;
-      $scope.mode = 'edit';
-
-      $scope.current.sort = $scope.current.sort || replacementDefaults.sort;
-      if ($scope.current.datasource === void 0) {
-        $scope.current.datasource = null;
-        $scope.current.type = 'query';
-        $scope.current.allFormat = 'glob';
-      }
-    };
-
-    $scope.duplicate = function(variable) {
-      $scope.current = angular.copy(variable);
-      $scope.variables.push($scope.current);
-      $scope.current.name = 'copy_of_'+variable.name;
-      $scope.updateSubmenuVisibility();
-    };
-
-    $scope.update = function() {
-      if ($scope.isValid()) {
-        $scope.runQuery().then(function() {
-          $scope.reset();
-          $scope.mode = 'list';
-        });
-      }
-    };
-
-    $scope.reset = function() {
-      $scope.currentIsNew = true;
-      $scope.current = angular.copy(replacementDefaults);
-    };
-
-    $scope.showSelectionOptions = function() {
-      if ($scope.current) {
-        if ($scope.current.type === 'query') {
-          return true;
-        }
-        if ($scope.current.type === 'custom') {
-          return true;
-        }
-      }
-      return false;
-    };
-
-    $scope.typeChanged = function () {
-      if ($scope.current.type === 'interval') {
-        $scope.current.query = '1m,10m,30m,1h,6h,12h,1d,7d,14d,30d';
-        $scope.current.refresh = 0;
-      }
-
-      if ($scope.current.type === 'query') {
-        $scope.current.query = '';
-      }
-
-      if ($scope.current.type === 'constant') {
-        $scope.current.query = '';
-        $scope.current.refresh = 0;
-        $scope.current.hide = 2;
-      }
-
-      if ($scope.current.type === 'datasource') {
-        $scope.current.query = $scope.datasourceTypes[0].value;
-        $scope.current.regex = '';
-        $scope.current.refresh = 1;
-      }
-    };
-
-    $scope.removeVariable = function(variable) {
-      var index = _.indexOf($scope.variables, variable);
-      $scope.variables.splice(index, 1);
-      $scope.updateSubmenuVisibility();
-    };
-
-  });
-
-});

+ 155 - 0
public/app/features/templating/editor_ctrl.ts

@@ -0,0 +1,155 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import coreModule from 'app/core/core_module';
+import {variableTypes} from './variable';
+
+export class VariableEditorCtrl {
+
+  /** @ngInject */
+  constructor(private $scope, private datasourceSrv, private variableSrv, templateSrv) {
+    $scope.variableTypes = variableTypes;
+    $scope.ctrl = {};
+
+    $scope.refreshOptions = [
+      {value: 0, text: "Never"},
+      {value: 1, text: "On Dashboard Load"},
+      {value: 2, text: "On Time Range Change"},
+    ];
+
+    $scope.sortOptions = [
+      {value: 0, text: "Disabled"},
+      {value: 1, text: "Alphabetical (asc)"},
+      {value: 2, text: "Alphabetical (desc)"},
+      {value: 3, text: "Numerical (asc)"},
+      {value: 4, text: "Numerical (desc)"},
+    ];
+
+    $scope.hideOptions = [
+      {value: 0, text: ""},
+      {value: 1, text: "Label"},
+      {value: 2, text: "Variable"},
+    ];
+
+    $scope.init = function() {
+      $scope.mode = 'list';
+
+      $scope.datasources = _.filter(datasourceSrv.getMetricSources(), function(ds) {
+        return !ds.meta.builtIn && ds.value !== null;
+      });
+
+      $scope.datasourceTypes = _($scope.datasources).uniqBy('meta.id').map(function(ds) {
+        return {text: ds.meta.name, value: ds.meta.id};
+      }).value();
+
+      $scope.variables = variableSrv.variables;
+      $scope.reset();
+
+      $scope.$watch('mode', function(val) {
+        if (val === 'new') {
+          $scope.reset();
+        }
+      });
+    };
+
+    $scope.add = function() {
+      if ($scope.isValid()) {
+        $scope.variables.push($scope.current);
+        $scope.update();
+        $scope.updateSubmenuVisibility();
+      }
+    };
+
+    $scope.isValid = function() {
+      if (!$scope.ctrl.form.$valid) {
+        return;
+      }
+
+      if (!$scope.current.name.match(/^\w+$/)) {
+        $scope.appEvent('alert-warning', ['Validation', 'Only word and digit characters are allowed in variable names']);
+        return false;
+      }
+
+      var sameName = _.find($scope.variables, { name: $scope.current.name });
+      if (sameName && sameName !== $scope.current) {
+        $scope.appEvent('alert-warning', ['Validation', 'Variable with the same name already exists']);
+        return false;
+      }
+
+      return true;
+    };
+
+    $scope.validate = function() {
+      $scope.infoText = '';
+      if ($scope.current.type === 'adhoc' && $scope.current.datasource !== null) {
+        $scope.infoText = 'Adhoc filters are applied automatically to all queries that target this datasource';
+        datasourceSrv.get($scope.current.datasource).then(ds => {
+          if (!ds.getTagKeys) {
+            $scope.infoText = 'This datasource does not support adhoc filters yet.';
+          }
+        });
+      }
+    };
+
+    $scope.runQuery = function() {
+      return variableSrv.updateOptions($scope.current).then(null, function(err) {
+        if (err.data && err.data.message) { err.message = err.data.message; }
+        $scope.appEvent("alert-error", ['Templating', 'Template variables could not be initialized: ' + err.message]);
+      });
+    };
+
+    $scope.edit = function(variable) {
+      $scope.current = variable;
+      $scope.currentIsNew = false;
+      $scope.mode = 'edit';
+      $scope.validate();
+    };
+
+    $scope.duplicate = function(variable) {
+      var clone = _.cloneDeep(variable.getModel());
+      $scope.current = variableSrv.createVariableFromModel(clone);
+      $scope.variables.push($scope.current);
+      $scope.current.name = 'copy_of_'+variable.name;
+      $scope.updateSubmenuVisibility();
+    };
+
+    $scope.update = function() {
+      if ($scope.isValid()) {
+        $scope.runQuery().then(function() {
+          $scope.reset();
+          $scope.mode = 'list';
+          templateSrv.updateTemplateData();
+        });
+      }
+    };
+
+    $scope.reset = function() {
+      $scope.currentIsNew = true;
+      $scope.current = variableSrv.createVariableFromModel({type: 'query'});
+    };
+
+    $scope.typeChanged = function() {
+      var old = $scope.current;
+      $scope.current = variableSrv.createVariableFromModel({type: $scope.current.type});
+      $scope.current.name = old.name;
+      $scope.current.hide = old.hide;
+      $scope.current.label = old.label;
+
+      var oldIndex = _.indexOf(this.variables, old);
+      if (oldIndex !== -1) {
+        this.variables[oldIndex] = $scope.current;
+      }
+
+      $scope.validate();
+    };
+
+    $scope.removeVariable = function(variable) {
+      var index = _.indexOf($scope.variables, variable);
+      $scope.variables.splice(index, 1);
+      $scope.updateSubmenuVisibility();
+    };
+  }
+}
+
+coreModule.controller('VariableEditorCtrl', VariableEditorCtrl);
+

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

@@ -0,0 +1,89 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {Variable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} from './variable_srv';
+
+export class IntervalVariable implements Variable {
+  auto_count: number;
+  auto_min: number;
+  options: any;
+  auto: boolean;
+  query: string;
+  refresh: number;
+  current: any;
+
+  defaults = {
+    type: 'interval',
+    name: '',
+    hide: 0,
+    label: '',
+    refresh: 2,
+    options: [],
+    current: {},
+    query: '1m,10m,30m,1h,6h,12h,1d,7d,14d,30d',
+    auto: false,
+    auto_min: '10s',
+    auto_count: 30,
+  };
+
+  /** @ngInject */
+  constructor(private model, private timeSrv, private templateSrv, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+    this.refresh = 2;
+  }
+
+  getModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option) {
+    this.updateAutoValue();
+    return this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  updateAutoValue() {
+    if (!this.auto) {
+      return;
+    }
+
+    // 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()};
+    });
+
+    this.updateAutoValue();
+    return this.variableSrv.validateVariableSelectionState(this);
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    this.updateAutoValue();
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    return this.current.value;
+  }
+}
+
+variableTypes['interval'] = {
+  name: 'Interval',
+  ctor: IntervalVariable,
+  description: 'Define a timespan interval (ex 1m, 1h, 1d)',
+};

+ 58 - 54
public/app/features/templating/partials/editor.html

@@ -1,4 +1,4 @@
-<div ng-controller="TemplateEditorCtrl" ng-init="init()">
+<div ng-controller="VariableEditorCtrl" ng-init="init()">
 	<div class="tabbed-view-header">
 		<h2 class="tabbed-view-title">
 			Templating
@@ -70,33 +70,23 @@
 			</div>
 		</div>
 
-		<div ng-if="mode === 'edit' || mode === 'new'">
+		<form ng-if="mode === 'edit' || mode === 'new'" name="ctrl.form">
 			<h5 class="section-heading">Variable</h5>
 			<div class="gf-form-group">
 				<div class="gf-form-inline">
 					<div class="gf-form max-width-19">
 						<span class="gf-form-label width-6">Name</span>
-						<input type="text" class="gf-form-input" placeholder="name" ng-model='current.name'></input>
+						<input type="text" class="gf-form-input" placeholder="name" ng-model='current.name' required></input>
 					</div>
 					<div class="gf-form max-width-19">
 						<span class="gf-form-label width-6">
               Type
               <info-popover mode="right-normal">
-                <dl>
-                  <dt>Query</dt>
-                  <dd>Variable values are fetched from a metric names query to a data source</dd>
-                  <dt>Interval</dt>
-                  <dd>Timespan variable type</dd>
-                  <dt>Datasource</dt>
-                  <dd>Dynamically switch data sources using this type of variable</dd>
-                  <dt>Custom</dt>
-                  <dd>Define variable values manually</dd>
-                </dl>
-                <a href="http://docs.grafana.org/reference/templating" target="_blank">Templating docs</a>
+								{{variableTypes[current.type].description}}
               </info-popover>
             </span>
 						<div class="gf-form-select-wrapper max-width-17">
-							<select class="gf-form-input" ng-model="current.type" ng-options="f.value as f.text for f in variableTypes" ng-change="typeChanged()"></select>
+							<select class="gf-form-input" ng-model="current.type" ng-options="k as v.name for (k, v) in variableTypes" ng-change="typeChanged()"></select>
 						</div>
 					</div>
         </div>
@@ -112,15 +102,14 @@
 						</div>
 					</div>
 				</div>
-
 			</div>
 
-			<div ng-show="current.type === 'interval'" class="gf-form-group">
+			<div ng-if="current.type === 'interval'" class="gf-form-group">
         <h5 class="section-heading">Interval Options</h5>
 
 				<div class="gf-form">
 					<span class="gf-form-label width-9">Values</span>
-					<input type="text" class="gf-form-input" placeholder="name" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur ng-change="runQuery()"></input>
+					<input type="text" class="gf-form-input" placeholder="name" ng-model='current.query' placeholder="1m,10m,1h,6h,1d,7d" ng-model-onblur ng-change="runQuery()" required></input>
 				</div>
 				<div class="gf-form">
 					<span class="gf-form-label width-9">Auto option</span>
@@ -144,15 +133,15 @@
 				</div>
 			</div>
 
-			<div ng-show="current.type === 'custom'" class="gf-form-group">
+			<div ng-if="current.type === 'custom'" class="gf-form-group">
         <h5 class="section-heading">Custom Options</h5>
 				<div class="gf-form">
 					<span class="gf-form-label width-13">Values separated by comma</span>
-					<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"></input>
+					<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue" required></input>
 				</div>
 			</div>
 
-			<div ng-show="current.type === 'constant'" class="gf-form-group">
+			<div ng-if="current.type === 'constant'" class="gf-form-group">
         <h5 class="section-heading">Constant options</h5>
 				<div class="gf-form">
 					<span class="gf-form-label">Value</span>
@@ -160,14 +149,14 @@
 				</div>
 			</div>
 
-			<div ng-show="current.type === 'query'" class="gf-form-group">
+			<div ng-if="current.type === 'query'" class="gf-form-group">
         <h5 class="section-heading">Query Options</h5>
 
         <div class="gf-form-inline">
           <div class="gf-form max-width-21">
-            <span class="gf-form-label width-7" ng-show="current.type === 'query'">Data source</span>
+            <span class="gf-form-label width-7">Data source</span>
             <div class="gf-form-select-wrapper max-width-14">
-              <select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"></select>
+              <select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required></select>
             </div>
           </div>
           <div class="gf-form max-width-21">
@@ -181,21 +170,10 @@
               <select class="gf-form-input" ng-model="current.refresh" ng-options="f.value as f.text for f in refreshOptions"></select>
             </div>
           </div>
-          <div class="gf-form max-width-21">
-            <span class="gf-form-label width-7">
-              Sort
-              <info-popover mode="right-normal">
-                How to sort the values of this variable.
-              </info-popover>
-            </span>
-            <div class="gf-form-select-wrapper max-width-14">
-              <select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions" ng-change="runQuery()"></select>
-            </div>
-          </div>
-        </div>
-        <div class="gf-form">
+				</div>
+				<div class="gf-form">
           <span class="gf-form-label width-7">Query</span>
-          <input type="text" class="gf-form-input" ng-model='current.query' placeholder="metric name or tags query" ng-model-onblur ng-change="runQuery()"></input>
+          <input type="text" class="gf-form-input" ng-model='current.query' placeholder="metric name or tags query" ng-model-onblur ng-change="runQuery()" required></input>
         </div>
         <div class="gf-form">
           <span class="gf-form-label width-7">
@@ -206,15 +184,26 @@
           </span>
           <input type="text" class="gf-form-input" ng-model='current.regex' placeholder="/.*-(.*)-.*/" ng-model-onblur ng-change="runQuery()"></input>
         </div>
-      </div>
+				<div class="gf-form max-width-21">
+					<span class="gf-form-label width-7">
+						Sort
+						<info-popover mode="right-normal">
+							How to sort the values of this variable.
+						</info-popover>
+					</span>
+					<div class="gf-form-select-wrapper max-width-14">
+						<select class="gf-form-input" ng-model="current.sort" ng-options="f.value as f.text for f in sortOptions" ng-change="runQuery()"></select>
+					</div>
+				</div>
+			</div>
 
-      <div ng-show="current.type === 'datasource'" class="gf-form-group">
-        <h5 class="section-heading">Data source options</h5>
+			<div ng-show="current.type === 'datasource'" class="gf-form-group">
+				<h5 class="section-heading">Data source options</h5>
 
-        <div class="gf-form">
-          <label class="gf-form-label width-12">Type</label>
-          <div class="gf-form-select-wrapper max-width-18">
-            <select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes" ng-change="runQuery()"></select>
+				<div class="gf-form">
+					<label class="gf-form-label width-12">Type</label>
+					<div class="gf-form-select-wrapper max-width-18">
+						<select class="gf-form-input" ng-model="current.query" ng-options="f.value as f.text for f in datasourceTypes" ng-change="runQuery()"></select>
           </div>
         </div>
 
@@ -233,8 +222,18 @@
         </div>
       </div>
 
-      <div class="section gf-form-group" ng-show="showSelectionOptions()">
-        <h5 class="section-heading">Selection Options</h5>
+			<div ng-if="current.type === 'adhoc'" class="gf-form-group">
+        <h5 class="section-heading">Options</h5>
+				<div class="gf-form max-width-21">
+					<span class="gf-form-label width-8">Data source</span>
+					<div class="gf-form-select-wrapper max-width-14">
+						<select class="gf-form-input" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources" required ng-change="validate()"></select>
+					</div>
+				</div>
+			</div>
+
+			<div class="section gf-form-group" ng-show="variableTypes[current.type].supportsMulti">
+				<h5 class="section-heading">Selection Options</h5>
         <div class="section">
           <gf-form-switch class="gf-form"
                           label="Multi-value"
@@ -271,7 +270,7 @@
         </div>
       </div>
 
-      <div class="gf-form-group">
+      <div class="gf-form-group" ng-show="current.options.length">
         <h5>Preview of values (shows max 20)</h5>
         <div class="gf-form-inline">
           <div class="gf-form" ng-repeat="option in current.options | limitTo: 20">
@@ -279,12 +278,17 @@
           </div>
         </div>
       </div>
-    </div>
 
-    <div class="gf-form-button-row p-y-0">
-      <button type="button" class="btn btn-success" ng-show="mode === 'edit'" ng-click="update();">Update</button>
-      <button type="button" class="btn btn-success" ng-show="mode === 'new'" ng-click="add();">Add</button>
-    </div>
-  </div>
+			<div class="alert alert-info gf-form-group" ng-if="infoText">
+				{{infoText}}
+			</div>
+
+			<div class="gf-form-button-row p-y-0">
+				<button type="submit" class="btn btn-success" ng-show="mode === 'edit'" ng-click="update();">Update</button>
+				<button type="submit" class="btn btn-success" ng-show="mode === 'new'" ng-click="add();">Add</button>
+			</div>
+
+		</form>
+	</div>
 </div>
 

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

@@ -0,0 +1,167 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+import {Variable, containsVariable, assignModelProperties, variableTypes} from './variable';
+import {VariableSrv} 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;
+  refresh: number;
+  hide: number;
+  name: string;
+  multi: boolean;
+  includeAll: boolean;
+
+  defaults = {
+    type: 'query',
+    query: '',
+    regex: '',
+    sort: 0,
+    datasource: null,
+    refresh: 0,
+    hide: 0,
+    name: '',
+    multi: false,
+    includeAll: false,
+    allValue: null,
+    options: [],
+    current: {},
+    tagsQuery: null,
+    tagValuesQuery: null,
+  };
+
+  constructor(private model, private datasourceSrv, private templateSrv, private variableSrv, private $q)  {
+    // copy model properties to this instance
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  getModel() {
+    // copy back model properties to model
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option){
+    return this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  setValueFromUrl(urlValue) {
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    if (this.current.text === 'All') {
+      return 'All';
+    }
+    return this.current.value;
+  }
+
+  updateOptions() {
+    return this.datasourceSrv.get(this.datasource)
+    .then(this.updateOptionsFromMetricFindQuery.bind(this))
+    .then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, 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.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 = matches[1];
+        }
+      }
+
+      options.push({text: text, value: value});
+    }
+
+    options = _.uniqBy(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(/.*?(\d+).*/);
+        if (!matches) {
+          return 0;
+        } else {
+          return parseInt(matches[1], 10);
+        }
+      });
+    }
+
+    if (reverseSort) {
+      options = options.reverse();
+    }
+
+    return options;
+  }
+
+  dependsOn(variable) {
+    return containsVariable(this.query, this.datasource, variable.name);
+  }
+}
+
+variableTypes['query'] = {
+  name: 'Query',
+  ctor: QueryVariable,
+  description: 'Variable values are fetched from a datasource query',
+  supportsMulti: true,
+};

+ 40 - 0
public/app/features/templating/specs/adhoc_variable_specs.ts

@@ -0,0 +1,40 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import {AdhocVariable} from '../adhoc_variable';
+
+describe('AdhocVariable', function() {
+
+  describe('when serializing to url', function() {
+
+    it('should set return key value and op seperated by pipe', function() {
+      var variable = new AdhocVariable({
+        filters: [
+          {key: 'key1', operator: '=', value: 'value1'},
+          {key: 'key2', operator: '!=', value: 'value2'},
+        ]
+      });
+      var urlValue = variable.getValueForUrl();
+      expect(urlValue).to.eql(["key1|=|value1", "key2|!=|value2"]);
+    });
+
+  });
+
+  describe('when deserializing from url', function() {
+
+    it('should restore filters', function() {
+      var variable = new AdhocVariable({});
+      variable.setValueFromUrl(["key1|=|value1", "key2|!=|value2"]);
+
+      expect(variable.filters[0].key).to.be('key1');
+      expect(variable.filters[0].operator).to.be('=');
+      expect(variable.filters[0].value).to.be('value1');
+
+      expect(variable.filters[1].key).to.be('key2');
+      expect(variable.filters[1].operator).to.be('!=');
+      expect(variable.filters[1].value).to.be('value2');
+    });
+
+  });
+
+});
+

+ 39 - 0
public/app/features/templating/specs/query_variable_specs.ts

@@ -0,0 +1,39 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import {QueryVariable} from '../query_variable';
+
+describe('QueryVariable', function() {
+
+  describe('when creating from model', function() {
+
+    it('should set defaults', function() {
+      var variable = new QueryVariable({}, null, null, null, null);
+      expect(variable.datasource).to.be(null);
+      expect(variable.refresh).to.be(0);
+      expect(variable.sort).to.be(0);
+      expect(variable.name).to.be('');
+      expect(variable.hide).to.be(0);
+      expect(variable.options.length).to.be(0);
+      expect(variable.multi).to.be(false);
+      expect(variable.includeAll).to.be(false);
+    });
+
+    it('get model should copy changes back to model', () => {
+      var variable = new QueryVariable({}, null, null, null, null);
+      variable.options = [{text: 'test'}];
+      variable.datasource = 'google';
+      variable.regex = 'asd';
+      variable.sort = 50;
+
+      var model = variable.getModel();
+      expect(model.options.length).to.be(1);
+      expect(model.options[0].text).to.be('test');
+      expect(model.datasource).to.be('google');
+      expect(model.regex).to.be('asd');
+      expect(model.sort).to.be(50);
+    });
+
+  });
+
+});
+

+ 237 - 0
public/app/features/templating/specs/template_srv_specs.ts

@@ -0,0 +1,237 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import '../all';
+import {Emitter} from 'app/core/core';
+
+describe('templateSrv', function() {
+  var _templateSrv, _variableSrv;
+
+  beforeEach(angularMocks.module('grafana.core'));
+  beforeEach(angularMocks.module('grafana.services'));
+
+  beforeEach(angularMocks.inject(function(variableSrv, templateSrv) {
+    _templateSrv = templateSrv;
+    _variableSrv = variableSrv;
+  }));
+
+  function initTemplateSrv(variables) {
+    _variableSrv.init({
+      templating: {list: variables},
+      events: new Emitter(),
+    });
+  }
+
+  describe('init', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: {value: 'oogle'}}]);
+    });
+
+    it('should initialize template data', function() {
+      var target = _templateSrv.replace('this.[[test]].filters');
+      expect(target).to.be('this.oogle.filters');
+    });
+  });
+
+  describe('replace can pass scoped vars', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: {value: 'oogle' }}]);
+    });
+
+    it('should replace $test with scoped value', function() {
+      var target = _templateSrv.replace('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}});
+      expect(target).to.be('this.mupp.filters');
+    });
+
+    it('should replace $test with scoped text', function() {
+      var target = _templateSrv.replaceWithText('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}});
+      expect(target).to.be('this.asd.filters');
+    });
+  });
+
+  describe('replace can pass multi / all format', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: {value: ['value1', 'value2'] }}]);
+    });
+
+    it('should replace $test with globbed value', function() {
+      var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
+      expect(target).to.be('this.{value1,value2}.filters');
+    });
+
+    it('should replace $test with piped value', function() {
+      var target = _templateSrv.replace('this=$test', {}, 'pipe');
+      expect(target).to.be('this=value1|value2');
+    });
+
+    it('should replace $test with piped value', function() {
+      var target = _templateSrv.replace('this=$test', {}, 'pipe');
+      expect(target).to.be('this=value1|value2');
+    });
+  });
+
+  describe('variable with all option', function() {
+    beforeEach(function() {
+      initTemplateSrv([{
+        type: 'query',
+        name: 'test',
+        current: {value: '$__all' },
+        options: [
+          {value: '$__all'}, {value: 'value1'}, {value: 'value2'}
+        ]
+      }]);
+    });
+
+    it('should replace $test with formatted all value', function() {
+      var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
+      expect(target).to.be('this.{value1,value2}.filters');
+    });
+  });
+
+  describe('variable with all option and custom value', function() {
+    beforeEach(function() {
+      initTemplateSrv([{
+        type: 'query',
+        name: 'test',
+        current: {value: '$__all' },
+        allValue: '*',
+        options: [
+          {value: 'value1'}, {value: 'value2'}
+        ]
+      }]);
+    });
+
+    it('should replace $test with formatted all value', function() {
+      var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
+      expect(target).to.be('this.*.filters');
+    });
+
+    it('should not escape custom all value', function() {
+      var target = _templateSrv.replace('this.$test', {}, 'regex');
+      expect(target).to.be('this.*');
+    });
+  });
+
+  describe('lucene format', function() {
+    it('should properly escape $test with lucene escape sequences', function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: {value: 'value/4' }}]);
+      var target = _templateSrv.replace('this:$test', {}, 'lucene');
+      expect(target).to.be("this:value\\\/4");
+    });
+  });
+
+  describe('format variable to string values', function() {
+    it('single value should return value', function() {
+      var result = _templateSrv.formatValue('test');
+      expect(result).to.be('test');
+    });
+
+    it('multi value and glob format should render glob string', function() {
+      var result = _templateSrv.formatValue(['test','test2'], 'glob');
+      expect(result).to.be('{test,test2}');
+    });
+
+    it('multi value and lucene should render as lucene expr', function() {
+      var result = _templateSrv.formatValue(['test','test2'], 'lucene');
+      expect(result).to.be('("test" OR "test2")');
+    });
+
+    it('multi value and regex format should render regex string', function() {
+      var result = _templateSrv.formatValue(['test.','test2'], 'regex');
+      expect(result).to.be('(test\\.|test2)');
+    });
+
+    it('multi value and pipe should render pipe string', function() {
+      var result = _templateSrv.formatValue(['test','test2'], 'pipe');
+      expect(result).to.be('test|test2');
+    });
+
+    it('slash should be properly escaped in regex format', function() {
+      var result = _templateSrv.formatValue('Gi3/14', 'regex');
+      expect(result).to.be('Gi3\\/14');
+    });
+
+  });
+
+  describe('can check if variable exists', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: { value: 'oogle' } }]);
+    });
+
+    it('should return true if exists', function() {
+      var result = _templateSrv.variableExists('$test');
+      expect(result).to.be(true);
+    });
+  });
+
+  describe('can hightlight variables in string', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: { value: 'oogle' } }]);
+    });
+
+    it('should insert html', function() {
+      var result = _templateSrv.highlightVariablesAsHtml('$test');
+      expect(result).to.be('<span class="template-variable">$test</span>');
+    });
+
+    it('should insert html anywhere in string', function() {
+      var result = _templateSrv.highlightVariablesAsHtml('this $test ok');
+      expect(result).to.be('this <span class="template-variable">$test</span> ok');
+    });
+
+    it('should ignore if variables does not exist', function() {
+      var result = _templateSrv.highlightVariablesAsHtml('this $google ok');
+      expect(result).to.be('this $google ok');
+    });
+  });
+
+  describe('updateTemplateData with simple value', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: { value: 'muuuu' } }]);
+    });
+
+    it('should set current value and update template data', function() {
+      var target = _templateSrv.replace('this.[[test]].filters');
+      expect(target).to.be('this.muuuu.filters');
+    });
+  });
+
+  describe('fillVariableValuesForUrl with multi value', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: { value: ['val1', 'val2'] }}]);
+    });
+
+    it('should set multiple url params', function() {
+      var params = {};
+      _templateSrv.fillVariableValuesForUrl(params);
+      expect(params['var-test']).to.eql(['val1', 'val2']);
+    });
+  });
+
+  describe('fillVariableValuesForUrl with multi value and scopedVars', function() {
+    beforeEach(function() {
+      initTemplateSrv([{type: 'query', name: 'test', current: { value: ['val1', 'val2'] }}]);
+    });
+
+    it('should set scoped value as url params', function() {
+      var params = {};
+      _templateSrv.fillVariableValuesForUrl(params, {'test': {value: 'val1'}});
+      expect(params['var-test']).to.eql('val1');
+    });
+  });
+
+  describe('replaceWithText', function() {
+    beforeEach(function() {
+      initTemplateSrv([
+        {type: 'query', name: 'server', current: { value: '{asd,asd2}', text: 'All' } },
+        {type: 'interval', name: 'period', current: { value: '$__auto_interval', text: 'auto' } }
+      ]);
+      _templateSrv.setGrafanaVariable('$__auto_interval', '13m');
+      _templateSrv.updateTemplateData();
+    });
+
+    it('should replace with text except for grafanaVariables', function() {
+      var target = _templateSrv.replaceWithText('Server: $server, period: $period');
+      expect(target).to.be('Server: All, period: 13m');
+    });
+  });
+});

+ 59 - 0
public/app/features/templating/specs/variable_specs.ts

@@ -0,0 +1,59 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import {containsVariable, assignModelProperties} from '../variable';
+
+describe('containsVariable', function() {
+
+  describe('when checking if a string contains a variable', function() {
+
+    it('should find it with $var syntax', function() {
+      var contains = containsVariable('this.$test.filters', 'test');
+      expect(contains).to.be(true);
+    });
+
+    it('should not find it if only part matches with $var syntax', function() {
+      var contains = containsVariable('this.$ServerDomain.filters', 'Server');
+      expect(contains).to.be(false);
+    });
+
+    it('should find it with [[var]] syntax', function() {
+      var contains = containsVariable('this.[[test]].filters', 'test');
+      expect(contains).to.be(true);
+    });
+
+    it('should find it when part of segment', function() {
+      var contains = containsVariable('metrics.$env.$group-*', 'group');
+      expect(contains).to.be(true);
+    });
+
+    it('should find it its the only thing', function() {
+      var contains = containsVariable('$env', 'env');
+      expect(contains).to.be(true);
+    });
+
+    it('should be able to pass in multiple test strings', function() {
+      var contains = containsVariable('asd','asd2.$env', 'env');
+      expect(contains).to.be(true);
+    });
+
+  });
+
+});
+
+describe('assignModelProperties', function() {
+
+  it('only set properties defined in defaults', function() {
+    var target: any = {test: 'asd'};
+    assignModelProperties(target, {propA: 1, propB: 2}, {propB: 0});
+    expect(target.propB).to.be(2);
+    expect(target.test).to.be('asd');
+  });
+
+  it('use default value if not found on source', function() {
+    var target: any = {test: 'asd'};
+    assignModelProperties(target, {propA: 1, propB: 2}, {propC: 10});
+    expect(target.propC).to.be(10);
+  });
+
+});
+

+ 142 - 0
public/app/features/templating/specs/variable_srv_init_specs.ts

@@ -0,0 +1,142 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import '../all';
+
+import _ from 'lodash';
+import helpers from 'test/specs/helpers';
+import {Emitter} from 'app/core/core';
+
+describe('VariableSrv init', 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.$location = $location;
+    ctx.variableSrv = $injector.get('variableSrv');
+    ctx.$rootScope.$digest();
+  }));
+
+  function describeInitScenario(desc, fn) {
+    describe(desc, function() {
+      var scenario: any = {
+        urlParams: {},
+        setup: setupFn => {
+          scenario.setupFn = setupFn;
+        }
+      };
+
+      beforeEach(function() {
+        scenario.setupFn();
+        ctx.datasource = {};
+        ctx.datasource.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult));
+
+        ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ctx.datasource));
+        ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources);
+
+        ctx.$location.search = sinon.stub().returns(scenario.urlParams);
+        ctx.dashboard = {templating: {list: scenario.variables}, events: new Emitter()};
+
+        ctx.variableSrv.init(ctx.dashboard);
+        ctx.$rootScope.$digest();
+
+        scenario.variables = ctx.variableSrv.variables;
+      });
+
+      fn(scenario);
+    });
+  }
+
+  ['query', 'interval', 'custom', 'datasource'].forEach(type => {
+    describeInitScenario('when setting ' + type + ' variable via url', scenario => {
+      scenario.setup(() => {
+        scenario.variables = [{
+          name: 'apps',
+          type: type,
+          current: {text: "test", value: "test"},
+          options: [{text: "test", value: "test"}]
+        }];
+        scenario.urlParams["var-apps"] = "new";
+      });
+
+      it('should update current value', () => {
+        expect(scenario.variables[0].current.value).to.be("new");
+        expect(scenario.variables[0].current.text).to.be("new");
+      });
+    });
+
+  });
+
+  describe('given dependent variables', () => {
+    var variableList = [
+      {
+        name: 'app',
+        type: 'query',
+        query: '',
+        current: {text: "app1", value: "app1"},
+        options: [{text: "app1", value: "app1"}]
+      },
+      {
+        name: 'server',
+        type: 'query',
+        refresh: 1,
+        query: '$app.*',
+        current: {text: "server1", value: "server1"},
+        options: [{text: "server1", value: "server1"}]
+      },
+    ];
+
+    describeInitScenario('when setting parent var from url', scenario => {
+      scenario.setup(() => {
+        scenario.variables = _.cloneDeep(variableList);
+        scenario.urlParams["var-app"] = "google";
+        scenario.queryResult = [{text: 'google-server1'}, {text: 'google-server2'}];
+      });
+
+      it('should update child variable', () => {
+        expect(scenario.variables[1].options.length).to.be(2);
+        expect(scenario.variables[1].current.text).to.be("google-server1");
+      });
+
+      it('should only update it once', () => {
+        expect(ctx.datasource.metricFindQuery.callCount).to.be(1);
+      });
+
+    });
+  });
+
+  describeInitScenario('when template variable is present in url multiple times', scenario => {
+    scenario.setup(() => {
+      scenario.variables = [{
+        name: 'apps',
+        type: 'query',
+        multi: true,
+        current: {text: "val1", value: "val1"},
+        options: [{text: "val1", value: "val1"}, {text: 'val2', value: 'val2'}, {text: 'val3', value: 'val3', selected: true}]
+      }];
+      scenario.urlParams["var-apps"] = ["val2", "val1"];
+    });
+
+    it('should update current value', function() {
+      var variable = ctx.variableSrv.variables[0];
+      expect(variable.current.value.length).to.be(2);
+      expect(variable.current.value[0]).to.be("val2");
+      expect(variable.current.value[1]).to.be("val1");
+      expect(variable.current.text).to.be("val2 + val1");
+      expect(variable.options[0].selected).to.be(true);
+      expect(variable.options[1].selected).to.be(true);
+    });
+
+    it('should set options that are not in value to selected false', function() {
+      var variable = ctx.variableSrv.variables[0];
+      expect(variable.options[2].selected).to.be(false);
+    });
+  });
+
+});
+

+ 395 - 0
public/app/features/templating/specs/variable_srv_specs.ts

@@ -0,0 +1,395 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+
+import '../all';
+
+import moment from 'moment';
+import helpers from 'test/specs/helpers';
+import {Emitter} from 'app/core/core';
+
+describe('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.$location = $location;
+    ctx.variableSrv = $injector.get('variableSrv');
+    ctx.variableSrv.init({
+      templating: {list: []},
+      events: new Emitter(),
+    });
+    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));
+        ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources);
+
+
+        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');
+      });
+    });
+
+    describeUpdateVariable('without sort', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 0};
+        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+      });
+
+      it('should return options without sort', function() {
+        expect(scenario.variable.options[0].text).to.be('bbb2');
+        expect(scenario.variable.options[1].text).to.be('aaa10');
+        expect(scenario.variable.options[2].text).to.be('ccc3');
+      });
+    });
+
+    describeUpdateVariable('with alphabetical sort (asc)', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 1};
+        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+      });
+
+      it('should return options with alphabetical sort', function() {
+        expect(scenario.variable.options[0].text).to.be('aaa10');
+        expect(scenario.variable.options[1].text).to.be('bbb2');
+        expect(scenario.variable.options[2].text).to.be('ccc3');
+      });
+    });
+
+    describeUpdateVariable('with alphabetical sort (desc)', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 2};
+        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+      });
+
+      it('should return options with alphabetical sort', function() {
+        expect(scenario.variable.options[0].text).to.be('ccc3');
+        expect(scenario.variable.options[1].text).to.be('bbb2');
+        expect(scenario.variable.options[2].text).to.be('aaa10');
+      });
+    });
+
+    describeUpdateVariable('with numerical sort (asc)', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 3};
+        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+      });
+
+      it('should return options with numerical sort', function() {
+        expect(scenario.variable.options[0].text).to.be('bbb2');
+        expect(scenario.variable.options[1].text).to.be('ccc3');
+        expect(scenario.variable.options[2].text).to.be('aaa10');
+      });
+    });
+
+    describeUpdateVariable('with numerical sort (desc)', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'query', query: 'apps.*', name: 'test', sort: 4};
+        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+      });
+
+      it('should return options with numerical sort', function() {
+        expect(scenario.variable.options[0].text).to.be('aaa10');
+        expect(scenario.variable.options[1].text).to.be('ccc3');
+        expect(scenario.variable.options[2].text).to.be('bbb2');
+      });
+    });
+
+    //
+    // datasource variable update
+    //
+    describeUpdateVariable('datasource variable with regex filter', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {
+          type: 'datasource',
+          query: 'graphite',
+          name: 'test',
+          current: {value: 'backend4_pee', text: 'backend4_pee'},
+          regex: '/pee$/'
+        };
+        scenario.metricSources = [
+          {name: 'backend1', meta: {id: 'influx'}},
+          {name: 'backend2_pee', meta: {id: 'graphite'}},
+          {name: 'backend3', meta: {id: 'graphite'}},
+          {name: 'backend4_pee', meta: {id: 'graphite'}},
+        ];
+      });
+
+      it('should set only contain graphite ds and filtered using regex', function() {
+        expect(scenario.variable.options.length).to.be(2);
+        expect(scenario.variable.options[0].value).to.be('backend2_pee');
+        expect(scenario.variable.options[1].value).to.be('backend4_pee');
+      });
+
+      it('should keep current value if available', function() {
+        expect(scenario.variable.current.value).to.be('backend4_pee');
+      });
+    });
+
+    //
+    // Custom variable update
+    //
+    describeUpdateVariable('update custom variable', function(scenario) {
+      scenario.setup(function() {
+        scenario.variableModel = {type: 'custom', query: 'hej, hop, asd', name: 'test'};
+      });
+
+      it('should update options array', function() {
+        expect(scenario.variable.options.length).to.be(3);
+        expect(scenario.variable.options[0].text).to.be('hej');
+        expect(scenario.variable.options[1].value).to.be('hop');
+      });
+    });
+});

+ 24 - 29
public/app/features/templating/templateSrv.js

@@ -1,10 +1,9 @@
 define([
   'angular',
   'lodash',
-  './editorCtrl',
-  './templateValuesSrv',
+  'app/core/utils/kbn',
 ],
-function (angular, _) {
+function (angular, _, kbn) {
   'use strict';
 
   var module = angular.module('grafana.services');
@@ -16,6 +15,7 @@ function (angular, _) {
     this._index = {};
     this._texts = {};
     this._grafanaVariables = {};
+    this._adhocVariables = {};
 
     this.init = function(variables) {
       this.variables = variables;
@@ -24,19 +24,32 @@ function (angular, _) {
 
     this.updateTemplateData = function() {
       this._index = {};
+      this._filters = {};
 
       for (var i = 0; i < this.variables.length; i++) {
         var variable = this.variables[i];
+
+        // add adhoc filters to it's own index
+        if (variable.type === 'adhoc') {
+          this._adhocVariables[variable.datasource] = variable;
+          continue;
+        }
+
         if (!variable.current || !variable.current.isNone && !variable.current.value) {
           continue;
         }
+
         this._index[variable.name] = variable;
       }
     };
 
-    function regexEscape(value) {
-      return value.replace(/[\\^$*+?.()|[\]{}\/]/g, '\\$&');
-    }
+    this.getAdhocFilters = function(datasourceName) {
+      var variable = this._adhocVariables[datasourceName];
+      if (variable) {
+        return variable.filters || [];
+      }
+      return [];
+    };
 
     function luceneEscape(value) {
       return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, "\\$1");
@@ -63,10 +76,10 @@ function (angular, _) {
       switch(format) {
         case "regex": {
           if (typeof value === 'string') {
-            return regexEscape(value);
+            return kbn.regexEscape(value);
           }
 
-          var escapedValues = _.map(value, regexEscape);
+          var escapedValues = _.map(value, kbn.regexEscape);
           return '(' + escapedValues.join('|') + ')';
         }
         case "lucene": {
@@ -97,17 +110,6 @@ function (angular, _) {
       return match && (self._index[match[1] || match[2]] !== void 0);
     };
 
-    this.containsVariable = function(str, variableName) {
-      if (!str) {
-        return false;
-      }
-
-      variableName = regexEscape(variableName);
-      var findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g');
-      var match = findVarRegex.exec(str);
-      return match !== null;
-    };
-
     this.highlightVariablesAsHtml = function(str) {
       if (!str || !_.isString(str)) { return str; }
 
@@ -196,18 +198,11 @@ function (angular, _) {
 
     this.fillVariableValuesForUrl = function(params, scopedVars) {
       _.each(this.variables, function(variable) {
-        var current = variable.current;
-        var value = current.value;
-
-        if (current.text === 'All') {
-          value = 'All';
-        }
-
         if (scopedVars && scopedVars[variable.name] !== void 0) {
-          value = scopedVars[variable.name].value;
+          params['var-' + variable.name] = scopedVars[variable.name].value;
+        } else {
+          params['var-' + variable.name] = variable.getValueForUrl();
         }
-
-        params['var-' + variable.name] = value;
       });
     };
 

+ 8 - 3
public/app/features/templating/templateValuesSrv.js

@@ -166,8 +166,7 @@ function (angular, _, $, kbn) {
         if (otherVariable === updatedVariable) {
           return;
         }
-        if ((otherVariable.type === "datasource" &&
-            templateSrv.containsVariable(otherVariable.regex, updatedVariable.name)) ||
+        if (templateSrv.containsVariable(otherVariable.regex, updatedVariable.name) ||
             templateSrv.containsVariable(otherVariable.query, updatedVariable.name) ||
             templateSrv.containsVariable(otherVariable.datasource, updatedVariable.name)) {
           return self.updateOptions(otherVariable);
@@ -188,6 +187,12 @@ function (angular, _, $, kbn) {
         return;
       }
 
+      if (variable.type === 'adhoc') {
+        variable.current = {};
+        variable.options = [];
+        return;
+      }
+
       // extract options in comma separated string
       variable.options = _.map(variable.query.split(/[,]+/), function(text) {
         return { text: text.trim(), value: text.trim() };
@@ -271,7 +276,7 @@ function (angular, _, $, kbn) {
 
     this.validateVariableSelectionState = function(variable) {
       if (!variable.current) {
-        if (!variable.options.length) { return; }
+        if (!variable.options.length) { return $q.when(); }
         return self.setVariableValue(variable, variable.options[0], false);
       }
 

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

@@ -0,0 +1,40 @@
+///<reference path="../../headers/common.d.ts" />
+
+import _ from 'lodash';
+import kbn from 'app/core/utils/kbn';
+
+export interface Variable {
+  setValue(option);
+  updateOptions();
+  dependsOn(variable);
+  setValueFromUrl(urlValue);
+  getValueForUrl();
+  getModel();
+}
+
+export var variableTypes = {};
+
+export function assignModelProperties(target, source, defaults) {
+  _.forEach(defaults, function(value, key) {
+    target[key] = source[key] === undefined ? value : source[key];
+  });
+}
+
+export function containsVariable(...args: any[]) {
+  var variableName = args[args.length-1];
+  var str = args[0] || '';
+
+  for (var i = 1; i < args.length-1; i++) {
+    str += args[i] || '';
+  }
+
+  variableName = kbn.regexEscape(variableName);
+  var findVarRegex = new RegExp('\\$(' + variableName + ')(?:\\W|$)|\\[\\[(' + variableName + ')\\]\\]', 'g');
+  var match = findVarRegex.exec(str);
+  return match !== null;
+}
+
+
+
+
+

+ 233 - 0
public/app/features/templating/variable_srv.ts

@@ -0,0 +1,233 @@
+///<reference path="../../headers/common.d.ts" />
+
+import angular from 'angular';
+import _ from 'lodash';
+import coreModule from 'app/core/core_module';
+import {Variable, variableTypes} from './variable';
+
+export class VariableSrv {
+  dashboard: any;
+  variables: any;
+  variableLock: any;
+
+  /** @ngInject */
+  constructor(private $rootScope, private $q, private $location, private $injector, private templateSrv) {
+    // update time variant variables
+    $rootScope.$on('refresh', this.onDashboardRefresh.bind(this), $rootScope);
+    $rootScope.$on('template-variable-value-updated', this.updateUrlParamsWithCurrentVariables.bind(this), $rootScope);
+  }
+
+  init(dashboard) {
+    this.variableLock = {};
+    this.dashboard = dashboard;
+
+    // create working class models representing variables
+    this.variables = dashboard.templating.list.map(this.createVariableFromModel.bind(this));
+    this.templateSrv.init(this.variables);
+
+    // register event to sync back to persisted model
+    this.dashboard.events.on('prepare-save-model', this.syncToDashboardModel.bind(this));
+
+    // init variables
+    for (let variable of this.variables) {
+      this.variableLock[variable.name] = this.$q.defer();
+    }
+
+    var queryParams = this.$location.search();
+    return this.$q.all(this.variables.map(variable => {
+      return this.processVariable(variable, queryParams);
+    }));
+  }
+
+  onDashboardRefresh() {
+    var promises = this.variables
+    .filter(variable => variable.refresh === 2)
+    .map(variable => {
+      var previousOptions = variable.options.slice();
+
+      return variable.updateOptions()
+      .then(this.variableUpdated.bind(this, variable))
+      .then(() => {
+        if (angular.toJson(previousOptions) !== angular.toJson(variable.options)) {
+          this.$rootScope.$emit('template-variable-value-updated');
+        }
+      });
+    });
+
+    return this.$q.all(promises);
+  }
+
+  processVariable(variable, queryParams) {
+    var dependencies = [];
+    var lock = this.variableLock[variable.name];
+
+    for (let otherVariable of this.variables) {
+      if (variable.dependsOn(otherVariable)) {
+        dependencies.push(this.variableLock[otherVariable.name].promise);
+      }
+    }
+
+    return this.$q.all(dependencies).then(() => {
+      var urlValue = queryParams['var-' + variable.name];
+      if (urlValue !== void 0) {
+        return variable.setValueFromUrl(urlValue).then(lock.resolve);
+      }
+
+      if (variable.refresh === 1 || variable.refresh === 2) {
+        return variable.updateOptions().then(lock.resolve);
+      }
+
+      lock.resolve();
+    }).finally(() => {
+      delete this.variableLock[variable.name];
+    });
+  }
+
+  createVariableFromModel(model) {
+    var ctor = variableTypes[model.type].ctor;
+    if (!ctor) {
+      throw "Unable to find variable constructor for " + model.type;
+    }
+
+    var variable = this.$injector.instantiate(ctor, {model: model});
+    return variable;
+  }
+
+  addVariable(model) {
+    var variable = this.createVariableFromModel(model);
+    this.variables.push(this.createVariableFromModel(variable));
+    return variable;
+  }
+
+  syncToDashboardModel() {
+    this.dashboard.templating.list = this.variables.map(variable => {
+      return variable.getModel();
+    });
+  }
+
+  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();
+    }
+
+    // cascade updates to variables that use this variable
+    var promises = _.map(this.variables, otherVariable => {
+      if (otherVariable === variable) {
+        return;
+      }
+
+      if (otherVariable.dependsOn(variable)) {
+        return this.updateOptions(otherVariable);
+      }
+    });
+
+    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 this.$q.when(); }
+      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]);
+      }
+    }
+  }
+
+  setOptionFromUrl(variable, urlValue) {
+    var promise = this.$q.when();
+
+    if (variable.refresh) {
+      promise = variable.updateOptions();
+    }
+
+    return promise.then(() => {
+      var option = _.find(variable.options, op => {
+        return op.text === urlValue || op.value === urlValue;
+      });
+
+      option = option || {text: urlValue, value: urlValue};
+      return variable.setValue(option);
+    });
+  }
+
+  setOptionAsCurrent(variable, option) {
+    variable.current = _.cloneDeep(option);
+
+    if (_.isArray(variable.current.text)) {
+      variable.current.text = variable.current.text.join(' + ');
+    }
+
+    this.selectOptionsForCurrentValue(variable);
+    return this.variableUpdated(variable);
+  }
+
+  updateUrlParamsWithCurrentVariables() {
+    // update url
+    var params = this.$location.search();
+
+    // remove variable params
+    _.each(params, function(value, key) {
+      if (key.indexOf('var-') === 0) {
+        delete params[key];
+      }
+    });
+
+    // add new values
+    this.templateSrv.fillVariableValuesForUrl(params);
+    // update url
+    this.$location.search(params);
+  }
+}
+
+coreModule.service('variableSrv', VariableSrv);

+ 2 - 2
public/app/partials/valueSelectDropdown.html

@@ -1,5 +1,5 @@
 <div class="variable-link-wrapper">
-	<a ng-click="vm.show()" class="variable-value-link">
+	<a ng-click="vm.show()" class="gf-form-label variable-value-link">
 		{{vm.linkText}}
 		<span ng-repeat="tag in vm.selectedTags" bs-tooltip='tag.valuesText' data-placement="bottom">
 			<span class="label-tag"tag-color-from-name="tag.text">
@@ -10,7 +10,7 @@
 		<i class="fa fa-caret-down"></i>
 	</a>
 
-	<input type="text" class="hidden-input input-small" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.queryChanged()" ></input>
+	<input type="text" class="hidden-input input-small gf-form-input" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.queryChanged()" ></input>
 
 	<div class="variable-value-dropdown" ng-if="vm.dropdownVisible" ng-class="{'multi': vm.variable.multi, 'single': !vm.variable.multi}">
 		<div class="variable-options-wrapper">

+ 32 - 0
public/app/plugins/datasource/cloudwatch/datasource.js

@@ -23,6 +23,7 @@ function (angular, _, moment, dateMath, CloudWatchAnnotationQuery) {
 
       var queries = [];
       options = angular.copy(options);
+      options.targets = this.expandTemplateVariable(options.targets, templateSrv);
       _.each(options.targets, function(target) {
         if (target.hide || !target.namespace || !target.metricName || _.isEmpty(target.statistics)) {
           return;
@@ -337,6 +338,37 @@ function (angular, _, moment, dateMath, CloudWatchAnnotationQuery) {
       });
     }
 
+    this.getExpandedVariables = function(target, dimensionKey, variable) {
+      return _.chain(variable.options)
+      .filter(function(v) {
+        return v.selected;
+      })
+      .map(function(v) {
+        var t = angular.copy(target);
+        t.dimensions[dimensionKey] = v.value;
+        return t;
+      }).value();
+    };
+
+    this.expandTemplateVariable = function(targets, templateSrv) {
+      var self = this;
+      return _.chain(targets)
+      .map(function(target) {
+        var dimensionKey = _.findKey(target.dimensions, function(v) {
+          return templateSrv.variableExists(v);
+        });
+
+        if (dimensionKey) {
+          var variable = _.find(templateSrv.variables, function(variable) {
+            return templateSrv.containsVariable(target.dimensions[dimensionKey], variable.name);
+          });
+          return self.getExpandedVariables(target, dimensionKey, variable);
+        } else {
+          return [target];
+        }
+      }).flatten().value();
+    };
+
     this.convertToCloudWatchTime = function(date, roundUp) {
       if (_.isString(date)) {
         date = dateMath.parse(date, roundUp);

+ 32 - 0
public/app/plugins/datasource/cloudwatch/specs/datasource_specs.ts

@@ -98,6 +98,38 @@ describe('CloudWatchDatasource', function() {
       });
       ctx.$rootScope.$apply();
     });
+
+    it('should generate the correct targets by expanding template variables', function() {
+      var templateSrv = {
+        variables: [
+          {
+            name: 'instance_id',
+            options: [
+              { value: 'i-23456789', selected: false },
+              { value: 'i-34567890', selected: true }
+            ]
+          }
+        ],
+        variableExists: function (e) { return true; },
+        containsVariable: function (str, variableName) { return str.indexOf('$' + variableName) !== -1; }
+      };
+
+      var targets = [
+        {
+          region: 'us-east-1',
+          namespace: 'AWS/EC2',
+          metricName: 'CPUUtilization',
+          dimensions: {
+            InstanceId: '$instance_id'
+          },
+          statistics: ['Average'],
+          period: 300
+        }
+      ];
+
+      var result = ctx.ds.expandTemplateVariable(targets, templateSrv);
+      expect(result[0].dimensions.InstanceId).to.be('i-34567890');
+    });
   });
 
   function describeMetricFindQuery(query, func) {

+ 17 - 2
public/app/plugins/datasource/elasticsearch/datasource.js

@@ -177,11 +177,14 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes
       var target;
       var sentTargets = [];
 
+      // add global adhoc filters to timeFilter
+      var adhocFilters = templateSrv.getAdhocFilters(this.name);
+
       for (var i = 0; i < options.targets.length; i++) {
         target = options.targets[i];
         if (target.hide) {continue;}
 
-        var queryObj = this.queryBuilder.build(target);
+        var queryObj = this.queryBuilder.build(target, adhocFilters);
         var esQuery = angular.toJson(queryObj);
         var luceneQuery = target.query || '*';
         luceneQuery = templateSrv.replace(luceneQuery, options.scopedVars, 'lucene');
@@ -247,7 +250,7 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes
               // Hide meta-fields and check field type
               if (key[0] !== '_' &&
                   (!query.type ||
-                    query.type && typeMap[subObj.type] === query.type)) {
+                   query.type && typeMap[subObj.type] === query.type)) {
 
                 fields[fieldName] = {
                   text: fieldName,
@@ -288,6 +291,10 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes
       esQuery = header + '\n' + esQuery + '\n';
 
       return this._post('_msearch?search_type=count', esQuery).then(function(res) {
+        if (!res.responses[0].aggregations) {
+          return [];
+        }
+
         var buckets = res.responses[0].aggregations["1"].buckets;
         return _.map(buckets, function(bucket) {
           return {text: bucket.key, value: bucket.key};
@@ -310,6 +317,14 @@ function (angular, _, moment, kbn, ElasticQueryBuilder, IndexPattern, ElasticRes
         return this.getTerms(query);
       }
     };
+
+    this.getTagKeys = function() {
+      return this.getFields({});
+    };
+
+    this.getTagValues = function(options) {
+      return this.getTerms({field: options.key, query: '*'});
+    };
   }
 
   return {

+ 19 - 1
public/app/plugins/datasource/elasticsearch/query_builder.js

@@ -98,7 +98,23 @@ function (queryDef) {
     return query;
   };
 
-  ElasticQueryBuilder.prototype.build = function(target) {
+  ElasticQueryBuilder.prototype.addAdhocFilters = function(query, adhocFilters) {
+    if (!adhocFilters) {
+      return;
+    }
+
+    var i, filter, condition;
+    var must = query.query.filtered.filter.bool.must;
+
+    for (i = 0; i < adhocFilters.length; i++) {
+      filter = adhocFilters[i];
+      condition = {};
+      condition[filter.key] = filter.value;
+      must.push({"term": condition});
+    }
+  };
+
+  ElasticQueryBuilder.prototype.build = function(target, adhocFilters) {
     // make sure query has defaults;
     target.metrics = target.metrics || [{ type: 'count', id: '1' }];
     target.dsType = 'elasticsearch';
@@ -125,6 +141,8 @@ function (queryDef) {
       }
     };
 
+    this.addAdhocFilters(query, adhocFilters);
+
     // handle document query
     if (target.bucketAggs.length === 0) {
       metric = target.metrics[0];

+ 12 - 0
public/app/plugins/datasource/elasticsearch/specs/query_builder_specs.ts

@@ -238,4 +238,16 @@ describe('ElasticQueryBuilder', function() {
     expect(firstLevel.aggs["2"].derivative.buckets_path).to.be("3");
   });
 
+  it('with adhoc filters', function() {
+    var query = builder.build({
+      metrics: [{type: 'Count', id: '0'}],
+      timeField: '@timestamp',
+      bucketAggs: [{type: 'date_histogram', field: '@timestamp', id: '3'}],
+    }, [
+      {key: 'key1', operator: '=', value: 'value1'}
+    ]);
+
+    expect(query.query.filtered.filter.bool.must[1].term["key1"]).to.be("value1");
+  });
+
 });

+ 38 - 16
public/app/plugins/datasource/influxdb/datasource.ts

@@ -7,6 +7,8 @@ import * as dateMath from 'app/core/utils/datemath';
 import InfluxSeries from './influx_series';
 import InfluxQuery from './influx_query';
 import ResponseParser from './response_parser';
+import InfluxQueryBuilder from './query_builder';
+
 
 export default class InfluxDatasource {
   type: string;
@@ -43,19 +45,23 @@ export default class InfluxDatasource {
 
   query(options) {
     var timeFilter = this.getTimeFilter(options);
+    var scopedVars = options.scopedVars ? _.cloneDeep(options.scopedVars) : {};
+    var targets = _.cloneDeep(options.targets);
     var queryTargets = [];
+    var queryModel;
     var i, y;
 
-    var allQueries = _.map(options.targets, (target) => {
+    var allQueries = _.map(targets, target => {
       if (target.hide) { return ""; }
 
       queryTargets.push(target);
 
       // build query
-      var queryModel = new InfluxQuery(target, this.templateSrv, options.scopedVars);
-      var query =  queryModel.render(true);
-      query = query.replace(/\$interval/g, (target.interval || options.interval));
-      return query;
+      scopedVars.interval = {value: target.interval || options.interval};
+
+      queryModel = new InfluxQuery(target, this.templateSrv, scopedVars);
+      return queryModel.render(true);
+
     }).reduce((acc, current) => {
       if (current !== "") {
         acc += ";" + current;
@@ -63,11 +69,21 @@ export default class InfluxDatasource {
       return acc;
     });
 
+    if (allQueries === '') {
+      return this.$q.when({data: []});
+    }
+
+    // add global adhoc filters to timeFilter
+    var adhocFilters = this.templateSrv.getAdhocFilters(this.name);
+    if (adhocFilters.length > 0 ) {
+      timeFilter += ' AND ' + queryModel.renderAdhocFilters(adhocFilters);
+    }
+
     // replace grafana variables
-    allQueries = allQueries.replace(/\$timeFilter/g, timeFilter);
+    scopedVars.timeFilter = {value: timeFilter};
 
     // replace templated variables
-    allQueries = this.templateSrv.replace(allQueries, options.scopedVars);
+    allQueries = this.templateSrv.replace(allQueries, scopedVars);
 
     return this._seriesQuery(allQueries).then((data): any => {
       if (!data || !data.results) {
@@ -102,7 +118,7 @@ export default class InfluxDatasource {
         }
       }
 
-      return { data: seriesList };
+      return {data: seriesList};
     });
   };
 
@@ -124,16 +140,23 @@ export default class InfluxDatasource {
   };
 
   metricFindQuery(query) {
-    var interpolated;
-    try {
-      interpolated = this.templateSrv.replace(query, null, 'regex');
-    } catch (err) {
-      return this.$q.reject(err);
-    }
+    var interpolated = this.templateSrv.replace(query, null, 'regex');
 
     return this._seriesQuery(interpolated)
       .then(_.curry(this.responseParser.parse)(query));
-  };
+  }
+
+  getTagKeys(options) {
+    var queryBuilder = new InfluxQueryBuilder({measurement: '', tags: []}, this.database);
+    var query = queryBuilder.buildExploreQuery('TAG_KEYS');
+    return this.metricFindQuery(query);
+  }
+
+  getTagValues(options) {
+    var queryBuilder = new InfluxQueryBuilder({measurement: '', tags: []}, this.database);
+    var query = queryBuilder.buildExploreQuery('TAG_VALUES', options.key);
+    return this.metricFindQuery(query);
+  }
 
   _seriesQuery(query) {
     if (!query) { return this.$q.when({results: []}); }
@@ -141,7 +164,6 @@ export default class InfluxDatasource {
     return this._influxRequest('GET', '/query', {q: query, epoch: 'ms'});
   }
 
-
   serializeParams(params) {
     if (!params) { return '';}
 

+ 24 - 2
public/app/plugins/datasource/influxdb/influx_query.ts

@@ -2,6 +2,7 @@
 
 import _ from 'lodash';
 import queryPart from './query_part';
+import kbn from 'app/core/utils/kbn';
 
 export default class InfluxQuery {
   target: any;
@@ -155,7 +156,7 @@ export default class InfluxQuery {
       if (operator !== '>' && operator !== '<') {
         value = "'" + value.replace(/\\/g, '\\\\') + "'";
       }
-    } else if (interpolate){
+    } else if (interpolate) {
       value = this.templateSrv.replace(value, this.scopedVars, 'regex');
     }
 
@@ -181,12 +182,26 @@ export default class InfluxQuery {
     return policy + measurement;
   }
 
+  interpolateQueryStr(value, variable, defaultFormatFn) {
+    // if no multi or include all do not regexEscape
+    if (!variable.multi && !variable.includeAll) {
+      return value;
+    }
+
+    if (typeof value === 'string') {
+      return kbn.regexEscape(value);
+    }
+
+    var escapedValues = _.map(value, kbn.regexEscape);
+    return escapedValues.join('|');
+  };
+
   render(interpolate?) {
     var target = this.target;
 
     if (target.rawQuery) {
       if (interpolate) {
-        return this.templateSrv.replace(target.query, this.scopedVars, 'regex');
+        return this.templateSrv.replace(target.query, this.scopedVars, this.interpolateQueryStr);
       } else {
         return target.query;
       }
@@ -236,4 +251,11 @@ export default class InfluxQuery {
 
     return query;
   }
+
+  renderAdhocFilters(filters) {
+    var conditions = _.map(filters, (tag, index) => {
+      return this.renderTagCondition(tag, index, false);
+    });
+    return conditions.join(' ');
+  }
 }

+ 13 - 0
public/app/plugins/datasource/influxdb/specs/influx_query_specs.ts

@@ -237,6 +237,19 @@ describe('InfluxQuery', function() {
       expect(query.target.select[0][2].type).to.be('math');
     });
 
+    describe('when render adhoc filters', function() {
+      it('should generate correct query segment', function() {
+        var query = new InfluxQuery({measurement: 'cpu', }, templateSrv, {});
+
+        var queryText = query.renderAdhocFilters([
+          {key: 'key1', operator: '=', value: 'value1'},
+          {key: 'key2', operator: '!=', value: 'value2'},
+        ]);
+
+        expect(queryText).to.be('"key1" = \'value1\' AND "key2" != \'value2\'');
+      });
+    });
+
   });
 
 });

+ 3 - 3
public/app/plugins/datasource/prometheus/datasource.ts

@@ -40,7 +40,7 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     return backendSrv.datasourceRequest(options);
   };
 
-  function regexEscape(value) {
+  function prometheusSpecialRegexEscape(value) {
     return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
   }
 
@@ -51,10 +51,10 @@ export function PrometheusDatasource(instanceSettings, $q, backendSrv, templateS
     }
 
     if (typeof value === 'string') {
-      return regexEscape(value);
+      return prometheusSpecialRegexEscape(value);
     }
 
-    var escapedValues = _.map(value, regexEscape);
+    var escapedValues = _.map(value, prometheusSpecialRegexEscape);
     return escapedValues.join('|');
   };
 

+ 2 - 1
public/app/plugins/panel/graph/graph.js

@@ -354,7 +354,8 @@ function (angular, $, moment, _, kbn, GraphTooltip, thresholdManExports) {
 
         function parseThresholdExpr(expr) {
           var match, operator, value, precision;
-          match = expr.match(/\s*([<=>~]*)\W*(\d+(\.\d+)?)/);
+          expr = String(expr);
+          match = expr.match(/\s*([<=>~]*)\s*(\-?\d+(\.\d+)?)/);
           if (match) {
             operator = match[1];
             value = parseFloat(match[2]);

+ 47 - 0
public/app/plugins/panel/graph/specs/graph_specs.ts

@@ -312,5 +312,52 @@ describe('grafanaGraph', function() {
          expect(ctx.plotOptions.yaxes[0].max).to.be(0);
       });
     });
+    describe('and negative values used', function() {
+      ctx.setup(function(ctrl, data) {
+        ctrl.panel.yaxes[0].min = '-10';
+        ctrl.panel.yaxes[0].max = '-13.14';
+        data[0] = new TimeSeries({
+          datapoints: [[120,10],[160,20]],
+          alias: 'series1',
+        });
+      });
+
+      it('should set min and max to negative', function() {
+         expect(ctx.plotOptions.yaxes[0].min).to.be(-10);
+         expect(ctx.plotOptions.yaxes[0].max).to.be(-13.14);
+      });
+    });
+  });
+  graphScenario('when using Y-Min and Y-Max settings stored as number', function(ctx) {
+    describe('and Y-Min is 0 and Y-Max is 100', function() {
+      ctx.setup(function(ctrl, data) {
+        ctrl.panel.yaxes[0].min = 0;
+        ctrl.panel.yaxes[0].max = 100;
+        data[0] = new TimeSeries({
+          datapoints: [[120,10],[160,20]],
+          alias: 'series1',
+        });
+      });
+
+      it('should set min to 0 and max to 100', function() {
+         expect(ctx.plotOptions.yaxes[0].min).to.be(0);
+         expect(ctx.plotOptions.yaxes[0].max).to.be(100);
+      });
+    });
+    describe('and Y-Min is -100 and Y-Max is -10.5', function() {
+      ctx.setup(function(ctrl, data) {
+        ctrl.panel.yaxes[0].min = -100;
+        ctrl.panel.yaxes[0].max = -10.5;
+        data[0] = new TimeSeries({
+          datapoints: [[120,10],[160,20]],
+          alias: 'series1',
+        });
+      });
+
+      it('should set min to -100 and max to -10.5', function() {
+         expect(ctx.plotOptions.yaxes[0].min).to.be(-100);
+         expect(ctx.plotOptions.yaxes[0].max).to.be(-10.5);
+      });
+    });
   });
 });

+ 120 - 202
public/app/plugins/panel/singlestat/editor.html

@@ -1,207 +1,125 @@
 <div class="editor-row">
-	<div class="section tight-form-container" style="margin-bottom: 20px">
-		<div class="tight-form">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Big value</strong>
-				</li>
-				<li class="tight-form-item">
-					Prefix
-				</li>
-				<li>
-					<input type="text" class="input-small tight-form-input"
-					ng-model="ctrl.panel.prefix" ng-change="ctrl.render()" ng-model-onblur>
-				</li>
-				<li class="tight-form-item">
-					Value
-				</li>
-				<li>
-					<select class="input-small tight-form-input"
-            ng-model="ctrl.panel.valueName"
-            ng-options="f for f in ctrl.valueNameOptions"
-            ng-change="ctrl.render()"></select>
-				</li>
-				<li class="tight-form-item">
-					Postfix
-				</li>
-				<li>
-					<input type="text" class="input-small tight-form-input last"
-					ng-model="ctrl.panel.postfix" ng-change="ctrl.render()" ng-model-onblur>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-		<div class="tight-form">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Font size</strong>
-				</li>
-				<li class="tight-form-item">
-					Prefix
-				</li>
-				<li>
-					<select class="input-small tight-form-input" ng-model="ctrl.panel.prefixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
-				</li>
-				<li class="tight-form-item">
-					Value
-				</li>
-				<li>
-					<select class="input-small tight-form-input" ng-model="ctrl.panel.valueFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
-				</li>
-				<li class="tight-form-item">
-					Postfix
-				</li>
-				<li>
-					<select class="input-small tight-form-input last" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-		<div class="tight-form">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Unit</strong>
-				</li>
-				<li class="dropdown" style="width: 266px;"
-					ng-model="ctrl.panel.format"
-					dropdown-typeahead="ctrl.unitFormats"
-					dropdown-typeahead-on-select="ctrl.setUnitFormat($subItem)">
-				</li>
-				<li class="tight-form-item">Decimals</li>
-				<li>
-					<input type="number" class="input-small tight-form-input last" placeholder="auto" bs-tooltip="'Override automatic decimal precision for legend and tooltips'" data-placement="right"
-					ng-model="ctrl.panel.decimals" ng-change="ctrl.refresh()" ng-model-onblur>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-	</div>
-</div>
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Value</h5>
 
-<div class="editor-row">
-	<div class="section" style="margin-bottom: 20px">
-		<div class="tight-form last">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Coloring</strong>
-				</li>
-				<li class="tight-form-item">
-					Background&nbsp;
-					<input class="cr1" id="ctrl.panel.colorBackground" type="checkbox"
-					ng-model="ctrl.panel.colorBackground" ng-checked="ctrl.panel.colorBackground" ng-change="ctrl.render()">
-					<label for="ctrl.panel.colorBackground" class="cr1"></label>
-				</li>
-				<li class="tight-form-item">
-					Value&nbsp;
-					<input class="cr1" id="ctrl.panel.colorValue" type="checkbox"
-					ng-model="ctrl.panel.colorValue" ng-checked="ctrl.panel.colorValue" ng-change="ctrl.render()">
-					<label for="ctrl.panel.colorValue" class="cr1"></label>
-				</li>
-				<li class="tight-form-item">
-					Thresholds<tip>Define two threshold values&lt;br /&gt; 50,80 will produce: &lt;50 = Green, 50:80 = Yellow, &gt;80 = Red</tip>
-				</li>
-				<li>
-					<input type="text" class="input-large tight-form-input" ng-model="ctrl.panel.thresholds" ng-blur="ctrl.render()" placeholder="50,80"></input>
-				</li>
-				<li class="tight-form-item">
-					Colors
-				</li>
-				<li class="tight-form-item">
-					<spectrum-picker ng-model="ctrl.panel.colors[0]" ng-change="ctrl.render()" ></spectrum-picker>
-					<spectrum-picker ng-model="ctrl.panel.colors[1]" ng-change="ctrl.render()" ></spectrum-picker>
-					<spectrum-picker ng-model="ctrl.panel.colors[2]" ng-change="ctrl.render()" ></spectrum-picker>
-				</li>
-				<li class="tight-form-item last">
-					<a class="pointer" ng-click="ctrl.invertColorOrder()">invert order</a>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-	</div>
-</div>
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label width-4">Stat</label>
+        <div class="gf-form-select-wrapper width-7">
+          <select class="gf-form-input" ng-model="ctrl.panel.valueName" ng-options="f for f in ctrl.valueNameOptions" ng-change="ctrl.render()"></select>
+        </div>
+        <label class="gf-form-label width-6">Font size</label>
+        <div class="gf-form-select-wrapper">
+          <select class="gf-form-input" ng-model="ctrl.panel.valueFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
+        </div>
+      </div>
+    </div>
 
-<div class="editor-row">
-	<div class="section" style="margin-bottom: 20px">
-		<div class="tight-form last">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Spark lines</strong>
-				</li>
-				<li class="tight-form-item">
-					Show&nbsp;
-					<input class="cr1" id="ctrl.panel.sparkline.show" type="checkbox"
-					ng-model="ctrl.panel.sparkline.show" ng-checked="ctrl.panel.sparkline.show" ng-change="ctrl.render()">
-					<label for="ctrl.panel.sparkline.show" class="cr1"></label>
-				</li>
-				<li class="tight-form-item">
-					Background mode&nbsp;
-					<input class="cr1" id="ctrl.panel.sparkline.full" type="checkbox"
-					ng-model="ctrl.panel.sparkline.full" ng-checked="ctrl.panel.sparkline.full" ng-change="ctrl.render()">
-					<label for="ctrl.panel.sparkline.full" class="cr1"></label>
-				</li>
-				<li class="tight-form-item">
-					Line Color
-				</li>
-				<li class="tight-form-item">
-					<spectrum-picker ng-model="ctrl.panel.sparkline.lineColor" ng-change="ctrl.render()" ></spectrum-picker>
-				</li>
-				<li class="tight-form-item">
-					Fill Color
-				</li>
-				<li class="tight-form-item last">
-					<spectrum-picker ng-model="ctrl.panel.sparkline.fillColor" ng-change="ctrl.render()" ></spectrum-picker>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-	</div>
-</div>
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label width-4">Prefix</label>
+        <input type="text" class="gf-form-input width-7" ng-model="ctrl.panel.prefix" ng-change="ctrl.render()" ng-model-onblur>
+        <label class="gf-form-label width-6">Font size</label>
+        <div class="gf-form-select-wrapper">
+          <select class="gf-form-input" ng-model="ctrl.panel.prefixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
+        </div>
+      </div>
+    </div>
 
-<div class="editor-row">
-	<div class="section" style="margin-bottom: 20px">
-		<div class="tight-form">
-			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 80px">
-					<strong>Gauge</strong>
-				</li>
-				<li class="tight-form-item">
-					Show&nbsp;
-					<input class="cr1" id="panel.gauge.show" type="checkbox"
-					ng-model="ctrl.panel.gauge.show" ng-checked="ctrl.panel.gauge.show" ng-change="ctrl.render()">
-					<label for="panel.gauge.show" class="cr1"></label>
-				</li>
-				<li class="tight-form-item">
-					Min
-				</li>
-				<li>
-					<input type="number" class="input-small tight-form-input" ng-model="ctrl.panel.gauge.minValue" ng-blur="ctrl.render()" placeholder="0"></input>
-				</li>
-				<li class="tight-form-item last">
-					Max
-				</li>
-				<li>
-					<input type="number" class="input-small tight-form-input last" ng-model="ctrl.panel.gauge.maxValue" ng-blur="ctrl.render()" placeholder="100"></input>
-					<span class="alert-state-critical" ng-show="ctrl.invalidGaugeRange">
-						&nbsp; <i class="fa fa-warning"></i>
-						Min value is bigger than max.
-            &nbsp;
-					</span>
-				</li>
-			</ul>
-			<div class="clearfix"></div>
-		</div>
-		<div class="tight-form last">
-			<li class="tight-form-item">
-				Threshold labels&nbsp;
-				<input class="cr1" id="panel.gauge.thresholdLabels" type="checkbox" ng-model="ctrl.panel.gauge.thresholdLabels" ng-checked="ctrl.panel.gauge.thresholdLabels" ng-change="ctrl.render()">
-				<label for="panel.gauge.thresholdLabels" class="cr1"></label>
-			</li>
-			<li class="tight-form-item">
-				Threshold markers&nbsp;
-				<input class="cr1" id="panel.gauge.thresholdMarkers" type="checkbox" ng-model="ctrl.panel.gauge.thresholdMarkers" ng-checked="ctrl.panel.gauge.thresholdMarkers" ng-change="ctrl.render()">
-				<label for="panel.gauge.thresholdMarkers" class="cr1"></label>
-			</li>
-			<div class="clearfix"></div>
-		</div>
-	</div>
+    <div class="gf-form">
+      <label class="gf-form-label width-4">Postfix</label>
+      <input type="text" class="gf-form-input width-7" ng-model="ctrl.panel.postfix" ng-change="ctrl.render()" ng-model-onblur>
+      <label class="gf-form-label width-6">Font size</label>
+      <div class="gf-form-select-wrapper">
+        <select class="input-small gf-form-input" ng-model="ctrl.panel.postfixFontSize" ng-options="f for f in ctrl.fontSizes" ng-change="ctrl.render()"></select>
+      </div>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Unit</h5>
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <div class="gf-form-dropdown-typeahead" ng-model="ctrl.panel.format" dropdown-typeahead2="ctrl.unitFormats" dropdown-typeahead-on-select="ctrl.setUnitFormat($subItem)"></div>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label">Decimals</label>
+        <input type="number" class="gf-form-input width-5" placeholder="auto" data-placement="right" bs-tooltip="'Override automatic decimal precision for legend and tooltips'" ng-model="ctrl.panel.decimals" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Coloring</h5>
+    <div class="gf-form-inline">
+      <gf-form-switch class="gf-form" label-class="width-8" label="Background" checked="ctrl.panel.colorBackground" on-change="ctrl.render()"></gf-form-switch>
+      <gf-form-switch class="gf-form" label-class="width-4" label="Value" checked="ctrl.panel.colorValue" on-change="ctrl.render()"></gf-form-switch>
+    </div>
+    <div class="gf-form-inline">
+      <div class="gf-form max-width-21">
+        <label class="gf-form-label width-8">Thresholds
+          <tip>Define two threshold values&lt;br /&gt; 50,80 will produce: &lt;50 = Green, 50:80 = Yellow, &gt;80 = Red</tip>
+        </label>
+        <input type="text" class="gf-form-input" ng-model="ctrl.panel.thresholds" ng-blur="ctrl.render()" placeholder="50,80"></input>
+      </div>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Colors</label>
+      <span class="gf-form-label">
+        <spectrum-picker ng-model="ctrl.panel.colors[0]" ng-change="ctrl.render()" ></spectrum-picker>
+      </span>
+      <span class="gf-form-label">
+        <spectrum-picker ng-model="ctrl.panel.colors[1]" ng-change="ctrl.render()" ></spectrum-picker>
+      </span>
+      <span class="gf-form-label">
+        <spectrum-picker ng-model="ctrl.panel.colors[2]" ng-change="ctrl.render()" ></spectrum-picker>
+      </span>
+      <span class="gf-form-label">
+        <a  ng-click="ctrl.invertColorOrder()">
+          Invert
+        </a>
+      </span>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Spark lines</h5>
+    <gf-form-switch class="gf-form" label-class="width-9" label="Show" checked="ctrl.panel.sparkline.show" on-change="ctrl.render()"></gf-form-switch>
+    <div ng-if="ctrl.panel.sparkline.show">
+      <gf-form-switch class="gf-form" label-class="width-9" label="Background mode" checked="ctrl.panel.sparkline.full" on-change="ctrl.render()"></gf-form-switch>
+      <div class="gf-form">
+        <label class="gf-form-label width-9">Line Color</label>
+        <span class="gf-form-label">
+          <spectrum-picker ng-model="ctrl.panel.sparkline.lineColor" ng-change="ctrl.render()" ></spectrum-picker>
+        </span>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-9">Fill Color</label>
+        <span class="gf-form-label">
+          <spectrum-picker ng-model="ctrl.panel.sparkline.fillColor" ng-change="ctrl.render()" ></spectrum-picker>
+        </span>
+      </div>
+    </div>
+  </div>
+
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Gauge</h5>
+    <gf-form-switch class="gf-form" label-class="width-9" label="Show" checked="ctrl.panel.gauge.show" on-change="ctrl.render()"></gf-form-switch>
+    <div ng-if="ctrl.panel.gauge.show">
+      <div class="gf-form">
+        <label class="gf-form-label width-9">Min</label>
+        <input type="number" class="gf-form-input width-6" placeholder="0" data-placement="right" ng-model="ctrl.panel.gauge.minValue" ng-change="ctrl.refresh()" ng-model-onblur>
+        <label class="gf-form-label alert-state-critical" ng-show="ctrl.invalidGaugeRange">
+          &nbsp; <i class="fa fa-warning"></i>
+          Min value is bigger than max.
+        </label>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-9">Max</label>
+        <input type="number" class="gf-form-input width-6" placeholder="0" data-placement="right" ng-model="ctrl.panel.gauge.maxValue" ng-change="ctrl.refresh()" ng-model-onblur>
+      </div>
+      <gf-form-switch class="gf-form" label-class="width-9" label="Threshold labels" checked="ctrl.panel.gauge.thresholdLabels" on-change="ctrl.render()"></gf-form-switch>
+      <gf-form-switch class="gf-form" label-class="width-9" label="Threshold markers" checked="ctrl.panel.gauge.thresholdMarkers" on-change="ctrl.render()"></gf-form-switch>
+    </div>
+  </div>
 </div>

+ 123 - 157
public/app/plugins/panel/table/editor.html

@@ -1,173 +1,139 @@
 <div class="editor-row">
-	<div class="section">
-		<h5>Data</h5>
-		<div class="tight-form-container">
-			<div class="tight-form">
-				<ul class="tight-form-list">
-					<li class="tight-form-item" style="width: 140px">
-						To Table Transform
-					</li>
-					<li>
-						<select class="input-large tight-form-input"
-							ng-model="editor.panel.transform"
-							ng-options="k as v.description for (k, v) in editor.transformers"
-							ng-change="editor.transformChanged()"></select>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
+	<div class="section gf-form-group">
+		<h5 class="section-heading">Data</h5>
+    <div class="gf-form">
+      <label class="gf-form-label width-10">Table Transform</label>
+      <div class="gf-form-select-wrapper max-width-15">
+        <select class="gf-form-input" ng-model="editor.panel.transform" ng-options="k as v.description for (k, v) in editor.transformers" ng-change="editor.transformChanged()"></select>
+      </div>
+    </div>
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label width-10">Columns</label>
+      </div>
+			<div class="gf-form" ng-repeat="column in editor.panel.columns">
+				<label class="gf-form-label">
+					<i class="pointer fa fa-remove" ng-click="editor.removeColumn(column)"></i>
+					<span>{{column.text}}</span>
+				</label>
 			</div>
-			<div class="tight-form">
-				<ul class="tight-form-list">
-					<li class="tight-form-item" style="width: 140px">
-						Columns
-					</li>
-					<li class="tight-form-item" ng-repeat="column in editor.panel.columns">
-						<i class="pointer fa fa-remove" ng-click="editor.removeColumn(column)"></i>
-						<span>
-							{{column.text}}
-						</span>
-					</li>
-					<li>
-						<metric-segment segment="editor.addColumnSegment" get-options="editor.getColumnOptions()" on-change="editor.addColumn()"></metric-segment>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
+			<div class="gf-form">
+				<metric-segment segment="editor.addColumnSegment" get-options="editor.getColumnOptions()" on-change="editor.addColumn()"></metric-segment>
 			</div>
 		</div>
 	</div>
 
-	<div class="section">
-		<h5>Table Display</h5>
-		<div class="tight-form-container">
-			<div class="tight-form">
-				<ul class="tight-form-list">
-					<li class="tight-form-item">
-						Pagination (Page size)
-					</li>
-					<li>
-						<input type="number" class="input-small tight-form-input" placeholder="100"
-						empty-to-null ng-model="editor.panel.pageSize" ng-change="editor.render()" ng-model-onblur>
-					</li>
-					<li class="tight-form-item">
-						<editor-checkbox text="Scroll" model="editor.panel.scroll" change="editor.render()"></editor-checkbox>
-					</li>
-					<li class="tight-form-item">
-						Font size
-					</li>
-					<li>
-						<select class="input-small tight-form-input" ng-model="editor.panel.fontSize" ng-options="f for f in editor.fontSizes" ng-change="editor.render()"></select>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
+	<div class="section gf-form-group">
+		<h5 class="section-heading">Table Display</h5>
+		<div class="gf-form-inline">
+			<div class="gf-form max-width-17">
+				<label class="gf-form-label width-11">Pagination (Page size)</label>
+				<input type="number" class="gf-form-input"
+					placeholder="100" data-placement="right"
+					ng-model="editor.panel.pageSize"
+					ng-change="editor.render()"
+					ng-model-onblur>
+			</div>
+			<gf-form-switch class="gf-form" label-class="width-4"
+				label="Scroll"
+				checked="editor.panel.scroll"
+				change="editor.render()"></gf-form-switch>
+			<div class="gf-form max-width-17">
+				<label class="gf-form-label width-6">Font size</label>
+				<div class="gf-form-select-wrapper max-width-15">
+					<select class="gf-form-input"
+							ng-model="editor.panel.fontSize"
+							ng-options="f for f in editor.fontSizes"
+							ng-change="editor.render()"></select>
+				</div>
 			</div>
 		</div>
 	</div>
 </div>
 
-<div class="editor-row" style="margin-top: 20px">
-	<h5>Column Styles</h5>
-
-	<div class="tight-form-container">
+<div class="editor-row">
+	<div class="section gf-form-group">
+		<h5 class="section-heading">Column Styles</h5>
 		<div ng-repeat="style in editor.panel.styles">
-			<div class="tight-form">
-				<ul class="tight-form-list pull-right">
-					<li class="tight-form-item last">
-						<i class="fa fa-remove pointer" ng-click="editor.removeColumnStyle(style)"></i>
-					</li>
-				</ul>
-
-				<ul class="tight-form-list">
-					<li class="tight-form-item">
-						Name or regex
-					</li>
-					<li>
-						<input type="text" ng-model="style.pattern" bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 class="input-medium tight-form-input">
-					</li>
-					<li class="tight-form-item" style="width: 86px">
-						Type
-					</li>
-					<li>
-						<select class="input-small tight-form-input"
-							ng-model="style.type"
-							ng-options="c.value as c.text for c in editor.columnTypes"
-							ng-change="editor.render()"
-							style="width: 150px"
-							></select>
-					</li>
-				</ul>
-				<ul class="tight-form-list" ng-if="style.type === 'date'">
-					<li class="tight-form-item">
-						Format
-					</li>
-					<li>
-						<metric-segment-model property="style.dateFormat" options="editor.dateFormats" on-change="editor.render()" custom="true"></metric-segment-model>
-					</li>
-				</ul>
-				<ul class="tight-form-list" ng-if="style.type === 'string'">
-					<li class="tight-form-item">
-						<editor-checkbox text="Sanitize HTML" model="style.sanitize" change="editor.render()"></editor-checkbox>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
-			</div>
-			<div class="tight-form" ng-if="style.type === 'number'">
-				<ul class="tight-form-list">
-					<li class="tight-form-item text-right" style="width: 93px">
-						Coloring
-					</li>
-					<li>
-						<select class="input-small tight-form-input"
-							ng-model="style.colorMode"
-							ng-options="c.value as c.text for c in editor.colorModes"
-							ng-change="editor.render()"
-							style="width: 150px"
-							></select>
-					</li>
-					<li class="tight-form-item">
-						Thresholds<tip>Comma separated values</tip>
-					</li>
-					<li>
-						<input type="text" class="input-small tight-form-input" style="width: 150px" ng-model="style.thresholds" ng-blur="editor.render()" placeholder="50,80" array-join></input>
-					</li>
-					<li class="tight-form-item" style="width: 60px">
-						Colors
-					</li>
-					<li class="tight-form-item">
-						<spectrum-picker ng-model="style.colors[0]" ng-change="editor.render()" ></spectrum-picker>
-						<spectrum-picker ng-model="style.colors[1]" ng-change="editor.render()" ></spectrum-picker>
-						<spectrum-picker ng-model="style.colors[2]" ng-change="editor.render()" ></spectrum-picker>
-					</li>
-					<li class="tight-form-item last">
-						<a class="pointer" ng-click="editor.invertColorOrder($index)">invert order</a>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
-			</div>
-			<div class="tight-form" ng-if="style.type === 'number'">
-				<ul class="tight-form-list">
-					<li class="tight-form-item text-right" style="width: 93px">
-						Unit
-					</li>
-					<li class="dropdown" style="width: 150px"
-						ng-model="style.unit"
-						dropdown-typeahead="editor.unitFormats"
-						dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)">
-					</li>
-					<li class="tight-form-item" style="width: 86px">
-						Decimals
-					</li>
-					<li style="width: 105px">
-						<input type="number" class="input-mini tight-form-input" ng-model="style.decimals" ng-change="editor.render()" ng-model-onblur>
-					</li>
-				</ul>
-				<div class="clearfix"></div>
+			<div class="gf-form-inline">
+				<div class="gf-form">
+					<label class="gf-form-label">Name or regex</label>
+					<input type="text" class="gf-form-input" ng-model="style.pattern" bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 ng-model-onblur>
+				</div>
+				<div class="gf-form">
+					<label class="gf-form-label">Type</label>
+					<div class="gf-form-select-wrapper">
+						<select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select>
+					</div>
+				</div>
+				<div class="gf-form" ng-if="style.type === 'date'">
+					<label class="gf-form-label">Format</label>
+					<metric-segment-model property="style.dateFormat" options="editor.dateFormats" on-change="editor.render()" custom="true"></metric-segment-model>
+				</div>
+				<gf-form-switch class="gf-form" label-class="width-8" ng-if="style.type === 'string'" label="Sanitize HTML" checked="style.sanitize" change="editor.render()"></gf-form-switch>
+				<div class="gf-form gf-form--grow">
+					<div class="gf-form-label gf-form-label--grow"></div>
+				</div>
+				<div class="gf-form">
+				  <label class="gf-form-label">
+				    <a class="pointer" ng-click="editor.removeColumnStyle(style)">
+				      <i class="fa fa-trash"></i>
+				    </a>
+				  </label>
+				</div>
 			</div>
 
-		</div>
-	</div>
+			<div class="gf-form-inline" ng-if="style.type === 'number'">
+				<div class="gf-form offset-width-8">
+					<label class="gf-form-label width-8">Unit</label>
+				</div>
+				<div class="gf-form">
+					<div class="gf-form-dropdown-typeahead" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
+				</div>
+				<div class="gf-form">
+					<label class="gf-form-label">Decimals</label>
+					<input type="number" class="gf-form-input width-4" data-placement="right" ng-model="style.decimals" ng-change="editor.render()" ng-model-onblur>
+        </div>
+				<div class="gf-form">
+          <label class="gf-form-label">Coloring</label>
+          <div class="gf-form-select-wrapper">
+            <select class="gf-form-input" ng-model="style.colorMode" ng-options="c.value as c.text for c in editor.colorModes" ng-change="editor.render()"></select>
+          </div>
+        </div>
+        <div class="gf-form gf-form--grow">
+          <div class="gf-form-label gf-form-label--grow"></div>
+        </div>
+      </div>
 
-	<button class="btn btn-inverse" style="margin-top: 20px" ng-click="editor.addColumnStyle()">
-		Add column style rule
-	</button>
-</div>
+      <div class="gf-form-inline" ng-if="style.type === 'number'">
+        <div class="gf-form max-width-17 offset-width-8">
+          <label class="gf-form-label width-8">Thresholds<tip>Comma separated values</tip></label>
+          <input type="text" class="gf-form-input" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()" array-join ng-model-onblur>
+        </div>
+        <div class="gf-form">
+          <label class="gf-form-label width-5">Colors</label>
+          <span class="gf-form-label">
+            <spectrum-picker ng-model="style.colors[0]" ng-change="editor.render()"></spectrum-picker>
+          </span>
+          <span class="gf-form-label">
+            <spectrum-picker ng-model="style.colors[1]" ng-change="editor.render()"></spectrum-picker>
+          </span>
+          <span class="gf-form-label">
+            <spectrum-picker ng-model="style.colors[2]" ng-change="editor.render()"></spectrum-picker>
+          </span>
+        </div>
+        <div class="gf-form gf-form--grow">
+          <div class="gf-form-label gf-form-label--grow">
+            <a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>
+          </div>
+        </div>
+      </div>
 
+    </div>
+  </div>
+  <div class="gf-form-button">
+    <button class="btn btn-inverse" ng-click="editor.addColumnStyle()">
+      <i class="fa fa-plus"></i>&nbsp;Add column style rule
+    </button>
+  </div>
+</div>

+ 2 - 1
public/app/plugins/panel/table/module.ts

@@ -78,7 +78,8 @@ class TablePanelCtrl extends MetricsPanelCtrl {
 
     if (this.panel.transform === 'annotations') {
       this.setTimeQueryStart();
-      return this.annotationsSrv.getAnnotations(this.dashboard).then(annotations => {
+      return this.annotationsSrv.getAnnotations({dashboard: this.dashboard, panel: this.panel, range: this.range})
+      .then(annotations => {
         return {data: annotations};
       });
     }

+ 1 - 1
public/sass/components/_gf-form.scss

@@ -7,6 +7,7 @@ $gf-form-margin: 0.25rem;
   align-items: center;
   text-align: left;
   position: relative;
+  font-size: $font-size-sm;
 
   &--offset-1 {
     margin-left: $spacer;
@@ -48,7 +49,6 @@ $gf-form-margin: 0.25rem;
 .gf-form-label {
   padding: $input-padding-y $input-padding-x;
   margin-right: $gf-form-margin;
-  line-height: $input-line-height;
   flex-shrink: 0;
 
   background-color: $input-label-bg;

+ 3 - 17
public/sass/components/_submenu.scss

@@ -1,6 +1,5 @@
 .submenu-controls {
   margin: 0 $panel-margin ($panel-margin*2) $panel-margin;
-  font-size: 16px;
 }
 
 .annotation-disabled, .annotation-disabled a {
@@ -18,22 +17,19 @@
 .submenu-item {
   margin-right: 20px;
   display: inline-block;
-  border-radius: 3px;
-  background-color: $panel-bg;
-  border: $panel-border;
-  margin-right: 10px;
+  margin-right: 15px;
   display: inline-block;
   float: left;
 
   .fa-caret-down {
     font-size: 75%;
     position: relative;
-    top: 1px;
+    top: -1px;
+    left: 1px;
   }
 }
 
 .variable-value-link {
-  font-size: 16px;
   padding-right: 10px;
   .label-tag {
     margin: 0 5px;
@@ -42,19 +38,9 @@
   padding: 8px 7px;
   box-sizing: content-box;
   display: inline-block;
-  font-weight: normal;
-  display: inline-block;
   color: $text-color;
 }
 
-.submenu-item-label {
-  padding: 8px 0px 8px 7px;
-  box-sizing: content-box;
-  display: inline-block;
-  font-weight: normal;
-  display: inline-block;
-}
-
 .variable-link-wrapper  {
   display: inline-block;
   position: relative;

+ 16 - 0
public/test/core/utils/emitter_specs.ts

@@ -24,6 +24,22 @@ describe("Emitter", () => {
       expect(sub2Called).to.be(true);
     });
 
+    it('when subscribing twice', () => {
+      var events = new Emitter();
+      var sub1Called = 0;
+
+      function handler() {
+        sub1Called += 1;
+      }
+
+      events.on('test', handler);
+      events.on('test', handler);
+
+      events.emit('test', null);
+
+      expect(sub1Called).to.be(2);
+    });
+
     it('should handle errors', () => {
       var events = new Emitter();
       var sub1Called = 0;

+ 0 - 388
public/test/specs/dashboardSrv-specs.js

@@ -1,388 +0,0 @@
-define([
-  'app/features/dashboard/dashboardSrv'
-], function() {
-  'use strict';
-
-  describe('dashboardSrv', function() {
-    var _dashboardSrv;
-
-    beforeEach(module('grafana.services'));
-    beforeEach(module(function($provide) {
-      $provide.value('contextSrv', {
-      });
-    }));
-
-    beforeEach(inject(function(dashboardSrv) {
-      _dashboardSrv = dashboardSrv;
-    }));
-
-    describe('when creating new dashboard with defaults only', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({}, {});
-      });
-
-      it('should have title', function() {
-        expect(model.title).to.be('No Title');
-      });
-
-      it('should have meta', function() {
-        expect(model.meta.canSave).to.be(true);
-        expect(model.meta.canShare).to.be(true);
-      });
-
-      it('should have default properties', function() {
-        expect(model.rows.length).to.be(0);
-      });
-    });
-
-    describe('when getting next panel id', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          rows: [{ panels: [{ id: 5 }]}]
-        });
-      });
-
-      it('should return max id + 1', function() {
-        expect(model.getNextPanelId()).to.be(6);
-      });
-    });
-
-    describe('row and panel manipulation', function() {
-      var dashboard;
-
-      beforeEach(function() {
-        dashboard = _dashboardSrv.create({});
-      });
-
-      it('row span should sum spans', function() {
-        var spanLeft = dashboard.rowSpan({ panels: [{ span: 2 }, { span: 3 }] });
-        expect(spanLeft).to.be(5);
-      });
-
-      it('adding default should split span in half', function() {
-        dashboard.rows = [{ panels: [{ span: 12, id: 7 }] }];
-        dashboard.addPanel({span: 4}, dashboard.rows[0]);
-
-        expect(dashboard.rows[0].panels[0].span).to.be(6);
-        expect(dashboard.rows[0].panels[1].span).to.be(6);
-        expect(dashboard.rows[0].panels[1].id).to.be(8);
-      });
-
-      it('duplicate panel should try to add it to same row', function() {
-        var panel = { span: 4, attr: '123', id: 10 };
-        dashboard.rows = [{ panels: [panel] }];
-        dashboard.duplicatePanel(panel, dashboard.rows[0]);
-
-        expect(dashboard.rows[0].panels[0].span).to.be(4);
-        expect(dashboard.rows[0].panels[1].span).to.be(4);
-        expect(dashboard.rows[0].panels[1].attr).to.be('123');
-        expect(dashboard.rows[0].panels[1].id).to.be(11);
-      });
-
-      it('duplicate panel should remove repeat data', function() {
-        var panel = { span: 4, attr: '123', id: 10, repeat: 'asd', scopedVars: { test: 'asd' }};
-        dashboard.rows = [{ panels: [panel] }];
-        dashboard.duplicatePanel(panel, dashboard.rows[0]);
-
-        expect(dashboard.rows[0].panels[1].repeat).to.be(undefined);
-        expect(dashboard.rows[0].panels[1].scopedVars).to.be(undefined);
-      });
-
-    });
-
-    describe('when creating dashboard with editable false', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          editable: false
-        });
-      });
-
-      it('should set editable false', function() {
-        expect(model.editable).to.be(false);
-      });
-
-    });
-
-    describe('when creating dashboard with old schema', function() {
-      var model;
-      var graph;
-      var singlestat;
-      var table;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          services: { filter: { time: { from: 'now-1d', to: 'now'}, list: [{}] }},
-          pulldowns: [
-            {type: 'filtering', enable: true},
-            {type: 'annotations', enable: true, annotations: [{name: 'old'}]}
-          ],
-          rows: [
-            {
-              panels: [
-                {
-                  type: 'graph', legend: true, aliasYAxis: { test: 2 },
-                  y_formats: ['kbyte', 'ms'],
-                  grid: {
-                    min: 1,
-                    max: 10,
-                    rightMin: 5,
-                    rightMax: 15,
-                    leftLogBase: 1,
-                    rightLogBase: 2,
-                    threshold1: 200,
-                    threshold2: 400,
-                    threshold1Color: 'yellow',
-                    threshold2Color: 'red',
-                  },
-                  leftYAxisLabel: 'left label',
-                  targets: [{refId: 'A'}, {}],
-                },
-                {
-                  type: 'singlestat', legend: true, thresholds: '10,20,30', aliasYAxis: { test: 2 }, grid: { min: 1, max: 10 },
-                  targets: [{refId: 'A'}, {}],
-                },
-                {
-                  type: 'table', legend: true, styles: [{ thresholds: ["10", "20", "30"]}, { thresholds: ["100", "200", "300"]}],
-                  targets: [{refId: 'A'}, {}],
-                }
-              ]
-            }
-          ]
-        });
-
-        graph = model.rows[0].panels[0];
-        singlestat = model.rows[0].panels[1];
-        table = model.rows[0].panels[2];
-      });
-
-      it('should have title', function() {
-        expect(model.title).to.be('No Title');
-      });
-
-      it('should have panel id', function() {
-        expect(graph.id).to.be(1);
-      });
-
-      it('should move time and filtering list', function() {
-        expect(model.time.from).to.be('now-1d');
-        expect(model.templating.list[0].allFormat).to.be('glob');
-      });
-
-      it('graphite panel should change name too graph', function() {
-        expect(graph.type).to.be('graph');
-      });
-
-      it('single stat panel should have two thresholds', function() {
-        expect(singlestat.thresholds).to.be('20,30');
-      });
-
-      it('queries without refId should get it', function() {
-        expect(graph.targets[1].refId).to.be('B');
-      });
-
-      it('update legend setting', function() {
-        expect(graph.legend.show).to.be(true);
-      });
-
-      it('move aliasYAxis to series override', function() {
-        expect(graph.seriesOverrides[0].alias).to.be("test");
-        expect(graph.seriesOverrides[0].yaxis).to.be(2);
-      });
-
-      it('should move pulldowns to new schema', function() {
-        expect(model.annotations.list[0].name).to.be('old');
-      });
-
-      it('table panel should only have two thresholds values', function() {
-        expect(table.styles[0].thresholds[0]).to.be("20");
-        expect(table.styles[0].thresholds[1]).to.be("30");
-        expect(table.styles[1].thresholds[0]).to.be("200");
-        expect(table.styles[1].thresholds[1]).to.be("300");
-      });
-
-      it('graph grid to yaxes options', function() {
-        expect(graph.yaxes[0].min).to.be(1);
-        expect(graph.yaxes[0].max).to.be(10);
-        expect(graph.yaxes[0].format).to.be('kbyte');
-        expect(graph.yaxes[0].label).to.be('left label');
-        expect(graph.yaxes[0].logBase).to.be(1);
-        expect(graph.yaxes[1].min).to.be(5);
-        expect(graph.yaxes[1].max).to.be(15);
-        expect(graph.yaxes[1].format).to.be('ms');
-        expect(graph.yaxes[1].logBase).to.be(2);
-
-        expect(graph.grid.rightMax).to.be(undefined);
-        expect(graph.grid.rightLogBase).to.be(undefined);
-        expect(graph.y_formats).to.be(undefined);
-      });
-
-      it('dashboard schema version should be set to latest', function() {
-        expect(model.schemaVersion).to.be(13);
-      });
-
-      it('graph thresholds should be migrated', function() {
-        expect(graph.thresholds.length).to.be(2);
-        expect(graph.thresholds[0].op).to.be('>');
-        expect(graph.thresholds[0].value).to.be(400);
-        expect(graph.thresholds[0].fillColor).to.be('red');
-        expect(graph.thresholds[1].value).to.be(200);
-        expect(graph.thresholds[1].fillColor).to.be('yellow');
-      });
-    });
-
-    describe('when creating dashboard model with missing list for annoations or templating', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          annotations: {
-            enable: true,
-          },
-          templating: {
-            enable: true
-          }
-        });
-      });
-
-      it('should add empty list', function() {
-        expect(model.annotations.list.length).to.be(0);
-        expect(model.templating.list.length).to.be(0);
-      });
-    });
-
-    describe('Given editable false dashboard', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          editable:  false,
-        });
-      });
-
-      it('Should set meta canEdit and canSave to false', function() {
-        expect(model.meta.canSave).to.be(false);
-        expect(model.meta.canEdit).to.be(false);
-      });
-
-      it('getSaveModelClone should remove meta', function() {
-        var clone = model.getSaveModelClone();
-        expect(clone.meta).to.be(undefined);
-      });
-    });
-
-    describe('when loading dashboard with old influxdb query schema', function() {
-      var model;
-      var target;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          rows: [{
-            panels: [{
-              type: 'graph',
-              grid: {},
-              yaxes: [{}, {}],
-              targets: [{
-                "alias": "$tag_datacenter $tag_source $col",
-                "column": "value",
-                "measurement": "logins.count",
-                "fields": [
-                  {
-                    "func": "mean",
-                    "name": "value",
-                    "mathExpr": "*2",
-                    "asExpr": "value"
-                  },
-                  {
-                    "name": "one-minute",
-                    "func": "mean",
-                    "mathExpr": "*3",
-                    "asExpr": "one-minute"
-                  }
-                ],
-                "tags": [],
-                "fill": "previous",
-                "function": "mean",
-                "groupBy": [
-                  {
-                    "interval": "auto",
-                    "type": "time"
-                  },
-                  {
-                    "key": "source",
-                    "type": "tag"
-                  },
-                  {
-                    "type": "tag",
-                    "key": "datacenter"
-                  }
-                ],
-              }]
-            }]
-          }]
-        });
-
-        target = model.rows[0].panels[0].targets[0];
-      });
-
-      it('should update query schema', function() {
-        expect(target.fields).to.be(undefined);
-        expect(target.select.length).to.be(2);
-        expect(target.select[0].length).to.be(4);
-        expect(target.select[0][0].type).to.be('field');
-        expect(target.select[0][1].type).to.be('mean');
-        expect(target.select[0][2].type).to.be('math');
-        expect(target.select[0][3].type).to.be('alias');
-      });
-
-    });
-
-    describe('when creating dashboard model with missing list for annoations or templating', function() {
-      var model;
-
-      beforeEach(function() {
-        model = _dashboardSrv.create({
-          annotations: {
-            enable: true,
-          },
-          templating: {
-            enable: true
-          }
-        });
-      });
-
-      it('should add empty list', function() {
-        expect(model.annotations.list.length).to.be(0);
-        expect(model.templating.list.length).to.be(0);
-      });
-    });
-
-    describe('Formatting epoch timestamp when timezone is set as utc', function() {
-      var dashboard;
-
-      beforeEach(function() {
-        dashboard = _dashboardSrv.create({
-          timezone: 'utc',
-        });
-      });
-
-      it('Should format timestamp with second resolution by default', function() {
-        expect(dashboard.formatDate(1234567890000)).to.be('2009-02-13 23:31:30');
-      });
-
-      it('Should format timestamp with second resolution even if second format is passed as parameter', function() {
-        expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss')).to.be('2009-02-13 23:31:30');
-      });
-
-      it('Should format timestamp with millisecond resolution if format is passed as parameter', function() {
-        expect(dashboard.formatDate(1234567890007,'YYYY-MM-DD HH:mm:ss.SSS')).to.be('2009-02-13 23:31:30.007');
-      });
-    });
-
-  });
-});

+ 1 - 0
public/test/specs/helpers.js

@@ -158,6 +158,7 @@ define([
       return _.template(text, this.templateSettings)(this.data);
     };
     this.init = function() {};
+    this.getAdhocFilters = function() { return []; };
     this.fillVariableValuesForUrl = function() {};
     this.updateTemplateData = function() { };
     this.variableExists = function() { return false; };

+ 0 - 267
public/test/specs/templateSrv-specs.js

@@ -1,267 +0,0 @@
-define([
-  '../mocks/dashboard-mock',
-  'lodash',
-  'app/features/templating/templateSrv'
-], function(dashboardMock) {
-  'use strict';
-
-  describe('templateSrv', function() {
-    var _templateSrv;
-    var _dashboard;
-
-    beforeEach(module('grafana.services'));
-    beforeEach(module(function() {
-      _dashboard = dashboardMock.create();
-    }));
-
-    beforeEach(inject(function(templateSrv) {
-      _templateSrv = templateSrv;
-    }));
-
-    describe('init', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'oogle' } }]);
-      });
-
-      it('should initialize template data', function() {
-        var target = _templateSrv.replace('this.[[test]].filters');
-        expect(target).to.be('this.oogle.filters');
-      });
-    });
-
-    describe('replace can pass scoped vars', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'oogle' } }]);
-      });
-
-      it('should replace $test with scoped value', function() {
-        var target = _templateSrv.replace('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}});
-        expect(target).to.be('this.mupp.filters');
-      });
-
-      it('should replace $test with scoped text', function() {
-        var target = _templateSrv.replaceWithText('this.$test.filters', {'test': {value: 'mupp', text: 'asd'}});
-        expect(target).to.be('this.asd.filters');
-      });
-    });
-
-    describe('replace can pass multi / all format', function() {
-      beforeEach(function() {
-        _templateSrv.init([{name: 'test', current: {value: ['value1', 'value2'] }}]);
-      });
-
-      it('should replace $test with globbed value', function() {
-        var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
-        expect(target).to.be('this.{value1,value2}.filters');
-      });
-
-      it('should replace $test with piped value', function() {
-        var target = _templateSrv.replace('this=$test', {}, 'pipe');
-        expect(target).to.be('this=value1|value2');
-      });
-
-      it('should replace $test with piped value', function() {
-        var target = _templateSrv.replace('this=$test', {}, 'pipe');
-        expect(target).to.be('this=value1|value2');
-      });
-    });
-
-    describe('variable with all option', function() {
-      beforeEach(function() {
-        _templateSrv.init([{
-          name: 'test',
-          current: {value: '$__all' },
-          options: [
-            {value: '$__all'}, {value: 'value1'}, {value: 'value2'}
-          ]
-        }]);
-      });
-
-      it('should replace $test with formatted all value', function() {
-        var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
-        expect(target).to.be('this.{value1,value2}.filters');
-      });
-    });
-
-    describe('variable with all option and custom value', function() {
-      beforeEach(function() {
-        _templateSrv.init([{
-          name: 'test',
-          current: {value: '$__all' },
-          allValue: '*',
-          options: [
-            {value: 'value1'}, {value: 'value2'}
-          ]
-        }]);
-      });
-
-      it('should replace $test with formatted all value', function() {
-        var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
-        expect(target).to.be('this.*.filters');
-      });
-
-      it('should not escape custom all value', function() {
-        var target = _templateSrv.replace('this.$test', {}, 'regex');
-        expect(target).to.be('this.*');
-      });
-    });
-
-    describe('lucene format', function() {
-      it('should properly escape $test with lucene escape sequences', function() {
-        _templateSrv.init([{name: 'test', current: {value: 'value/4' }}]);
-        var target = _templateSrv.replace('this:$test', {}, 'lucene');
-        expect(target).to.be("this:value\\\/4");
-      });
-    });
-
-    describe('format variable to string values', function() {
-      it('single value should return value', function() {
-        var result = _templateSrv.formatValue('test');
-        expect(result).to.be('test');
-      });
-
-      it('multi value and glob format should render glob string', function() {
-        var result = _templateSrv.formatValue(['test','test2'], 'glob');
-        expect(result).to.be('{test,test2}');
-      });
-
-      it('multi value and lucene should render as lucene expr', function() {
-        var result = _templateSrv.formatValue(['test','test2'], 'lucene');
-        expect(result).to.be('("test" OR "test2")');
-      });
-
-      it('multi value and regex format should render regex string', function() {
-        var result = _templateSrv.formatValue(['test.','test2'], 'regex');
-        expect(result).to.be('(test\\.|test2)');
-      });
-
-      it('multi value and pipe should render pipe string', function() {
-        var result = _templateSrv.formatValue(['test','test2'], 'pipe');
-        expect(result).to.be('test|test2');
-      });
-
-      it('slash should be properly escaped in regex format', function() {
-        var result = _templateSrv.formatValue('Gi3/14', 'regex');
-        expect(result).to.be('Gi3\\/14');
-      });
-
-    });
-
-    describe('can check if variable exists', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'oogle' } }]);
-      });
-
-      it('should return true if exists', function() {
-        var result = _templateSrv.variableExists('$test');
-        expect(result).to.be(true);
-      });
-    });
-
-    describe('can hightlight variables in string', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'oogle' } }]);
-      });
-
-      it('should insert html', function() {
-        var result = _templateSrv.highlightVariablesAsHtml('$test');
-        expect(result).to.be('<span class="template-variable">$test</span>');
-      });
-
-      it('should insert html anywhere in string', function() {
-        var result = _templateSrv.highlightVariablesAsHtml('this $test ok');
-        expect(result).to.be('this <span class="template-variable">$test</span> ok');
-      });
-
-      it('should ignore if variables does not exist', function() {
-        var result = _templateSrv.highlightVariablesAsHtml('this $google ok');
-        expect(result).to.be('this $google ok');
-      });
-
-    });
-
-    describe('when checking if a string contains a variable', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'muuuu' } }]);
-      });
-
-      it('should find it with $var syntax', function() {
-        var contains = _templateSrv.containsVariable('this.$test.filters', 'test');
-        expect(contains).to.be(true);
-      });
-
-      it('should not find it if only part matches with $var syntax', function() {
-        var contains = _templateSrv.containsVariable('this.$ServerDomain.filters', 'Server');
-        expect(contains).to.be(false);
-      });
-
-      it('should find it with [[var]] syntax', function() {
-        var contains = _templateSrv.containsVariable('this.[[test]].filters', 'test');
-        expect(contains).to.be(true);
-      });
-
-      it('should find it when part of segment', function() {
-        var contains = _templateSrv.containsVariable('metrics.$env.$group-*', 'group');
-        expect(contains).to.be(true);
-      });
-
-      it('should find it its the only thing', function() {
-        var contains = _templateSrv.containsVariable('$env', 'env');
-        expect(contains).to.be(true);
-      });
-    });
-
-    describe('updateTemplateData with simple value', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: 'muuuu' } }]);
-      });
-
-      it('should set current value and update template data', function() {
-        var target = _templateSrv.replace('this.[[test]].filters');
-        expect(target).to.be('this.muuuu.filters');
-      });
-    });
-
-    describe('fillVariableValuesForUrl with multi value', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: ['val1', 'val2'] }}]);
-      });
-
-      it('should set multiple url params', function() {
-        var params = {};
-        _templateSrv.fillVariableValuesForUrl(params);
-        expect(params['var-test']).to.eql(['val1', 'val2']);
-      });
-    });
-
-    describe('fillVariableValuesForUrl with multi value and scopedVars', function() {
-      beforeEach(function() {
-        _templateSrv.init([{ name: 'test', current: { value: ['val1', 'val2'] }}]);
-      });
-
-      it('should set multiple url params', function() {
-        var params = {};
-        _templateSrv.fillVariableValuesForUrl(params, {'test': {value: 'val1'}});
-        expect(params['var-test']).to.eql('val1');
-      });
-    });
-
-    describe('replaceWithText', function() {
-      beforeEach(function() {
-        _templateSrv.init([
-          { name: 'server', current: { value: '{asd,asd2}', text: 'All' } },
-          { name: 'period', current: { value: '$__auto_interval', text: 'auto' } }
-        ]);
-        _templateSrv.setGrafanaVariable('$__auto_interval', '13m');
-        _templateSrv.updateTemplateData();
-      });
-
-      it('should replace with text except for grafanaVariables', function() {
-        var target = _templateSrv.replaceWithText('Server: $server, period: $period');
-        expect(target).to.be('Server: All, period: 13m');
-      });
-    });
-
-  });
-
-});

+ 32 - 428
public/test/specs/templateValuesSrv-specs.js

@@ -1,9 +1,8 @@
 define([
   '../mocks/dashboard-mock',
   './helpers',
-  'moment',
   'app/features/templating/templateValuesSrv'
-], function(dashboardMock, helpers, moment) {
+], function(dashboardMock, helpers) {
   'use strict';
 
   describe('templateValuesSrv', function() {
@@ -13,442 +12,47 @@ define([
     beforeEach(ctx.providePhase(['datasourceSrv', 'timeSrv', 'templateSrv', '$location']));
     beforeEach(ctx.createService('templateValuesSrv'));
 
-    describe('update interval variable options', function() {
-      var variable = { type: 'interval', query: 'auto,1s,2h,5h,1d', name: 'test' };
-
-      beforeEach(function() {
-        ctx.service.updateOptions(variable);
-      });
-
-      it('should update options array', function() {
-        expect(variable.options.length).to.be(5);
-        expect(variable.options[1].text).to.be('1s');
-        expect(variable.options[1].value).to.be('1s');
-      });
-    });
-
     describe('when template variable is present in url', function() {
-      var variable = {
-        name: 'apps',
-        current: {text: "test", value: "test"},
-        options: [{text: "test", value: "test"}]
-      };
-
-      beforeEach(function(done) {
-        var dashboard = { templating: { list: [variable] } };
-        var urlParams = {};
-        urlParams["var-apps"] = "new";
-        ctx.$location.search = sinon.stub().returns(urlParams);
-        ctx.service.init(dashboard).then(function() { done(); });
-        ctx.$rootScope.$digest();
-      });
-
-      it('should update current value', function() {
-        expect(variable.current.value).to.be("new");
-        expect(variable.current.text).to.be("new");
-      });
-    });
-
-    describe('when template variable is present in url multiple times', function() {
-      var variable = {
-        name: 'apps',
-        multi: true,
-        current: {text: "val1", value: "val1"},
-        options: [{text: "val1", value: "val1"}, {text: 'val2', value: 'val2'}, {text: 'val3', value: 'val3', selected: true}]
-      };
-
-      beforeEach(function(done) {
-        var dashboard = { templating: { list: [variable] } };
-        var urlParams = {};
-        urlParams["var-apps"] = ["val2", "val1"];
-        ctx.$location.search = sinon.stub().returns(urlParams);
-        ctx.service.init(dashboard).then(function() { done(); });
-        ctx.$rootScope.$digest();
-      });
-
-      it('should update current value', function() {
-        expect(variable.current.value.length).to.be(2);
-        expect(variable.current.value[0]).to.be("val2");
-        expect(variable.current.value[1]).to.be("val1");
-        expect(variable.current.text).to.be("val2 + val1");
-        expect(variable.options[0].selected).to.be(true);
-        expect(variable.options[1].selected).to.be(true);
-      });
-
-      it('should set options that are not in value to selected false', function() {
-        expect(variable.options[2].selected).to.be(false);
-      });
-    });
-
-    function describeUpdateVariable(desc, fn) {
-      describe(desc, function() {
-        var scenario = {};
-        scenario.setup = function(setupFn) {
-          scenario.setupFn = setupFn;
+      describe('and setting simple variable', function() {
+        var variable = {
+          name: 'apps',
+          current: {text: "test", value: "test"},
+          options: [{text: "test", value: "test"}]
         };
 
-        beforeEach(function() {
-          scenario.setupFn();
-          var ds = {};
-          ds.metricFindQuery = sinon.stub().returns(ctx.$q.when(scenario.queryResult));
-          ctx.datasourceSrv.get = sinon.stub().returns(ctx.$q.when(ds));
-          ctx.datasourceSrv.getMetricSources = sinon.stub().returns(scenario.metricSources);
-
-          ctx.service.updateOptions(scenario.variable);
+        beforeEach(function(done) {
+          var dashboard = { templating: { list: [variable] } };
+          var urlParams = {};
+          urlParams["var-apps"] = "new";
+          ctx.$location.search = sinon.stub().returns(urlParams);
+          ctx.service.init(dashboard).then(function() { done(); });
           ctx.$rootScope.$digest();
         });
 
-        fn(scenario);
-      });
-    }
-
-    describeUpdateVariable('interval variable without auto', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = { type: 'interval', query: '1s,2h,5h,1d', name: 'test' };
-      });
-
-      it('should update options array', function() {
-        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');
-      });
-    });
-
-    describeUpdateVariable('query variable with empty current object and refresh', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = { 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.variable = {
-          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.variable = {
-          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.variable = {
-          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.variable = { 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('interval variable without auto', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = { type: 'interval', query: '1s,2h,5h,1d', name: 'test' };
-      });
-
-      it('should update options array', function() {
-        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');
-      });
-    });
-
-    describeUpdateVariable('interval variable with auto', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = { 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');
-      });
-    });
-
-    describeUpdateVariable('update custom variable', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'custom', query: 'hej, hop, asd', name: 'test'};
-      });
-
-      it('should update options array', function() {
-        expect(scenario.variable.options.length).to.be(3);
-        expect(scenario.variable.options[0].text).to.be('hej');
-        expect(scenario.variable.options[1].value).to.be('hop');
-      });
-
-      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');
-      });
-    });
-
-    describeUpdateVariable('basic query variable', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = { 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.variable = { type: 'query', query: 'apps.*', name: 'test' };
-        scenario.variable.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.variable = { type: 'query', query: 'apps.*', name: 'test' };
-        scenario.variable.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.variable = { type: 'query', query: 'apps.*', name: 'test' };
-        scenario.variable.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.variable = { type: 'query', query: 'apps.*', name: 'test' };
-        scenario.variable.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.variable = { type: 'query', query: 'apps.*', name: 'test' };
-        scenario.variable.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.variable = {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.variable = { 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');
-      });
-    });
-
-    describeUpdateVariable('datasource variable with regex filter', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {
-          type: 'datasource',
-          query: 'graphite',
-          name: 'test',
-          current: {value: 'backend4_pee', text: 'backend4_pee'},
-          regex: '/pee$/'
-        };
-        scenario.metricSources = [
-          {name: 'backend1', meta: {id: 'influx'}},
-          {name: 'backend2_pee', meta: {id: 'graphite'}},
-          {name: 'backend3', meta: {id: 'graphite'}},
-          {name: 'backend4_pee', meta: {id: 'graphite'}},
-        ];
-      });
-
-      it('should set only contain graphite ds and filtered using regex', function() {
-        expect(scenario.variable.options.length).to.be(2);
-        expect(scenario.variable.options[0].value).to.be('backend2_pee');
-        expect(scenario.variable.options[1].value).to.be('backend4_pee');
-      });
-
-      it('should keep current value if available', function() {
-        expect(scenario.variable.current.value).to.be('backend4_pee');
-      });
-    });
-
-    describeUpdateVariable('without sort', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'query', query: 'apps.*', name: 'test', sort: 0};
-        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
-      });
-
-      it('should return options without sort', function() {
-        expect(scenario.variable.options[0].text).to.be('bbb2');
-        expect(scenario.variable.options[1].text).to.be('aaa10');
-        expect(scenario.variable.options[2].text).to.be('ccc3');
-      });
-    });
-
-    describeUpdateVariable('with alphabetical sort (asc)', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'query', query: 'apps.*', name: 'test', sort: 1};
-        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
-      });
-
-      it('should return options with alphabetical sort', function() {
-        expect(scenario.variable.options[0].text).to.be('aaa10');
-        expect(scenario.variable.options[1].text).to.be('bbb2');
-        expect(scenario.variable.options[2].text).to.be('ccc3');
-      });
-    });
-
-    describeUpdateVariable('with alphabetical sort (desc)', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'query', query: 'apps.*', name: 'test', sort: 2};
-        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
-      });
-
-      it('should return options with alphabetical sort', function() {
-        expect(scenario.variable.options[0].text).to.be('ccc3');
-        expect(scenario.variable.options[1].text).to.be('bbb2');
-        expect(scenario.variable.options[2].text).to.be('aaa10');
-      });
-    });
-
-    describeUpdateVariable('with numerical sort (asc)', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'query', query: 'apps.*', name: 'test', sort: 3};
-        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
+        it('should update current value', function() {
+          expect(variable.current.value).to.be("new");
+          expect(variable.current.text).to.be("new");
+        });
       });
 
-      it('should return options with numerical sort', function() {
-        expect(scenario.variable.options[0].text).to.be('bbb2');
-        expect(scenario.variable.options[1].text).to.be('ccc3');
-        expect(scenario.variable.options[2].text).to.be('aaa10');
-      });
+      // describe('and setting adhoc variable', function() {
+      //   var variable = {name: 'filters', type: 'adhoc'};
+      //
+      //   beforeEach(function(done) {
+      //     var dashboard = { templating: { list: [variable] } };
+      //     var urlParams = {};
+      //     urlParams["var-filters"] = "hostname|gt|server2";
+      //     ctx.$location.search = sinon.stub().returns(urlParams);
+      //     ctx.service.init(dashboard).then(function() { done(); });
+      //     ctx.$rootScope.$digest();
+      //   });
+      //
+      //   it('should update current value', function() {
+      //     expect(variable.tags[0]).to.eq({tag: 'hostname', value: 'server2'});
+      //   });
+      // });
     });
 
-    describeUpdateVariable('with numerical sort (desc)', function(scenario) {
-      scenario.setup(function() {
-        scenario.variable = {type: 'query', query: 'apps.*', name: 'test', sort: 4};
-        scenario.queryResult = [{text: 'bbb2'}, {text: 'aaa10'}, { text: 'ccc3'}];
-      });
 
-      it('should return options with numerical sort', function() {
-        expect(scenario.variable.options[0].text).to.be('aaa10');
-        expect(scenario.variable.options[1].text).to.be('ccc3');
-        expect(scenario.variable.options[2].text).to.be('bbb2');
-      });
-    });
   });
 });

+ 2 - 1
public/test/specs/unsavedChangesSrv-specs.js

@@ -1,6 +1,6 @@
 define([
   'app/features/dashboard/unsavedChangesSrv',
-  'app/features/dashboard/dashboardSrv'
+  'app/features/dashboard/dashboard_srv'
 ], function() {
   'use strict';
 
@@ -14,6 +14,7 @@ define([
     var dash;
     var scope;
 
+    beforeEach(module('grafana.core'));
     beforeEach(module('grafana.services'));
     beforeEach(module(function($provide) {
       $provide.value('contextSrv', _contextSrvStub);

+ 4 - 2
public/vendor/flot/jquery.flot.js

@@ -1663,8 +1663,10 @@ Licensed under the MIT license.
                 delta = max - min;
 
             if (delta == 0.0) {
-                // degenerate case
-                var widen = max == 0 ? 1 : 0.01;
+                // Grafana fix: wide Y min and max using increased wideFactor
+                // when all series values are the same
+                var wideFactor = 0.25;
+                var widen = max == 0 ? 1 : max * wideFactor;
 
                 if (opts.min == null)
                     min -= widen;

+ 33 - 34
tasks/options/phantomjs.js

@@ -1,34 +1,33 @@
-module.exports = function(config,grunt) {
-  'use strict';
-
-  grunt.registerTask('phantomjs', 'Copy phantomjs binary from node', function() {
-
-    var dest = './vendor/phantomjs/phantomjs';
-    var confDir = './node_modules/phantomjs-prebuilt/lib/';
-
-    if (!grunt.file.exists(dest)){
-
-      var m=grunt.file.read(confDir+"location.js")
-      var src=/= \"([^\"]*)\"/.exec(m)[1];
-
-      if (!grunt.file.isPathAbsolute(src)) {
-        src = confDir+src;
-      }
-
-      try {
-        grunt.config('copy.phantom_bin', {
-          src: src,
-          dest: dest,
-          options: { mode: true},
-        });
-        grunt.task.run('copy:phantom_bin');
-      } catch (err) {
-        grunt.verbose.writeln(err);
-        grunt.fail.warn('No working Phantomjs binary available')
-      }
-
-    } else {
-      grunt.log.writeln('Phantomjs already imported from node');
-    }
-  });
-};
+module.exports = function(config,grunt) {
+  'use strict';
+
+  grunt.registerTask('phantomjs', 'Copy phantomjs binary to vendor/', function() {
+
+    var dest = './vendor/phantomjs/phantomjs';
+    var confDir = './node_modules/phantomjs-prebuilt/lib/';
+
+    src = config.phjs
+
+    if (!src){
+      var m=grunt.file.read(confDir+"location.js")
+      var src=/= \"([^\"]*)\"/.exec(m)[1];
+
+      if (!grunt.file.isPathAbsolute(src)) {
+        src = confDir+src;
+      }
+    }
+
+    try {
+      grunt.config('copy.phantom_bin', {
+        src: src,
+        dest: dest,
+        options: { mode: true},
+      });
+      grunt.task.run('copy:phantom_bin');
+    } catch (err) {
+      grunt.verbose.writeln(err);
+      grunt.fail.warn('No working Phantomjs binary available')
+    }
+
+  });
+};