Browse Source

Merge branch 'master' into alerting_opentsdb

bergquist 9 years ago
parent
commit
2df8c649b7
41 changed files with 1006 additions and 806 deletions
  1. 8 4
      Gruntfile.js
  2. 6 3
      appveyor.yml
  3. 3 0
      build.go
  4. 0 75
      docker/blocks/influxdb/config.toml
  5. 2 1
      docker/blocks/influxdb/fig
  6. 92 0
      docker/blocks/influxdb/influxdb.conf
  7. 1 1
      pkg/api/alerting.go
  8. 16 0
      pkg/api/annotations.go
  9. 3 2
      pkg/api/api.go
  10. 6 0
      pkg/api/dtos/annotations.go
  11. 2 0
      pkg/cmd/grafana-server/main.go
  12. 10 8
      pkg/models/notifications.go
  13. 11 8
      pkg/services/alerting/notifiers/webhook.go
  14. 7 0
      pkg/services/annotations/annotations.go
  15. 10 8
      pkg/services/notifications/notifications.go
  16. 11 6
      pkg/services/notifications/webhook.go
  17. 20 18
      pkg/services/sqlstore/alert.go
  18. 14 0
      pkg/services/sqlstore/annotation.go
  19. 1 0
      pkg/social/google_oauth.go
  20. 5 0
      pkg/tsdb/influxdb/influxdb.go
  21. 4 2
      pkg/tsdb/prometheus/prometheus.go
  22. 1 1
      pkg/tsdb/prometheus/prometheus_test.go
  23. 12 6
      public/app/core/utils/rangeutil.ts
  24. 19 1
      public/app/features/alerting/alert_tab_ctrl.ts
  25. 1 1
      public/app/features/alerting/notification_edit_ctrl.ts
  26. 10 1
      public/app/features/alerting/partials/alert_tab.html
  27. 14 9
      public/app/features/alerting/partials/notification_edit.html
  28. 22 0
      public/app/features/dashboard/alerting_srv.ts
  29. 1 0
      public/app/features/dashboard/all.js
  30. 2 0
      public/app/features/dashboard/dashboard_ctrl.ts
  31. 71 553
      public/app/features/dashboard/dashboard_srv.ts
  32. 5 88
      public/app/features/dashboard/dashnav/dashnav.ts
  33. 576 0
      public/app/features/dashboard/model.ts
  34. 1 1
      public/app/features/dashboard/specs/dashboard_srv_specs.ts
  35. 12 2
      public/app/plugins/datasource/cloudwatch/datasource.js
  36. 1 1
      public/app/plugins/datasource/cloudwatch/partials/config.html
  37. 1 1
      public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json
  38. 5 1
      public/app/plugins/panel/graph/graph.ts
  39. 9 4
      public/app/plugins/panel/graph/graph_tooltip.js
  40. 4 0
      public/sass/components/edit_sidemenu.scss
  41. 7 0
      public/test/core/utils/rangeutil_specs.ts

+ 8 - 4
Gruntfile.js

@@ -12,12 +12,16 @@ module.exports = function (grunt) {
     platform: process.platform.replace('win32', 'windows'),
   };
 
-  if (process.platform.match(/^win/)) {
-    config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86';
+  if (grunt.option('arch')) {
+    config.arch = grunt.option('arch');
+  } else {
+    config.arch = os.arch();
+
+    if (process.platform.match(/^win/)) {
+      config.arch = process.env.hasOwnProperty('ProgramFiles(x86)') ? 'x64' : 'x86';
+    }
   }
 
-  config.arch = grunt.option('arch') || os.arch();
-
   config.phjs = grunt.option('phjsToRelease');
 
   config.pkg.version = grunt.option('pkgVer') || config.pkg.version;

+ 6 - 3
appveyor.yml

@@ -25,10 +25,13 @@ install:
 build_script:
   - go run build.go build
   - grunt release
-  #- 7z a grafana.zip %APPVEYOR_BUILD_FOLDER%\dist\*
+  - go run build.go sha1-dist
   - cp dist/* .
 
 artifacts:
-  - path: grafana-*windows-ia32.zip
-  #- path: dist/*
+  - path: grafana-*windows-*.*
     name: binzip
+
+deploy:
+  - provider: Environment
+    name: GrafanaBuildsS3

+ 3 - 0
build.go

@@ -98,6 +98,9 @@ func main() {
 			createDebPackages()
 			sha1FilesInDist()
 
+    case "sha1-dist":
+      sha1FilesInDist()
+
 		case "latest":
 			makeLatestDistCopies()
 			sha1FilesInDist()

+ 0 - 75
docker/blocks/influxdb/config.toml

@@ -1,75 +0,0 @@
-bind-address = "0.0.0.0"
-
-[logging]
-level  = "debug"
-file   = "/opt/influxdb/shared/data/influxdb.log"         # stdout to log to standard out
-
-[admin]
-port   = 8083              # binding is disabled if the port isn't set
-assets = "/opt/influxdb/current/admin"
-
-[api]
-port     = 8086    # binding is disabled if the port isn't set
-
-read-timeout = "5s"
-
-[input_plugins]
-
-  [input_plugins.graphite]
-  enabled = true
-  port = 2004
-  database = "graphite"  # store graphite data in this database
-
-
-[raft]
-port = 8090
-dir  = "/opt/influxdb/shared/data/raft"
-
-[storage]
-dir = "/opt/influxdb/shared/data/db"
-# How many requests to potentially buffer in memory. If the buffer gets filled then writes
-# will still be logged and once the local storage has caught up (or compacted) the writes
-# will be replayed from the WAL
-write-buffer-size = 10000
-default-engine = "rocksdb"
-max-open-shards = 0
-point-batch-size = 100
-write-batch-size = 5000000
-retention-sweep-period = "10m"
-
-[storage.engines.rocksdb]
-max-open-files = 1000
-lru-cache-size = "200m"
-
-[storage.engines.leveldb]
-max-open-files = 1000
-lru-cache-size = "200m"
-
-[cluster]
-protobuf_port = 8099
-protobuf_timeout = "2s" # the write timeout on the protobuf conn any duration parseable by time.ParseDuration
-protobuf_heartbeat = "200ms" # the heartbeat interval between the servers. must be parseable by time.ParseDuration
-protobuf_min_backoff = "1s" # the minimum backoff after a failed heartbeat attempt
-protobuf_max_backoff = "10s" # the maxmimum backoff after a failed heartbeat attempt
-write-buffer-size = 10000
-ax-response-buffer-size = 100000
-oncurrent-shard-query-limit = 10
-
-[sharding]
-  replication-factor = 1
-
-  [sharding.short-term]
-  duration = "7d"
-  split = 1
-
-  [sharding.long-term]
-  duration = "30d"
-  split = 1
-  # split-random = "/^Hf.*/"
-
-[wal]
-dir   = "/opt/influxdb/shared/data/wal"
-flush-after = 1000 # the number of writes after which wal will be flushed, 0 for flushing on every write
-bookmark-after = 1000 # the number of writes after which a bookmark will be created
-index-after = 1000
-requests-per-logfile = 10000

+ 2 - 1
docker/blocks/influxdb/fig

@@ -1,11 +1,12 @@
 influxdb:
-  #image: influxdb/influxdb:1.0-alpine
   image: influxdb:latest
   container_name: influxdb
   ports:
     - "2004:2004"
     - "8083:8083"
     - "8086:8086"
+  volumes:
+    - ./blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
 
 fake-influxdb-data:
   image: grafana/fake-data-gen

+ 92 - 0
docker/blocks/influxdb/influxdb.conf

@@ -0,0 +1,92 @@
+reporting-disabled = false
+
+[meta]
+  # Where the metadata/raft database is stored
+  dir = "/var/lib/influxdb/meta"
+
+  retention-autocreate = true
+
+  # If log messages are printed for the meta service
+  logging-enabled = true
+  pprof-enabled = false
+
+  # The default duration for leases.
+  lease-duration = "1m0s"
+
+[data]
+  # Controls if this node holds time series data shards in the cluster
+  enabled = true
+
+  dir = "/var/lib/influxdb/data"
+
+  # These are the WAL settings for the storage engine >= 0.9.3
+  wal-dir = "/var/lib/influxdb/wal"
+  wal-logging-enabled = true
+
+
+[coordinator]
+  write-timeout = "10s"
+  max-concurrent-queries = 0
+  query-timeout = "0"
+  log-queries-after = "0"
+  max-select-point = 0
+  max-select-series = 0
+  max-select-buckets = 0
+
+[retention]
+  enabled = true
+  check-interval = "30m"
+
+[shard-precreation]
+  enabled = true
+  check-interval = "10m"
+  advance-period = "30m"
+
+[monitor]
+  store-enabled = true # Whether to record statistics internally.
+  store-database = "_internal" # The destination database for recorded statistics
+  store-interval = "10s" # The interval at which to record statistics
+
+[admin]
+  enabled = true
+  bind-address = ":8083"
+  https-enabled = false
+  https-certificate = "/etc/ssl/influxdb.pem"
+
+[http]
+  enabled = true
+  bind-address = ":8086"
+  auth-enabled = true
+  log-enabled = true
+  write-tracing = false
+  pprof-enabled = false
+  https-enabled = false
+  https-certificate = "/etc/ssl/influxdb.pem"
+  ### Use a separate private key location.
+  # https-private-key = ""
+  max-row-limit = 10000
+  realm = "InfluxDB"
+
+  unix-socket-enabled = false # enable http service over unix domain socket
+  # bind-socket = "/var/run/influxdb.sock"
+
+[subscriber]
+  enabled = true
+
+[[graphite]]
+  enabled = false
+
+[[collectd]]
+  enabled = false
+
+[[opentsdb]]
+  enabled = false
+
+[[udp]]
+  enabled = false
+
+[continuous_queries]
+  log-enabled = true
+  enabled = true
+  # run-interval = "1s" # interval for how often continuous queries will be checked if they need to run
+

+ 1 - 1
pkg/api/alerting.go

@@ -252,7 +252,7 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R
 	return ApiSuccess("Test notification sent")
 }
 
