Преглед изворни кода

Merge remote-tracking branch 'upstream/master' into postgres-query-builder

Sven Klemm пре 7 година
родитељ
комит
e590068082
34 измењених фајлова са 644 додато и 209 уклоњено
  1. 12 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 0 1
      ROADMAP.md
  4. 1 1
      circle.yml
  5. 1 1
      docs/sources/administration/provisioning.md
  6. 1 1
      docs/sources/alerting/rules.md
  7. 1 1
      pkg/api/index.go
  8. 7 2
      pkg/services/alerting/extractor.go
  9. 16 0
      pkg/services/alerting/extractor_test.go
  10. 6 1
      pkg/services/alerting/notifiers/opsgenie.go
  11. 96 55
      pkg/services/alerting/notifiers/telegram.go
  12. 66 0
      pkg/services/alerting/notifiers/telegram_test.go
  13. 63 0
      pkg/services/alerting/test-data/panel-with-id-0.json
  14. 14 9
      pkg/services/sqlstore/alert.go
  15. 121 6
      pkg/services/sqlstore/alert_test.go
  16. 1 4
      pkg/services/sqlstore/dashboard_folder_test.go
  17. 1 4
      pkg/services/sqlstore/dashboard_test.go
  18. 28 24
      pkg/social/generic_oauth.go
  19. 1 14
      pkg/tsdb/opentsdb/opentsdb.go
  20. 1 0
      public/app/core/components/scroll/scroll.ts
  21. 65 60
      public/app/core/components/sidemenu/sidemenu.html
  22. 11 8
      public/app/features/dashboard/dashgrid/DashboardRow.tsx
  23. 20 3
      public/app/features/dashboard/specs/DashboardRow.jest.tsx
  24. 5 1
      public/app/features/templating/specs/template_srv.jest.ts
  25. 6 0
      public/app/features/templating/template_srv.ts
  26. 38 1
      public/app/plugins/panel/graph/specs/threshold_manager_specs.ts
  27. 22 8
      public/app/plugins/panel/graph/threshold_manager.ts
  28. 11 0
      public/app/plugins/panel/graph/thresholds_form.ts
  29. 11 0
      public/img/resize-handle-white.svg
  30. 1 0
      public/sass/_variables.dark.scss
  31. 1 0
      public/sass/_variables.light.scss
  32. 5 0
      public/sass/components/_dashboard_grid.scss
  33. 9 2
      public/sass/components/_sidemenu.scss
  34. 1 1
      scripts/build/Dockerfile

+ 12 - 0
CHANGELOG.md

