Torkel Ödegaard 10 лет назад
Родитель
Сommit
293d0c3093
36 измененных файлов с 1067 добавлено и 321 удалено
  1. 0 19
      public/app/controllers/search.js
  2. 1 0
      public/app/directives/all.js
  3. 26 0
      public/app/directives/giveFocus.js
  4. 131 59
      public/app/directives/templateParamSelector.js
  5. 1 0
      public/app/features/dashboard/all.js
  6. 5 0
      public/app/features/dashboard/dashboardCtrl.js
  7. 2 5
      public/app/features/dashboard/dashboardSrv.js
  8. 172 0
      public/app/features/dashboard/dynamicDashboardSrv.js
  9. 1 1
      public/app/features/dashboard/partials/dashboardTopNav.html
  10. 26 0
      public/app/features/dashboard/partials/variableValueSelect.html
  11. 6 3
      public/app/features/dashboard/submenuCtrl.js
  12. 108 108
      public/app/features/dashboard/unsavedChangesSrv.js
  13. 1 0
      public/app/features/panel/panelHelper.js
  14. 1 1
      public/app/features/panel/panelMenu.js
  15. 1 0
      public/app/features/panel/panelSrv.js
  16. 3 1
      public/app/features/templating/editorCtrl.js
  17. 13 1
      public/app/features/templating/templateSrv.js
  18. 8 10
      public/app/features/templating/templateValuesSrv.js
  19. 2 2
      public/app/filters/all.js
  20. 1 1
      public/app/panels/graph/axisEditor.html
  21. 1 2
      public/app/panels/graph/graph.js
  22. 1 1
      public/app/partials/dashboard.html
  23. 45 11
      public/app/partials/panelgeneral.html
  24. 41 7
      public/app/partials/roweditor.html
  25. 1 1
      public/app/partials/search.html
  26. 16 7
      public/app/partials/submenu.html
  27. 92 70
      public/app/partials/templating_editor.html
  28. 3 3
      public/app/plugins/datasource/graphite/datasource.js
  29. 5 0
      public/css/less/forms.less
  30. 57 0
      public/css/less/submenu.less
  31. 1 1
      public/css/less/variables.dark.less
  32. 2 7
      public/test/specs/dashboardSrv-specs.js
  33. 180 0
      public/test/specs/dynamicDashboardSrv-specs.js
  34. 28 0
      public/test/specs/templateSrv-specs.js
  35. 83 0
      public/test/specs/unsavedChangesSrv-specs.js
  36. 2 0
      public/test/test-main.js

+ 0 - 19
public/app/controllers/search.js

@@ -140,25 +140,6 @@ function (angular, _, config) {
 
   });
 