-//POST /api/:alertId/pause
+//POST /api/alerts/:alertId/pause
 func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response {
 	cmd := models.PauseAlertCommand{
 		OrgId:   c.OrgId,

+ 16 - 0
pkg/api/annotations.go

@@ -44,3 +44,19 @@ func GetAnnotations(c *middleware.Context) Response {
 
 	return Json(200, result)
 }
+
+func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response {
+	repo := annotations.GetRepository()
+
+	err := repo.Delete(&annotations.DeleteParams{
+		AlertId:     cmd.PanelId,
+		DashboardId: cmd.DashboardId,
+		PanelId:     cmd.PanelId,
+	})
+
+	if err != nil {
+		return ApiError(500, "Failed to delete annotations", err)
+	}
+
+	return ApiSuccess("Annotations deleted")
+}

+ 3 - 2
pkg/api/api.go

@@ -252,7 +252,7 @@ func Register(r *macaron.Macaron) {
 
 		r.Group("/alerts", func() {
 			r.Post("/test", bind(dtos.AlertTestCommand{}), wrap(AlertTest))
-			r.Post("/:alertId/pause", ValidateOrgAlert, bind(dtos.PauseAlertCommand{}), wrap(PauseAlert))
+			r.Post("/:alertId/pause", bind(dtos.PauseAlertCommand{}), wrap(PauseAlert), reqEditorRole)
 			r.Get("/:alertId", ValidateOrgAlert, wrap(GetAlert))
 			r.Get("/", wrap(GetAlerts))
 			r.Get("/states-for-dashboard", wrap(GetAlertStatesForDashboard))
@@ -266,9 +266,10 @@ func Register(r *macaron.Macaron) {
 			r.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), wrap(UpdateAlertNotification))
 			r.Get("/:notificationId", wrap(GetAlertNotificationById))
 			r.Delete("/:notificationId", wrap(DeleteAlertNotification))
-		}, reqOrgAdmin)
+		}, reqEditorRole)
 
 		r.Get("/annotations", wrap(GetAnnotations))
+		r.Post("/annotations/mass-delete", reqOrgAdmin, bind(dtos.DeleteAnnotationsCmd{}), wrap(DeleteAnnotations))
 
 		// error test
 		r.Get("/metrics/error", wrap(GenerateError))

+ 6 - 0
pkg/api/dtos/annotations.go

@@ -15,3 +15,9 @@ type Annotation struct {
 
 	Data *simplejson.Json `json:"data"`
 }
+
+type DeleteAnnotationsCmd struct {
+	AlertId     int64 `json:"alertId"`
+	DashboardId int64 `json:"dashboardId"`
+	PanelId     int64 `json:"panelId"`
+}

+ 2 - 0
pkg/cmd/grafana-server/main.go

