Просмотр исходного кода

Merge branch 'master' into react-mobx

Torkel Ödegaard 8 лет назад
Родитель
Сommit
3e7420320c
27 измененных файлов с 752 добавлено и 595 удалено
  1. 3 0
      CHANGELOG.md
  2. 1 1
      pkg/services/alerting/notifiers/pagerduty.go
  3. 23 3
      pkg/services/alerting/notifiers/pagerduty_test.go
  4. 63 54
      pkg/tsdb/prometheus/prometheus.go
  5. 15 6
      pkg/tsdb/prometheus/prometheus_test.go
  6. 1 0
      pkg/tsdb/prometheus/types.go
  7. 2 0
      public/app/core/constants.ts
  8. 3 0
      public/app/core/controllers/json_editor_ctrl.ts
  9. 5 0
      public/app/core/directives/misc.ts
  10. 3 3
      public/app/features/dashboard/all.ts
  11. 0 109
      public/app/features/dashboard/dashboardLoaderSrv.js
  12. 139 0
      public/app/features/dashboard/dashboard_loader_srv.ts
  13. 37 15
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  14. 1 0
      public/app/features/dashboard/export/export_modal.ts
  15. 1 1
      public/app/features/dashboard/settings/settings.html
  16. 49 51
      public/app/features/dashboard/share_snapshot_ctrl.ts
  17. 10 5
      public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts
  18. 0 189
      public/app/features/dashboard/unsavedChangesSrv.js
  19. 216 0
      public/app/features/dashboard/unsaved_changes_srv.ts
  20. 16 1
      public/app/features/panel/panel_ctrl.ts
  21. 0 153
      public/app/features/plugins/datasource_srv.js
  22. 152 0
      public/app/features/plugins/datasource_srv.ts
  23. 3 0
      public/app/partials/edit_json.html
  24. 3 3
      public/app/plugins/datasource/prometheus/metric_find_query.ts
  25. 1 1
      public/sass/base/font-awesome/_larger.scss
  26. 4 0
      public/sass/components/_panel_add_panel.scss
  27. 1 0
      public/sass/components/_panel_singlestat.scss

+ 3 - 0
CHANGELOG.md