@@ -1,11 +1,23 @@
 # 5.1.0 (unreleased)
 
 * **Postgres/MySQL**: Ability to insert 0s or nulls for missing intervals [#9487](https://github.com/grafana/grafana/issues/9487), thanks [@svenklemm](https://github.com/svenklemm)
+* **Graph**: Thresholds for Right Y axis [#7107](https://github.com/grafana/grafana/issues/7107), thx [@ilgizar](https://github.com/ilgizar)
+* **Alerting**: Pausing/un alerts now updates new_state_date [#10942](https://github.com/grafana/grafana/pull/10942)
+* **Templating**: Add comma templating format [#10632](https://github.com/grafana/grafana/issues/10632), thx [@mtanda](https://github.com/mtanda)
 
+### Minor
+* **OpsGenie**: Add triggered alerts as description [#11046](https://github.com/grafana/grafana/pull/11046), thx [@llamashoes](https://github.com/llamashoes)
 
 # 5.0.1 (unreleased)
 
 * **Postgres**: PostgreSQL error when using ipv6 address as hostname in connection string [#11055](https://github.com/grafana/grafana/issues/11055), thanks [@svenklemm](https://github.com/svenklemm)
+* **Dashboards**: Changing templated value from dropdown is causing unsaved changes [#11063](https://github.com/grafana/grafana/issues/11063)
+* **Prometheus**: Fixes bundled Prometheus 2.0 dashboard [#11016](https://github.com/grafana/grafana/issues/11016), thx [@roidelapluie](https://github.com/roidelapluie)
+* **Sidemenu**: Profile menu "invisible" when gravatar is disabled [#11097](https://github.com/grafana/grafana/issues/11097)
+* **Dashboard**: Fixes a bug with resizeable handles for panels [#11103](https://github.com/grafana/grafana/issues/11103)
+* **Alerting**: Telegram inline image mode fails when caption too long [#10975](https://github.com/grafana/grafana/issues/10975)
+* **Alerting**: Fixes silent failing validation [#11145](https://github.com/grafana/grafana/pull/11145)
+* **OAuth**: Only use jwt token if it contains an email address [#11127](https://github.com/grafana/grafana/pull/11127)
 
 # 5.0.0-stable (2018-03-01)
 

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
 ### Building the backend
 ```bash
 go get github.com/grafana/grafana
-cd ~/go/src/github.com/grafana/grafana
+cd $GOPATH/src/github.com/grafana/grafana
 go run build.go setup
 go run build.go build
 ```

+ 0 - 1
ROADMAP.md

@@ -8,7 +8,6 @@ But it will give you an idea of our current vision and plan.
 - v5.1
   - Crossplatform builds & build speed improvements
   - Enterprise LDAP
-  - New template interpolation syntax
   - Provisioning workflow
   - First login registration view
   - IFQL Initial support

+ 1 - 1
circle.yml

@@ -9,7 +9,7 @@ machine:
     GOPATH: "/home/ubuntu/.go_workspace"
     ORG_PATH: "github.com/grafana"
     REPO_PATH: "${ORG_PATH}/grafana"
-    GODIST: "go1.9.3.linux-amd64.tar.gz"
+    GODIST: "go1.10.linux-amd64.tar.gz"
   post:
     - mkdir -p ~/download
     - mkdir -p ~/docker

+ 1 - 1
docs/sources/administration/provisioning.md

@@ -144,7 +144,7 @@ Since not all datasources have the same configuration settings we only have the
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | graphiteVersion | string | Graphite |  Graphite version  |
 | timeInterval | string | Elastic, Influxdb & Prometheus | Lowest interval/step value that should be used for this data source |
-| esVersion | string | Elastic | Elasticsearch version |
+| esVersion | string | Elastic | Elasticsearch version as an number (2/5/56) |
 | timeField | string | Elastic | Which field that should be used as timestamp |
 | interval | string | Elastic | Index date time format |
 | authType | string | Cloudwatch | Auth provider. keys/credentials/arn |

+ 1 - 1
docs/sources/alerting/rules.md

@@ -59,7 +59,7 @@ avg() OF query(A, 5m, now) IS BELOW 14
 ```
 
 - `avg()` Controls how the values for **each** series should be reduced to a value that can be compared against the threshold. Click on the function to change it to another aggregation function.
-- `query(A, 5m, now)`  The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes from now to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes from now to 2 minutes from now. This is useful if you want to ignore the last 2 minutes of data.
+- `query(A, 5m, now)`  The letter defines what query to execute from the **Metrics** tab. The second two parameters define the time range, `5m, now` means 5 minutes ago to now. You can also do `10m, now-2m` to define a time range that will be 10 minutes ago to 2 minutes ago. This is useful if you want to ignore the last 2 minutes of data.
 - `IS BELOW 14`  Defines the type of threshold and the threshold value.  You can click on `IS BELOW` to change the type of threshold.
 
 The query used in an alert rule cannot contain any template variables. Currently we only support `AND` and `OR` operators between conditions and they are executed serially.

+ 1 - 1
pkg/api/index.go

@@ -74,7 +74,7 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
 	}
 
 	if setting.DisableGravatar {
-		data.User.GravatarUrl = setting.AppSubUrl + "/public/img/transparent.png"
+		data.User.GravatarUrl = setting.AppSubUrl + "/public/img/user_profile.png"
 	}
 
 	if len(data.User.Name) == 0 {

+ 7 - 2
pkg/services/alerting/extractor.go

@@ -143,10 +143,15 @@ func (e *DashAlertExtractor) GetAlertFromPanels(jsonWithPanels *simplejson.Json)
 
 		// validate
 		_, err = NewRuleFromDBAlert(alert)
-		if err == nil && alert.ValidToSave() {
+		if err != nil {
+			return nil, err
+		}
+
+		if alert.ValidToSave() {
 			alerts = append(alerts, alert)
 		} else {
-			return nil, err
+			e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
+			return nil, m.ErrDashboardContainsInvalidAlertData
 		}
 	}
 

+ 16 - 0
pkg/services/alerting/extractor_test.go

@@ -150,6 +150,22 @@ func TestAlertRuleExtraction(t *testing.T) {
 			})
 		})
 
+		Convey("Panel with id set to zero should return error", func() {
+			panelWithIdZero, err := ioutil.ReadFile("./test-data/panel-with-id-0.json")
+			So(err, ShouldBeNil)
+
+			dashJson, err := simplejson.NewJson([]byte(panelWithIdZero))
+			So(err, ShouldBeNil)
+			dash := m.NewDashboardFromJson(dashJson)
+			extractor := NewDashAlertExtractor(dash, 1)
+
+			_, err = extractor.GetAlerts()
+
+			Convey("panel with id 0 should return error", func() {
+				So(err, ShouldNotBeNil)
+			})
+		})
+
 		Convey("Parse alerts from dashboard without rows", func() {
 			json, err := ioutil.ReadFile("./test-data/v5-dashboard.json")
 			So(err, ShouldBeNil)

+ 6 - 1
pkg/services/alerting/notifiers/opsgenie.go

@@ -99,11 +99,16 @@ func (this *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) err
 		return err
 	}
 
+	customData := "Triggered metrics:\n\n"
+	for _, evt := range evalContext.EvalMatches {
+		customData = customData + fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
+	}
+
 	bodyJSON := simplejson.New()
 	bodyJSON.Set("message", evalContext.Rule.Name)
 	bodyJSON.Set("source", "Grafana")
 	bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.Id, 10))
-	bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message))
+	bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s\n%s", evalContext.Rule.Name, ruleUrl, evalContext.Rule.Message, customData))
 
 	details := simplejson.New()
 	details.Set("url", ruleUrl)

+ 96 - 55
pkg/services/alerting/notifiers/telegram.go

@@ -12,6 +12,10 @@ import (
 	"os"
 )
 
+const (
+	captionLengthLimit = 200
+)
+
 var (
 	telegramApiUrl string = "https://api.telegram.org/bot%s/%s"
 )
@@ -82,88 +86,81 @@ func NewTelegramNotifier(model *m.AlertNotification) (alerting.Notifier, error)
 }
 
 func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, sendImageInline bool) *m.SendWebhookSync {
-	var imageFile *os.File
-	var err error
-
 	if sendImageInline {
-		imageFile, err = os.Open(evalContext.ImageOnDiskPath)
-		defer imageFile.Close()
-		if err != nil {
-			sendImageInline = false // fall back to text message
+		cmd, err := this.buildMessageInlineImage(evalContext)
+		if err == nil {
+			return cmd
+		} else {
+			this.log.Error("Could not generate Telegram message with inline image.", "err", err)
 		}
 	}
 
-	message := ""
+	return this.buildMessageLinkedImage(evalContext)
+}
 
-	if sendImageInline {
-		// Telegram's API does not allow HTML formatting for image captions.
-		message = fmt.Sprintf("%s\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
-	} else {
-		message = fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
-	}
+func (this *TelegramNotifier) buildMessageLinkedImage(evalContext *alerting.EvalContext) *m.SendWebhookSync {
+	message := fmt.Sprintf("<b>%s</b>\nState: %s\nMessage: %s\n", evalContext.GetNotificationTitle(), evalContext.Rule.Name, evalContext.Rule.Message)
 
 	ruleUrl, err := evalContext.GetRuleUrl()
 	if err == nil {
 		message = message + fmt.Sprintf("URL: %s\n", ruleUrl)
 	}
 
-	if !sendImageInline {
-		// only attach this if we are not sending it inline.
-		if evalContext.ImagePublicUrl != "" {
-			message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl)
-		}
+	if evalContext.ImagePublicUrl != "" {
+		message = message + fmt.Sprintf("Image: %s\n", evalContext.ImagePublicUrl)
 	}
 
-	metrics := ""
-	fieldLimitCount := 4
-	for index, evt := range evalContext.EvalMatches {
-		metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value)
-		if index > fieldLimitCount {
-			break
-		}
+	metrics := generateMetricsMessage(evalContext)
+	if metrics != "" {
+		message = message + fmt.Sprintf("\n<i>Metrics:</i>%s", metrics)
 	}
 
-	if metrics != "" {
-		if sendImageInline {
-			// Telegram's API does not allow HTML formatting for image captions.
-			message = message + fmt.Sprintf("\nMetrics:%s", metrics)
-		} else {
-			message = message + fmt.Sprintf("\n<i>Metrics:</i>%s", metrics)
-		}
+	cmd := this.generateTelegramCmd(message, "text", "sendMessage", func(w *multipart.Writer) {
+		fw, _ := w.CreateFormField("parse_mode")
+		fw.Write([]byte("html"))
+	})
+	return cmd
+}
+
+func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.EvalContext) (*m.SendWebhookSync, error) {
+	var imageFile *os.File
+	var err error
+
+	imageFile, err = os.Open(evalContext.ImageOnDiskPath)
+	defer imageFile.Close()
+	if err != nil {
+		return nil, err
 	}
 
-	var body bytes.Buffer
+	ruleUrl, err := evalContext.GetRuleUrl()
+
+	metrics := generateMetricsMessage(evalContext)
+	message := generateImageCaption(evalContext, ruleUrl, metrics)
+
+	cmd := this.generateTelegramCmd(message, "caption", "sendPhoto", func(w *multipart.Writer) {
+		fw, _ := w.CreateFormFile("photo", evalContext.ImageOnDiskPath)
+		io.Copy(fw, imageFile)
+	})
+	return cmd, nil
+}
 
+func (this *TelegramNotifier) generateTelegramCmd(message string, messageField string, apiAction string, extraConf func(writer *multipart.Writer)) *m.SendWebhookSync {
+	var body bytes.Buffer
 	w := multipart.NewWriter(&body)
+
 	fw, _ := w.CreateFormField("chat_id")
 	fw.Write([]byte(this.ChatID))
 
-	if sendImageInline {
-		fw, _ = w.CreateFormField("caption")
-		fw.Write([]byte(message))
+	fw, _ = w.CreateFormField(messageField)
+	fw.Write([]byte(message))
 
-		fw, _ = w.CreateFormFile("photo", evalContext.ImageOnDiskPath)
-		io.Copy(fw, imageFile)
-	} else {
-		fw, _ = w.CreateFormField("text")
-		fw.Write([]byte(message))
-
-		fw, _ = w.CreateFormField("parse_mode")
-		fw.Write([]byte("html"))
-	}
+	extraConf(w)
 
 	w.Close()
 
-	apiMethod := ""
-	if sendImageInline {
-		this.log.Info("Sending telegram image notification", "photo", evalContext.ImageOnDiskPath, "chat_id", this.ChatID, "bot_token", this.BotToken)
-		apiMethod = "sendPhoto"
-	} else {
-		this.log.Info("Sending telegram text notification", "chat_id", this.ChatID, "bot_token", this.BotToken)
-		apiMethod = "sendMessage"
-	}
+	this.log.Info("Sending telegram notification", "chat_id", this.ChatID, "bot_token", this.BotToken, "apiAction", apiAction)
+	url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiAction)
 
-	url := fmt.Sprintf(telegramApiUrl, this.BotToken, apiMethod)
 	cmd := &m.SendWebhookSync{
 		Url:        url,
 		Body:       body.String(),
@@ -175,6 +172,50 @@ func (this *TelegramNotifier) buildMessage(evalContext *alerting.EvalContext, se
 	return cmd
 }
 
+func generateMetricsMessage(evalContext *alerting.EvalContext) string {
+	metrics := ""
+	fieldLimitCount := 4
+	for index, evt := range evalContext.EvalMatches {
+		metrics += fmt.Sprintf("\n%s: %s", evt.Metric, evt.Value)
+		if index > fieldLimitCount {
+			break
+		}
+	}
+	return metrics
+}
+
+func generateImageCaption(evalContext *alerting.EvalContext, ruleUrl string, metrics string) string {
+	message := evalContext.GetNotificationTitle()
+
+	if len(evalContext.Rule.Message) > 0 {
+		message = fmt.Sprintf("%s\nMessage: %s", message, evalContext.Rule.Message)
+	}
+
+	if len(message) > captionLengthLimit {
+		message = message[0:captionLengthLimit]
+
+	}
+
+	if len(ruleUrl) > 0 {
+		urlLine := fmt.Sprintf("\nURL: %s", ruleUrl)
+		message = appendIfPossible(message, urlLine, captionLengthLimit)
+	}
+
+	if metrics != "" {
+		metricsLines := fmt.Sprintf("\n\nMetrics:%s", metrics)
+		message = appendIfPossible(message, metricsLines, captionLengthLimit)
+	}
+
+	return message
+}
+func appendIfPossible(message string, extra string, sizeLimit int) string {
+	if len(extra)+len(message) <= sizeLimit {
+		return message + extra
+	}
+	log.Debug("Line too long for image caption.", "value", extra)
+	return message
+}
+
 func (this *TelegramNotifier) ShouldNotify(context *alerting.EvalContext) bool {
 	return defaultShouldNotify(context)
 }

+ 66 - 0
pkg/services/alerting/notifiers/telegram_test.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
 	. "github.com/smartystreets/goconvey/convey"
 )
 
@@ -50,6 +51,71 @@ func TestTelegramNotifier(t *testing.T) {
 				So(telegramNotifier.ChatID, ShouldEqual, "-1234567890")
 			})
 
+			Convey("generateCaption should generate a message with all pertinent details", func() {
+				evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
+					Name:    "This is an alarm",
+					Message: "Some kind of message.",
+					State:   m.AlertStateOK,
+				})
+
+				caption := generateImageCaption(evalContext, "http://grafa.url/abcdef", "")
+				So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+				So(caption, ShouldContainSubstring, "Some kind of message.")
+				So(caption, ShouldContainSubstring, "[OK] This is an alarm")
+				So(caption, ShouldContainSubstring, "http://grafa.url/abcdef")
+			})
+
+			Convey("When generating a message", func() {
+
+				Convey("URL should be skipped if it's too long", func() {
+					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
+						Name:    "This is an alarm",
+						Message: "Some kind of message.",
+						State:   m.AlertStateOK,
+					})
+
+					caption := generateImageCaption(evalContext,
+						"http://grafa.url/abcdefaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+						"foo bar")
+					So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+					So(caption, ShouldContainSubstring, "Some kind of message.")
+					So(caption, ShouldContainSubstring, "[OK] This is an alarm")
+					So(caption, ShouldContainSubstring, "foo bar")
+					So(caption, ShouldNotContainSubstring, "http")
+				})
+
+				Convey("Message should be trimmed if it's too long", func() {
+					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
+						Name:    "This is an alarm",
+						Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise I will. Yes siree that's it.",
+						State:   m.AlertStateOK,
+					})
+
+					caption := generateImageCaption(evalContext,
+						"http://grafa.url/foo",
+						"")
+					So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+					So(caption, ShouldContainSubstring, "[OK] This is an alarm")
+					So(caption, ShouldNotContainSubstring, "http")
+					So(caption, ShouldContainSubstring, "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I promise ")
+				})
+
+				Convey("Metrics should be skipped if they dont fit", func() {
+					evalContext := alerting.NewEvalContext(nil, &alerting.Rule{
+						Name:    "This is an alarm",
+						Message: "Some kind of message that is too long for appending to our pretty little message, this line is actually exactly 197 chars long and I will get there in the end I ",
+						State:   m.AlertStateOK,
+					})
+
+					caption := generateImageCaption(evalContext,
+						"http://grafa.url/foo",
+						"foo bar long song")
+					So(len(caption), ShouldBeLessThanOrEqualTo, 200)
+					So(caption, ShouldContainSubstring, "[OK] This is an alarm")
+					So(caption, ShouldNotContainSubstring, "http")
+					So(caption, ShouldNotContainSubstring, "foo bar")
+				})
+			})
 		})
 	})
 }

+ 63 - 0
pkg/services/alerting/test-data/panel-with-id-0.json

@@ -0,0 +1,63 @@
+{
+  "id": 57,
+  "title": "Graphite 4",
+  "originalTitle": "Graphite 4",
+  "tags": ["graphite"],
+  "rows": [
+  {
+    "panels": [
+    {
+      "title": "Active desktop users",
+      "id": 0,
+      "editable": true,
+      "type": "graph",
+      "targets": [
+      {
+        "refId": "A",
+        "target": "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)"
+      }
+      ],
+      "datasource": null,
+      "alert": {
+        "name": "name1",
+        "message": "desc1",
+        "handler": 1,
+        "frequency": "60s",
+        "conditions": [
+        {
+          "type": "query",
+          "query": {"params": ["A", "5m", "now"]},
+          "reducer": {"type": "avg", "params": []},
+          "evaluator": {"type": ">", "params": [100]}
+        }
+        ]
+      }
+    },
+    {
+      "title": "Active mobile users",
+      "id": 4,
+      "targets": [
+        {"refId": "A", "target": ""},
+        {"refId": "B", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}
+      ],
+      "datasource": "graphite2",
+      "alert": {
+        "name": "name2",
+        "message": "desc2",
+        "handler": 0,
+        "frequency": "60s",
+        "severity": "warning",
+        "conditions": [
+        {
+          "type": "query",
+          "query":  {"params": ["B", "5m", "now"]},
+          "reducer": {"type": "avg", "params": []},
+          "evaluator": {"type": ">", "params": [100]}
+        }
+        ]
+      }
+    }
+    ]
+  }
+]
+      }

+ 14 - 9
pkg/services/sqlstore/alert.go

@@ -10,6 +10,9 @@ import (
 	m "github.com/grafana/grafana/pkg/models"
 )
 
+// timeNow makes it possible to test usage of time
+var timeNow = time.Now
+
 func init() {
 	bus.AddHandler("sql", SaveAlerts)
 	bus.AddHandler("sql", HandleAlertsQuery)
@@ -147,7 +150,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error {
 			return err
 		}
 
-		if err := upsertAlerts(existingAlerts, cmd, sess); err != nil {
+		if err := updateAlerts(existingAlerts, cmd, sess); err != nil {
 			return err
 		}
 
@@ -159,7 +162,7 @@ func SaveAlerts(cmd *m.SaveAlertsCommand) error {
 	})
 }
 
-func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error {
+func updateAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBSession) error {
 	for _, alert := range cmd.Alerts {
 		update := false
 		var alertToUpdate *m.Alert
@@ -175,7 +178,7 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
 
 		if update {
 			if alertToUpdate.ContainsUpdates(alert) {
-				alert.Updated = time.Now()
+				alert.Updated = timeNow()
 				alert.State = alertToUpdate.State
 				sess.MustCols("message")
 				_, err := sess.Id(alert.Id).Update(alert)
@@ -186,10 +189,10 @@ func upsertAlerts(existingAlerts []*m.Alert, cmd *m.SaveAlertsCommand, sess *DBS
 				sqlog.Debug("Alert updated", "name", alert.Name, "id", alert.Id)
 			}
 		} else {
-			alert.Updated = time.Now()
-			alert.Created = time.Now()
+			alert.Updated = timeNow()
+			alert.Created = timeNow()
 			alert.State = m.AlertStatePending
-			alert.NewStateDate = time.Now()
+			alert.NewStateDate = timeNow()
 
 			_, err := sess.Insert(alert)
 			if err != nil {
@@ -253,7 +256,7 @@ func SetAlertState(cmd *m.SetAlertStateCommand) error {
 
 		alert.State = cmd.State
 		alert.StateChanges += 1
-		alert.NewStateDate = time.Now()
+		alert.NewStateDate = timeNow()
 		alert.EvalData = cmd.EvalData
 
 		if cmd.Error == "" {
@@ -276,11 +279,13 @@ func PauseAlert(cmd *m.PauseAlertCommand) error {
 		var buffer bytes.Buffer
 		params := make([]interface{}, 0)
 
-		buffer.WriteString(`UPDATE alert SET state = ?`)
+		buffer.WriteString(`UPDATE alert SET state = ?, new_state_date = ?`)
 		if cmd.Paused {
 			params = append(params, string(m.AlertStatePaused))
+			params = append(params, timeNow())
 		} else {
 			params = append(params, string(m.AlertStatePending))
+			params = append(params, timeNow())
 		}
 
 		buffer.WriteString(` WHERE id IN (?` + strings.Repeat(",?", len(cmd.AlertIds)-1) + `)`)
@@ -306,7 +311,7 @@ func PauseAllAlerts(cmd *m.PauseAllAlertCommand) error {
 			newState = string(m.AlertStatePending)
 		}
 
-		res, err := sess.Exec(`UPDATE alert SET state = ?`, newState)
+		res, err := sess.Exec(`UPDATE alert SET state = ?, new_state_date = ?`, newState, timeNow())
 		if err != nil {
 			return err
 		}

+ 121 - 6
pkg/services/sqlstore/alert_test.go

@@ -6,9 +6,26 @@ import (
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	. "github.com/smartystreets/goconvey/convey"
+	"time"
 )
 
+func mockTimeNow() {
+	var timeSeed int64
+	timeNow = func() time.Time {
+		fakeNow := time.Unix(timeSeed, 0)
+		timeSeed += 1
+		return fakeNow
+	}
+}
+
+func resetTimeNow() {
+	timeNow = time.Now
+}
+
 func TestAlertingDataAccess(t *testing.T) {
+	mockTimeNow()
+	defer resetTimeNow()
+
 	Convey("Testing Alerting data access", t, func() {
 		InitTestDB(t)
 
@@ -50,13 +67,11 @@ func TestAlertingDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 			})
 
-			Convey("can pause alert", func() {
-				cmd := &m.PauseAllAlertCommand{
-					Paused: true,
-				}
+			alert, _ := getAlertById(1)
+			stateDateBeforePause := alert.NewStateDate
 
-				err = PauseAllAlerts(cmd)
-				So(err, ShouldBeNil)
+			Convey("can pause all alerts", func() {
+				pauseAllAlerts(true)
 
 				Convey("cannot updated paused alert", func() {
 					cmd := &m.SetAlertStateCommand{
@@ -67,6 +82,19 @@ func TestAlertingDataAccess(t *testing.T) {
 					err = SetAlertState(cmd)
 					So(err, ShouldNotBeNil)
 				})
+
+				Convey("pausing alerts should update their NewStateDate", func() {
+					alert, _ = getAlertById(1)
+					stateDateAfterPause := alert.NewStateDate
+					So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause)
+				})
+
+				Convey("unpausing alerts should update their NewStateDate again", func() {
+					pauseAllAlerts(false)
+					alert, _ = getAlertById(1)
+					stateDateAfterUnpause := alert.NewStateDate
+					So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterUnpause)
+				})
 			})
 		})
 
@@ -214,3 +242,90 @@ func TestAlertingDataAccess(t *testing.T) {
 		})
 	})
 }
+
+func TestPausingAlerts(t *testing.T) {
+	mockTimeNow()
+	defer resetTimeNow()
+
+	Convey("Given an alert", t, func() {
+		InitTestDB(t)
+
+		testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
+		alert, _ := insertTestAlert("Alerting title", "Alerting message", testDash.OrgId, testDash.Id, simplejson.New())
+
+		stateDateBeforePause := alert.NewStateDate
+		stateDateAfterPause := stateDateBeforePause
+		Convey("when paused", func() {
+			pauseAlert(testDash.OrgId, 1, true)
+
+			Convey("the NewStateDate should be updated", func() {
+				alert, _ := getAlertById(1)
+
+				stateDateAfterPause = alert.NewStateDate
+				So(stateDateBeforePause, ShouldHappenBefore, stateDateAfterPause)
+			})
+		})
+
+		Convey("when unpaused", func() {
+			pauseAlert(testDash.OrgId, 1, false)
+
+			Convey("the NewStateDate should be updated again", func() {
+				alert, _ := getAlertById(1)
+
+				stateDateAfterUnpause := alert.NewStateDate
+				So(stateDateAfterPause, ShouldHappenBefore, stateDateAfterUnpause)
+			})
+		})
+	})
+}
+func pauseAlert(orgId int64, alertId int64, pauseState bool) (int64, error) {
+	cmd := &m.PauseAlertCommand{
+		OrgId:    orgId,
+		AlertIds: []int64{alertId},
+		Paused:   pauseState,
+	}
+	err := PauseAlert(cmd)
+	So(err, ShouldBeNil)
+	return cmd.ResultCount, err
+}
+func insertTestAlert(title string, message string, orgId int64, dashId int64, settings *simplejson.Json) (*m.Alert, error) {
+	items := []*m.Alert{
+		{
+			PanelId:     1,
+			DashboardId: dashId,
+			OrgId:       orgId,
+			Name:        title,
+			Message:     message,
+			Settings:    settings,
+			Frequency:   1,
+		},
+	}
+
+	cmd := m.SaveAlertsCommand{
+		Alerts:      items,
+		DashboardId: dashId,
+		OrgId:       orgId,
+		UserId:      1,
+	}
+
+	err := SaveAlerts(&cmd)
+	return cmd.Alerts[0], err
+}
+
+func getAlertById(id int64) (*m.Alert, error) {
+	q := &m.GetAlertByIdQuery{
+		Id: id,
+	}
+	err := GetAlertById(q)
+	So(err, ShouldBeNil)
+	return q.Result, err
+}
+
+func pauseAllAlerts(pauseState bool) error {
+	cmd := &m.PauseAllAlertCommand{
+		Paused: pauseState,
+	}
+	err := PauseAllAlerts(cmd)
+	So(err, ShouldBeNil)
+	return err
+}

+ 1 - 4
pkg/services/sqlstore/dashboard_folder_test.go

@@ -3,7 +3,6 @@ package sqlstore
 import (
 	"testing"
 
-	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 
 	m "github.com/grafana/grafana/pkg/models"
@@ -11,10 +10,8 @@ import (
 )
 
 func TestDashboardFolderDataAccess(t *testing.T) {
-	var x *xorm.Engine
-
 	Convey("Testing DB", t, func() {
-		x = InitTestDB(t)
+		InitTestDB(t)
 
 		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
 			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")

+ 1 - 4
pkg/services/sqlstore/dashboard_test.go

@@ -5,7 +5,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/go-xorm/xorm"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
@@ -15,10 +14,8 @@ import (
 )
 
 func TestDashboardDataAccess(t *testing.T) {
-	var x *xorm.Engine
-
 	Convey("Testing DB", t, func() {
-		x = InitTestDB(t)
+		InitTestDB(t)
 
 		Convey("Given saved dashboard", func() {
 			savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")

+ 28 - 24
pkg/social/generic_oauth.go

@@ -180,6 +180,7 @@ type UserInfoJson struct {
 
 func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
 	var data UserInfoJson
+	var err error
 
 	if s.extractToken(&data, token) != true {
 		response, err := HttpGet(client, s.apiUrl)
@@ -193,20 +194,17 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token)
 		}
 	}
 
-	name, err := s.extractName(data)
-	if err != nil {
-		return nil, err
-	}
+	name := s.extractName(&data)
 
-	email, err := s.extractEmail(data, client)
-	if err != nil {
-		return nil, err
+	email := s.extractEmail(&data)
+	if email == "" {
+		email, err = s.FetchPrivateEmail(client)
+		if err != nil {
+			return nil, err
+		}
 	}
 
-	login, err := s.extractLogin(data, email)
-	if err != nil {
-		return nil, err
-	}
+	login := s.extractLogin(&data, email)
 
 	userInfo := &BasicUserInfo{
 		Name:  name,
@@ -251,49 +249,55 @@ func (s *SocialGenericOAuth) extractToken(data *UserInfoJson, token *oauth2.Toke
 		return false
 	}
 
+	email := s.extractEmail(data)
+	if email == "" {
+		s.log.Debug("No email found in id_token", "json", string(payload), "data", data)
+		return false
+	}
+
 	s.log.Debug("Received id_token", "json", string(payload), "data", data)
 	return true
 }
 
-func (s *SocialGenericOAuth) extractEmail(data UserInfoJson, client *http.Client) (string, error) {
+func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
 	if data.Email != "" {
-		return data.Email, nil
+		return data.Email
 	}
 
 	if data.Attributes["email:primary"] != nil {
-		return data.Attributes["email:primary"][0], nil
+		return data.Attributes["email:primary"][0]
 	}
 
 	if data.Upn != "" {
 		emailAddr, emailErr := mail.ParseAddress(data.Upn)
 		if emailErr == nil {
-			return emailAddr.Address, nil
+			return emailAddr.Address
 		}
 	}
 
-	return s.FetchPrivateEmail(client)
+	return ""
 }
 
-func (s *SocialGenericOAuth) extractLogin(data UserInfoJson, email string) (string, error) {
+func (s *SocialGenericOAuth) extractLogin(data *UserInfoJson, email string) string {
 	if data.Login != "" {
-		return data.Login, nil
+		return data.Login
 	}
 
 	if data.Username != "" {
-		return data.Username, nil
+		return data.Username
 	}
 
-	return email, nil
+	return email
 }
 
-func (s *SocialGenericOAuth) extractName(data UserInfoJson) (string, error) {
+func (s *SocialGenericOAuth) extractName(data *UserInfoJson) string {
 	if data.Name != "" {
-		return data.Name, nil
+		return data.Name
 	}
 
 	if data.DisplayName != "" {
-		return data.DisplayName, nil
+		return data.DisplayName
 	}
 
-	return "", nil
+	return ""
 }

+ 1 - 14
pkg/tsdb/opentsdb/opentsdb.go

@@ -22,23 +22,10 @@ import (
 )
 
 type OpenTsdbExecutor struct {
-	//*models.DataSource
-	//httpClient *http.Client
 }
 
 func NewOpenTsdbExecutor(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
-	/*
-		httpClient, err := datasource.GetHttpClient()
-
-		if err != nil {
-			return nil, err
-		}
-	*/
-
-	return &OpenTsdbExecutor{
-	//DataSource: datasource,
-	//httpClient: httpClient,
-	}, nil
+	return &OpenTsdbExecutor{}, nil
 }
 
 var (

+ 1 - 0
public/app/core/components/scroll/scroll.ts

@@ -8,6 +8,7 @@ export function geminiScrollbar() {
     link: function(scope, elem, attrs) {
       let scrollbar = new PerfectScrollbar(elem[0], {
         wheelPropagation: true,
+        wheelSpeed: 3,
       });
       let lastPos = 0;
 

+ 65 - 60
public/app/core/components/sidemenu/sidemenu.html

@@ -1,73 +1,78 @@
 <a class="sidemenu__logo" ng-click="ctrl.toggleSideMenu()">
-	<img src="public/img/grafana_icon.svg"></img>
+  <img src="public/img/grafana_icon.svg"></img>
 </a>
 
 <a class="sidemenu__logo_small_breakpoint" ng-click="ctrl.toggleSideMenuSmallBreakpoint()">
   <i class="fa fa-bars"></i>
-  <span class="sidemenu__close"><i class="fa fa-times"></i>&nbsp;Close</span>
+  <span class="sidemenu__close">
+    <i class="fa fa-times"></i>&nbsp;Close</span>
 </a>
 
 <div class="sidemenu__top">
-	<div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
-		<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
-			<span class="icon-circle sidemenu-icon">
-				<i class="{{::item.icon}}" ng-show="::item.icon"></i>
-				<img ng-src="{{::item.img}}" ng-show="::item.img">
-			</span>
-		</a>
-		<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
-			<li class="side-menu-header">
-				<span class="sidemenu-item-text">{{::item.text}}</span>
-			</li>
-			<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
-				<a href="{{::child.url}}">
-					<i class="{{::child.icon}}" ng-show="::child.icon"></i>
-					{{::child.text}}
-				</a>
-			</li>
-		</ul>
-	</div>
+  <div ng-repeat="item in ::ctrl.mainLinks" class="sidemenu-item dropdown">
+    <a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
+      <span class="icon-circle sidemenu-icon">
+        <i class="{{::item.icon}}" ng-show="::item.icon"></i>
+        <img ng-src="{{::item.img}}" ng-show="::item.img">
+      </span>
+    </a>
+    <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu" ng-if="::item.children">
+      <li class="side-menu-header">
+        <span class="sidemenu-item-text">{{::item.text}}</span>
+      </li>
+      <li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}">
+        <a href="{{::child.url}}">
+          <i class="{{::child.icon}}" ng-show="::child.icon"></i>
+          {{::child.text}}
+        </a>
+      </li>
+    </ul>
+  </div>
 </div>
 
 <div class="sidemenu__bottom">
-	<div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
-		<a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
-			<span class="icon-circle sidemenu-icon"><i class="fa fa-fw fa-sign-in"></i></span>
-		</a>
-		<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
-			<li class="side-menu-header">
-				<span class="sidemenu-item-text">Sign In</span>
-			</li>
-		</ul>
-	</div>
+  <div ng-show="::!ctrl.isSignedIn" class="sidemenu-item">
+    <a href="{{ctrl.loginUrl}}" class="sidemenu-link" target="_self">
+      <span class="icon-circle sidemenu-icon">
+        <i class="fa fa-fw fa-sign-in"></i>
+      </span>
+    </a>
+    <a href="{{ctrl.loginUrl}}">
+      <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
+        <li class="side-menu-header">
+          <span class="sidemenu-item-text">Sign In</span>
+        </li>
+      </ul>
+    </a>
+  </div>
 
-	<div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
-		<a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
-			<span class="icon-circle sidemenu-icon">
-				<i class="{{::item.icon}}" ng-show="::item.icon"></i>
-				<img ng-src="{{::item.img}}" ng-show="::item.img">
-			</span>
-		</a>
-		<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
-			<li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
-				<a ng-click="ctrl.switchOrg()">
-					<div>
-						<div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
-						<div class="sidemenu-org-switcher__org-current">Current Org:</div>
-					</div>
-					<div class="sidemenu-org-switcher__switch"><i class="fa fa-fw fa-random"></i>Switch</div>
-				</a>
-			</li>
-			<li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
-				<a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
-					<i class="{{::child.icon}}" ng-show="::child.icon"></i>
-					{{::child.text}}
-				</a>
-			</li>
-			<li class="side-menu-header">
-				<span class="sidemenu-item-text">{{::item.text}}</span>
-			</li>
-		</ul>
-	</div>
+  <div ng-repeat="item in ::ctrl.bottomNav" class="sidemenu-item dropdown dropup">
+    <a href="{{::item.url}}" class="sidemenu-link" target="{{::item.target}}">
+      <span class="icon-circle sidemenu-icon">
+        <i class="{{::item.icon}}" ng-show="::item.icon"></i>
+        <img ng-src="{{::item.img}}" ng-show="::item.img">
+      </span>
+    </a>
+    <ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
+      <li ng-if="item.showOrgSwitcher" class="sidemenu-org-switcher">
+        <a ng-click="ctrl.switchOrg()">
+          <div>
+            <div class="sidemenu-org-switcher__org-name">{{ctrl.contextSrv.user.orgName}}</div>
+            <div class="sidemenu-org-switcher__org-current">Current Org:</div>
+          </div>
+          <div class="sidemenu-org-switcher__switch">
+            <i class="fa fa-fw fa-random"></i>Switch</div>
+        </a>
+      </li>
+      <li ng-repeat="child in ::item.children" ng-class="{divider: child.divider}" ng-hide="::child.hideFromMenu">
+        <a href="{{::child.url}}" target="{{::child.target}}" ng-click="ctrl.itemClicked(child, $event)">
+          <i class="{{::child.icon}}" ng-show="::child.icon"></i>
+          {{::child.text}}
+        </a>
+      </li>
+      <li class="side-menu-header">
+        <span class="sidemenu-item-text">{{::item.text}}</span>
+      </li>
+    </ul>
+  </div>
 </div>
-

+ 11 - 8
public/app/features/dashboard/dashgrid/DashboardRow.tsx

@@ -4,6 +4,7 @@ import { PanelModel } from '../panel_model';
 import { PanelContainer } from './PanelContainer';
 import templateSrv from 'app/features/templating/template_srv';
 import appEvents from 'app/core/app_events';
+import config from 'app/core/config';
 
 export interface DashboardRowProps {
   panel: PanelModel;
@@ -94,14 +95,16 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
           {title}
           <span className="dashboard-row__panel_count">({hiddenPanels} hidden panels)</span>
         </a>
-        <div className="dashboard-row__actions">
-          <a className="pointer" onClick={this.openSettings}>
-            <i className="fa fa-cog" />
-          </a>
-          <a className="pointer" onClick={this.delete}>
-            <i className="fa fa-trash" />
-          </a>
-        </div>
+        {config.bootData.user.orgRole !== 'Viewer' && (
+          <div className="dashboard-row__actions">
+            <a className="pointer" onClick={this.openSettings}>
+              <i className="fa fa-cog" />
+            </a>
+            <a className="pointer" onClick={this.delete}>
+              <i className="fa fa-trash" />
+            </a>
+          </div>
+        )}
         <div className="dashboard-row__drag grid-drag-handle" />
       </div>
     );

+ 20 - 3
public/app/features/dashboard/specs/DashboardRow.jest.tsx

@@ -2,19 +2,26 @@ import React from 'react';
 import { shallow } from 'enzyme';
 import { DashboardRow } from '../dashgrid/DashboardRow';
 import { PanelModel } from '../panel_model';
+import config from '../../../core/config';
 
 describe('DashboardRow', () => {
   let wrapper, panel, getPanelContainer, dashboardMock;
 
   beforeEach(() => {
-    dashboardMock = {toggleRow: jest.fn()};
+    dashboardMock = { toggleRow: jest.fn() };
+
+    config.bootData = {
+      user: {
+        orgRole: 'Admin',
+      },
+    };
 
     getPanelContainer = jest.fn().mockReturnValue({
       getDashboard: jest.fn().mockReturnValue(dashboardMock),
-      getPanelLoader: jest.fn()
+      getPanelLoader: jest.fn(),
     });
 
-    panel = new PanelModel({collapsed: false});
+    panel = new PanelModel({ collapsed: false });
     wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
   });
 
@@ -30,4 +37,14 @@ describe('DashboardRow', () => {
     expect(dashboardMock.toggleRow.mock.calls).toHaveLength(1);
   });
 
+  it('should have two actions as admin', () => {
+    expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
+  });
+
+  it('should have zero actions as viewer', () => {
+    config.bootData.user.orgRole = 'Viewer';
+    panel = new PanelModel({ collapsed: false });
+    wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
+    expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
+  });
 });

+ 5 - 1
public/app/features/templating/specs/template_srv.jest.ts

@@ -107,7 +107,6 @@ describe('templateSrv', function() {
       ]);
     });
 
-
     it('should replace $test with globbed value', function() {
       var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
       expect(target).toBe('this.{value1,value2}.filters');
@@ -261,6 +260,11 @@ describe('templateSrv', function() {
       expect(result).toBe('test');
     });
 
+    it('multi value and csv format should render csv string', function() {
+      var result = _templateSrv.formatValue(['test', 'test2'], 'csv');
+      expect(result).toBe('test,test2');
+    });
+
     it('slash should be properly escaped in regex format', function() {
       var result = _templateSrv.formatValue('Gi3/14', 'regex');
       expect(result).toBe('Gi3\\/14');

+ 6 - 0
public/app/features/templating/template_srv.ts

@@ -115,6 +115,12 @@ export class TemplateSrv {
         }
         return this.distributeVariable(value, variable.name);
       }
+      case 'csv': {
+        if (_.isArray(value)) {
+          return value.join(',');
+        }
+        return value;
+      }
       default: {
         if (_.isArray(value)) {
           return '{' + value.join(',') + '}';

+ 38 - 1
public/app/plugins/panel/graph/specs/threshold_manager_specs.ts

@@ -1,5 +1,7 @@
 import { describe, it, expect } from '../../../../../test/lib/common';
 
+import angular from 'angular';
+import TimeSeries from 'app/core/time_series2';
 import { ThresholdManager } from '../threshold_manager';
 
 describe('ThresholdManager', function() {
@@ -15,9 +17,13 @@ describe('ThresholdManager', function() {
         panelCtrl: {},
       };
 
-      ctx.setup = function(thresholds) {
+      ctx.setup = function(thresholds, data) {
         ctx.panel.thresholds = thresholds;
         var manager = new ThresholdManager(ctx.panelCtrl);
+        if (data !== undefined) {
+          var element = angular.element('<div grafana-graph><div>');
+          manager.prepare(element, data);
+        }
         manager.addFlotOptions(ctx.options, ctx.panel);
       };
 
@@ -101,5 +107,36 @@ describe('ThresholdManager', function() {
         expect(markings[1].yaxis.to).to.be(-Infinity);
       });
     });
+
+    plotOptionsScenario('for threshold on two Y axes', ctx => {
+      var data = new Array(2);
+      data[0] = new TimeSeries({
+        datapoints: [[0, 1], [300, 2]],
+        alias: 'left',
+      });
+      data[0].yaxis = 1;
+      data[1] = new TimeSeries({
+        datapoints: [[0, 1], [300, 2]],
+        alias: 'right',
+      });
+      data[1].yaxis = 2;
+      ctx.setup(
+        [
+          { op: 'gt', value: 100, line: true, colorMode: 'critical' },
+          { op: 'gt', value: 200, line: true, colorMode: 'critical', yaxis: 'right' },
+        ],
+        data
+      );
+
+      it('should add first threshold for left axis', function() {
+        var markings = ctx.options.grid.markings;
+        expect(markings[0].yaxis.from).to.be(100);
+      });
+
+      it('should add second threshold for right axis', function() {
+        var markings = ctx.options.grid.markings;
+        expect(markings[1].y2axis.from).to.be(200);
+      });
+    });
   });
 });

+ 22 - 8
public/app/plugins/panel/graph/threshold_manager.ts

@@ -222,16 +222,30 @@ export class ThresholdManager {
 
       // fill
       if (threshold.fill) {
-        options.grid.markings.push({
-          yaxis: { from: threshold.value, to: limit },
-          color: fillColor,
-        });
+        if (threshold.yaxis === 'right' && this.hasSecondYAxis) {
+          options.grid.markings.push({
+            y2axis: { from: threshold.value, to: limit },
+            color: fillColor,
+          });
+        } else {
+          options.grid.markings.push({
+            yaxis: { from: threshold.value, to: limit },
+            color: fillColor,
+          });
+        }
       }
       if (threshold.line) {
-        options.grid.markings.push({
-          yaxis: { from: threshold.value, to: threshold.value },
-          color: lineColor,
-        });
+        if (threshold.yaxis === 'right' && this.hasSecondYAxis) {
+          options.grid.markings.push({
+            y2axis: { from: threshold.value, to: threshold.value },
+            color: lineColor,
+          });
+        } else {
+          options.grid.markings.push({
+            yaxis: { from: threshold.value, to: threshold.value },
+            color: lineColor,
+          });
+        }
       }
     }
   }

+ 11 - 0
public/app/plugins/panel/graph/thresholds_form.ts

@@ -29,6 +29,7 @@ export class ThresholdFormCtrl {
       op: 'gt',
       fill: true,
       line: true,
+      yaxis: 'left',
     });
     this.panelCtrl.render();
   }
@@ -109,6 +110,16 @@ var template = `
         </span>
       </div>
 
+      <div class="gf-form">
+        <label class="gf-form-label">Y-Axis</label>
+        <div class="gf-form-select-wrapper">
+          <select class="gf-form-input" ng-model="threshold.yaxis"
+                  ng-init="threshold.yaxis = threshold.yaxis === 'left' || threshold.yaxis === 'right' ? threshold.yaxis : 'left'"
+                  ng-options="f for f in ['left', 'right']" ng-change="ctrl.render()" ng-disabled="ctrl.disabled">
+          </select>
+        </div>
+      </div>
+
       <div class="gf-form">
         <label class="gf-form-label">
           <a class="pointer" ng-click="ctrl.removeThreshold($index)" ng-disabled="ctrl.disabled">

+ 11 - 0
public/img/resize-handle-white.svg

@@ -0,0 +1,11 @@
+<?xml version="1.0" standalone="no"?>
+<!-- Generator: Adobe Fireworks CS6, Export SVG Extension by Aaron Beall (http://fireworks.abeall.com) . Version: 0.6.1  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg id="Untitled-Page%201" viewBox="0 0 6 6" style="background-color:#ffffff00" version="1.1"
+	xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
+	x="0px" y="0px" width="6px" height="6px"
+>
+	<g opacity="0.302">
+		<path d="M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z" fill="#FFFFFF"/>
+	</g>
+</svg>

+ 1 - 0
public/sass/_variables.dark.scss

@@ -259,6 +259,7 @@ $navbar-button-border: #2f2f32;
 // Sidemenu
 // -------------------------
 $side-menu-bg: $black;
+$side-menu-bg-mobile: $side-menu-bg;
 $side-menu-item-hover-bg: $dark-2;
 $side-menu-shadow: 0 0 20px black;
 $side-menu-link-color: $link-color;

+ 1 - 0
public/sass/_variables.light.scss

@@ -200,6 +200,7 @@ $input-invalid-border-color: lighten($red, 5%);
 // Sidemenu
 // -------------------------
 $side-menu-bg: $dark-2;
+$side-menu-bg-mobile: rgba(0, 0, 0, 0); //$gray-6;
 $side-menu-item-hover-bg: $gray-1;
 $side-menu-shadow: 5px 0px 10px -5px $gray-1;
 $side-menu-link-color: $gray-6;

+ 5 - 0
public/sass/components/_dashboard_grid.scss

@@ -44,6 +44,11 @@
     border-right: 2px solid $gray-1;
     border-bottom: 2px solid $gray-1;
   }
+  // temp fix since we use old commit of grid component
+  // this can be removed when we revert to non fork grid component
+  .react-grid-item > .react-resizable-handle {
+    background-image: url('../img/resize-handle-white.svg');
+  }
 }
 
 .theme-light {

+ 9 - 2
public/sass/components/_sidemenu.scss

@@ -71,7 +71,7 @@
         // important to overlap it otherwise it can be hidden
         // again by the mouse getting outside the hover space
         left: $side-menu-width - 2px;
-        @include animation("dropdown-anim 150ms ease-in-out 100ms forwards");
+        @include animation('dropdown-anim 150ms ease-in-out 100ms forwards');
         z-index: $zindex-sidemenu;
       }
     }
@@ -193,9 +193,13 @@ li.sidemenu-org-switcher {
 
 @include media-breakpoint-down(xs) {
   .sidemenu-open--xs {
+    li {
+      font-size: $font-size-lg;
+    }
+
     .sidemenu {
       width: 100%;
-      background: $side-menu-bg;
+      background: $side-menu-bg-mobile;
       position: initial;
       height: auto;
       box-shadow: $side-menu-shadow;
@@ -214,6 +218,9 @@ li.sidemenu-org-switcher {
     .sidemenu__bottom {
       display: block;
     }
+    .sidemenu-item {
+      border-right: 2px solid transparent;
+    }
   }
 
   .sidemenu {

+ 1 - 1
scripts/build/Dockerfile

@@ -21,7 +21,7 @@ RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A170311380
 RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - && \
     yum install -y nodejs --nogpgcheck
 
-ENV GOLANG_VERSION 1.9.3
+ENV GOLANG_VERSION 1.10
 
 RUN wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo && \
     yum install -y yarn --nogpgcheck && \