-  module.directive('xngFocus', function() {
-    return function(scope, element, attrs) {
-      element.click(function(e) {
-        e.stopPropagation();
-      });
-
-      scope.$watch(attrs.xngFocus,function (newValue) {
-        if (!newValue) {
-          return;
-        }
-        setTimeout(function() {
-          element.focus();
-          var pos = element.val().length * 2;
-          element[0].setSelectionRange(pos, pos);
-        }, 200);
-      },true);
-    };
-  });
-
   module.directive('tagColorFromName', function() {
 
     function djb2(str) {

+ 1 - 0
public/app/directives/all.js

@@ -16,4 +16,5 @@ define([
   './grafanaVersionCheck',
   './dropdown.typeahead',
   './topnav',
+  './giveFocus',
 ], function () {});

+ 26 - 0
public/app/directives/giveFocus.js

@@ -0,0 +1,26 @@
+define([
+  'angular'
+],
+function (angular) {
+  'use strict';
+
+  angular.module('grafana.directives').directive('giveFocus', function() {
+    return function(scope, element, attrs) {
+      element.click(function(e) {
+        e.stopPropagation();
+      });
+
+      scope.$watch(attrs.giveFocus,function (newValue) {
+        if (!newValue) {
+          return;
+        }
+        setTimeout(function() {
+          element.focus();
+          var pos = element.val().length * 2;
+          element[0].setSelectionRange(pos, pos);
+        }, 200);
+      },true);
+    };
+  });
+
+});

+ 131 - 59
public/app/directives/templateParamSelector.js

@@ -4,84 +4,156 @@ define([
   'lodash',
   'jquery',
 ],
-function (angular, app, _, $) {
+function (angular, app, _) {
   'use strict';
 
   angular
     .module('grafana.directives')
-    .directive('templateParamSelector', function($compile) {
-      var inputTemplate = '<input type="text" data-provide="typeahead" ' +
-                            ' class="tight-form-clear-input input-medium"' +
-                            ' spellcheck="false" style="display:none"></input>';
+    .directive('variableValueSelect', function($compile, $window, $timeout) {
+      return {
+        scope: {
+          variable: "=",
+          onUpdated: "&"
+        },
+        templateUrl: 'app/features/dashboard/partials/variableValueSelect.html',
+        link: function(scope, elem) {
+          var bodyEl = angular.element($window.document.body);
+          var variable = scope.variable;
 
-      var buttonTemplate = '<a  class="tight-form-item tabindex="1">{{variable.current.text}} <i class="fa fa-caret-down"></i></a>';
+          scope.show = function() {
+            if (scope.selectorOpen) {
+              return;
+            }
 
-      return {
-        link: function($scope, elem) {
-          var $input = $(inputTemplate);
-          var $button = $(buttonTemplate);
-          var variable = $scope.variable;
-
-          $input.appendTo(elem);
-          $button.appendTo(elem);
-
-          function updateVariableValue(value) {
-            $scope.$apply(function() {
-              var selected = _.findWhere(variable.options, { text: value });
-              if (!selected) {
-                selected = { text: value, value: value };
+            scope.selectorOpen = true;
+            scope.giveFocus = 1;
+            scope.oldCurrentText = variable.current.text;
+            scope.highlightIndex = -1;
+
+            var currentValues = variable.current.value;
+
+            if (_.isString(currentValues)) {
+              currentValues  = [currentValues];
+            }
+
+            scope.options = _.map(variable.options, function(option) {
+              if (_.indexOf(currentValues, option.value) >= 0) {
+                option.selected = true;
               }
-              $scope.setVariableValue($scope.variable, selected);
+              return option;
+            });
+
+            scope.search = {query: '', options: scope.options};
+
+            $timeout(function() {
+              bodyEl.on('click', scope.bodyOnClick);
+            }, 0, false);
+          };
+
+          scope.queryChanged = function() {
+            scope.highlightIndex = -1;
+            scope.search.options = _.filter(scope.options, function(option) {
+              return option.text.toLowerCase().indexOf(scope.search.query.toLowerCase()) !== -1;
             });
-          }
-
-          $input.attr('data-provide', 'typeahead');
-          $input.typeahead({
-            minLength: 0,
-            items: 1000,
-            updater: function(value) {
-              $input.val(value);
-              $input.trigger('blur');
-              return value;
+          };
+
+          scope.keyDown = function (evt) {
+            if (evt.keyCode === 27) {
+              scope.hide();
             }
-          });
+            if (evt.keyCode === 40) {
+              scope.moveHighlight(1);
+            }
+            if (evt.keyCode === 38) {
+              scope.moveHighlight(-1);
+            }
+            if (evt.keyCode === 13) {
+              scope.optionSelected(scope.search.options[scope.highlightIndex], {});
+            }
+          };
 
-          var typeahead = $input.data('typeahead');
-          typeahead.lookup = function () {
-            var options = _.map(variable.options, function(option) { return option.text; });
-            this.query = this.$element.val() || '';
-            return this.process(options);
+          scope.moveHighlight = function(direction) {
+            scope.highlightIndex = (scope.highlightIndex + direction) % scope.search.options.length;
           };
 
-          $button.click(function() {
-            $input.css('width', ($button.width() + 16) + 'px');
+          scope.optionSelected = function(option, event) {
+            option.selected = !option.selected;
+
+            var hideAfter = true;
+            var setAllExceptCurrentTo = function(newValue) {
+              _.each(scope.options, function(other) {
+                if (option !== other) { other.selected = newValue; }
+              });
+            };
+
+            if (option.text === 'All') {
+              setAllExceptCurrentTo(false);
+            }
+            else if (!variable.multi) {
+              setAllExceptCurrentTo(false);
+            } else {
+              if (event.ctrlKey || event.metaKey || event.shiftKey) {
+                hideAfter = false;
+              }
+              else {
+                setAllExceptCurrentTo(false);
+              }
+            }
+
+            var selected = _.filter(scope.options, {selected: true});
 
-            $button.hide();
-            $input.show();
-            $input.focus();
+            if (selected.length === 0) {
+              option.selected = true;
+              selected = [option];
+            }
 
-            var typeahead = $input.data('typeahead');
-            if (typeahead) {
-              $input.val('');
-              typeahead.lookup();
+            if (selected.length > 1 && selected.length !== scope.options.length) {
+              if (selected[0].text === 'All') {
+                selected[0].selected = false;
+                selected = selected.slice(1, selected.length);
+              }
             }
 
-          });
+            variable.current = {
+              text: _.pluck(selected, 'text').join(', '),
+              value: _.pluck(selected, 'value'),
+            };
 
-          $input.blur(function() {
-            if ($input.val() !== '') { updateVariableValue($input.val()); }
-            $input.hide();
-            $button.show();
-            $button.focus();
-          });
+            // only single value
+            if (variable.current.value.length === 1) {
+              variable.current.value = selected[0].value;
+            }
 
-          $scope.$on('$destroy', function() {
-            $button.unbind();
-            typeahead.destroy();
-          });
+            scope.updateLinkText();
+            scope.onUpdated();
+
+            if (hideAfter) {
+              scope.hide();
+            }
+          };
+
+          scope.hide = function() {
+            scope.selectorOpen = false;
+            bodyEl.off('click', scope.bodyOnClick);
+          };
 
-          $compile(elem.contents())($scope);
-        }
+          scope.bodyOnClick = function(e) {
+            var dropdown = elem.find('.variable-value-dropdown');
+            if (dropdown.has(e.target).length === 0) {
+              scope.$apply(scope.hide);
+            }
+          };
+
+          scope.updateLinkText = function() {
+            scope.labelText = variable.label || '$' + variable.name;
+            scope.linkText = variable.current.text;
+          };
+
+          scope.$watchGroup(['variable.hideLabel', 'variable.name', 'variable.label', 'variable.current.text'], function() {
+            scope.updateLinkText();
+          });
+        },
       };
     });
+
 });

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

@@ -16,5 +16,6 @@ define([
   './unsavedChangesSrv',
   './directives/dashSearchView',
   './graphiteImportCtrl',
+  './dynamicDashboardSrv',
   './importCtrl',
 ], function () {});

+ 5 - 0
public/app/features/dashboard/dashboardCtrl.js

@@ -15,7 +15,9 @@ function (angular, $, config) {
       dashboardKeybindings,
       timeSrv,
       templateValuesSrv,
+      dynamicDashboardSrv,
       dashboardSrv,
+      unsavedChangesSrv,
       dashboardViewStateSrv,
       contextSrv,
       $timeout) {
@@ -46,6 +48,9 @@ function (angular, $, config) {
       // template values service needs to initialize completely before
       // the rest of the dashboard can load
       templateValuesSrv.init(dashboard).finally(function() {
+        dynamicDashboardSrv.init(dashboard);
+        unsavedChangesSrv.init(dashboard, $scope);
+
         $scope.dashboard = dashboard;
         $scope.dashboardMeta = dashboard.meta;
         $scope.dashboardViewState = dashboardViewStateSrv.create($scope);

+ 2 - 5
public/app/features/dashboard/dashboardSrv.js

@@ -10,7 +10,7 @@ function (angular, $, kbn, _, moment) {
 
   var module = angular.module('grafana.services');
 
-  module.factory('dashboardSrv', function(contextSrv)  {
+  module.factory('dashboardSrv', function()  {
 
     function DashboardModel (data, meta) {
       if (!data) {
@@ -59,10 +59,6 @@ function (angular, $, kbn, _, moment) {
       meta.canStar = meta.canStar === false ? false : true;
       meta.canDelete = meta.canDelete === false ? false : true;
 
-      if (contextSrv.hasRole('Viewer')) {
-        meta.canSave = false;
-      }
-
       if (!this.editable) {
         meta.canEdit = false;
         meta.canDelete = false;
@@ -180,6 +176,7 @@ function (angular, $, kbn, _, moment) {
 
       var currentRow = this.rows[rowIndex];
       currentRow.panels.push(newPanel);
+      return newPanel;
     };
 
     p.formatDate = function(date, format) {

+ 172 - 0
public/app/features/dashboard/dynamicDashboardSrv.js

@@ -0,0 +1,172 @@
+define([
+  'angular',
+  'lodash',
+],
+function (angular, _) {
+  'use strict';
+
+  var module = angular.module('grafana.services');
+
+  module.service('dynamicDashboardSrv', function()  {
+    var self = this;
+
+    this.init = function(dashboard) {
+      this.iteration = new Date().getTime();
+      this.process(dashboard);
+    };
+
+    this.update = function(dashboard) {
+      this.iteration = this.iteration + 1;
+      this.process(dashboard);
+    };
+
+    this.process = function(dashboard) {
+      if (dashboard.templating.list.length === 0) { return; }
+      this.dashboard = dashboard;
+
+      var i, j, row, panel;
+      for (i = 0; i < this.dashboard.rows.length; i++) {
+        row = this.dashboard.rows[i];
+
+        // repeat panels first
+        for (j = 0; j < row.panels.length; j++) {
+          panel = row.panels[j];
+          if (panel.repeat) {
+            this.repeatPanel(panel, row);
+          }
+          // clean up old left overs
+          else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
+            row.panels = _.without(row.panels, panel);
+            j = j - 1;
+          }
+        }
+
+        // handle row repeats
+        if (row.repeat) {
+          this.repeatRow(row);
+        }
+        // clean up old left overs
+        else if (row.repeatRowId && row.repeatIteration !== this.iteration) {
+          this.dashboard.rows.splice(i, 1);
+          i = i - 1;
+        }
+      }
+    };
+
+    // returns a new row clone or reuses a clone from previous iteration
+    this.getRowClone = function(sourceRow, index) {
+      if (index === 0) {
+        return sourceRow;
+      }
+
+      var i, panel, row, copy;
+      var sourceRowId = _.indexOf(this.dashboard.rows, sourceRow) + 1;
+
+      // look for row to reuse
+      for (i = 0; i < this.dashboard.rows.length; i++) {
+        row = this.dashboard.rows[i];
+        if (row.repeatRowId === sourceRowId && row.repeatIteration !== this.iteration) {
+          copy = row;
+          break;
+        }
+      }
+
+      if (!copy) {
+        copy = angular.copy(sourceRow);
+        this.dashboard.rows.push(copy);
+
+        // set new panel ids
+        for (i = 0; i < copy.panels.length; i++) {
+          panel = copy.panels[i];
+          panel.id = this.dashboard.getNextPanelId();
+        }
+      }
+
+      copy.repeat = null;
+      copy.repeatRowId = sourceRowId;
+      copy.repeatIteration = this.iteration;
+      return copy;
+    };
+
+    // returns a new panel clone or reuses a clone from previous iteration
+    this.repeatRow = function(row) {
+      var variables = this.dashboard.templating.list;
+      var variable = _.findWhere(variables, {name: row.repeat.replace('$', '')});
+      if (!variable) {
+        return;
+      }
+
+      var selected, copy, i, panel;
+      if (variable.current.text === 'All') {
+        selected = variable.options.slice(1, variable.options.length);
+      } else {
+        selected = _.filter(variable.options, {selected: true});
+      }
+
+      _.each(selected, function(option, index) {
+        copy = self.getRowClone(row, index);
+
+        for (i = 0; i < copy.panels.length; i++) {
+          panel = copy.panels[i];
+          panel.scopedVars = panel.scopedVars || {};
+          panel.scopedVars[variable.name] = option;
+        }
+      });
+    };
+
+    this.getPanelClone = function(sourcePanel, row, index) {
+      // if first clone return source
+      if (index === 0) {
+        return sourcePanel;
+      }
+
+      var i, tmpId, panel, clone;
+
+      // first try finding an existing clone to use
+      for (i = 0; i < row.panels.length; i++) {
+        panel = row.panels[i];
+        if (panel.repeatIteration !== this.iteration && panel.repeatPanelId === sourcePanel.id) {
+          clone = panel;
+          break;
+        }
+      }
+
+      if (!clone) {
+        clone = { id: this.dashboard.getNextPanelId() };
+        row.panels.push(clone);
+      }
+
+      // save id
+      tmpId = clone.id;
+      // copy properties from source
+      angular.extend(clone, sourcePanel);
+      // restore id
+      clone.id = tmpId;
+      clone.repeatIteration = this.iteration;
+      clone.repeatPanelId = sourcePanel.id;
+      clone.repeat = null;
+      return clone;
+    };
+
+    this.repeatPanel = function(panel, row) {
+      var variables = this.dashboard.templating.list;
+      var variable = _.findWhere(variables, {name: panel.repeat});
+      if (!variable) { return; }
+
+      var selected;
+      if (variable.current.text === 'All') {
+        selected = variable.options.slice(1, variable.options.length);
+      } else {
+        selected = _.filter(variable.options, {selected: true});
+      }
+
+      _.each(selected, function(option, index) {
+        var copy = self.getPanelClone(panel, row, index);
+        copy.scopedVars = {};
+        copy.scopedVars[variable.name] = option;
+      });
+    };
+
+  });
+
+});

+ 1 - 1
public/app/features/dashboard/partials/dashboardTopNav.html

@@ -27,7 +27,7 @@
 				<li ng-show="dashboardMeta.canShare">
 					<a class="pointer" ng-click="shareDashboard()" bs-tooltip="'Share dashboard'" data-placement="bottom"><i class="fa fa-share-square-o"></i></a>
 				</li>
-				<li ng-show="dashboardMeta.canSave">
+				<li ng-show="dashboardMeta.canSave && contextSrv.isEditor">
 					<a ng-click="saveDashboard()" bs-tooltip="'Save dashboard'" data-placement="bottom"><i class="fa fa-save"></i></a>
 				</li>
 				<li class="dropdown">

+ 26 - 0
public/app/features/dashboard/partials/variableValueSelect.html

@@ -0,0 +1,26 @@
+<span class="template-variable" ng-show="!variable.hideLabel" style="padding-right: 5px">
+	{{labelText}}:
+</span>
+
+<div style="position: relative; display: inline-block">
+	<a ng-click="show()" class="variable-value-link">
+		{{linkText}}
+		<i class="fa fa-caret-down"></i>
+	</a>
+
+	<div ng-if="selectorOpen" class="variable-value-dropdown">
+		<div class="variable-search-wrapper">
+			<span style="position: relative;">
+				<input type="text" placeholder="Search values..." ng-keydown="keyDown($event)" give-focus="giveFocus" tabindex="1" ng-model="search.query" spellcheck='false' ng-change="queryChanged()" />
+			</span>
+		</div>
+
+		<div class="variable-options-container" ng-if="!query.tagcloud">
+			<a class="variable-option pointer" bindonce ng-repeat="option in search.options"
+				ng-class="{'selected': option.selected, 'highlighted': $index === highlightIndex}" ng-click="optionSelected(option, $event)">
+				<span >{{option.text}}</label>
+				<span class="fa fa-fw variable-option-icon"></span>
+			</a>
+		</div>
+	</div>
+</div>

+ 6 - 3
public/app/features/dashboard/submenuCtrl.js

@@ -7,7 +7,7 @@ function (angular, _) {
 
   var module = angular.module('grafana.controllers');
 
-  module.controller('SubmenuCtrl', function($scope, $q, $rootScope, templateValuesSrv) {
+  module.controller('SubmenuCtrl', function($scope, $q, $rootScope, templateValuesSrv, dynamicDashboardSrv) {
     var _d = {
       enable: true
     };
@@ -26,8 +26,11 @@ function (angular, _) {
       $rootScope.$broadcast('refresh');
     };
 
-    $scope.setVariableValue = function(param, option) {
-      templateValuesSrv.setVariableValue(param, option);
+    $scope.variableUpdated = function(variable) {
+      templateValuesSrv.variableUpdated(variable).then(function() {
+        dynamicDashboardSrv.update($scope.dashboard);
+        $rootScope.$broadcast('refresh');
+      });
     };
 
     $scope.init();

+ 108 - 108
public/app/features/dashboard/unsavedChangesSrv.js

@@ -1,128 +1,113 @@
 define([
   'angular',
   'lodash',
-  'config',
 ],
-function(angular, _, config) {
+function(angular, _) {
   'use strict';
 
-  if (!config.unsaved_changes_warning) {
-    return;
-  }
-
   var module = angular.module('grafana.services');
 
-  module.service('unsavedChangesSrv', function($rootScope, $modal, $q, $location, $timeout) {
+  module.service('unsavedChangesSrv', function($modal, $q, $location, $timeout, contextSrv, $window) {
 
-    var self = this;
-    var modalScope = $rootScope.$new();
+    function Tracker(dashboard, scope) {
+      var self = this;
 
-    $rootScope.$on("dashboard-loaded", function(event, newDashboard) {
-      // wait for different services to patch the dashboard (missing properties)
-      $timeout(function() {
-        self.original = newDashboard.getSaveModelClone();
-        self.current = newDashboard;
-      }, 1200);
-    });
-
-    $rootScope.$on("dashboard-saved", function(event, savedDashboard) {
-      self.original = savedDashboard.getSaveModelClone();
-      self.current = savedDashboard;
-      self.orignalPath = $location.path();
-    });
-
-    $rootScope.$on("$routeChangeSuccess", function() {
-      self.original = null;
-      self.originalPath = $location.path();
-    });
-
-    this.ignoreChanges = function() {
-      if (!self.current || !self.current.meta) { return true; }
-
-      var meta = self.current.meta;
-      return !meta.canSave || meta.fromScript || meta.fromFile;
-    };
+      this.original = dashboard.getSaveModelClone();
+      this.current = dashboard;
+      this.originalPath = $location.path();
+      this.scope = scope;
 
-    window.onbeforeunload = function() {
-      if (self.ignoreChanges()) { return; }
-      if (self.has_unsaved_changes()) {
-        return "There are unsaved changes to this dashboard";
-      }
-    };
+      // register events
+      scope.onAppEvent('dashboard-saved', function() {
+        self.original = self.current.getSaveModelClone();
+        self.originalPath = $location.path();
+      });
+
+      $window.onbeforeunload = function() {
+        if (self.ignoreChanges()) { return; }
+        if (self.hasChanges()) {
+          return "There are unsaved changes to this dashboard";
+        }
+      };
 
-    this.init = function() {
-      $rootScope.$on("$locationChangeStart", function(event, next) {
+      scope.$on("$locationChangeStart", function(event, next) {
         // check if we should look for changes
         if (self.originalPath === $location.path()) { return true; }
         if (self.ignoreChanges()) { return true; }
 
-        if (self.has_unsaved_changes()) {
+        if (self.hasChanges()) {
           event.preventDefault();
           self.next = next;
 
-          $timeout(self.open_modal);
+          $timeout(function() {
+            self.open_modal();
+          });
         }
       });
-    };
+    }
 
-    this.open_modal = function() {
-      var confirmModal = $modal({
-        template: './app/partials/unsaved-changes.html',
-        modalClass: 'confirm-modal',
-        persist: true,
-        show: false,
-        scope: modalScope,
-        keyboard: false
-      });
+    var p = Tracker.prototype;
 
-      $q.when(confirmModal).then(function(modalEl) {
-        modalEl.modal('show');
-      });
+    // for some dashboards and users
+    // changes should be ignored
+    p.ignoreChanges = function() {
+      if (!this.original) { return false; }
+      if (!contextSrv.isEditor) { return true; }
+      if (!this.current || !this.current.meta) { return true; }
+
+      var meta = this.current.meta;
+      return !meta.canSave || meta.fromScript || meta.fromFile;
     };
 
-    this.has_unsaved_changes = function() {
-      if (!self.original) {
-        return false;
-      }
+    // remove stuff that should not count in diff
+    p.cleanDashboardFromIgnoredChanges = function(dash) {
+      // ignore time and refresh
+      dash.time = 0;
+      dash.refresh = 0;
+      dash.schemaVersion = 0;
+
+      // filter row and panels properties that should be ignored
+      dash.rows = _.filter(dash.rows, function(row) {
+        if (row.repeatRowId) {
+          return false;
+        }
 
-      var current = self.current.getSaveModelClone();
-      var original = self.original;
+        row.panels = _.filter(row.panels, function(panel) {
+          if (panel.repeatPanelId) {
+            return false;
+          }
+
+          // remove scopedVars
+          panel.scopedVars = null;
+
+          // ignore panel legend sort
+          if (panel.legend)  {
+            delete panel.legend.sort;
+            delete panel.legend.sortDesc;
+          }
 
-      // ignore timespan changes
-      current.time = original.time = {};
-      current.refresh = original.refresh;
-      // ignore version
-      current.version = original.version;
+          return true;
+        });
+
+        // ignore collapse state
+        row.collapse = false;
+        return true;
+      });
 
       // ignore template variable values
-      _.each(current.templating.list, function(value, index) {
+      _.each(dash.templating.list, function(value) {
         value.current = null;
         value.options = null;
-
-        if (original.templating.list.length > index) {
-          original.templating.list[index].current = null;
-          original.templating.list[index].options = null;
-        }
       });
 
-      // ignore some panel and row stuff
-      current.forEachPanel(function(panel, panelIndex, row, rowIndex) {
-        var originalRow = original.rows[rowIndex];
-        var originalPanel = original.getPanelById(panel.id);
-        // ignore row collapse state
-        if (originalRow) {
-          row.collapse = originalRow.collapse;
-        }
-        if (originalPanel) {
-          // ignore graph legend sort
-          if (originalPanel.legend && panel.legend)  {
-            delete originalPanel.legend.sortDesc;
-            delete originalPanel.legend.sort;
-            delete panel.legend.sort;
-            delete panel.legend.sortDesc;
-          }
-        }
-      });
+    };
+
+    p.hasChanges = function() {
+      var current = this.current.getSaveModelClone();
+      var original = this.original;
+
+      this.cleanDashboardFromIgnoredChanges(current);
+      this.cleanDashboardFromIgnoredChanges(original);
 
       var currentTimepicker = _.findWhere(current.nav, { type: 'timepicker' });
       var originalTimepicker = _.findWhere(original.nav, { type: 'timepicker' });
@@ -141,28 +126,43 @@ function(angular, _, config) {
       return false;
     };
 
-    this.goto_next = function() {
-      var baseLen = $location.absUrl().length - $location.url().length;
-      var nextUrl = self.next.substring(baseLen);
-      $location.url(nextUrl);
-    };
+    p.open_modal = function() {
+      var tracker = this;
 
-    modalScope.ignore = function() {
-      self.original = null;
-      self.goto_next();
-    };
+      var modalScope = this.scope.$new();
+      modalScope.ignore = function() {
+        tracker.original = null;
+        tracker.goto_next();
+      };
 
-    modalScope.save = function() {
-      var unregister = $rootScope.$on('dashboard-saved', function() {
-        self.goto_next();
+      modalScope.save = function() {
+        tracker.scope.$emit('save-dashboard');
+      };
+
+      var confirmModal = $modal({
+        template: './app/partials/unsaved-changes.html',
+        modalClass: 'confirm-modal',
+        persist: false,
+        show: false,
+        scope: modalScope,
+        keyboard: false
       });
 
-      $timeout(unregister, 2000);
+      $q.when(confirmModal).then(function(modalEl) {
+        modalEl.modal('show');
+      });
+    };
 
-      $rootScope.$emit('save-dashboard');
+    p.goto_next = function() {
+      var baseLen = $location.absUrl().length - $location.url().length;
+      var nextUrl = this.next.substring(baseLen);
+      $location.url(nextUrl);
     };
 
-  }).run(function(unsavedChangesSrv) {
-    unsavedChangesSrv.init();
+    this.Tracker = Tracker;
+    this.init = function(dashboard, scope) {
+      // wait for different services to patch the dashboard (missing properties)
+      $timeout(function() { new Tracker(dashboard, scope); }, 1200);
+    };
   });
 });

+ 1 - 0
public/app/features/panel/panelHelper.js

@@ -68,6 +68,7 @@ function (angular, _, kbn, $) {
         targets: scope.panel.targets,
         format: scope.panel.renderer === 'png' ? 'png' : 'json',
         maxDataPoints: scope.resolution,
+        scopedVars: scope.panel.scopedVars,
         cacheTimeout: scope.panel.cacheTimeout
       };
 

+ 1 - 1
public/app/features/panel/panelMenu.js

@@ -11,7 +11,7 @@ function (angular, $, _) {
     .directive('panelMenu', function($compile, linkSrv) {
       var linkTemplate =
           '<span class="panel-title drag-handle pointer">' +
-            '<span class="panel-title-text drag-handle">{{panel.title | interpolateTemplateVars}}</span>' +
+            '<span class="panel-title-text drag-handle">{{panel.title | interpolateTemplateVars:this}}</span>' +
             '<span class="panel-links-icon"></span>' +
             '<span class="panel-time-info" ng-show="panelMeta.timeInfo"><i class="fa fa-clock-o"></i> {{panelMeta.timeInfo}}</span>' +
           '</span>';

+ 1 - 0
public/app/features/panel/panelSrv.js

@@ -11,6 +11,7 @@ function (angular, _, config) {
   module.service('panelSrv', function($rootScope, $timeout, datasourceSrv, $q) {
 
     this.init = function($scope) {
+
       if (!$scope.panel.span) { $scope.panel.span = 12; }
 
       $scope.inspector = {};

+ 3 - 1
public/app/features/templating/editorCtrl.js

@@ -17,6 +17,8 @@ function (angular, _) {
       options: [],
       includeAll: false,
       allFormat: 'glob',
+      multi: false,
+      multiFormat: 'glob',
     };
 
     $scope.init = function() {
@@ -75,7 +77,7 @@ function (angular, _) {
       if ($scope.current.datasource === void 0) {
         $scope.current.datasource = null;
         $scope.current.type = 'query';
-        $scope.current.allFormat = 'Glob';
+        $scope.current.allFormat = 'glob';
       }
     };
 

+ 13 - 1
public/app/features/templating/templateSrv.js

@@ -29,11 +29,23 @@ function (angular, _) {
       _.each(this.variables, function(variable) {
         if (!variable.current || !variable.current.value) { return; }
 
-        this._values[variable.name] = variable.current.value;
+        this._values[variable.name] = this.renderVariableValue(variable);
         this._texts[variable.name] = variable.current.text;
       }, this);
     };
 
+    this.renderVariableValue = function(variable) {
+      var value = variable.current.value;
+      if (_.isString(value)) {
+        return value;
+      } else {
+        if (variable.multiFormat === 'regex values') {
+          return '(' + value.join('|') + ')';
+        }
+        return '{' + value.join(',') + '}';
+      }
+    };
+
     this.setGrafanaVariable = function (name, value) {
       this._grafanaVariables[name] = value;
     };

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

@@ -73,17 +73,15 @@ function (angular, _, kbn) {
       templateSrv.setGrafanaVariable('$__auto_interval', interval);
     };
 
-    this.setVariableValue = function(variable, option, recursive) {
+    this.setVariableValue = function(variable, option) {
       variable.current = option;
-
       templateSrv.updateTemplateData();
+      return this.updateOptionsInChildVariables(variable);
+    };
 
-      return this.updateOptionsInChildVariables(variable)
-        .then(function() {
-          if (!recursive) {
-            $rootScope.$broadcast('refresh');
-          }
-        });
+    this.variableUpdated = function(variable) {
+      templateSrv.updateTemplateData();
+      return this.updateOptionsInChildVariables(variable);
     };
 
     this.updateOptionsInChildVariables = function(updatedVariable) {
@@ -130,11 +128,11 @@ function (angular, _, kbn) {
           if (variable.current) {
             var currentOption = _.findWhere(variable.options, { text: variable.current.text });
             if (currentOption) {
-              return self.setVariableValue(variable, currentOption, true);
+              return self.setVariableValue(variable, currentOption);
             }
           }
 
-          return self.setVariableValue(variable, variable.options[0], true);
+          return self.setVariableValue(variable, variable.options[0]);
         });
       });
     };

+ 2 - 2
public/app/filters/all.js

@@ -56,8 +56,8 @@ define(['angular', 'jquery', 'lodash', 'moment'], function (angular, $, _, momen
   });
 
   module.filter('interpolateTemplateVars', function(templateSrv) {
-    function interpolateTemplateVars(text) {
-      return templateSrv.replaceWithText(text);
+    function interpolateTemplateVars(text, scope) {
+      return templateSrv.replaceWithText(text, scope.panel.scopedVars);
     }
 
     interpolateTemplateVars.$stateful = true;

+ 1 - 1
public/app/panels/graph/axisEditor.html

@@ -190,7 +190,7 @@
 	<div class="section">
 		<div class="tight-form">
 			<ul class="tight-form-list">
-				<li class="tight-form-item" style="width: 100px">
+				<li class="tight-form-item" style="width: 105px">
 					<strong>Legend values</strong>
 				</li>
 				<li class="tight-form-item">

+ 1 - 2
public/app/panels/graph/graph.js

@@ -112,7 +112,7 @@ function (angular, $, kbn, moment, _, GraphTooltip) {
           }
 
           if (elem.width() === 0) {
-            return;
+            return true;
           }
         }
 
@@ -277,7 +277,6 @@ function (angular, $, kbn, moment, _, GraphTooltip) {
           if (legendSideLastValue !== null && panel.legend.rightSide !== legendSideLastValue) {
             return true;
           }
-          return false;
         }
 
         function addTimeAxis(options) {

+ 1 - 1
public/app/partials/dashboard.html

@@ -79,7 +79,7 @@
 					</div>
 
 					<!-- Panels, draggable needs to be disabled in fullscreen because Firefox bug -->
-					<div ng-repeat="(name, panel) in row.panels" class="panel"
+					<div ng-repeat="(name, panel) in row.panels track by panel.id" class="panel"
 						ui-draggable="{{!dashboardViewState.fullscreen}}" drag="panel.id"
 						ui-on-Drop="onDrop($data, row, panel)"
 						drag-handle-class="drag-handle" panel-width>

+ 45 - 11
public/app/partials/panelgeneral.html

@@ -1,17 +1,51 @@
 <div class="editor-row">
   <div class="section">
     <h5>General options</h5>
-    <div class="editor-option">
-      <label class="small">Title</label><input type="text" class="input-medium" ng-model='panel.title'></input>
-    </div>
-    <div class="editor-option">
-      <label class="small">Span</label> <select class="input-mini" ng-model="panel.span" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10,11,12]"></select>
-    </div>
-		<div class="editor-option">
-      <label class="small">Height</label><input type="text" class="input-small" ng-model='panel.height'></select>
-    </div>
-		<editor-opt-bool text="Transparent" model="panel.transparent"></editor-opt-bool>
-  </div>
+		<div class="tight-form">
+			<ul class="tight-form-list">
+				<li class="tight-form-item">
+					Title
+				</li>
+				<li>
+					<input type="text" class="input-xlarge tight-form-input" ng-model='panel.title'></input>
+				</li>
+				<li class="tight-form-item">
+					Span
+				</li>
+				<li>
+					<select class="input-mini tight-form-input" ng-model="panel.span" ng-options="f for f in [0,1,2,3,4,5,6,7,8,9,10,11,12]"></select>
+				</li>
+				<li class="tight-form-item">
+					Height
+				</li>
+				<li>
+					<input type="text" class="input-small tight-form-input" ng-model='panel.height'></input>
+				</li>
+				<li class="tight-form-item">
+					<label class="checkbox-label" for="panel.transparent">Transparent</label>
+					<input class="cr1" id="panel.transparent" type="checkbox" ng-model="panel.transparent" ng-checked="panel.transparent">
+					<label for="panel.transparent" class="cr1"></label>
+				</li>
+			</ul>
+			<div class="clearfix"></div>
+		</div>
+	</div>
+	<div class="section">
+		<h5>Templating options</h5>
+		<div class="tight-form">
+			<ul class="tight-form-list">
+				<li class="tight-form-item">
+					Repeat Panel
+				</li>
+				<li>
+					<select class="input-small tight-form-input last" ng-model="panel.repeat" ng-options="f.name as f.name for f in dashboard.templating.list">
+						<option value=""></option>
+					</select>
+				</li>
+			</ul>
+			<div class="clearfix"></div>
+		</div>
+	</div>
 </div>
 
 <panel-link-editor panel="panel"></panel-link-editor>

+ 41 - 7
public/app/partials/roweditor.html

@@ -1,3 +1,4 @@
+
 <div class="gf-box-header">
 	<div class="gf-box-title">
 		<i class="fa fa-th-list"></i>
@@ -16,15 +17,48 @@
 
 <div class="gf-box-body">
 
-	<div class="editor-row" ng-if="editor.index == 0">
-		<div class="editor-option">
-			<label class="small">Title</label><input type="text" class="input-xlarge" ng-model='row.title'></input>
+	<div class="editor-row">
+		<div class="section">
+			<h5>Row details</h5>
+			<div class="tight-form">
+				<ul class="tight-form-list">
+					<li class="tight-form-item">
+						Title
+					</li>
+					<li>
+						<input type="text" class="input-xlarge tight-form-input" ng-model='row.title'></input>
+					</li>
+					<li class="tight-form-item">
+						Height
+					</li>
+					<li>
+						<input type="text" class="input-small tight-form-input" ng-model='row.height'></input>
+					</li>
+					<li class="tight-form-item">
+						<label class="checkbox-label" for="row.showTitle">Show Title</label>
+						<input class="cr1" id="row.showTitle" type="checkbox" ng-model="row.showTitle" ng-checked="row.showTitle">
+						<label for="row.showTitle" class="cr1"></label>
+					</li>
+				</ul>
+				<div class="clearfix"></div>
+			</div>
 		</div>
-		<div class="editor-option">
-			<label class="small">Height</label><input type="text" class="input-mini" ng-model='row.height'></input>
+		<div class="section">
+			<h5>Templating options</h5>
+			<div class="tight-form">
+				<ul class="tight-form-list">
+					<li class="tight-form-item">
+						Repeat Row
+					</li>
+					<li>
+						<select class="input-small tight-form-input last" ng-model="row.repeat" ng-options="f.name as f.name for f in dashboard.templating.list">
+							<option value=""></option>
+						</select>
+					</li>
+				</ul>
+				<div class="clearfix"></div>
+			</div>
 		</div>
-		<editor-opt-bool text="Editable" model="row.editable"></editor-opt-bool>
-		<editor-opt-bool text="Show title" model="row.showTitle"></editor-opt-bool>
 	</div>
 
 </div>

+ 1 - 1
public/app/partials/search.html

@@ -2,7 +2,7 @@
 
 	<div class="search-field-wrapper">
 		<span style="position: relative;">
-			<input  type="text" placeholder="Find dashboards by name" xng-focus="giveSearchFocus" tabindex="1"
+			<input  type="text" placeholder="Find dashboards by name" give-focus="giveSearchFocus" tabindex="1"
 			ng-keydown="keyDown($event)" ng-model="query.query" ng-model-options="{ debounce: 500 }" spellcheck='false' ng-change="search()" />
 		</span>
 		<div class="search-switches">

+ 16 - 7
public/app/partials/submenu.html

@@ -1,19 +1,28 @@
 <div class="submenu-controls" ng-controller="SubmenuCtrl">
 	<div class="tight-form borderless">
 
-
 		<ul class="tight-form-list" ng-if="dashboard.templating.list.length > 0">
-			<li ng-repeat-start="variable in variables" class="tight-form-item template-param-name">
-				<span class="template-variable ">
-					${{variable.name}}:
-				</span>
+			<li ng-repeat="variable in variables" class="tight-form-item template-param-name dropdown">
+				<variable-value-select variable="variable" on-updated="variableUpdated(variable)"></variable-value-select>
 			</li>
 
+			<!-- <li class="dropdown" ng&#45;repeat&#45;end> -->
+			<!-- 	<a class="tight&#45;form&#45;item" tabindex="1" data&#45;toggle="dropdown">{{variable.current.text}} <i class="fa fa&#45;caret&#45;down"></i></a> -->
+			<!-- 	<div class="dropdown&#45;menu variable&#45;values&#45;dropdown"> -->
+			<!-- 		<input type="text" class="fluid&#45;width"> -->
+			<!-- 		<div class="variable&#45;values&#45;list"> -->
+			<!-- 			<div class="variable&#45;values&#45;list&#45;item" ng&#45;repeat="option in variable.options"> -->
+			<!-- 				<editor&#45;checkbox text="{{option.text}}" model="asd" change="buildUrl()"></editor&#45;checkbox> -->
+			<!-- 			</div> -->
+			<!-- 		</div> -->
+			<!-- 	</div> -->
+			<!-- </li> -->
+      <!--  -->
+			<!--
 			<li ng-repeat-end template-param-selector>
 			</li>
+			-->
 
-			<li class="tight-form-item" style="width: 15px">
-			</li>
 		</ul>
 
 		<ul class="tight-form-list" ng-if="dashboard.annotations.list.length > 0">

+ 92 - 70
public/app/partials/templating_editor.html

@@ -37,7 +37,7 @@
 								{{variable.query}}
 							</td>
 							<td style="width: 1%">
-								<a ng-click="edit(variable)" class="btn btn-success btn-small">
+								<a ng-click="edit(variable)" class="btn btn-inverse btn-small">
 									<i class="fa fa-edit"></i>
 									Edit
 								</a>
@@ -56,97 +56,119 @@
 		</div>
 
 		<div ng-if="editor.index == 1 || (editor.index == 2 && !currentIsNew)">
-			<div class="editor-option">
-				<div class="editor-row">
-					<div class="editor-option">
-						<label class="small">Variable name</label>
-						<input type="text" class="input-medium" ng-model='current.name' placeholder="name" required></input>
-					</div>
-					<div class="editor-option">
-						<label class="small">Type</label>
-						<select class="input-medium" ng-model="current.type" ng-options="f for f in ['query', 'interval', 'custom']" ng-change="typeChanged()"></select>
-					</div>
-					<div class="editor-option" ng-show="current.type === 'query'">
-						<label class="small">Datasource</label>
-						<select class="input input-medium" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"></select>
-					</div>
-
-					<editor-opt-bool text="Refresh on load" show-if="current.type === 'query'"
-						tip="Check if you want values to be updated on dashboard load, will slow down dashboard load time"
-						model="current.refresh"></editor-opt-bool>
-				</div>
-
-				<div ng-show="current.type === 'interval'">
+			<div class="editor-row">
+				<div class="section">
+					<h5>General</h5>
 					<div class="editor-row">
 						<div class="editor-option">
-							<label class="small">Values</label>
-							<input type="text" class="input-xxlarge" ng-model='current.query' ng-blur="runQuery()" placeholder="name"></input>
+							<label class="small">Variable name</label>
+							<input type="text" class="input-medium" ng-model='current.name' placeholder="name" required></input>
+						</div>
+						<div class="editor-option">
+							<label class="small">Type</label>
+							<select class="input-small" ng-model="current.type" ng-options="f for f in ['query', 'interval', 'custom']" ng-change="typeChanged()"></select>
+						</div>
+						<div class="editor-option" ng-show="current.type === 'query'">
+							<label class="small">Datasource</label>
+							<select class="input input-medium" ng-model="current.datasource" ng-options="f.value as f.name for f in datasources"></select>
 						</div>
 					</div>
-					<div class="editor-row">
-						<editor-opt-bool text="Include auto interval" model="current.auto" change="runQuery()"></editor-opt-bool>
-						<div class="editor-option" ng-show="current.auto">
-							<label class="small">Auto interval steps <tip>How many steps, roughly, the interval is rounded and will not always match this count<tip></label>
-							<select class="input-mini" ng-model="current.auto_count" ng-options="f for f in [3,5,10,30,50,100,200]" ng-change="runQuery()"></select>
+
+					<div ng-show="current.type === 'interval'">
+						<div class="editor-row">
+							<div class="editor-option">
+								<label class="small">Values</label>
+								<input type="text" class="input-large" ng-model='current.query' ng-blur="runQuery()" placeholder="name"></input>
+							</div>
+							<editor-opt-bool text="Include auto interval" model="current.auto" change="runQuery()"></editor-opt-bool>
+							<div class="editor-option" ng-show="current.auto">
+								<label class="small">Auto interval steps <tip>How many steps, roughly, the interval is rounded and will not always match this count<tip></label>
+								<select class="input-mini" ng-model="current.auto_count" ng-options="f for f in [3,5,10,30,50,100,200]" ng-change="runQuery()"></select>
+							</div>
 						</div>
 					</div>
-				</div>
 
-				<div ng-show="current.type === 'custom'">
-					<div class="editor-row">
-						<div class="editor-option">
-							<label class="small">Values seperated by comma</label>
-							<input type="text" class="input-xxlarge" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"></input>
+					<div ng-show="current.type === 'custom'">
+						<div class="editor-row">
+							<div class="editor-option">
+								<label class="small">Values seperated by comma</label>
+								<input type="text" class="input-xxlarge" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"></input>
+							</div>
+						</div>
+					</div>
+
+					<div ng-show="current.type === 'query'">
+						<h5>Values Query</h5>
+						<div class="editor-row">
+							<div class="editor-option form-inline">
+								<label class="small">Variable values query</label>
+								<input type="text" class="input-xxlarge" ng-model='current.query' placeholder="apps.servers.*"></input>
+								<button class="btn btn-small btn-success" ng-click="runQuery()" bs-tooltip="'Execute query'" data-placement="right"><i class="fa fa-play"></i></button>
+							</div>
+						</div>
+
+						<div class="editor-row" style="margin: 15px 0">
+							<div class="editor-option form-inline">
+								<label class="small">regex (optional, if you want to extract part of a series name or metric node segment)</label>
+								<input type="text" class="input-xxlarge" ng-model='current.regex' placeholder="/.*-(.*)-.*/"></input>
+								<button class="btn btn-small btn-success" ng-click="runQuery()" bs-tooltip="'execute query'" data-placement="right"><i class="fa fa-play"></i></button>
+							</div>
+						</div>
+
+						<div class="editor-row" style="margin: 15px 0">
+							<editor-opt-bool text="Refresh on load" show-if="current.type === 'query'"
+								tip="Check if you want values to be updated on dashboard load, will slow down dashboard load time"
+								model="current.refresh"></editor-opt-bool>
+
+							<editor-opt-bool text="All option" model="current.includeAll" change="runQuery()"></editor-opt-bool>
+							<div class="editor-option" ng-show="current.includeAll">
+								<label class="small">All format</label>
+								<select class="input-medium" ng-model="current.allFormat" ng-change="runQuery()" ng-options="f for f in ['glob', 'wildcard', 'regex wildcard', 'regex values']"></select>
+							</div>
+							<div class="editor-option" ng-show="current.includeAll">
+								<label class="small">All value</label>
+								<input type="text" class="input-xlarge" ng-model='current.options[0].value'></input>
+							</div>
 						</div>
 					</div>
 				</div>
 
-				<div ng-show="current.type === 'query'">
-					<div class="editor-row">
-						<div class="editor-option form-inline">
-							<label class="small">Variable values query</label>
-							<input type="text" class="input-xxlarge" ng-model='current.query' placeholder="apps.servers.*"></input>
-							<button class="btn btn-small btn-success" ng-click="runQuery()" bs-tooltip="'Execute query'" data-placement="right"><i class="fa fa-play"></i></button>
+				<div class="section">
+					<div class="section">
+						<h5>Display options</h5>
+						<div class="editor-option">
+							<label class="small">Variable label</label>
+							<input type="text" class="input-medium" ng-model='current.label' placeholder=""></input>
 						</div>
+						<editor-opt-bool text="Hide Label" model="current.hideLabel"></editor-opt-bool>
 					</div>
 
-					<div class="editor-row" style="margin: 15px 0">
-						<div class="editor-option form-inline">
-							<label class="small">regex (optional, if you want to extract part of a series name or metric node segment)</label>
-							<input type="text" class="input-xxlarge" ng-model='current.regex' placeholder="/.*-(.*)-.*/"></input>
-							<button class="btn btn-small btn-success" ng-click="runQuery()" bs-tooltip="'execute query'" data-placement="right"><i class="fa fa-play"></i></button>
+					<div class="section">
+						<h5>Multi-value selection <tip>Enables multiple values to be selected at the same time</tip></h5>
+						<editor-opt-bool text="Enable" model="current.multi"></editor-opt-bool>
+						<div class="editor-option" ng-show="current.multi">
+							<label class="small">Multi value format</label>
+							<select class="input-medium" ng-model="current.multiFormat" ng-options="f for f in ['glob', 'regex values']"></select>
 						</div>
 					</div>
 
 					<div class="editor-row" style="margin: 15px 0">
-						<editor-opt-bool text="All option" model="current.includeAll" change="runQuery()"></editor-opt-bool>
-						<div class="editor-option" ng-show="current.includeAll">
-							<label class="small">All format</label>
-							<select class="input-medium" ng-model="current.allFormat" ng-change="runQuery()" ng-options="f for f in ['glob', 'wildcard', 'regex wildcard', 'regex values']"></select>
+						<div class="editor-option" >
+							<label class="small">Variable values (shows max 20)</label>
+							<ul class="grafana-options-list">
+								<li ng-repeat="option in current.options | limitTo: 20">
+									{{option.text}}
+								</li>
+							</ul>
 						</div>
-						<div class="editor-option" ng-show="current.includeAll">
-							<label class="small">All value</label>
-							<input type="text" class="input-xlarge" ng-model='current.options[0].value'></input>
-						</div>
-					</div>
-				</div>
-			</div>
-			<div class="editor-option">
-				<div class="editor-row">
-					<div class="editor-option" >
-						<label class="small">Variable values (showing 20/{{current.options.length}})</label>
-						<ul class="grafana-options-list">
-							<li ng-repeat="option in current.options | limitTo: 20">
-								{{option.text}}
-							</li>
-						</ul>
 					</div>
 				</div>
 			</div>
-		</div>
 
-		<button type="button" class="btn btn-success" ng-show="editor.index === 2" ng-click="update();">Update</button>
-		<button type="button" class="btn btn-success" ng-show="editor.index === 1" ng-click="add();">Add</button>
+			<button type="button" class="btn btn-success pull-right" ng-show="editor.index === 2" ng-click="update();">Update</button>
+			<button type="button" class="btn btn-success pull-right" ng-show="editor.index === 1" ng-click="add();">Add</button>
+
+			<div class="clearfix"></div>
+		</div>
 	</div>
-</div>
 

+ 3 - 3
public/app/plugins/datasource/graphite/datasource.js

@@ -36,7 +36,7 @@ function (angular, _, $, config, kbn, moment) {
           maxDataPoints: options.maxDataPoints,
         };
 
-        var params = this.buildGraphiteParams(graphOptions);
+        var params = this.buildGraphiteParams(graphOptions, options.scopedVars);
 
         if (options.format === 'png') {
           return $q.when(this.url + '/render' + '?' + params.join('&'));
@@ -231,7 +231,7 @@ function (angular, _, $, config, kbn, moment) {
       '#Y', '#Z'
     ];
 
-    GraphiteDatasource.prototype.buildGraphiteParams = function(options) {
+    GraphiteDatasource.prototype.buildGraphiteParams = function(options, scopedVars) {
       var graphite_options = ['from', 'until', 'rawData', 'format', 'maxDataPoints', 'cacheTimeout'];
       var clean_options = [], targets = {};
       var target, targetValue, i;
@@ -252,7 +252,7 @@ function (angular, _, $, config, kbn, moment) {
           continue;
         }
 
-        targetValue = templateSrv.replace(target.target);
+        targetValue = templateSrv.replace(target.target, scopedVars);
         targetValue = targetValue.replace(intervalFormatFixRegex, fixIntervalFormat);
         targets[this._seriesRefLetters[i]] = targetValue;
       }

+ 5 - 0
public/css/less/forms.less

@@ -11,6 +11,11 @@ input[type="checkbox"].cr1 {
   display: none;
 }
 
+.editor-option label.cr1 {
+  display: inline-block;
+  margin: 5px 0 1px 0;
+}
+
 label.cr1 {
   display: inline-block;
   height: 19px;

+ 57 - 0
public/css/less/submenu.less

@@ -19,3 +19,60 @@
   }
 }
 
+.variable-value-link {
+  font-size: 16px;
+  margin-right: 20px;
+}
+
+.variable-value-dropdown {
+  position: absolute;
+  top: 27px;
+  min-width: 150px;
+  max-height: 400px;
+  background: @grafanaPanelBackground;
+  box-shadow: 0px 0px 55px 0px black;
+  border: 1px solid @grafanaTargetFuncBackground;
+  z-index: 1000;
+  font-size: @baseFontSize;
+  padding: 0;
+  border-radius: 3px 3px 0 0;
+}
+
+.variable-options-container {
+  max-height: 350px;
+  overflow: auto;
+  display: block;
+  line-height: 26px;
+}
+
+.variable-option {
+  display: block;
+  padding: 0 8px;
+
+  &:hover, &.highlighted {
+    background-color: @blueDark;
+  }
+
+  .fa {
+    line-height: 26px;
+    float: right;
+    padding-left: 4px;
+  }
+
+  &.selected {
+    .variable-option-icon:before {
+      content: "\f00c";
+    }
+  }
+}
+
+.variable-search-wrapper {
+  input {
+    width: 100%;
+    padding: 7px 8px;
+    height: 100%;
+    box-sizing: border-box;
+    margin-bottom: 6px;
+  }
+}
+

+ 1 - 1
public/css/less/variables.dark.less

@@ -63,7 +63,7 @@
 @monoFontFamily:        Menlo, Monaco, Consolas, "Courier New", monospace;
 
 @baseFontSize:          14px;
-@baseFontWeight:		400;
+@baseFontWeight:		    400;
 @baseFontFamily:        @sansFontFamily;
 @baseLineHeight:        20px;
 @altFontFamily:         @serifFontFamily;

+ 2 - 7
public/test/specs/dashboardSrv-specs.js

@@ -1,17 +1,12 @@
 define([
-  'helpers',
   'features/dashboard/dashboardSrv'
-], function(helpers) {
+], function() {
   'use strict';
 
   describe('dashboardSrv', function() {
     var _dashboardSrv;
-    var contextSrv = new helpers.ContextSrvStub();
 
     beforeEach(module('grafana.services'));
-    beforeEach(module(function($provide) {
-      $provide.value('contextSrv', contextSrv);
-    }));
 
     beforeEach(inject(function(dashboardSrv) {
       _dashboardSrv = dashboardSrv;
@@ -29,7 +24,7 @@ define([
       });
 
       it('should have meta', function() {
-        expect(model.meta.canSave).to.be(false);
+        expect(model.meta.canSave).to.be(true);
         expect(model.meta.canShare).to.be(true);
       });
 

+ 180 - 0
public/test/specs/dynamicDashboardSrv-specs.js

@@ -0,0 +1,180 @@
+define([
+  'features/dashboard/dynamicDashboardSrv',
+  'features/dashboard/dashboardSrv'
+], function() {
+  'use strict';
+
+  function dynamicDashScenario(desc, func)  {
+
+    describe(desc, function() {
+      var ctx = {};
+
+      ctx.setup = function (setupFunc) {
+
+        beforeEach(module('grafana.services'));
+
+        beforeEach(inject(function(dynamicDashboardSrv, dashboardSrv) {
+          ctx.dynamicDashboardSrv = dynamicDashboardSrv;
+          ctx.dashboardSrv = dashboardSrv;
+
+          var model = {
+            rows: [],
+            templating: { list: [] }
+          };
+
+          setupFunc(model);
+          ctx.dash = ctx.dashboardSrv.create(model);
+          ctx.dynamicDashboardSrv.init(ctx.dash);
+          ctx.rows = ctx.dash.rows;
+        }));
+      };
+
+      func(ctx);
+    });
+  }
+
+  dynamicDashScenario('given dashboard with panel repeat', function(ctx) {
+    ctx.setup(function(dash) {
+      dash.rows.push({
+        panels: [{id: 2, repeat: 'apps'}]
+      });
+      dash.templating.list.push({
+        name: 'apps',
+        current: {
+          text: 'se1, se2',
+          value: ['se1', 'se2']
+        },
+        options: [
+          {text: 'se1', value: 'se1', selected: true},
+          {text: 'se2', value: 'se2', selected: true},
+        ]
+      });
+    });
+
+    it('should repeat panel one time', function() {
+      expect(ctx.rows[0].panels.length).to.be(2);
+    });
+
+    it('should mark panel repeated', function() {
+      expect(ctx.rows[0].panels[0].repeat).to.be('apps');
+      expect(ctx.rows[0].panels[1].repeatPanelId).to.be(2);
+    });
+
+    it('should set scopedVars on panels', function() {
+      expect(ctx.rows[0].panels[0].scopedVars.apps.value).to.be('se1');
+      expect(ctx.rows[0].panels[1].scopedVars.apps.value).to.be('se2');
+    });
+
+    describe('After a second iteration', function() {
+      var repeatedPanelAfterIteration1;
+
+      beforeEach(function() {
+        repeatedPanelAfterIteration1 = ctx.rows[0].panels[1];
+        ctx.rows[0].panels[0].fill = 10;
+        ctx.dynamicDashboardSrv.update(ctx.dash);
+      });
+
+      it('should have reused same panel instances', function() {
+        expect(ctx.rows[0].panels[1]).to.be(repeatedPanelAfterIteration1);
+      });
+
+      it('reused panel should copy properties from source', function() {
+        expect(ctx.rows[0].panels[1].fill).to.be(10);
+      });
+
+      it('should have same panel count', function() {
+        expect(ctx.rows[0].panels.length).to.be(2);
+      });
+    });
+
+    describe('After a second iteration and selected values reduced', function() {
+      beforeEach(function() {
+        ctx.dash.templating.list[0].options[1].selected = false;
+        ctx.dynamicDashboardSrv.update(ctx.dash);
+      });
+
+      it('should clean up repeated panel', function() {
+        expect(ctx.rows[0].panels.length).to.be(1);
+      });
+    });
+
+  });
+
+  dynamicDashScenario('given dashboard with row repeat', function(ctx) {
+    ctx.setup(function(dash) {
+      dash.rows.push({
+        repeat: 'servers',
+        panels: [{id: 2}]
+      });
+      dash.templating.list.push({
+        name: 'servers',
+        current: {
+          text: 'se1, se2',
+          value: ['se1', 'se2']
+        },
+        options: [
+          {text: 'se1', value: 'se1', selected: true},
+          {text: 'se2', value: 'se2', selected: true},
+        ]
+      });
+    });
+
+    it('should repeat row one time', function() {
+      expect(ctx.rows.length).to.be(2);
+    });
+
+    it('should keep panel ids on first row', function() {
+      expect(ctx.rows[0].panels[0].id).to.be(2);
+    });
+
+    it('should mark second row as repeated', function() {
+      expect(ctx.rows[0].repeat).to.be('servers');
+    });
+
+    it('should clear repeat field on repeated row', function() {
+      expect(ctx.rows[1].repeat).to.be(null);
+    });
+
+    it('should generate a repeartRowId based on repeat row index', function() {
+      expect(ctx.rows[1].repeatRowId).to.be(1);
+    });
+
+    it('should set scopedVars on row panels', function() {
+      expect(ctx.rows[0].panels[0].scopedVars.servers.value).to.be('se1');
+      expect(ctx.rows[1].panels[0].scopedVars.servers.value).to.be('se2');
+    });
+
+    describe('After a second iteration', function() {
+      var repeatedRowAfterFirstIteration;
+
+      beforeEach(function() {
+        repeatedRowAfterFirstIteration = ctx.rows[1];
+        ctx.rows[0].height = 500;
+        ctx.dynamicDashboardSrv.update(ctx.dash);
+      });
+
+      it('should still only have 2 rows', function() {
+        expect(ctx.rows.length).to.be(2);
+      });
+
+      it.skip('should have updated props from source', function() {
+        expect(ctx.rows[1].height).to.be(500);
+      });
+
+      it('should reuse row instance', function() {
+        expect(ctx.rows[1]).to.be(repeatedRowAfterFirstIteration);
+      });
+    });
+
+    describe('After a second iteration and selected values reduced', function() {
+      beforeEach(function() {
+        ctx.dash.templating.list[0].options[1].selected = false;
+        ctx.dynamicDashboardSrv.update(ctx.dash);
+      });
+
+      it('should remove repeated second row', function() {
+        expect(ctx.rows.length).to.be(1);
+      });
+    });
+  });
+});

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

@@ -45,6 +45,34 @@ define([
       });
     });
 
+    describe('render variable to string values', function() {
+      it('single value should return value', function() {
+        var result = _templateSrv.renderVariableValue({current: {value: 'test'}});
+        expect(result).to.be('test');
+      });
+
+      it('multi value and glob format should render glob string', function() {
+        var result = _templateSrv.renderVariableValue({
+          multiFormat: 'glob',
+          current: {
+            value: ['test','test2'],
+          }
+        });
+        expect(result).to.be('{test,test2}');
+      });
+
+      it('multi value and regex format should render regex string', function() {
+        var result = _templateSrv.renderVariableValue({
+          multiFormat: 'regex values',
+          current: {
+            value: ['test','test2'],
+          }
+        });
+        expect(result).to.be('(test|test2)');
+      });
+
+    });
+
     describe('can check if variable exists', function() {
       beforeEach(function() {
         _templateSrv.init([{ name: 'test', current: { value: 'oogle' } }]);

+ 83 - 0
public/test/specs/unsavedChangesSrv-specs.js

@@ -0,0 +1,83 @@
+define([
+  'features/dashboard/unsavedChangesSrv',
+  'features/dashboard/dashboardSrv'
+], function() {
+  'use strict';
+
+  describe("unsavedChangesSrv", function() {
+    var _unsavedChangesSrv;
+    var _dashboardSrv;
+    var _location;
+    var _contextSrvStub = { isEditor: true };
+    var _rootScope;
+    var tracker;
+    var dash;
+    var scope;
+
+    beforeEach(module('grafana.services'));
+    beforeEach(module(function($provide) {
+      $provide.value('contextSrv', _contextSrvStub);
+      $provide.value('$window', {});
+    }));
+
+    beforeEach(inject(function(unsavedChangesSrv, $location, $rootScope, dashboardSrv) {
+      _unsavedChangesSrv = unsavedChangesSrv;
+      _dashboardSrv = dashboardSrv;
+      _location = $location;
+      _rootScope = $rootScope;
+    }));
+
+    beforeEach(function() {
+      dash = _dashboardSrv.create({
+        rows: [
+          {
+            panels: [{ test: "asd", legend: { } }]
+          }
+        ]
+      });
+      scope = _rootScope.$new();
+      scope.appEvent = sinon.spy();
+      scope.onAppEvent = sinon.spy();
+
+      tracker = new _unsavedChangesSrv.Tracker(dash, scope);
+    });
+
+    it('No changes should not have changes', function() {
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+    it('Simple change should be registered', function() {
+      dash.property = "google";
+      expect(tracker.hasChanges()).to.be(true);
+    });
+
+    it('Should ignore a lot of changes', function() {
+      dash.time = {from: '1h'};
+      dash.refresh = true;
+      dash.schemaVersion = 10;
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+    it('Should ignore row collapse change', function() {
+      dash.rows[0].collapse = true;
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+    it('Should ignore panel legend changes', function() {
+      dash.rows[0].panels[0].legend.sortDesc = true;
+      dash.rows[0].panels[0].legend.sort = "avg";
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+    it('Should ignore panel repeats', function() {
+      dash.rows[0].panels.push({repeatPanelId: 10});
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+    it('Should ignore row repeats', function() {
+      dash.rows.push({repeatRowId: 10});
+      expect(tracker.hasChanges()).to.be(false);
+    });
+
+  });
+});

+ 2 - 0
public/test/test-main.js

@@ -140,6 +140,8 @@ require([
     'specs/dashboardSrv-specs',
     'specs/dashboardViewStateSrv-specs',
     'specs/soloPanelCtrl-specs',
+    'specs/dynamicDashboardSrv-specs',
+    'specs/unsavedChangesSrv-specs',
   ];
 
   var pluginSpecs = (config.plugins.specs || []).map(function (spec) {