@@ -25,6 +25,8 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w:
 Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`.
 From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages.
 
+The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
+
 ## New Features
 * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson)
 * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm)
@@ -55,6 +57,7 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
 * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand)
 * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu)
 * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm)
+* **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222)
 
 # 4.6.3 (2017-12-14)
 

+ 1 - 1
pkg/services/alerting/notifiers/pagerduty.go

@@ -42,7 +42,7 @@ var (
 )
 
 func NewPagerdutyNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
-	autoResolve := model.Settings.Get("autoResolve").MustBool(true)
+	autoResolve := model.Settings.Get("autoResolve").MustBool(false)
 	key := model.Settings.Get("integrationKey").MustString()
 	if key == "" {
 		return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"}

+ 23 - 3
pkg/services/alerting/notifiers/pagerduty_test.go

@@ -10,7 +10,6 @@ import (
 
 func TestPagerdutyNotifier(t *testing.T) {
 	Convey("Pagerduty notifier tests", t, func() {
-
 		Convey("Parsing alert notification from settings", func() {
 			Convey("empty settings should return error", func() {
 				json := `{ }`
@@ -26,10 +25,31 @@ func TestPagerdutyNotifier(t *testing.T) {
 				So(err, ShouldNotBeNil)
 			})
 
+			Convey("auto resolve should default to false", func() {
+				json := `{ "integrationKey": "abcdefgh0123456789" }`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "pagerduty_testing",
+					Type:     "pagerduty",
+					Settings: settingsJSON,
+				}
+
+				not, err := NewPagerdutyNotifier(model)
+				pagerdutyNotifier := not.(*PagerdutyNotifier)
+
+				So(err, ShouldBeNil)
+				So(pagerdutyNotifier.Name, ShouldEqual, "pagerduty_testing")
+				So(pagerdutyNotifier.Type, ShouldEqual, "pagerduty")
+				So(pagerdutyNotifier.Key, ShouldEqual, "abcdefgh0123456789")
+				So(pagerdutyNotifier.AutoResolve, ShouldBeFalse)
+			})
+
 			Convey("settings should trigger incident", func() {
 				json := `
 				{
-          "integrationKey": "abcdefgh0123456789"
+		  			"integrationKey": "abcdefgh0123456789",
+					"autoResolve": false
 				}`
 
 				settingsJSON, _ := simplejson.NewJson([]byte(json))
@@ -46,8 +66,8 @@ func TestPagerdutyNotifier(t *testing.T) {
 				So(pagerdutyNotifier.Name, ShouldEqual, "pagerduty_testing")
 				So(pagerdutyNotifier.Type, ShouldEqual, "pagerduty")
 				So(pagerdutyNotifier.Key, ShouldEqual, "abcdefgh0123456789")
+				So(pagerdutyNotifier.AutoResolve, ShouldBeFalse)
 			})
-
 		})
 	})
 }

+ 63 - 54
pkg/tsdb/prometheus/prometheus.go

@@ -83,41 +83,48 @@ func (e *PrometheusExecutor) getClient(dsInfo *models.DataSource) (apiv1.API, er
 }
 
 func (e *PrometheusExecutor) Query(ctx context.Context, dsInfo *models.DataSource, tsdbQuery *tsdb.TsdbQuery) (*tsdb.Response, error) {
-	result := &tsdb.Response{}
+	result := &tsdb.Response{
+		Results: map[string]*tsdb.QueryResult{},
+	}
 
 	client, err := e.getClient(dsInfo)
 	if err != nil {
 		return nil, err
 	}
 
-	query, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery)
+	querys, err := parseQuery(dsInfo, tsdbQuery.Queries, tsdbQuery)
 	if err != nil {
 		return nil, err
 	}
 
-	timeRange := apiv1.Range{
-		Start: query.Start,
-		End:   query.End,
-		Step:  query.Step,
-	}
+	for _, query := range querys {
+		timeRange := apiv1.Range{
+			Start: query.Start,
+			End:   query.End,
+			Step:  query.Step,
+		}
 
-	span, ctx := opentracing.StartSpanFromContext(ctx, "alerting.prometheus")
-	span.SetTag("expr", query.Expr)
-	span.SetTag("start_unixnano", int64(query.Start.UnixNano()))
-	span.SetTag("stop_unixnano", int64(query.End.UnixNano()))
-	defer span.Finish()
+		plog.Debug("Sending query", "start", timeRange.Start, "end", timeRange.End, "step", timeRange.Step, "query", query.Expr)
 
-	value, err := client.QueryRange(ctx, query.Expr, timeRange)
+		span, ctx := opentracing.StartSpanFromContext(ctx, "alerting.prometheus")
+		span.SetTag("expr", query.Expr)
+		span.SetTag("start_unixnano", int64(query.Start.UnixNano()))
+		span.SetTag("stop_unixnano", int64(query.End.UnixNano()))
+		defer span.Finish()
 
-	if err != nil {
-		return nil, err
-	}
+		value, err := client.QueryRange(ctx, query.Expr, timeRange)
 
-	queryResult, err := parseResponse(value, query)
-	if err != nil {
-		return nil, err
+		if err != nil {
+			return nil, err
+		}
+
+		queryResult, err := parseResponse(value, query)
+		if err != nil {
+			return nil, err
+		}
+		result.Results[query.RefId] = queryResult
 	}
-	result.Results = queryResult
+
 	return result, nil
 }
 
@@ -140,51 +147,54 @@ func formatLegend(metric model.Metric, query *PrometheusQuery) string {
 	return string(result)
 }
 
-func parseQuery(dsInfo *models.DataSource, queries []*tsdb.Query, queryContext *tsdb.TsdbQuery) (*PrometheusQuery, error) {
-	queryModel := queries[0]
+func parseQuery(dsInfo *models.DataSource, queries []*tsdb.Query, queryContext *tsdb.TsdbQuery) ([]*PrometheusQuery, error) {
+	qs := []*PrometheusQuery{}
+	for _, queryModel := range queries {
+		expr, err := queryModel.Model.Get("expr").String()
+		if err != nil {
+			return nil, err
+		}
 
-	expr, err := queryModel.Model.Get("expr").String()
-	if err != nil {
-		return nil, err
-	}
+		format := queryModel.Model.Get("legendFormat").MustString("")
 
-	format := queryModel.Model.Get("legendFormat").MustString("")
+		start, err := queryContext.TimeRange.ParseFrom()
+		if err != nil {
+			return nil, err
+		}
 
-	start, err := queryContext.TimeRange.ParseFrom()
-	if err != nil {
-		return nil, err
-	}
+		end, err := queryContext.TimeRange.ParseTo()
+		if err != nil {
+			return nil, err
+		}
 
-	end, err := queryContext.TimeRange.ParseTo()
-	if err != nil {
-		return nil, err
-	}
+		dsInterval, err := tsdb.GetIntervalFrom(dsInfo, queryModel.Model, time.Second*15)
+		if err != nil {
+			return nil, err
+		}
 
-	dsInterval, err := tsdb.GetIntervalFrom(dsInfo, queryModel.Model, time.Second*15)
-	if err != nil {
-		return nil, err
-	}
+		intervalFactor := queryModel.Model.Get("intervalFactor").MustInt64(1)
+		interval := intervalCalculator.Calculate(queryContext.TimeRange, dsInterval)
+		step := time.Duration(int64(interval.Value) * intervalFactor)
 
-	intervalFactor := queryModel.Model.Get("intervalFactor").MustInt64(1)
-	interval := intervalCalculator.Calculate(queryContext.TimeRange, dsInterval)
-	step := time.Duration(int64(interval.Value) * intervalFactor)
+		qs = append(qs, &PrometheusQuery{
+			Expr:         expr,
+			Step:         step,
+			LegendFormat: format,
+			Start:        start,
+			End:          end,
+			RefId:        queryModel.RefId,
+		})
+	}
 
-	return &PrometheusQuery{
-		Expr:         expr,
-		Step:         step,
-		LegendFormat: format,
-		Start:        start,
-		End:          end,
-	}, nil
+	return qs, nil
 }
 
-func parseResponse(value model.Value, query *PrometheusQuery) (map[string]*tsdb.QueryResult, error) {
-	queryResults := make(map[string]*tsdb.QueryResult)
+func parseResponse(value model.Value, query *PrometheusQuery) (*tsdb.QueryResult, error) {
 	queryRes := tsdb.NewQueryResult()
 
 	data, ok := value.(model.Matrix)
 	if !ok {
-		return queryResults, fmt.Errorf("Unsupported result format: %s", value.Type().String())
+		return queryRes, fmt.Errorf("Unsupported result format: %s", value.Type().String())
 	}
 
 	for _, v := range data {
@@ -204,6 +214,5 @@ func parseResponse(value model.Value, query *PrometheusQuery) (map[string]*tsdb.
 		queryRes.Series = append(queryRes.Series, &series)
 	}
 
-	queryResults["A"] = queryRes
-	return queryResults, nil
+	return queryRes, nil
 }

+ 15 - 6
pkg/tsdb/prometheus/prometheus_test.go

@@ -60,9 +60,10 @@ func TestPrometheus(t *testing.T) {
 			Convey("with 48h time range", func() {
 				queryContext.TimeRange = tsdb.NewTimeRange("12h", "now")
 
-				model, err := parseQuery(dsInfo, queryModels, queryContext)
-
+				models, err := parseQuery(dsInfo, queryModels, queryContext)
 				So(err, ShouldBeNil)
+
+				model := models[0]
 				So(model.Step, ShouldEqual, time.Second*30)
 			})
 		})
@@ -83,18 +84,22 @@ func TestPrometheus(t *testing.T) {
 			Convey("with 48h time range", func() {
 				queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
 
-				model, err := parseQuery(dsInfo, queryModels, queryContext)
+				models, err := parseQuery(dsInfo, queryModels, queryContext)
 
 				So(err, ShouldBeNil)
+
+				model := models[0]
 				So(model.Step, ShouldEqual, time.Minute*2)
 			})
 
 			Convey("with 1h time range", func() {
 				queryContext.TimeRange = tsdb.NewTimeRange("1h", "now")
 
-				model, err := parseQuery(dsInfo, queryModels, queryContext)
+				models, err := parseQuery(dsInfo, queryModels, queryContext)
 
 				So(err, ShouldBeNil)
+
+				model := models[0]
 				So(model.Step, ShouldEqual, time.Second*15)
 			})
 		})
@@ -116,9 +121,11 @@ func TestPrometheus(t *testing.T) {
 				Convey("with 48h time range", func() {
 					queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
 
-					model, err := parseQuery(dsInfo, queryModels, queryContext)
+					models, err := parseQuery(dsInfo, queryModels, queryContext)
 
 					So(err, ShouldBeNil)
+
+					model := models[0]
 					So(model.Step, ShouldEqual, time.Minute*20)
 				})
 			})
@@ -139,9 +146,11 @@ func TestPrometheus(t *testing.T) {
 				Convey("with 48h time range", func() {
 					queryContext.TimeRange = tsdb.NewTimeRange("48h", "now")
 
-					model, err := parseQuery(dsInfo, queryModels, queryContext)
+					models, err := parseQuery(dsInfo, queryModels, queryContext)
 
 					So(err, ShouldBeNil)
+
+					model := models[0]
 					So(model.Step, ShouldEqual, time.Minute*2)
 				})
 			})

+ 1 - 0
pkg/tsdb/prometheus/types.go

@@ -8,4 +8,5 @@ type PrometheusQuery struct {
 	LegendFormat string
 	Start        time.Time
 	End          time.Time
+	RefId        string
 }

+ 2 - 0
public/app/core/constants.ts

@@ -6,3 +6,5 @@ export const REPEAT_DIR_VERTICAL = 'v';
 export const DEFAULT_PANEL_SPAN = 4;
 export const DEFAULT_ROW_HEIGHT = 250;
 export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
+
+export const LS_PANEL_COPY_KEY = 'panel-copy';

+ 3 - 0
public/app/core/controllers/json_editor_ctrl.ts

@@ -6,11 +6,14 @@ export class JsonEditorCtrl {
   constructor($scope) {
     $scope.json = angular.toJson($scope.object, true);
     $scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
+    $scope.canCopy = $scope.enableCopy;
 
     $scope.update = function() {
       var newObject = angular.fromJson($scope.json);
       $scope.updateHandler(newObject, $scope.object);
     };
+
+    $scope.getContentForClipboard = () => $scope.json;
   }
 }
 

+ 5 - 0
public/app/core/directives/misc.ts

@@ -2,6 +2,7 @@ import angular from 'angular';
 import Clipboard from 'clipboard';
 import coreModule from '../core_module';
 import kbn from 'app/core/utils/kbn';
+import { appEvents } from 'app/core/core';
 
 /** @ngInject */
 function tip($compile) {
@@ -32,6 +33,10 @@ function clipboardButton() {
         },
       });
 
+      scope.clipboard.on('success', () => {
+        appEvents.emit('alert-success', ['Content copied to clipboard']);
+      });
+
       scope.$on('$destroy', function() {
         if (scope.clipboard) {
           scope.clipboard.destroy();

+ 3 - 3
public/app/features/dashboard/all.ts

@@ -1,18 +1,18 @@
 import './dashboard_ctrl';
 import './alerting_srv';
 import './history/history';
-import './dashboardLoaderSrv';
+import './dashboard_loader_srv';
 import './dashnav/dashnav';
 import './submenu/submenu';
 import './save_as_modal';
 import './save_modal';
 import './shareModalCtrl';
-import './shareSnapshotCtrl';
+import './share_snapshot_ctrl';
 import './dashboard_srv';
 import './view_state_srv';
 import './validation_srv';
 import './time_srv';
-import './unsavedChangesSrv';
+import './unsaved_changes_srv';
 import './unsaved_changes_modal';
 import './timepicker/timepicker';
 import './upload';

+ 0 - 109
public/app/features/dashboard/dashboardLoaderSrv.js

@@ -1,109 +0,0 @@
-define([
-  'angular',
-  'moment',
-  'lodash',
-  'jquery',
-  'app/core/utils/kbn',
-  'app/core/utils/datemath',
-  'app/core/services/impression_srv'
-],
-function (angular, moment, _, $, kbn, dateMath, impressionSrv) {
-  'use strict';
-
-  kbn = kbn.default;
-  impressionSrv = impressionSrv.default;
-
-  var module = angular.module('grafana.services');
-
-  module.service('dashboardLoaderSrv', function(backendSrv,
-                                                   dashboardSrv,
-                                                   datasourceSrv,
-                                                   $http, $q, $timeout,
-                                                   contextSrv, $routeParams,
-                                                   $rootScope) {
-    var self = this;
-
-    this._dashboardLoadFailed = function(title, snapshot) {
-      snapshot = snapshot || false;
-      return {
-        meta: { canStar: false, isSnapshot: snapshot, canDelete: false, canSave: false, canEdit: false, dashboardNotFound: true },
-        dashboard: {title: title }
-      };
-    };
-
-    this.loadDashboard = function(type, slug) {
-      var promise;
-
-      if (type === 'script') {
-        promise = this._loadScriptedDashboard(slug);
-      } else if (type === 'snapshot') {
-        promise = backendSrv.get('/api/snapshots/' + $routeParams.slug)
-          .catch(function() {
-            return self._dashboardLoadFailed("Snapshot not found", true);
-          });
-      } else {
-        promise = backendSrv.getDashboard($routeParams.type, $routeParams.slug)
-          .then(function(result) {
-            if (result.meta.isFolder) {
-              $rootScope.appEvent("alert-error", ['Dashboard not found']);
-              throw new Error("Dashboard not found");
-            }
-            return result;
-          })
-          .catch(function() {
-            return self._dashboardLoadFailed("Not found");
-          });
-      }
-
-      promise.then(function(result) {
-
-        if (result.meta.dashboardNotFound !== true) {
-          impressionSrv.addDashboardImpression(result.dashboard.id);
-        }
-
-        return result;
-      });
-
-      return promise;
-    };
-
-    this._loadScriptedDashboard = function(file) {
-      var url = 'public/dashboards/'+file.replace(/\.(?!js)/,"/") + '?' + new Date().getTime();
-
-      return $http({ url: url, method: "GET" })
-      .then(this._executeScript).then(function(result) {
-        return { meta: { fromScript: true, canDelete: false, canSave: false, canStar: false}, dashboard: result.data };
-      }, function(err) {
-        console.log('Script dashboard error '+ err);
-        $rootScope.appEvent('alert-error', ["Script Error", "Please make sure it exists and returns a valid dashboard"]);
-        return self._dashboardLoadFailed('Scripted dashboard');
-      });
-    };
-
-    this._executeScript = function(result) {
-      var services = {
-        dashboardSrv: dashboardSrv,
-        datasourceSrv: datasourceSrv,
-        $q: $q,
-      };
-
-      /*jshint -W054 */
-      var script_func = new Function('ARGS','kbn','dateMath','_','moment','window','document','$','jQuery', 'services', result.data);
-      var script_result = script_func($routeParams, kbn, dateMath, _ , moment, window, document, $, $, services);
-
-      // Handle async dashboard scripts
-      if (_.isFunction(script_result)) {
-        var deferred = $q.defer();
-        script_result(function(dashboard) {
-          $timeout(function() {
-            deferred.resolve({ data: dashboard });
-          });
-        });
-        return deferred.promise;
-      }
-
-      return { data: script_result };
-    };
-
-  });
-});

+ 139 - 0
public/app/features/dashboard/dashboard_loader_srv.ts

@@ -0,0 +1,139 @@
+import angular from 'angular';
+import moment from 'moment';
+import _ from 'lodash';
+import $ from 'jquery';
+import kbn from 'app/core/utils/kbn';
+import * as dateMath from 'app/core/utils/datemath';
+import impressionSrv from 'app/core/services/impression_srv';
+
+export class DashboardLoaderSrv {
+  /** @ngInject */
+  constructor(
+    private backendSrv,
+    private dashboardSrv,
+    private datasourceSrv,
+    private $http,
+    private $q,
+    private $timeout,
+    contextSrv,
+    private $routeParams,
+    private $rootScope
+  ) {}
+
+  _dashboardLoadFailed(title, snapshot) {
+    snapshot = snapshot || false;
+    return {
+      meta: {
+        canStar: false,
+        isSnapshot: snapshot,
+        canDelete: false,
+        canSave: false,
+        canEdit: false,
+        dashboardNotFound: true,
+      },
+      dashboard: { title: title },
+    };
+  }
+
+  loadDashboard(type, slug) {
+    var promise;
+
+    if (type === 'script') {
+      promise = this._loadScriptedDashboard(slug);
+    } else if (type === 'snapshot') {
+      promise = this.backendSrv.get('/api/snapshots/' + this.$routeParams.slug).catch(() => {
+        return this._dashboardLoadFailed('Snapshot not found', true);
+      });
+    } else {
+      promise = this.backendSrv
+        .getDashboard(this.$routeParams.type, this.$routeParams.slug)
+        .then(result => {
+          if (result.meta.isFolder) {
+            this.$rootScope.appEvent('alert-error', ['Dashboard not found']);
+            throw new Error('Dashboard not found');
+          }
+          return result;
+        })
+        .catch(() => {
+          return this._dashboardLoadFailed('Not found', true);
+        });
+    }
+
+    promise.then(function(result) {
+      if (result.meta.dashboardNotFound !== true) {
+        impressionSrv.addDashboardImpression(result.dashboard.id);
+      }
+
+      return result;
+    });
+
+    return promise;
+  }
+
+  _loadScriptedDashboard(file) {
+    var url = 'public/dashboards/' + file.replace(/\.(?!js)/, '/') + '?' + new Date().getTime();
+
+    return this.$http({ url: url, method: 'GET' })
+      .then(this._executeScript)
+      .then(
+        function(result) {
+          return {
+            meta: {
+              fromScript: true,
+              canDelete: false,
+              canSave: false,
+              canStar: false,
+            },
+            dashboard: result.data,
+          };
+        },
+        function(err) {
+          console.log('Script dashboard error ' + err);
+          this.$rootScope.appEvent('alert-error', [
+            'Script Error',
+            'Please make sure it exists and returns a valid dashboard',
+          ]);
+          return this._dashboardLoadFailed('Scripted dashboard');
+        }
+      );
+  }
+
+  _executeScript(result) {
+    var services = {
+      dashboardSrv: this.dashboardSrv,
+      datasourceSrv: this.datasourceSrv,
+      $q: this.$q,
+    };
+
+    /*jshint -W054 */
+    var script_func = new Function(
+      'ARGS',
+      'kbn',
+      'dateMath',
+      '_',
+      'moment',
+      'window',
+      'document',
+      '$',
+      'jQuery',
+      'services',
+      result.data
+    );
+    var script_result = script_func(this.$routeParams, kbn, dateMath, _, moment, window, document, $, $, services);
+
+    // Handle async dashboard scripts
+    if (_.isFunction(script_result)) {
+      var deferred = this.$q.defer();
+      script_result(dashboard => {
+        this.$timeout(() => {
+          deferred.resolve({ data: dashboard });
+        });
+      });
+      return deferred.promise;
+    }
+
+    return { data: script_result };
+  }
+}
+
+angular.module('grafana.services').service('dashboardLoaderSrv', DashboardLoaderSrv);

+ 37 - 15
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx

@@ -2,9 +2,11 @@ import React from 'react';
 import _ from 'lodash';
 
 import config from 'app/core/config';
-import {PanelModel} from '../panel_model';
-import {PanelContainer} from './PanelContainer';
+import { PanelModel } from '../panel_model';
+import { PanelContainer } from './PanelContainer';
 import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
+import store from 'app/core/store';
+import { LS_PANEL_COPY_KEY } from 'app/core/constants';
 
 export interface AddPanelPanelProps {
   panel: PanelModel;
@@ -24,46 +26,67 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
       panelPlugins: this.getPanelPlugins(),
       filter: '',
     };
-
-    this.onPanelSelected = this.onPanelSelected.bind(this);
   }
 
   getPanelPlugins() {
     let panels = _.chain(config.panels)
-      .filter({hideFromList: false})
+      .filter({ hideFromList: false })
       .map(item => item)
       .value();
 
     // add special row type
-    panels.push({id: 'row', name: 'Row', sort: 8, info: {logos: {small: 'public/img/icn-row.svg'}}});
+    panels.push({ id: 'row', name: 'Row', sort: 8, info: { logos: { small: 'public/img/icn-row.svg' } } });
+
+    let copiedPanelJson = store.get(LS_PANEL_COPY_KEY);
+    if (copiedPanelJson) {
+      let copiedPanel = JSON.parse(copiedPanelJson);
+      let pluginInfo = _.find(panels, { id: copiedPanel.type });
+      if (pluginInfo) {
+        let pluginCopy = _.cloneDeep(pluginInfo);
+        pluginCopy.name = copiedPanel.title;
+        pluginCopy.sort = -1;
+        pluginCopy.defaults = copiedPanel;
+        panels.push(pluginCopy);
+      }
+    }
 
     // add sort by sort property
     return _.sortBy(panels, 'sort');
   }
 
-  onPanelSelected(panelPluginInfo) {
+  onAddPanel = panelPluginInfo => {
     const panelContainer = this.props.getPanelContainer();
     const dashboard = panelContainer.getDashboard();
-    const {gridPos} = this.props.panel;
+    const { gridPos } = this.props.panel;
 
     var newPanel: any = {
       type: panelPluginInfo.id,
       title: 'Panel Title',
-      gridPos: {x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h}
+      gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
     };
 
     if (panelPluginInfo.id === 'row') {
       newPanel.title = 'Row title';
-      newPanel.gridPos = {x: 0, y: 0};
+      newPanel.gridPos = { x: 0, y: 0 };
+    }
+
+    // apply panel template / defaults
+    if (panelPluginInfo.defaults) {
+      _.defaults(newPanel, panelPluginInfo.defaults);
+      newPanel.gridPos.w = panelPluginInfo.defaults.gridPos.w;
+      newPanel.gridPos.h = panelPluginInfo.defaults.gridPos.h;
+      newPanel.title = panelPluginInfo.defaults.title;
+      store.delete(LS_PANEL_COPY_KEY);
     }
 
     dashboard.addPanel(newPanel);
     dashboard.removePanel(this.props.panel);
-  }
+  };
 
-  renderPanelItem(panel) {
+  renderPanelItem(panel, index) {
+    console.log('render panel', index);
     return (
-      <div key={panel.id} className="add-panel__item" onClick={() => this.onPanelSelected(panel)} title={panel.name}>
+      <div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
         <img className="add-panel__item-img" src={panel.info.logos.small} />
         <div className="add-panel__item-name">{panel.name}</div>
       </div>
@@ -75,7 +98,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
       <div className="panel-container">
         <div className="add-panel">
           <div className="add-panel__header">
-            <i className="gicon gicon-add-panel"></i>
+            <i className="gicon gicon-add-panel" />
             <span className="add-panel__title">New Panel</span>
             <span className="add-panel__sub-title">Select a visualization</span>
           </div>
@@ -87,4 +110,3 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
     );
   }
 }
-

+ 1 - 0
public/app/features/dashboard/export/export_modal.ts

@@ -31,6 +31,7 @@ export class DashExportCtrl {
     var clone = this.dash;
     let editScope = this.$rootScope.$new();
     editScope.object = clone;
+    editScope.enableCopy = true;
 
     this.$rootScope.appEvent('show-modal', {
       src: 'public/app/partials/edit_json.html',

+ 1 - 1
public/app/features/dashboard/settings/settings.html

@@ -89,7 +89,7 @@
 	<h3 class="dashboard-settings__header">View JSON</h3>
 
 	<div class="gf-form">
-		<textarea class="gf-form-input" ng-model="ctrl.json" rows="30" spellcheck="false"></textarea>
+		<code-editor content="ctrl.json" data-mode="json" data-max-lines=30 ></code-editor>
 	</div>
 </div>
 

+ 49 - 51
public/app/features/dashboard/shareSnapshotCtrl.js → public/app/features/dashboard/share_snapshot_ctrl.ts

@@ -1,14 +1,8 @@
-define([
-  'angular',
-  'lodash',
-],
-function (angular, _) {
-  'use strict';
-
-  var module = angular.module('grafana.controllers');
-
-  module.controller('ShareSnapshotCtrl', function($scope, $rootScope, $location, backendSrv, $timeout, timeSrv) {
+import angular from 'angular';
+import _ from 'lodash';
 
+export class ShareSnapshotCtrl {
+  constructor($scope, $rootScope, $location, backendSrv, $timeout, timeSrv) {
     $scope.snapshot = {
       name: $scope.dashboard.title,
       expires: 0,
@@ -18,16 +12,16 @@ function (angular, _) {
     $scope.step = 1;
 
     $scope.expireOptions = [
-      {text: '1 Hour', value: 60*60},
-      {text: '1 Day',  value: 60*60*24},
-      {text: '7 Days', value: 60*60*24*7},
-      {text: 'Never',  value: 0},
+      { text: '1 Hour', value: 60 * 60 },
+      { text: '1 Day', value: 60 * 60 * 24 },
+      { text: '7 Days', value: 60 * 60 * 24 * 7 },
+      { text: 'Never', value: 0 },
     ];
 
     $scope.accessOptions = [
-      {text: 'Anyone with the link', value: 1},
-      {text: 'Organization users',  value: 2},
-      {text: 'Public on the web', value: 3},
+      { text: 'Anyone with the link', value: 1 },
+      { text: 'Organization users', value: 2 },
+      { text: 'Public on the web', value: 3 },
     ];
 
     $scope.init = function() {
@@ -42,7 +36,7 @@ function (angular, _) {
 
     $scope.createSnapshot = function(external) {
       $scope.dashboard.snapshot = {
-        timestamp: new Date()
+        timestamp: new Date(),
       };
 
       if (!external) {
@@ -71,29 +65,32 @@ function (angular, _) {
 
       var postUrl = external ? $scope.externalUrl + $scope.apiUrl : $scope.apiUrl;
 
-      backendSrv.post(postUrl, cmdData).then(function(results) {
-        $scope.loading = false;
+      backendSrv.post(postUrl, cmdData).then(
+        function(results) {
+          $scope.loading = false;
+
+          if (external) {
+            $scope.deleteUrl = results.deleteUrl;
+            $scope.snapshotUrl = results.url;
+            $scope.saveExternalSnapshotRef(cmdData, results);
+          } else {
+            var url = $location.url();
+            var baseUrl = $location.absUrl();
 
-        if (external) {
-          $scope.deleteUrl = results.deleteUrl;
-          $scope.snapshotUrl = results.url;
-          $scope.saveExternalSnapshotRef(cmdData, results);
-        } else {
-          var url = $location.url();
-          var baseUrl = $location.absUrl();
+            if (url !== '/') {
+              baseUrl = baseUrl.replace(url, '') + '/';
+            }
 
-          if (url !== '/') {
-            baseUrl = baseUrl.replace(url, '') + '/';
+            $scope.snapshotUrl = baseUrl + 'dashboard/snapshot/' + results.key;
+            $scope.deleteUrl = baseUrl + 'api/snapshots-delete/' + results.deleteKey;
           }
 
-          $scope.snapshotUrl = baseUrl + 'dashboard/snapshot/' + results.key;
-          $scope.deleteUrl = baseUrl + 'api/snapshots-delete/' + results.deleteKey;
+          $scope.step = 2;
+        },
+        function() {
+          $scope.loading = false;
         }
-
-        $scope.step = 2;
-      }, function() {
-        $scope.loading = false;
-      });
+      );
     };
 
     $scope.getSnapshotUrl = function() {
@@ -116,21 +113,22 @@ function (angular, _) {
 
       // remove annotation queries
       dash.annotations.list = _.chain(dash.annotations.list)
-      .filter(function(annotation) {
-        return annotation.enable;
-      })
-      .map(function(annotation) {
-        return {
-          name: annotation.name,
-          enable: annotation.enable,
-          iconColor: annotation.iconColor,
-          snapshotData: annotation.snapshotData
-        };
-      }).value();
+        .filter(function(annotation) {
+          return annotation.enable;
+        })
+        .map(function(annotation) {
+          return {
+            name: annotation.name,
+            enable: annotation.enable,
+            iconColor: annotation.iconColor,
+            snapshotData: annotation.snapshotData,
+          };
+        })
+        .value();
 
       // remove template queries
       _.each(dash.templating.list, function(variable) {
-        variable.query = "";
+        variable.query = '';
         variable.options = variable.current;
         variable.refresh = false;
       });
@@ -168,7 +166,7 @@ function (angular, _) {
       cmdData.deleteKey = results.deleteKey;
       backendSrv.post('/api/snapshots/', cmdData);
     };
+  }
+}
 
-  });
-
-});
+angular.module('grafana.controllers').controller('ShareSnapshotCtrl', ShareSnapshotCtrl);

+ 10 - 5
public/app/features/dashboard/specs/unsaved_changes_srv_specs.ts

@@ -1,12 +1,15 @@
 import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
-import 'app/features/dashboard/unsavedChangesSrv';
+import { Tracker } from 'app/features/dashboard/unsaved_changes_srv';
 import 'app/features/dashboard/dashboard_srv';
+import { contextSrv } from 'app/core/core';
 
 describe('unsavedChangesSrv', function() {
-  var _unsavedChangesSrv;
   var _dashboardSrv;
   var _contextSrvStub = { isEditor: true };
   var _rootScope;
+  var _location;
+  var _timeout;
+  var _window;
   var tracker;
   var dash;
   var scope;
@@ -21,10 +24,12 @@ describe('unsavedChangesSrv', function() {
   );
 
   beforeEach(
-    angularMocks.inject(function(unsavedChangesSrv, $location, $rootScope, dashboardSrv) {
-      _unsavedChangesSrv = unsavedChangesSrv;
+    angularMocks.inject(function($location, $rootScope, dashboardSrv, $timeout, $window) {
       _dashboardSrv = dashboardSrv;
       _rootScope = $rootScope;
+      _location = $location;
+      _timeout = $timeout;
+      _window = $window;
     })
   );
 
@@ -42,7 +47,7 @@ describe('unsavedChangesSrv', function() {
     scope.appEvent = sinon.spy();
     scope.onAppEvent = sinon.spy();
 
-    tracker = new _unsavedChangesSrv.Tracker(dash, scope);
+    tracker = new Tracker(dash, scope, undefined, _location, _window, _timeout, contextSrv, _rootScope);
   });
 
   it('No changes should not have changes', function() {

+ 0 - 189
public/app/features/dashboard/unsavedChangesSrv.js

@@ -1,189 +0,0 @@
-define([
-  'angular',
-  'lodash',
-],
-function(angular, _) {
-  'use strict';
-
-  var module = angular.module('grafana.services');
-
-  module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
-
-    function Tracker(dashboard, scope, originalCopyDelay) {
-      var self = this;
-
-      this.current = dashboard;
-      this.originalPath = $location.path();
-      this.scope = scope;
-
-      // register events
-      scope.onAppEvent('dashboard-saved', function() {
-        this.original = this.current.getSaveModelClone();
-        this.originalPath = $location.path();
-      }.bind(this));
-
-      $window.onbeforeunload = function() {
-        if (self.ignoreChanges()) { return; }
-        if (self.hasChanges()) {
-          return "There are unsaved changes to this dashboard";
-        }
-      };
-
-      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.hasChanges()) {
-          event.preventDefault();
-          self.next = next;
-
-          $timeout(function() {
-            self.open_modal();
-          });
-        }
-      });
-
-      if (originalCopyDelay) {
-        $timeout(function() {
-          // wait for different services to patch the dashboard (missing properties)
-          self.original = dashboard.getSaveModelClone();
-        }, originalCopyDelay);
-      } else {
-        self.original = dashboard.getSaveModelClone();
-      }
-    }
-
-    var p = Tracker.prototype;
-
-    // for some dashboards and users
-    // changes should be ignored
-    p.ignoreChanges = function() {
-      if (!this.original) { return true; }
-      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;
-    };
-
-    // 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;
-        }
-
-        row.panels = _.filter(row.panels, function(panel) {
-          if (panel.repeatPanelId) {
-            return false;
-          }
-
-          // remove scopedVars
-          panel.scopedVars = null;
-
-          // ignore span changes
-          panel.span = null;
-
-          // ignore panel legend sort
-          if (panel.legend)  {
-            delete panel.legend.sort;
-            delete panel.legend.sortDesc;
-          }
-
-          return true;
-        });
-
-        // ignore collapse state
-        row.collapse = false;
-        return true;
-      });
-
-      dash.panels = _.filter(dash.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;
-        }
-
-        return true;
-      });
-
-      // ignore template variable values
-      _.each(dash.templating.list, function(value) {
-        value.current = null;
-        value.options = null;
-        value.filters = null;
-      });
-    };
-
-    p.hasChanges = function() {
-      var current = this.current.getSaveModelClone();
-      var original = this.original;
-
-      this.cleanDashboardFromIgnoredChanges(current);
-      this.cleanDashboardFromIgnoredChanges(original);
-
-      var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
-      var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
-
-      if (currentTimepicker && originalTimepicker) {
-        currentTimepicker.now = originalTimepicker.now;
-      }
-
-      var currentJson = angular.toJson(current);
-      var originalJson = angular.toJson(original);
-
-      return currentJson !== originalJson;
-    };
-
-    p.discardChanges = function() {
-      this.original = null;
-      this.gotoNext();
-    };
-
-    p.open_modal = function() {
-      $rootScope.appEvent('show-modal', {
-        templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
-        modalClass: 'modal--narrow confirm-modal'
-      });
-    };
-
-    p.saveChanges = function() {
-      var self = this;
-      var cancel = $rootScope.$on('dashboard-saved', function() {
-        cancel();
-        $timeout(function() {
-          self.gotoNext();
-        });
-      });
-
-      $rootScope.appEvent('save-dashboard');
-    };
-
-    p.gotoNext = function() {
-      var baseLen = $location.absUrl().length - $location.url().length;
-      var nextUrl = this.next.substring(baseLen);
-      $location.url(nextUrl);
-    };
-
-    this.Tracker = Tracker;
-    this.init = function(dashboard, scope) {
-      this.tracker = new Tracker(dashboard, scope, 1000);
-      return this.tracker;
-    };
-  });
-});

+ 216 - 0
public/app/features/dashboard/unsaved_changes_srv.ts

@@ -0,0 +1,216 @@
+import angular from 'angular';
+import _ from 'lodash';
+
+export class Tracker {
+  current: any;
+  originalPath: any;
+  scope: any;
+  original: any;
+  next: any;
+  $window: any;
+
+  /** @ngInject */
+  constructor(
+    dashboard,
+    scope,
+    originalCopyDelay,
+    private $location,
+    $window,
+    private $timeout,
+    private contextSrv,
+    private $rootScope
+  ) {
+    this.$location = $location;
+    this.$window = $window;
+
+    this.current = dashboard;
+    this.originalPath = $location.path();
+    this.scope = scope;
+
+    // register events
+    scope.onAppEvent('dashboard-saved', () => {
+      this.original = this.current.getSaveModelClone();
+      this.originalPath = $location.path();
+    });
+
+    $window.onbeforeunload = () => {
+      if (this.ignoreChanges()) {
+        return '';
+      }
+      if (this.hasChanges()) {
+        return 'There are unsaved changes to this dashboard';
+      }
+      return '';
+    };
+
+    scope.$on('$locationChangeStart', (event, next) => {
+      // check if we should look for changes
+      if (this.originalPath === $location.path()) {
+        return true;
+      }
+      if (this.ignoreChanges()) {
+        return true;
+      }
+
+      if (this.hasChanges()) {
+        event.preventDefault();
+        this.next = next;
+
+        this.$timeout(() => {
+          this.open_modal();
+        });
+      }
+      return false;
+    });
+
+    if (originalCopyDelay) {
+      this.$timeout(() => {
+        // wait for different services to patch the dashboard (missing properties)
+        this.original = dashboard.getSaveModelClone();
+      }, originalCopyDelay);
+    } else {
+      this.original = dashboard.getSaveModelClone();
+    }
+  }
+
+  // for some dashboards and users
+  // changes should be ignored
+  ignoreChanges() {
+    if (!this.original) {
+      return true;
+    }
+    if (!this.contextSrv.isEditor) {
+      return true;
+    }
+    if (!this.current || !this.current.meta) {
+      return true;
+    }
+
+    var meta = this.current.meta;
+    return !meta.canSave || meta.fromScript || meta.fromFile;
+  }
+
+  // remove stuff that should not count in diff
+  cleanDashboardFromIgnoredChanges(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;
+      }
+
+      row.panels = _.filter(row.panels, function(panel) {
+        if (panel.repeatPanelId) {
+          return false;
+        }
+
+        // remove scopedVars
+        panel.scopedVars = null;
+
+        // ignore span changes
+        panel.span = null;
+
+        // ignore panel legend sort
+        if (panel.legend) {
+          delete panel.legend.sort;
+          delete panel.legend.sortDesc;
+        }
+
+        return true;
+      });
+
+      // ignore collapse state
+      row.collapse = false;
+      return true;
+    });
+
+    dash.panels = _.filter(dash.panels, 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;
+      }
+
+      return true;
+    });
+
+    // ignore template variable values
+    _.each(dash.templating.list, function(value) {
+      value.current = null;
+      value.options = null;
+      value.filters = null;
+    });
+  }
+
+  hasChanges() {
+    var current = this.current.getSaveModelClone();
+    var original = this.original;
+
+    this.cleanDashboardFromIgnoredChanges(current);
+    this.cleanDashboardFromIgnoredChanges(original);
+
+    var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
+    var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
+
+    if (currentTimepicker && originalTimepicker) {
+      currentTimepicker.now = originalTimepicker.now;
+    }
+
+    var currentJson = angular.toJson(current);
+    var originalJson = angular.toJson(original);
+
+    return currentJson !== originalJson;
+  }
+
+  discardChanges() {
+    this.original = null;
+    this.gotoNext();
+  }
+
+  open_modal() {
+    this.$rootScope.appEvent('show-modal', {
+      templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
+      modalClass: 'modal--narrow confirm-modal',
+    });
+  }
+
+  saveChanges() {
+    var self = this;
+    var cancel = this.$rootScope.$on('dashboard-saved', () => {
+      cancel();
+      this.$timeout(() => {
+        self.gotoNext();
+      });
+    });
+
+    this.$rootScope.appEvent('save-dashboard');
+  }
+
+  gotoNext() {
+    var baseLen = this.$location.absUrl().length - this.$location.url().length;
+    var nextUrl = this.next.substring(baseLen);
+    this.$location.url(nextUrl);
+  }
+}
+
+/** @ngInject */
+export function unsavedChangesSrv($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
+  this.Tracker = Tracker;
+  this.init = function(dashboard, scope) {
+    this.tracker = new Tracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope);
+    return this.tracker;
+  };
+}
+
+angular.module('grafana.services').service('unsavedChangesSrv', unsavedChangesSrv);

+ 16 - 1
public/app/features/panel/panel_ctrl.ts

@@ -4,7 +4,8 @@ import $ from 'jquery';
 import { appEvents, profiler } from 'app/core/core';
 import { PanelModel } from 'app/features/dashboard/panel_model';
 import Remarkable from 'remarkable';
-import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
+import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, LS_PANEL_COPY_KEY } from 'app/core/constants';
+import store from 'app/core/store';
 
 const TITLE_HEIGHT = 27;
 const PANEL_BORDER = 2;
@@ -190,11 +191,19 @@ export class PanelCtrl {
         click: 'ctrl.duplicate()',
         role: 'Editor',
       });
+
+      menu.push({
+        text: 'Add to Panel List',
+        click: 'ctrl.addToPanelList()',
+        role: 'Editor',
+      });
     }
+
     menu.push({
       text: 'Panel JSON',
       click: 'ctrl.editPanelJson(); dismiss();',
     });
+
     this.events.emit('init-panel-actions', menu);
     return menu;
   }
@@ -263,6 +272,7 @@ export class PanelCtrl {
     let editScope = this.$scope.$root.$new();
     editScope.object = this.panel.getSaveModel();
     editScope.updateHandler = this.replacePanel.bind(this);
+    editScope.enableCopy = true;
 
     this.publishAppEvent('show-modal', {
       src: 'public/app/partials/edit_json.html',
@@ -270,6 +280,11 @@ export class PanelCtrl {
     });
   }
 
+  addToPanelList() {
+    store.set(LS_PANEL_COPY_KEY, JSON.stringify(this.panel.getSaveModel()));
+    appEvents.emit('alert-success', ['Panel temporarily added to panel list']);
+  }
+
   replacePanel(newPanel, oldPanel) {
     let dashboard = this.dashboard;
     let index = _.findIndex(dashboard.panels, panel => {

+ 0 - 153
public/app/features/plugins/datasource_srv.js

@@ -1,153 +0,0 @@
-define([
-  'angular',
-  'lodash',
-  'app/core/core_module',
-  'app/core/config',
-  './plugin_loader',
-],
-function (angular, _, coreModule, config, pluginLoader) {
-  'use strict';
-
-  config = config.default;
-
-  coreModule.default.service('datasourceSrv', function($q, $injector, $rootScope, templateSrv) {
-    var self = this;
-
-    this.init = function() {
-      this.datasources = {};
-    };
-
-    this.get = function(name) {
-      if (!name) {
-        return this.get(config.defaultDatasource);
-      }
-
-      name = templateSrv.replace(name);
-
-      if (name === 'default') {
-        return this.get(config.defaultDatasource);
-      }
-
-      if (this.datasources[name]) {
-        return $q.when(this.datasources[name]);
-      }
-
-      return this.loadDatasource(name);
-    };
-
-    this.loadDatasource = function(name) {
-      var dsConfig = config.datasources[name];
-      if (!dsConfig) {
-        return $q.reject({message: "Datasource named " + name + " was not found"});
-      }
-
-      var deferred = $q.defer();
-      var pluginDef = dsConfig.meta;
-
-      pluginLoader.importPluginModule(pluginDef.module).then(function(plugin) {
-        // check if its in cache now
-        if (self.datasources[name]) {
-          deferred.resolve(self.datasources[name]);
-          return;
-        }
-
-        // plugin module needs to export a constructor function named Datasource
-        if (!plugin.Datasource) {
-          throw "Plugin module is missing Datasource constructor";
-        }
-
-        var instance = $injector.instantiate(plugin.Datasource, {instanceSettings: dsConfig});
-        instance.meta = pluginDef;
-        instance.name = name;
-        self.datasources[name] = instance;
-        deferred.resolve(instance);
-      }).catch(function(err) {
-        $rootScope.appEvent('alert-error', [dsConfig.name + ' plugin failed', err.toString()]);
-      });
-
-      return deferred.promise;
-    };
-
-    this.getAll = function() {
-      return config.datasources;
-    };
-
-    this.getAnnotationSources = function() {
-      var sources = [];
-
-      this.addDataSourceVariables(sources);
-
-      _.each(config.datasources, function(value) {
-        if (value.meta && value.meta.annotations) {
-          sources.push(value);
-        }
-      });
-
-      return sources;
-    };
-
-    this.getMetricSources = function(options) {
-      var metricSources = [];
-
-      _.each(config.datasources, function(value, key) {
-        if (value.meta && value.meta.metrics) {
-          metricSources.push({value: key, name: key, meta: value.meta});
-
-          if (key === config.defaultDatasource) {
-            metricSources.push({value: null, name: 'default', meta: value.meta});
-          }
-        }
-      });
-
-      if (!options || !options.skipVariables) {
-        this.addDataSourceVariables(metricSources);
-      }
-
-      metricSources.sort(function(a, b) {
-        // these two should always be at the bottom
-        if (a.meta.id === "mixed" || a.meta.id === "grafana") {
-          return 1;
-        }
-        if (b.meta.id === "mixed" || b.meta.id === "grafana") {
-          return -1;
-        }
-        if (a.name.toLowerCase() > b.name.toLowerCase()) {
-          return 1;
-        }
-        if (a.name.toLowerCase() < b.name.toLowerCase()) {
-          return -1;
-        }
-        return 0;
-      });
-
-      return metricSources;
-    };
-
-    this.addDataSourceVariables = function(list) {
-      // look for data source variables
-      for (var i = 0; i < templateSrv.variables.length; i++) {
-        var variable = templateSrv.variables[i];
-        if (variable.type !== 'datasource') {
-          continue;
-        }
-
-        var first = variable.current.value;
-        if (first === 'default') {
-          first = config.defaultDatasource;
-        }
-
-        var ds = config.datasources[first];
-
-        if (ds) {
-          list.push({
-            name: '$' + variable.name,
-            value: '$' + variable.name,
-            meta: ds.meta,
-          });
-        }
-      }
-    };
-
-    this.init();
-  });
-});

+ 152 - 0
public/app/features/plugins/datasource_srv.ts

@@ -0,0 +1,152 @@
+import _ from 'lodash';
+import coreModule from 'app/core/core_module';
+import config from 'app/core/config';
+import { importPluginModule } from './plugin_loader';
+
+export class DatasourceSrv {
+  datasources: any;
+
+  /** @ngInject */
+  constructor(private $q, private $injector, $rootScope, private templateSrv) {
+    this.init();
+  }
+
+  init() {
+    this.datasources = {};
+  }
+
+  get(name) {
+    if (!name) {
+      return this.get(config.defaultDatasource);
+    }
+
+    name = this.templateSrv.replace(name);
+
+    if (name === 'default') {
+      return this.get(config.defaultDatasource);
+    }
+
+    if (this.datasources[name]) {
+      return this.$q.when(this.datasources[name]);
+    }
+
+    return this.loadDatasource(name);
+  }
+
+  loadDatasource(name) {
+    var dsConfig = config.datasources[name];
+    if (!dsConfig) {
+      return this.$q.reject({ message: 'Datasource named ' + name + ' was not found' });
+    }
+
+    var deferred = this.$q.defer();
+    var pluginDef = dsConfig.meta;
+
+    importPluginModule(pluginDef.module)
+      .then(plugin => {
+        // check if its in cache now
+        if (this.datasources[name]) {
+          deferred.resolve(this.datasources[name]);
+          return;
+        }
+
+        // plugin module needs to export a constructor function named Datasource
+        if (!plugin.Datasource) {
+          throw new Error('Plugin module is missing Datasource constructor');
+        }
+
+        var instance = this.$injector.instantiate(plugin.Datasource, { instanceSettings: dsConfig });
+        instance.meta = pluginDef;
+        instance.name = name;
+        this.datasources[name] = instance;
+        deferred.resolve(instance);
+      })
+      .catch(function(err) {
+        this.$rootScope.appEvent('alert-error', [dsConfig.name + ' plugin failed', err.toString()]);
+      });
+
+    return deferred.promise;
+  }
+
+  getAll() {
+    return config.datasources;
+  }
+
+  getAnnotationSources() {
+    var sources = [];
+
+    this.addDataSourceVariables(sources);
+
+    _.each(config.datasources, function(value) {
+      if (value.meta && value.meta.annotations) {
+        sources.push(value);
+      }
+    });
+
+    return sources;
+  }
+
+  getMetricSources(options) {
+    var metricSources = [];
+
+    _.each(config.datasources, function(value, key) {
+      if (value.meta && value.meta.metrics) {
+        metricSources.push({ value: key, name: key, meta: value.meta });
+
+        if (key === config.defaultDatasource) {
+          metricSources.push({ value: null, name: 'default', meta: value.meta });
+        }
+      }
+    });
+
+    if (!options || !options.skipVariables) {
+      this.addDataSourceVariables(metricSources);
+    }
+
+    metricSources.sort(function(a, b) {
+      // these two should always be at the bottom
+      if (a.meta.id === 'mixed' || a.meta.id === 'grafana') {
+        return 1;
+      }
+      if (b.meta.id === 'mixed' || b.meta.id === 'grafana') {
+        return -1;
+      }
+      if (a.name.toLowerCase() > b.name.toLowerCase()) {
+        return 1;
+      }
+      if (a.name.toLowerCase() < b.name.toLowerCase()) {
+        return -1;
+      }
+      return 0;
+    });
+
+    return metricSources;
+  }
+
+  addDataSourceVariables(list) {
+    // look for data source variables
+    for (var i = 0; i < this.templateSrv.variables.length; i++) {
+      var variable = this.templateSrv.variables[i];
+      if (variable.type !== 'datasource') {
+        continue;
+      }
+
+      var first = variable.current.value;
+      if (first === 'default') {
+        first = config.defaultDatasource;
+      }
+
+      var ds = config.datasources[first];
+
+      if (ds) {
+        list.push({
+          name: '$' + variable.name,
+          value: '$' + variable.name,
+          meta: ds.meta,
+        });
+      }
+    }
+  }
+}
+
+coreModule.service('datasourceSrv', DatasourceSrv);

+ 3 - 0
public/app/partials/edit_json.html

@@ -16,6 +16,9 @@
 
 		<div class="gf-form-button-row">
 			<button type="button" class="btn btn-success" ng-show="canUpdate" ng-click="update(); dismiss();">Update</button>
+			<button class="btn btn-secondary" ng-if="canCopy" clipboard-button="getContentForClipboard()">
+				<i class="fa fa-clipboard"></i>&nbsp;Copy to Clipboard
+			</button>
 		</div>
 	</div>
 </div>

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

@@ -12,9 +12,9 @@ export default class PrometheusMetricFindQuery {
   }
 
   process() {
-    var label_values_regex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]+)\)$/;
-    var metric_names_regex = /^metrics\((.+)\)$/;
-    var query_result_regex = /^query_result\((.+)\)$/;
+    var label_values_regex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]+)\)\s*$/;
+    var metric_names_regex = /^metrics\((.+)\)\s*$/;
+    var query_result_regex = /^query_result\((.+)\)\s*$/;
 
     var label_values_query = this.query.match(label_values_regex);
     if (label_values_query) {

+ 1 - 1
public/sass/base/font-awesome/_larger.scss

@@ -8,7 +8,7 @@
   vertical-align: -15%;
 }
 .#{$fa-css-prefix}-2x {
-  font-size: 2em;
+  font-size: 2em !important;
 }
 .#{$fa-css-prefix}-3x {
   font-size: 3em;

+ 4 - 0
public/sass/components/_panel_add_panel.scss

@@ -65,3 +65,7 @@
 .add-panel__item-img {
   height: calc(100% - 15px);
 }
+
+.add-panel__item-icon {
+  padding: 2px;
+}

+ 1 - 0
public/sass/components/_panel_singlestat.scss

@@ -6,6 +6,7 @@
 }
 
 .singlestat-panel-value-container {
+  line-height: 1;
   display: table-cell;
   vertical-align: middle;
   text-align: center;