@@ -103,8 +103,10 @@ func writePIDFile() {
 
 func listenToSystemSignals(server models.GrafanaServer) {
 	signalChan := make(chan os.Signal, 1)
+	ignoreChan := make(chan os.Signal, 1)
 	code := 0
 
+	signal.Notify(ignoreChan, syscall.SIGHUP)
 	signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
 
 	select {

+ 10 - 8
pkg/models/notifications.go

@@ -17,17 +17,19 @@ type SendEmailCommandSync struct {
 }
 
 type SendWebhook struct {
-	Url      string
-	User     string
-	Password string
-	Body     string
+	Url        string
+	User       string
+	Password   string
+	Body       string
+	HttpMethod string
 }
 
 type SendWebhookSync struct {
-	Url      string
-	User     string
-	Password string
-	Body     string
+	Url        string
+	User       string
+	Password   string
+	Body       string
+	HttpMethod string
 }
 
 type SendResetPasswordEmailCommand struct {

+ 11 - 8
pkg/services/alerting/notifiers/webhook.go

@@ -24,16 +24,18 @@ func NewWebHookNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
 		Url:          url,
 		User:         model.Settings.Get("user").MustString(),
 		Password:     model.Settings.Get("password").MustString(),
+		HttpMethod:   model.Settings.Get("httpMethod").MustString("POST"),
 		log:          log.New("alerting.notifier.webhook"),
 	}, nil
 }
 
 type WebhookNotifier struct {
 	NotifierBase
-	Url      string
-	User     string
-	Password string
-	log      log.Logger
+	Url        string
+	User       string
+	Password   string
+	HttpMethod string
+	log        log.Logger
 }
 
 func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error {
@@ -59,10 +61,11 @@ func (this *WebhookNotifier) Notify(evalContext *alerting.EvalContext) error {
 	body, _ := bodyJSON.MarshalJSON()
 
 	cmd := &m.SendWebhookSync{
-		Url:      this.Url,
-		User:     this.User,
-		Password: this.Password,
-		Body:     string(body),
+		Url:        this.Url,
+		User:       this.User,
+		Password:   this.Password,
+		Body:       string(body),
+		HttpMethod: this.HttpMethod,
 	}
 
 	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {

+ 7 - 0
pkg/services/annotations/annotations.go

@@ -5,6 +5,7 @@ import "github.com/grafana/grafana/pkg/components/simplejson"
 type Repository interface {
 	Save(item *Item) error
 	Find(query *ItemQuery) ([]*Item, error)
+	Delete(params *DeleteParams) error
 }
 
 type ItemQuery struct {
@@ -20,6 +21,12 @@ type ItemQuery struct {
 	Limit int64 `json:"alertId"`
 }
 
+type DeleteParams struct {
+	AlertId     int64 `json:"alertId"`
+	DashboardId int64 `json:"dashboardId"`
+	PanelId     int64 `json:"panelId"`
+}
+
 var repositoryInstance Repository
 
 func GetRepository() Repository {

+ 10 - 8
pkg/services/notifications/notifications.go

@@ -61,19 +61,21 @@ func Init() error {
 
 func SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {
 	return sendWebRequestSync(ctx, &Webhook{
-		Url:      cmd.Url,
-		User:     cmd.User,
-		Password: cmd.Password,
-		Body:     cmd.Body,
+		Url:        cmd.Url,
+		User:       cmd.User,
+		Password:   cmd.Password,
+		Body:       cmd.Body,
+		HttpMethod: cmd.HttpMethod,
 	})
 }
 
 func sendWebhook(cmd *m.SendWebhook) error {
 	addToWebhookQueue(&Webhook{
-		Url:      cmd.Url,
-		User:     cmd.User,
-		Password: cmd.Password,
-		Body:     cmd.Body,
+		Url:        cmd.Url,
+		User:       cmd.User,
+		Password:   cmd.Password,
+		Body:       cmd.Body,
+		HttpMethod: cmd.HttpMethod,
 	})
 
 	return nil

+ 11 - 6
pkg/services/notifications/webhook.go

@@ -15,10 +15,11 @@ import (
 )
 
 type Webhook struct {
-	Url      string
-	User     string
-	Password string
-	Body     string
+	Url        string
+	User       string
+	Password   string
+	Body       string
+	HttpMethod string
 }
 
 var webhookQueue chan *Webhook
@@ -44,13 +45,17 @@ func processWebhookQueue() {
 }
 
 func sendWebRequestSync(ctx context.Context, webhook *Webhook) error {
-	webhookLog.Debug("Sending webhook", "url", webhook.Url)
+	webhookLog.Debug("Sending webhook", "url", webhook.Url, "http method", webhook.HttpMethod)
 
 	client := &http.Client{
 		Timeout: time.Duration(10 * time.Second),
 	}
 
-	request, err := http.NewRequest(http.MethodPost, webhook.Url, bytes.NewReader([]byte(webhook.Body)))
+	if webhook.HttpMethod == "" {
+		webhook.HttpMethod = http.MethodPost
+	}
+
+	request, err := http.NewRequest(webhook.HttpMethod, webhook.Url, bytes.NewReader([]byte(webhook.Body)))
 	if webhook.User != "" && webhook.Password != "" {
 		request.Header.Add("Authorization", util.GetBasicAuthHeader(webhook.User, webhook.Password))
 	}

+ 20 - 18
pkg/services/sqlstore/alert.go

@@ -46,13 +46,23 @@ func GetAllAlertQueryHandler(query *m.GetAllAlertsQuery) error {
 	return nil
 }
 
+func deleteAlertByIdInternal(alertId int64, reason string, sess *xorm.Session) error {
+	sqlog.Debug("Deleting alert", "id", alertId, "reason", reason)
+
+	if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", alertId); err != nil {
+		return err
+	}
+
+	if _, err := sess.Exec("DELETE FROM annotation WHERE alert_id = ?", alertId); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func DeleteAlertById(cmd *m.DeleteAlertCommand) error {
 	return inTransaction(func(sess *xorm.Session) error {
-		if _, err := sess.Exec("DELETE FROM alert WHERE id = ?", cmd.AlertId); err != nil {
-			return err
-		}
-
-		return nil
+		return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess)
 	})
 }
 
@@ -110,12 +120,7 @@ func DeleteAlertDefinition(dashboardId int64, sess *xorm.Session) error {
 	sess.Where("dashboard_id = ?", dashboardId).Find(&alerts)
 
 	for _, alert := range alerts {
-		_, err := sess.Exec("DELETE FROM alert WHERE id = ? ", alert.Id)
-		if err != nil {
-			return err
-		}
-
-		sqlog.Debug("Alert deleted (due to dashboard deletion)", "name", alert.Name, "id", alert.Id)
+		deleteAlertByIdInternal(alert.Id, "Dashboard deleted", sess)
 	}
 
 	return nil
@@ -195,12 +200,7 @@ func deleteMissingAlerts(alerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *xorm
 		}
 
 		if missing {
-			_, err := sess.Exec("DELETE FROM alert WHERE id = ?", missingAlert.Id)
-			if err != nil {
-				return err
-			}
-
-			sqlog.Debug("Alert deleted", "name", missingAlert.Name, "id", missingAlert.Id)
+			deleteAlertByIdInternal(missingAlert.Id, "Removed from dashboard", sess)
 		}
 	}
 
@@ -248,7 +248,9 @@ func PauseAlertRule(cmd *m.PauseAlertCommand) error {
 	return inTransaction(func(sess *xorm.Session) error {
 		alert := m.Alert{}
 
-		if has, err := sess.Id(cmd.AlertId).Get(&alert); err != nil {
+		has, err := x.Where("id = ? AND org_id=?", cmd.AlertId, cmd.OrgId).Get(&alert)
+
+		if err != nil {
 			return err
 		} else if !has {
 			return fmt.Errorf("Could not find alert")

+ 14 - 0
pkg/services/sqlstore/annotation.go

@@ -84,3 +84,17 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 
 	return items, nil
 }
+
+func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error {
+	return inTransaction(func(sess *xorm.Session) error {
+
+		sql := "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
+
+		_, err := sess.Exec(sql, params.DashboardId, params.PanelId)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	})
+}

+ 1 - 0
pkg/social/google_oauth.go

@@ -46,5 +46,6 @@ func (s *SocialGoogle) UserInfo(client *http.Client) (*BasicUserInfo, error) {
 	return &BasicUserInfo{
 		Name:     data.Name,
 		Email:    data.Email,
+		Login:    data.Email,
 	}, nil
 }

+ 5 - 0
pkg/tsdb/influxdb/influxdb.go

@@ -124,10 +124,15 @@ func (e *InfluxDBExecutor) createRequest(query string) (*http.Request, error) {
 	req.URL.RawQuery = params.Encode()
 
 	req.Header.Set("User-Agent", "Grafana")
+
 	if e.BasicAuth {
 		req.SetBasicAuth(e.BasicAuthUser, e.BasicAuthPassword)
 	}
 
+	if e.User != "" {
+		req.SetBasicAuth(e.User, e.Password)
+	}
+
 	glog.Debug("Influxdb request", "url", req.URL.String())
 	return req, nil
 }

+ 4 - 2
pkg/tsdb/prometheus/prometheus.go

@@ -84,8 +84,10 @@ func formatLegend(metric pmodel.Metric, query *PrometheusQuery) string {
 	reg, _ := regexp.Compile(`\{\{\s*(.+?)\s*\}\}`)
 
 	result := reg.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte {
-		ind := strings.Replace(strings.Replace(string(in), "{{", "", 1), "}}", "", 1)
-		if val, exists := metric[pmodel.LabelName(ind)]; exists {
+		labelName := strings.Replace(string(in), "{{", "", 1)
+		labelName = strings.Replace(labelName, "}}", "", 1)
+		labelName = strings.TrimSpace(labelName)
+		if val, exists := metric[pmodel.LabelName(labelName)]; exists {
 			return []byte(val)
 		}
 

+ 1 - 1
pkg/tsdb/prometheus/prometheus_test.go

@@ -17,7 +17,7 @@ func TestPrometheus(t *testing.T) {
 			}
 
 			query := &PrometheusQuery{
-				LegendFormat: "legend {{app}} {{device}} {{broken}}",
+				LegendFormat: "legend {{app}} {{ device }} {{broken}}",
 			}
 
 			So(formatLegend(metric, query), ShouldEqual, "legend backend mobile {{broken}}")

+ 12 - 6
public/app/core/utils/rangeutil.ts

@@ -82,8 +82,9 @@ function formatDate(date) {
 // now/d
 // if no to <expr> then to now is assumed
 export function describeTextRange(expr: any) {
+  let isLast = (expr.indexOf('+') !== 0);
   if (expr.indexOf('now') === -1) {
-    expr = 'now-' + expr;
+    expr = (isLast ? 'now-' : 'now') + expr;
   }
 
   let opt = rangeIndex[expr + ' to now'];
@@ -91,15 +92,20 @@ export function describeTextRange(expr: any) {
     return opt;
   }
 
-  opt = {from: expr, to: 'now'};
+  if (isLast) {
+    opt = {from: expr, to: 'now'};
+  } else {
+    opt = {from: 'now', to: expr};
+  }
 
-  let parts = /^now-(\d+)(\w)/.exec(expr);
+  let parts = /^now([-+])(\d+)(\w)/.exec(expr);
   if (parts) {
-    let unit = parts[2];
-    let amount = parseInt(parts[1]);
+    let unit = parts[3];
+    let amount = parseInt(parts[2]);
     let span = spans[unit];
     if (span) {
-      opt.display = 'Last ' + amount + ' ' + span.display;
+      opt.display = isLast ? 'Last ' : 'Next ';
+      opt.display += amount + ' ' + span.display;
       opt.section = span.section;
       if (amount > 1) {
         opt.display += 's';

+ 19 - 1
public/app/features/alerting/alert_tab_ctrl.ts

@@ -59,7 +59,7 @@ export class AlertTabCtrl {
       this.panelCtrl.render();
     });
 
-       // build notification model
+    // build notification model
     this.notifications = [];
     this.alertNotifications = [];
     this.alertHistory = [];
@@ -352,6 +352,24 @@ export class AlertTabCtrl {
     this.evaluatorParamsChanged();
   }
 
+  clearHistory() {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete Alert History',
+      text: 'Are you sure you want to remove all history & annotations for this alert?',
+      icon: 'fa-trash',
+      yesText: 'Yes',
+      onConfirm: () => {
+        this.backendSrv.post('/api/annotations/mass-delete', {
+          dashboardId: this.panelCtrl.dashboard.id,
+          panelId: this.panel.id,
+        }).then(res => {
+          this.alertHistory = [];
+          this.panelCtrl.refresh();
+        });
+      }
+    });
+  }
+
   test() {
     this.testing = true;
 

+ 1 - 1
public/app/features/alerting/notification_edit_ctrl.ts

@@ -18,7 +18,7 @@ export class AlertNotificationEditCtrl {
       this.model = {
         type: 'email',
         settings: {
-          severityFilter: 'none'
+          httpMethod: 'POST'
         },
         isDefault: false
       };

+ 10 - 1
public/app/features/alerting/partials/alert_tab.html

@@ -125,7 +125,16 @@
 		</div>
 
 		<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
-      <h5 class="section-heading">State history <span class="muted small">(last 50 state changes)</span></h5>
+			<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
+      <h5 class="section-heading" style="whitespace: nowrap">
+				State history <span class="muted small">(last 50 state changes)</span>
+			</h5>
+
+      <div ng-show="ctrl.alertHistory.length === 0">
+        <br>
+        <i>No state changes recorded</i>
+      </div>
+
 			<section class="card-section card-list-layout-list">
 				<ol class="card-list" >
 					<li class="card-item-wrapper" ng-repeat="ah in ctrl.alertHistory">

+ 14 - 9
public/app/features/alerting/partials/notification_edit.html

@@ -32,19 +32,24 @@
     <div class="gf-form-group" ng-if="ctrl.model.type === 'webhook'">
       <h3 class="page-heading">Webhook settings</h3>
       <div class="gf-form">
-        <span class="gf-form-label width-6">Url</span>
+        <span class="gf-form-label width-10">Url</span>
         <input type="text" required class="gf-form-input max-width-26" ng-model="ctrl.model.settings.url"></input>
       </div>
-      <div class="gf-form-inline">
-        <div class="gf-form">
-          <span class="gf-form-label width-6">Username</span>
-          <input type="text" class="gf-form-input max-width-10" ng-model="ctrl.model.settings.username"></input>
-        </div>
-        <div class="gf-form">
-          <span class="gf-form-label width-6">Password</span>
-          <input type="text" class="gf-form-input max-width-10" ng-model="ctrl.model.settings.password"></input>
+      <div class="gf-form">
+        <span class="gf-form-label width-10">Http Method</span>
+        <div class="gf-form-select-wrapper width-14">
+          <select class="gf-form-input" ng-model="ctrl.model.settings.httpMethod" ng-options="t for t in ['POST', 'PUT']">
+          </select>
         </div>
       </div>
+      <div class="gf-form">
+        <span class="gf-form-label width-10">Username</span>
+        <input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.username"></input>
+      </div>
+      <div class="gf-form">
+        <span class="gf-form-label width-10">Password</span>
+        <input type="text" class="gf-form-input max-width-14" ng-model="ctrl.model.settings.password"></input>
+      </div>
     </div>
 
     <div class="gf-form-group" ng-if="ctrl.model.type === 'slack'">

+ 22 - 0
public/app/features/dashboard/alerting_srv.ts

@@ -0,0 +1,22 @@
+///<reference path="../../headers/common.d.ts" />
+
+import config from 'app/core/config';
+import angular from 'angular';
+import moment from 'moment';
+import _ from 'lodash';
+
+import coreModule from 'app/core/core_module';
+
+export class AlertingSrv {
+  dashboard: any;
+  alerts: any[];
+
+  init(dashboard, alerts) {
+    this.dashboard = dashboard;
+    this.alerts = alerts || [];
+  }
+}
+
+
+coreModule.service('alertingSrv', AlertingSrv);
+

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

@@ -1,5 +1,6 @@
 define([
   './dashboard_ctrl',
+  './alerting_srv',
   './dashboardLoaderSrv',
   './dashnav/dashnav',
   './submenu/submenu',

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

@@ -16,6 +16,7 @@ export class DashboardCtrl {
     dashboardKeybindings,
     timeSrv,
     variableSrv,
+    alertingSrv,
     dashboardSrv,
     unsavedChangesSrv,
     dynamicDashboardSrv,
@@ -43,6 +44,7 @@ export class DashboardCtrl {
 
         // init services
         timeSrv.init(dashboard);
+        alertingSrv.init(dashboard, data.alerts);
 
         // template values service needs to initialize completely before
         // the rest of the dashboard can load

+ 71 - 553
public/app/features/dashboard/dashboard_srv.ts

@@ -1,595 +1,113 @@
 ///<reference path="../../headers/common.d.ts" />
 
-import config from 'app/core/config';
-import angular from 'angular';
-import moment from 'moment';
 import _ from 'lodash';
-import $ from 'jquery';
-
-import {Emitter} from 'app/core/core';
-import {contextSrv} from 'app/core/services/context_srv';
 import coreModule from 'app/core/core_module';
+import {DashboardModel} from './model';
 
-export class DashboardModel {
-  id: any;
-  title: any;
-  autoUpdate: any;
-  description: any;
-  tags: any;
-  style: any;
-  timezone: any;
-  editable: any;
-  hideControls: any;
-  sharedCrosshair: any;
-  rows: any;
-  time: any;
-  timepicker: any;
-  templating: any;
-  annotations: any;
-  refresh: any;
-  snapshot: any;
-  schemaVersion: number;
-  version: number;
-  revision: number;
-  links: any;
-  gnetId: any;
-  meta: any;
-  events: any;
-
-  constructor(data, meta) {
-    if (!data) {
-      data = {};
-    }
-
-    this.events = new Emitter();
-    this.id = data.id || null;
-    this.revision = data.revision;
-    this.title = data.title || 'No Title';
-    this.autoUpdate = data.autoUpdate;
-    this.description = data.description;
-    this.tags = data.tags || [];
-    this.style = data.style || "dark";
-    this.timezone = data.timezone || '';
-    this.editable = data.editable !== false;
-    this.hideControls = data.hideControls || false;
-    this.sharedCrosshair = data.sharedCrosshair || false;
-    this.rows = data.rows || [];
-    this.time = data.time || { from: 'now-6h', to: 'now' };
-    this.timepicker = data.timepicker || {};
-    this.templating = this.ensureListExist(data.templating);
-    this.annotations = this.ensureListExist(data.annotations);
-    this.refresh = data.refresh;
-    this.snapshot = data.snapshot;
-    this.schemaVersion = data.schemaVersion || 0;
-    this.version = data.version || 0;
-    this.links = data.links || [];
-    this.gnetId = data.gnetId || null;
-
-    this.updateSchema(data);
-    this.initMeta(meta);
-  }
-
-  private initMeta(meta) {
-    meta = meta || {};
-
-    meta.canShare = meta.canShare !== false;
-    meta.canSave = meta.canSave !== false;
-    meta.canStar = meta.canStar !== false;
-    meta.canEdit = meta.canEdit !== false;
-
-    if (!this.editable) {
-      meta.canEdit = false;
-      meta.canDelete = false;
-      meta.canSave = false;
-      this.hideControls = true;
-    }
+export class DashboardSrv {
+  dash: any;
 
-    this.meta = meta;
+  /** @ngInject */
+  constructor(private backendSrv, private $rootScope, private $location) {
   }
 
-  // cleans meta data and other non peristent state
-  getSaveModelClone() {
-    // temp remove stuff
-    var events = this.events;
-    var meta = this.meta;
-    delete this.events;
-    delete this.meta;
-
-    events.emit('prepare-save-model');
-    var copy = $.extend(true, {}, this);
-
-    // restore properties
-    this.events = events;
-    this.meta = meta;
-    return copy;
+  create(dashboard, meta) {
+    return new DashboardModel(dashboard, meta);
   }
 
-  private ensureListExist(data) {
-    if (!data) { data = {}; }
-    if (!data.list) { data.list = []; }
-    return data;
+  setCurrent(dashboard) {
+    this.dash = dashboard;
   }
 
-  getNextPanelId() {
-    var i, j, row, panel, max = 0;
-    for (i = 0; i < this.rows.length; i++) {
-      row = this.rows[i];
-      for (j = 0; j < row.panels.length; j++) {
-        panel = row.panels[j];
-        if (panel.id > max) { max = panel.id; }
-      }
-    }
-    return max + 1;
+  getCurrent() {
+    return this.dash;
   }
 
-  forEachPanel(callback) {
-    var i, j, row;
-    for (i = 0; i < this.rows.length; i++) {
-      row = this.rows[i];
-      for (j = 0; j < row.panels.length; j++) {
-        callback(row.panels[j], j, row, i);
-      }
+  saveDashboard(options) {
+    if (!this.dash.meta.canSave && options.makeEditable !== true) {
+      return Promise.resolve();
     }
-  }
 
-  getPanelById(id) {
-    for (var i = 0; i < this.rows.length; i++) {
-      var row = this.rows[i];
-      for (var j = 0; j < row.panels.length; j++) {
-        var panel = row.panels[j];
-        if (panel.id === id) {
-          return panel;
-        }
-      }
-    }
-    return null;
-  }
+    var clone = this.dash.getSaveModelClone();
 
-  rowSpan(row) {
-    return _.reduce(row.panels, function(p,v) {
-      return p + v.span;
-    },0);
-  };
+    return this.backendSrv.saveDashboard(clone, options).then(data => {
+      this.dash.version = data.version;
 
-  addPanel(panel, row) {
-    var rowSpan = this.rowSpan(row);
-    var panelCount = row.panels.length;
-    var space = (12 - rowSpan) - panel.span;
-    panel.id = this.getNextPanelId();
+      this.$rootScope.appEvent('dashboard-saved', this.dash);
 
-    // try to make room of there is no space left
-    if (space <= 0) {
-      if (panelCount === 1) {
-        row.panels[0].span = 6;
-        panel.span = 6;
-      } else if (panelCount === 2) {
-        row.panels[0].span = 4;
-        row.panels[1].span = 4;
-        panel.span = 4;
+      var dashboardUrl = '/dashboard/db/' + data.slug;
+      if (dashboardUrl !== this.$location.path()) {
+        this.$location.url(dashboardUrl);
       }
-    }
 
-    row.panels.push(panel);
+      this.$rootScope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
+    }).catch(this.handleSaveDashboardError.bind(this));
   }
 
-  isSubmenuFeaturesEnabled() {
-    var visableTemplates = _.filter(this.templating.list, function(template) {
-      return template.hideVariable === undefined || template.hideVariable === false;
-    });
+  handleSaveDashboardError(err) {
+    if (err.data && err.data.status === "version-mismatch") {
+      err.isHandled = true;
 
-    return visableTemplates.length > 0 || this.annotations.list.length > 0 || this.links.length > 0;
-  }
-
-  getPanelInfoById(panelId) {
-    var result: any = {};
-    _.each(this.rows, function(row) {
-      _.each(row.panels, function(panel, index) {
-        if (panel.id === panelId) {
-          result.panel = panel;
-          result.row = row;
-          result.index = index;
+      this.$rootScope.appEvent('confirm-modal', {
+        title: 'Conflict',
+        text: 'Someone else has updated this dashboard.',
+        text2: 'Would you still like to save this dashboard?',
+        yesText: "Save & Overwrite",
+        icon: "fa-warning",
+        onConfirm: () => {
+          this.saveDashboard({overwrite: true});
         }
       });
-    });
-
-    if (!result.panel) {
-      return null;
-    }
-
-    return result;
-  }
-
-  duplicatePanel(panel, row) {
-    var rowIndex = _.indexOf(this.rows, row);
-    var newPanel = angular.copy(panel);
-    newPanel.id = this.getNextPanelId();
-
-    delete newPanel.repeat;
-    delete newPanel.repeatIteration;
-    delete newPanel.repeatPanelId;
-    delete newPanel.scopedVars;
-
-    var currentRow = this.rows[rowIndex];
-    currentRow.panels.push(newPanel);
-    return newPanel;
-  }
-
-  formatDate(date, format) {
-    date = moment.isMoment(date) ? date : moment(date);
-    format = format || 'YYYY-MM-DD HH:mm:ss';
-    this.timezone = this.getTimezone();
-
-    return this.timezone === 'browser' ?
-      moment(date).format(format) :
-      moment.utc(date).format(format);
-  }
-
-  getRelativeTime(date) {
-    date = moment.isMoment(date) ? date : moment(date);
-
-    return this.timezone === 'browser' ?
-      moment(date).fromNow() :
-      moment.utc(date).fromNow();
-  }
-
-  getNextQueryLetter(panel) {
-    var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
-
-    return _.find(letters, function(refId) {
-      return _.every(panel.targets, function(other) {
-        return other.refId !== refId;
-      });
-    });
-  }
-
-  isTimezoneUtc() {
-    return this.getTimezone() === 'utc';
-  }
-
-  getTimezone() {
-    return this.timezone ? this.timezone : contextSrv.user.timezone;
-  }
-
-  private updateSchema(old) {
-    var i, j, k;
-    var oldVersion = this.schemaVersion;
-    var panelUpgrades = [];
-    this.schemaVersion = 13;
-
-    if (oldVersion === this.schemaVersion) {
-      return;
     }
 
-    // version 2 schema changes
-    if (oldVersion < 2) {
-
-      if (old.services) {
-        if (old.services.filter) {
-          this.time = old.services.filter.time;
-          this.templating.list = old.services.filter.list || [];
-        }
-      }
-
-      panelUpgrades.push(function(panel) {
-        // rename panel type
-        if (panel.type === 'graphite') {
-          panel.type = 'graph';
-        }
-
-        if (panel.type !== 'graph') {
-          return;
-        }
-
-        if (_.isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; }
-
-        if (panel.grid) {
-          if (panel.grid.min) {
-            panel.grid.leftMin = panel.grid.min;
-            delete panel.grid.min;
-          }
-
-          if (panel.grid.max) {
-            panel.grid.leftMax = panel.grid.max;
-            delete panel.grid.max;
-          }
-        }
-
-        if (panel.y_format) {
-          panel.y_formats[0] = panel.y_format;
-          delete panel.y_format;
-        }
-
-        if (panel.y2_format) {
-          panel.y_formats[1] = panel.y2_format;
-          delete panel.y2_format;
+    if (err.data && err.data.status === "name-exists") {
+      err.isHandled = true;
+
+      this.$rootScope.appEvent('confirm-modal', {
+        title: 'Conflict',
+        text: 'Dashboard with the same name exists.',
+        text2: 'Would you still like to save this dashboard?',
+        yesText: "Save & Overwrite",
+        icon: "fa-warning",
+        onConfirm: () => {
+          this.saveDashboard({overwrite: true});
         }
       });
     }
 
-    // schema version 3 changes
-    if (oldVersion < 3) {
-      // ensure panel ids
-      var maxId = this.getNextPanelId();
-      panelUpgrades.push(function(panel) {
-        if (!panel.id) {
-          panel.id = maxId;
-          maxId += 1;
+    if (err.data && err.data.status === "plugin-dashboard") {
+      err.isHandled = true;
+
+      this.$rootScope.appEvent('confirm-modal', {
+        title: 'Plugin Dashboard',
+        text: err.data.message,
+        text2: 'Your changes will be lost when you update the plugin. Use Save As to create custom version.',
+        yesText: "Overwrite",
+        icon: "fa-warning",
+        altActionText: "Save As",
+        onAltAction: () => {
+          this.saveDashboardAs();
+        },
+        onConfirm: function() {
+          this.saveDashboard({overwrite: true});
         }
       });
     }
-
-    // schema version 4 changes
-    if (oldVersion < 4) {
-      // move aliasYAxis changes
-      panelUpgrades.push(function(panel) {
-        if (panel.type !== 'graph') { return; }
-        _.each(panel.aliasYAxis, function(value, key) {
-          panel.seriesOverrides = [{ alias: key, yaxis: value }];
-        });
-        delete panel.aliasYAxis;
-      });
-    }
-
-    if (oldVersion < 6) {
-      // move pulldowns to new schema
-      var annotations = _.find(old.pulldowns, { type: 'annotations' });
-
-      if (annotations) {
-        this.annotations = {
-          list: annotations.annotations || [],
-        };
-      }
-
-      // update template variables
-      for (i = 0 ; i < this.templating.list.length; i++) {
-        var variable = this.templating.list[i];
-        if (variable.datasource === void 0) { variable.datasource = null; }
-        if (variable.type === 'filter') { variable.type = 'query'; }
-        if (variable.type === void 0) { variable.type = 'query'; }
-        if (variable.allFormat === void 0) { variable.allFormat = 'glob'; }
-      }
-    }
-
-    if (oldVersion < 7) {
-      if (old.nav && old.nav.length) {
-        this.timepicker = old.nav[0];
-      }
-
-      // ensure query refIds
-      panelUpgrades.push(function(panel) {
-        _.each(panel.targets, function(target) {
-          if (!target.refId) {
-            target.refId = this.getNextQueryLetter(panel);
-            }
-          }.bind(this));
-        });
-      }
-
-      if (oldVersion < 8) {
-        panelUpgrades.push(function(panel) {
-          _.each(panel.targets, function(target) {
-            // update old influxdb query schema
-            if (target.fields && target.tags && target.groupBy) {
-              if (target.rawQuery) {
-                delete target.fields;
-                delete target.fill;
-              } else {
-                target.select = _.map(target.fields, function(field) {
-                  var parts = [];
-                  parts.push({type: 'field', params: [field.name]});
-                  parts.push({type: field.func, params: []});
-                  if (field.mathExpr) {
-                    parts.push({type: 'math', params: [field.mathExpr]});
-                  }
-                  if (field.asExpr) {
-                    parts.push({type: 'alias', params: [field.asExpr]});
-                  }
-                  return parts;
-                });
-                delete target.fields;
-                _.each(target.groupBy, function(part) {
-                  if (part.type === 'time' && part.interval)  {
-                    part.params = [part.interval];
-                    delete part.interval;
-                  }
-                  if (part.type === 'tag' && part.key) {
-                    part.params = [part.key];
-                    delete part.key;
-                  }
-                });
-
-                if (target.fill) {
-                  target.groupBy.push({type: 'fill', params: [target.fill]});
-                  delete target.fill;
-                }
-              }
-            }
-          });
-        });
-      }
-
-      // schema version 9 changes
-      if (oldVersion < 9) {
-        // move aliasYAxis changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'singlestat' && panel.thresholds !== "") { return; }
-
-          if (panel.thresholds) {
-            var k = panel.thresholds.split(",");
-
-            if (k.length >= 3) {
-              k.shift();
-              panel.thresholds = k.join(",");
-            }
-          }
-        });
-      }
-
-      // schema version 10 changes
-      if (oldVersion < 10) {
-        // move aliasYAxis changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'table') { return; }
-
-          _.each(panel.styles, function(style) {
-            if (style.thresholds && style.thresholds.length >= 3) {
-              var k = style.thresholds;
-              k.shift();
-              style.thresholds = k;
-            }
-          });
-        });
-      }
-
-      if (oldVersion < 12) {
-        // update template variables
-        _.each(this.templating.list, function(templateVariable) {
-          if (templateVariable.refresh) { templateVariable.refresh = 1; }
-          if (!templateVariable.refresh) { templateVariable.refresh = 0; }
-          if (templateVariable.hideVariable) {
-            templateVariable.hide = 2;
-          } else if (templateVariable.hideLabel) {
-            templateVariable.hide = 1;
-          } else {
-            templateVariable.hide = 0;
-          }
-        });
-      }
-
-      if (oldVersion < 12) {
-        // update graph yaxes changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'graph') { return; }
-          if (!panel.grid) { return; }
-
-          if (!panel.yaxes) {
-            panel.yaxes = [
-              {
-                show: panel['y-axis'],
-                min: panel.grid.leftMin,
-                max: panel.grid.leftMax,
-                logBase: panel.grid.leftLogBase,
-                format: panel.y_formats[0],
-                label: panel.leftYAxisLabel,
-              },
-              {
-                show: panel['y-axis'],
-                min: panel.grid.rightMin,
-                max: panel.grid.rightMax,
-                logBase: panel.grid.rightLogBase,
-                format: panel.y_formats[1],
-                label: panel.rightYAxisLabel,
-              }
-            ];
-
-            panel.xaxis = {
-              show: panel['x-axis'],
-            };
-
-            delete panel.grid.leftMin;
-            delete panel.grid.leftMax;
-            delete panel.grid.leftLogBase;
-            delete panel.grid.rightMin;
-            delete panel.grid.rightMax;
-            delete panel.grid.rightLogBase;
-            delete panel.y_formats;
-            delete panel.leftYAxisLabel;
-            delete panel.rightYAxisLabel;
-            delete panel['y-axis'];
-            delete panel['x-axis'];
-          }
-        });
-      }
-
-      if (oldVersion < 13) {
-        // update graph yaxes changes
-        panelUpgrades.push(function(panel) {
-          if (panel.type !== 'graph') { return; }
-
-          panel.thresholds = [];
-          var t1: any = {}, t2: any = {};
-
-          if (panel.grid.threshold1 !== null) {
-            t1.value = panel.grid.threshold1;
-            if (panel.grid.thresholdLine) {
-              t1.line = true;
-              t1.lineColor = panel.grid.threshold1Color;
-              t1.colorMode = 'custom';
-            } else {
-              t1.fill = true;
-              t1.fillColor = panel.grid.threshold1Color;
-              t1.colorMode = 'custom';
-            }
-          }
-
-          if (panel.grid.threshold2 !== null) {
-            t2.value = panel.grid.threshold2;
-            if (panel.grid.thresholdLine) {
-              t2.line = true;
-              t2.lineColor = panel.grid.threshold2Color;
-              t2.colorMode = 'custom';
-            } else {
-              t2.fill = true;
-              t2.fillColor = panel.grid.threshold2Color;
-              t2.colorMode = 'custom';
-            }
-          }
-
-          if (_.isNumber(t1.value)) {
-            if (_.isNumber(t2.value)) {
-              if (t1.value > t2.value) {
-                t1.op = t2.op = 'lt';
-                panel.thresholds.push(t1);
-                panel.thresholds.push(t2);
-              } else {
-                t1.op = t2.op = 'gt';
-                panel.thresholds.push(t1);
-                panel.thresholds.push(t2);
-              }
-            } else {
-              t1.op = 'gt';
-              panel.thresholds.push(t1);
-            }
-          }
-
-          delete panel.grid.threshold1;
-          delete panel.grid.threshold1Color;
-          delete panel.grid.threshold2;
-          delete panel.grid.threshold2Color;
-          delete panel.grid.thresholdLine;
-        });
-      }
-
-      if (panelUpgrades.length === 0) {
-        return;
-      }
-
-      for (i = 0; i < this.rows.length; i++) {
-        var row = this.rows[i];
-        for (j = 0; j < row.panels.length; j++) {
-          for (k = 0; k < panelUpgrades.length; k++) {
-            panelUpgrades[k].call(this, row.panels[j]);
-          }
-        }
-      }
-    }
-}
-
-
-export class DashboardSrv {
-  currentDashboard: any;
-
-  create(dashboard, meta) {
-    return new DashboardModel(dashboard, meta);
   }
 
-  setCurrent(dashboard) {
-    this.currentDashboard = dashboard;
-  }
+  saveDashboardAs() {
+    var newScope = this.$rootScope.$new();
+    newScope.clone = this.dash.getSaveModelClone();
+    newScope.clone.editable = true;
+    newScope.clone.hideControls = false;
 
-  getCurrent() {
-    return this.currentDashboard;
+    this.$rootScope.appEvent('show-modal', {
+      src: 'public/app/features/dashboard/partials/saveDashboardAs.html',
+      scope: newScope,
+      modalClass: 'modal--narrow'
+    });
   }
+
 }
 
 coreModule.service('dashboardSrv', DashboardSrv);

+ 5 - 88
public/app/features/dashboard/dashnav/dashnav.ts

@@ -9,7 +9,7 @@ import {DashboardExporter} from '../export/exporter';
 export class DashNavCtrl {
 
   /** @ngInject */
-  constructor($scope, $rootScope, alertSrv, $location, playlistSrv, backendSrv, $timeout, datasourceSrv) {
+  constructor($scope, $rootScope, dashboardSrv, $location, playlistSrv, backendSrv, $timeout, datasourceSrv) {
 
     $scope.init = function() {
       $scope.onAppEvent('save-dashboard', $scope.saveDashboard);
@@ -71,88 +71,14 @@ export class DashNavCtrl {
     $scope.makeEditable = function() {
       $scope.dashboard.editable = true;
 
-      var clone = $scope.dashboard.getSaveModelClone();
-
-      backendSrv.saveDashboard(clone, {overwrite: false}).then(function(data) {
-        $scope.dashboard.version = data.version;
-        $scope.appEvent('dashboard-saved', $scope.dashboard);
-        $scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
-
+      return dashboardSrv.saveDashboard({makeEditable: true, overwrite: false}).then(function() {
         // force refresh whole page
         window.location.href = window.location.href;
-      }, $scope.handleSaveDashError);
+      });
     };
 
     $scope.saveDashboard = function(options) {
-      if ($scope.dashboardMeta.canSave === false) {
-        return;
-      }
-
-      var clone = $scope.dashboard.getSaveModelClone();
-
-      backendSrv.saveDashboard(clone, options).then(function(data) {
-        $scope.dashboard.version = data.version;
-        $scope.appEvent('dashboard-saved', $scope.dashboard);
-
-        var dashboardUrl = '/dashboard/db/' + data.slug;
-
-        if (dashboardUrl !== $location.path()) {
-          $location.url(dashboardUrl);
-        }
-
-        $scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
-      }, $scope.handleSaveDashError);
-    };
-
-    $scope.handleSaveDashError = function(err) {
-      if (err.data && err.data.status === "version-mismatch") {
-        err.isHandled = true;
-
-        $scope.appEvent('confirm-modal', {
-          title: 'Conflict',
-          text: 'Someone else has updated this dashboard.',
-          text2: 'Would you still like to save this dashboard?',
-          yesText: "Save & Overwrite",
-          icon: "fa-warning",
-          onConfirm: function() {
-            $scope.saveDashboard({overwrite: true});
-          }
-        });
-      }
-
-      if (err.data && err.data.status === "name-exists") {
-        err.isHandled = true;
-
-        $scope.appEvent('confirm-modal', {
-          title: 'Conflict',
-          text: 'Dashboard with the same name exists.',
-          text2: 'Would you still like to save this dashboard?',
-          yesText: "Save & Overwrite",
-          icon: "fa-warning",
-          onConfirm: function() {
-            $scope.saveDashboard({overwrite: true});
-          }
-        });
-      }
-
-      if (err.data && err.data.status === "plugin-dashboard") {
-        err.isHandled = true;
-
-        $scope.appEvent('confirm-modal', {
-          title: 'Plugin Dashboard',
-          text: err.data.message,
-          text2: 'Your changes will be lost when you update the plugin. Use Save As to create custom version.',
-          yesText: "Overwrite",
-          icon: "fa-warning",
-          altActionText: "Save As",
-          onAltAction: function() {
-            $scope.saveDashboardAs();
-          },
-          onConfirm: function() {
-            $scope.saveDashboard({overwrite: true});
-          }
-        });
-      }
+      return dashboardSrv.saveDashboard(options);
     };
 
     $scope.deleteDashboard = function() {
@@ -189,16 +115,7 @@ export class DashNavCtrl {
     };
 
     $scope.saveDashboardAs = function() {
-      var newScope = $rootScope.$new();
-      newScope.clone = $scope.dashboard.getSaveModelClone();
-      newScope.clone.editable = true;
-      newScope.clone.hideControls = false;
-
-      $scope.appEvent('show-modal', {
-        src: 'public/app/features/dashboard/partials/saveDashboardAs.html',
-        scope: newScope,
-        modalClass: 'modal--narrow'
-      });
+      return dashboardSrv.saveDashboardAs();
     };
 
     $scope.viewJson = function() {

+ 576 - 0
public/app/features/dashboard/model.ts

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

+ 1 - 1
public/app/features/dashboard/specs/dashboard_srv_specs.ts

@@ -6,7 +6,7 @@ describe('dashboardSrv', function() {
   var _dashboardSrv;
 
   beforeEach(() => {
-    _dashboardSrv = new DashboardSrv();
+    _dashboardSrv = new DashboardSrv({}, {}, {});
   });
 
   describe('when creating new dashboard with defaults only', function() {

+ 12 - 2
public/app/plugins/datasource/cloudwatch/datasource.js

@@ -358,9 +358,15 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) {
     }
 
     this.getExpandedVariables = function(target, dimensionKey, variable) {
+      /* if the all checkbox is marked we should add all values to the targets */
+      var allSelected = _.find(variable.options, {'selected': true, 'text': 'All'});
       return _.chain(variable.options)
       .filter(function(v) {
-        return v.selected;
+        if (allSelected) {
+          return v.text !== 'All';
+        } else {
+          return v.selected;
+        }
       })
       .map(function(v) {
         var t = angular.copy(target);
@@ -369,6 +375,10 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) {
       }).value();
     };
 
+    this.containsVariable = function (str, variableName) {
+      return str.indexOf('$' + variableName) !== -1;
+    };
+
     this.expandTemplateVariable = function(targets, templateSrv) {
       var self = this;
       return _.chain(targets)
@@ -379,7 +389,7 @@ function (angular, _, moment, dateMath, kbn, CloudWatchAnnotationQuery) {
 
         if (dimensionKey) {
           var variable = _.find(templateSrv.variables, function(variable) {
-            return templateSrv.containsVariable(target.dimensions[dimensionKey], variable.name);
+            return self.containsVariable(target.dimensions[dimensionKey], variable.name);
           });
           return self.getExpandedVariables(target, dimensionKey, variable);
         } else {

+ 1 - 1
public/app/plugins/datasource/cloudwatch/partials/config.html

@@ -11,7 +11,7 @@
 	<div class="gf-form">
 		<label class="gf-form-label width-13">Default Region</label>
 		<div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
-			<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'cn-north-1', 'eu-central-1', 'eu-west-1', 'sa-east-1', 'us-east-1', 'us-west-1', 'us-west-2']"></select>
+			<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'cn-north-1', 'eu-central-1', 'eu-west-1', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2']"></select>
 			<info-popover mode="right-absolute">
 				Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
 			</info-popover>

+ 1 - 1
public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json

@@ -478,7 +478,7 @@
           "steppedLine": false,
           "targets": [
             {
-              "expr": "prometheus_evaluator_duration_milliseconds{quantile!=\"0.01\", quantile!=\"0.05\"}",
+              "expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\"}",
               "interval": "",
               "intervalFactor": 2,
               "legendFormat": "{{quantile}}",

+ 5 - 1
public/app/plugins/panel/graph/graph.ts

@@ -392,17 +392,21 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
           position: 'BOTTOM',
           markerSize: 5,
         };
+
         types['$__ok'] = {
           color: 'rgba(11, 237, 50, 1)',
           position: 'BOTTOM',
           markerSize: 5,
         };
-        types['$__nodata'] = {
+
+        types['$__no_data'] = {
           color: 'rgba(150, 150, 150, 1)',
           position: 'BOTTOM',
           markerSize: 5,
         };
 
+        types['$__execution_error'] = ['$__no_data'];
+
         for (var i = 0; i < annotations.length; i++) {
           var item = annotations[i];
           if (item.newState) {

+ 9 - 4
public/app/plugins/panel/graph/graph_tooltip.js

@@ -149,8 +149,6 @@ function ($, _) {
 
         seriesHtml = '';
 
-        absoluteTime = dashboard.formatDate(seriesHoverInfo.time, tooltipFormat);
-
         // Dynamically reorder the hovercard for the current time point if the
         // option is enabled, sort by yaxis by default.
         if (panel.tooltip.sort === 2) {
@@ -161,13 +159,14 @@ function ($, _) {
           seriesHoverInfo.sort(function(a, b) {
             return a.value - b.value;
           });
-        }
-        else {
+        } else {
           seriesHoverInfo.sort(function(a, b) {
             return a.yaxis - b.yaxis;
           });
         }
 
+        var distance, time;
+
         for (i = 0; i < seriesHoverInfo.length; i++) {
           hoverInfo = seriesHoverInfo[i];
 
@@ -175,6 +174,11 @@ function ($, _) {
             continue;
           }
 
+          if (! distance || hoverInfo.distance < distance) {
+            distance = hoverInfo.distance;
+            time = hoverInfo.time;
+          }
+
           var highlightClass = '';
           if (item && i === item.seriesIndex) {
             highlightClass = 'graph-tooltip-list-item--highlight';
@@ -190,6 +194,7 @@ function ($, _) {
           plot.highlight(hoverInfo.index, hoverInfo.hoverIndex);
         }
 
+        absoluteTime = dashboard.formatDate(time, tooltipFormat);
         self.showTooltip(absoluteTime, seriesHtml, pos);
       }
       // single series tooltip

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

@@ -4,6 +4,10 @@
   flex-direction: row;
 }
 
+.edit-tab-content {
+  flex-grow: 1;
+}
+
 .edit-sidemenu-aside {
   width: 16rem;
 }

+ 7 - 0
public/test/core/utils/rangeutil_specs.ts

@@ -31,6 +31,13 @@ describe("rangeUtil", () => {
       expect(info.from).to.be('now-13h')
     });
 
+    it('should handle non default future amount', () => {
+      var info = rangeUtil.describeTextRange('+3h');
+      expect(info.display).to.be('Next 3 hours')
+      expect(info.from).to.be('now')
+      expect(info.to).to.be('now+3h')
+    });
+
     it('should handle now/d', () => {
       var info = rangeUtil.describeTextRange('now/d');
       expect(info.display).to.be('Today so far');