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

Merge remote-tracking branch 'origin/develop' into gauge-value-mappings

Peter Holmberg 7 лет назад
Родитель
Сommit
c2b1f504a0
56 измененных файлов с 967 добавлено и 486 удалено
  1. 21 0
      CHANGELOG.md
  2. 23 21
      docs/sources/alerting/notifications.md
  3. 3 3
      packaging/publish/publish_both.sh
  4. 0 3
      pkg/api/index.go
  5. 215 0
      pkg/services/alerting/notifiers/googlechat.go
  6. 53 0
      pkg/services/alerting/notifiers/googlechat_test.go
  7. 1 1
      pkg/tsdb/cloudwatch/metric_find_query.go
  8. 1 1
      public/app/core/angular_wrappers.ts
  9. 2 1
      public/app/core/components/Animations/FadeIn.tsx
  10. 4 2
      public/app/core/components/Picker/ResetStyles.tsx
  11. 4 3
      public/app/core/components/Switch/Switch.tsx
  12. 10 11
      public/app/core/components/TagFilter/TagFilter.tsx
  13. 18 40
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  14. 1 1
      public/app/core/components/colorpicker/SeriesColorPicker.tsx
  15. 1 1
      public/app/core/components/search/search.html
  16. 6 8
      public/app/core/components/search/search.ts
  17. 1 1
      public/app/core/components/sidemenu/TopSectionItem.tsx
  18. 3 0
      public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap
  19. 39 17
      public/app/core/logs_model.ts
  20. 100 73
      public/app/features/dashboard/dashgrid/DataSourcePicker.tsx
  21. 1 1
      public/app/features/dashboard/dashgrid/EditorTabBody.tsx
  22. 1 15
      public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx
  23. 62 16
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx
  24. 36 0
      public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx
  25. 65 0
      public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx
  26. 15 7
      public/app/features/explore/Explore.tsx
  27. 9 8
      public/app/features/explore/LogLabels.tsx
  28. 39 59
      public/app/features/explore/Logs.tsx
  29. 18 5
      public/app/features/explore/TimePicker.tsx
  30. 11 4
      public/app/features/panel/metrics_tab.ts
  31. 6 1
      public/app/features/panel/panel_editor_tab.ts
  32. 3 2
      public/app/features/templating/custom_variable.ts
  33. 1 1
      public/app/features/templating/partials/editor.html
  34. 4 2
      public/app/features/templating/specs/variable_srv.test.ts
  35. 1 1
      public/app/plugins/datasource/loki/plugin.json
  36. 1 0
      public/app/plugins/panel/graph/module.ts
  37. 63 0
      public/app/plugins/panel/graph/specs/time_region_manager.test.ts
  38. 9 43
      public/app/plugins/panel/graph/tab_display.html
  39. 2 0
      public/app/plugins/panel/graph/tab_thresholds_time_regions.html
  40. 0 1
      public/app/plugins/panel/graph/thresholds_form.html
  41. 18 10
      public/app/plugins/panel/graph/time_region_manager.ts
  42. 1 2
      public/app/plugins/panel/graph/time_regions_form.html
  43. 5 2
      public/app/plugins/panel/singlestat/module.ts
  44. 5 0
      public/sass/_variables.dark.scss
  45. 6 1
      public/sass/_variables.light.scss
  46. 5 5
      public/sass/base/_reboot.scss
  47. 1 1
      public/sass/components/_buttons.scss
  48. 1 5
      public/sass/components/_dashboard_grid.scss
  49. 5 0
      public/sass/components/_form_select_box.scss
  50. 5 3
      public/sass/components/_gf-form.scss
  51. 8 14
      public/sass/components/_panel_editor.scss
  52. 14 6
      public/sass/components/_panel_logs.scss
  53. 13 7
      public/sass/components/_switch.scss
  54. 12 23
      public/sass/components/_toggle_button_group.scss
  55. 14 53
      public/sass/mixins/_mixins.scss
  56. 1 1
      public/views/index-template.html

+ 21 - 0
CHANGELOG.md

@@ -1,5 +1,8 @@
 # 5.5.0 (unreleased)
 # 5.5.0 (unreleased)
 
 
+### New Features
+* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
+
 ### Minor
 ### Minor
 
 
 * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
 * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi)
@@ -7,6 +10,24 @@
 * **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
 * **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
 * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
 * **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
 
 
+# 5.4.1 (2018-12-10)
+
+* **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262)
+* **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999). 
+* **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319)
+* **InfluxDB**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti)
+* **Plugins**: Panel plugins should no receive the panel-initialized event again as usual. 
+* **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284)
+* **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313](
+https://github.com/grafana/grafana/pull/14313)
+* **Quotas**: Fixed for updating org & user quotas. [#14347](https://github.com/grafana/grafana/pull/14347), thx [#moznion](https://github.com/moznion)
+* **Cloudwatch**: Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate to auto complete list. [#14401](https://github.com/grafana/grafana/pull/14401), thx [@sglajchEG](https://github.com/sglajchEG)
+* **Dashboard Search**: Fixed filtering by tag issues. 
+* **Graph**: Fixed time region issues, [#14425](https://github.com/grafana/grafana/issues/14425), [#14280](https://github.com/grafana/grafana/issues/14280)
+* **Graph**: Fixed issue with series color picker popover being placed outside window. 
+
+ 
+
 # 5.4.0 (2018-12-03)
 # 5.4.0 (2018-12-03)
 
 
 * **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150)
 * **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150)

+ 23 - 21
docs/sources/alerting/notifications.md

@@ -157,27 +157,29 @@ There are a couple of configuration options which need to be set up in Grafana U
 
 
 Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
 Once these two properties are set, you can send the alerts to Kafka for further processing or throttling.
 
 
-### All supported notifiers
-
-Name | Type |Support images | Support reminders
------|------------ | ------ | ------ |
-Slack | `slack` | yes | yes
-Pagerduty | `pagerduty` | yes | yes
-Email | `email` | yes | yes
-Webhook | `webhook` | link | yes
-Kafka | `kafka` | no | yes
-Hipchat | `hipchat` | yes | yes
-VictorOps | `victorops` | yes | yes
-Sensu | `sensu` | yes | yes
-OpsGenie | `opsgenie` | yes | yes
-Threema | `threema` | yes | yes
-Pushover | `pushover` | no | yes
-Telegram | `telegram` | no | yes
-Line | `line` | no | yes
-Microsoft Teams | `teams` | yes | yes
-Prometheus Alertmanager | `prometheus-alertmanager` | no | no
-
-
+### Google Hangouts Chat
+
+Notifications can be sent by setting up an incoming webhook in Google Hangouts chat. Configuring such a webhook is described [here](https://developers.google.com/hangouts/chat/how-tos/webhooks).
+
+### All supported notifier
+
+Name | Type |Support images
+-----|------------ | ------
+Slack | `slack` | yes
+Pagerduty | `pagerduty` | yes
+Email | `email` | yes
+Webhook | `webhook` | link
+Kafka | `kafka` | no
+Google Hangouts Chat | `googlechat` | yes
+Hipchat | `hipchat` | yes
+VictorOps | `victorops` | yes
+Sensu | `sensu` | yes
+OpsGenie | `opsgenie` | yes
+Threema | `threema` | yes
+Pushover | `pushover` | no
+Telegram | `telegram` | no
+Line | `line` | no
+Prometheus Alertmanager | `prometheus-alertmanager` | no
 
 
 # Enable images in notifications {#external-image-store}
 # Enable images in notifications {#external-image-store}
 
 

+ 3 - 3
packaging/publish/publish_both.sh

@@ -1,7 +1,7 @@
 #! /usr/bin/env bash
 #! /usr/bin/env bash
-version=5.0.2
+version=5.4.1
 
 
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb
+wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
 
 
 package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
 package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
 package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
 package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
@@ -11,7 +11,7 @@ package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
 package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
 package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
 package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
 package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
 
 
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${version}-1.x86_64.rpm
+wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm
 
 
 package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose

+ 0 - 3
pkg/api/index.go

@@ -147,9 +147,6 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
 			SubTitle: "Explore your data",
 			SubTitle: "Explore your data",
 			Icon:     "fa fa-rocket",
 			Icon:     "fa fa-rocket",
 			Url:      setting.AppSubUrl + "/explore",
 			Url:      setting.AppSubUrl + "/explore",
-			Children: []*dtos.NavLink{
-				{Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore"},
-			},
 		})
 		})
 	}
 	}
 
 

+ 215 - 0
pkg/services/alerting/notifiers/googlechat.go

@@ -0,0 +1,215 @@
+package notifiers
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+func init() {
+	alerting.RegisterNotifier(&alerting.NotifierPlugin{
+		Type: "googlechat",
+		Name: "Google Hangouts Chat",
+		Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message " +
+			"format (https://developers.google.com/hangouts/chat/reference/message-formats/).",
+		Factory: NewGoogleChatNotifier,
+		OptionsTemplate: `
+      <h3 class="page-heading">Google Hangouts Chat settings</h3>
+      <div class="gf-form max-width-30">
+        <span class="gf-form-label width-6">Url</span>
+        <input type="text" required class="gf-form-input max-width-30" ng-model="ctrl.model.settings.url" placeholder="Google Hangouts Chat incoming webhook url"></input>
+      </div>
+    `,
+	})
+}
+
+func NewGoogleChatNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
+	url := model.Settings.Get("url").MustString()
+	if url == "" {
+		return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
+	}
+
+	return &GoogleChatNotifier{
+		NotifierBase: NewNotifierBase(model),
+		Url:          url,
+		log:          log.New("alerting.notifier.googlechat"),
+	}, nil
+}
+
+type GoogleChatNotifier struct {
+	NotifierBase
+	Url string
+	log log.Logger
+}
+
+/**
+Structs used to build a custom Google Hangouts Chat message card.
+See: https://developers.google.com/hangouts/chat/reference/message-formats/cards
+*/
+type outerStruct struct {
+	Cards []card `json:"cards"`
+}
+
+type card struct {
+	Header   header    `json:"header"`
+	Sections []section `json:"sections"`
+}
+
+type header struct {
+	Title string `json:"title"`
+}
+
+type section struct {
+	Widgets []widget `json:"widgets"`
+}
+
+// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget)
+type widget interface {
+}
+
+type buttonWidget struct {
+	Buttons []button `json:"buttons"`
+}
+
+type textParagraphWidget struct {
+	Text text `json:"textParagraph"`
+}
+
+type text struct {
+	Text string `json:"text"`
+}
+
+type imageWidget struct {
+	Image image `json:"image"`
+}
+
+type image struct {
+	ImageUrl string `json:"imageUrl"`
+}
+
+type button struct {
+	TextButton textButton `json:"textButton"`
+}
+
+type textButton struct {
+	Text    string  `json:"text"`
+	OnClick onClick `json:"onClick"`
+}
+
+type onClick struct {
+	OpenLink openLink `json:"openLink"`
+}
+
+type openLink struct {
+	Url string `json:"url"`
+}
+
+func (this *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
+	this.log.Info("Executing Google Chat notification")
+
+	headers := map[string]string{
+		"Content-Type": "application/json; charset=UTF-8",
+	}
+
+	ruleUrl, err := evalContext.GetRuleUrl()
+	if err != nil {
+		this.log.Error("evalContext returned an invalid rule URL")
+	}
+
+	// add a text paragraph widget for the message
+	widgets := []widget{
+		textParagraphWidget{
+			Text: text{
+				Text: evalContext.Rule.Message,
+			},
+		},
+	}
+
+	// add a text paragraph widget for the fields
+	var fields []textParagraphWidget
+	fieldLimitCount := 4
+	for index, evt := range evalContext.EvalMatches {
+		fields = append(fields,
+			textParagraphWidget{
+				Text: text{
+					Text: "<i>" + evt.Metric + ": " + fmt.Sprint(evt.Value) + "</i>",
+				},
+			},
+		)
+		if index > fieldLimitCount {
+			break
+		}
+	}
+	widgets = append(widgets, fields)
+
+	// if an image exists, add it as an image widget
+	if evalContext.ImagePublicUrl != "" {
+		widgets = append(widgets, imageWidget{
+			Image: image{
+				ImageUrl: evalContext.ImagePublicUrl,
+			},
+		})
+	} else {
+		this.log.Info("Could not retrieve a public image URL.")
+	}
+
+	// add a button widget (link to Grafana)
+	widgets = append(widgets, buttonWidget{
+		Buttons: []button{
+			{
+				TextButton: textButton{
+					Text: "OPEN IN GRAFANA",
+					OnClick: onClick{
+						OpenLink: openLink{
+							Url: ruleUrl,
+						},
+					},
+				},
+			},
+		},
+	})
+
+	// add text paragraph widget for the build version and timestamp
+	widgets = append(widgets, textParagraphWidget{
+		Text: text{
+			Text: "Grafana v" + setting.BuildVersion + " | " + (time.Now()).Format(time.RFC822),
+		},
+	})
+
+	// nest the required structs
+	res1D := &outerStruct{
+		Cards: []card{
+			{
+				Header: header{
+					Title: evalContext.GetNotificationTitle(),
+				},
+				Sections: []section{
+					{
+						Widgets: widgets,
+					},
+				},
+			},
+		},
+	}
+	body, _ := json.Marshal(res1D)
+
+	cmd := &m.SendWebhookSync{
+		Url:        this.Url,
+		HttpMethod: "POST",
+		HttpHeader: headers,
+		Body:       string(body),
+	}
+
+	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
+		this.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", this.Name)
+		return err
+	}
+
+	return nil
+}

+ 53 - 0
pkg/services/alerting/notifiers/googlechat_test.go

@@ -0,0 +1,53 @@
+package notifiers
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestGoogleChatNotifier(t *testing.T) {
+	Convey("Google Hangouts Chat notifier tests", t, func() {
+
+		Convey("Parsing alert notification from settings", func() {
+			Convey("empty settings should return error", func() {
+				json := `{ }`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "ops",
+					Type:     "googlechat",
+					Settings: settingsJSON,
+				}
+
+				_, err := NewGoogleChatNotifier(model)
+				So(err, ShouldNotBeNil)
+			})
+
+			Convey("from settings", func() {
+				json := `
+				{
+          			"url": "http://google.com"
+				}`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "ops",
+					Type:     "googlechat",
+					Settings: settingsJSON,
+				}
+
+				not, err := NewGoogleChatNotifier(model)
+				webhookNotifier := not.(*GoogleChatNotifier)
+
+				So(err, ShouldBeNil)
+				So(webhookNotifier.Name, ShouldEqual, "ops")
+				So(webhookNotifier.Type, ShouldEqual, "googlechat")
+				So(webhookNotifier.Url, ShouldEqual, "http://google.com")
+			})
+
+		})
+	})
+}

+ 1 - 1
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -101,7 +101,7 @@ func init() {
 		"AWS/RDS":              {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
 		"AWS/RDS":              {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"},
 		"AWS/Route53":          {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
 		"AWS/Route53":          {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"},
 		"AWS/S3":               {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
 		"AWS/S3":               {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"},
-		"AWS/SES":              {"Bounce", "Complaint", "Delivery", "Reject", "Send"},
+		"AWS/SES":              {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
 		"AWS/SNS":              {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
 		"AWS/SNS":              {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
 		"AWS/SQS":              {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
 		"AWS/SQS":              {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"},
 		"AWS/States":           {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"},
 		"AWS/States":           {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"},

+ 1 - 1
public/app/core/angular_wrappers.ts

@@ -16,7 +16,7 @@ export function registerAngularDirectives() {
   react2AngularDirective('searchResult', SearchResult, []);
   react2AngularDirective('searchResult', SearchResult, []);
   react2AngularDirective('tagFilter', TagFilter, [
   react2AngularDirective('tagFilter', TagFilter, [
     'tags',
     'tags',
-    ['onSelect', { watchDepth: 'reference' }],
+    ['onChange', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
   ]);
 }
 }

+ 2 - 1
public/app/core/components/Animations/FadeIn.tsx

@@ -5,6 +5,7 @@ interface Props {
   duration: number;
   duration: number;
   children: JSX.Element;
   children: JSX.Element;
   in: boolean;
   in: boolean;
+  unmountOnExit?: boolean;
 }
 }
 
 
 export const FadeIn: SFC<Props> = props => {
 export const FadeIn: SFC<Props> = props => {
@@ -21,7 +22,7 @@ export const FadeIn: SFC<Props> = props => {
   };
   };
 
 
   return (
   return (
-    <Transition in={props.in} timeout={props.duration}>
+    <Transition in={props.in} timeout={props.duration} unmountOnExit={props.unmountOnExit || false}>
       {state => (
       {state => (
         <div
         <div
           style={{
           style={{

+ 4 - 2
public/app/core/components/Picker/ResetStyles.tsx

@@ -1,4 +1,4 @@
-export default {
+export default {
   clearIndicator: () => ({}),
   clearIndicator: () => ({}),
   container: () => ({}),
   container: () => ({}),
   control: () => ({}),
   control: () => ({}),
@@ -11,7 +11,9 @@
   loadingIndicator: () => ({}),
   loadingIndicator: () => ({}),
   loadingMessage: () => ({}),
   loadingMessage: () => ({}),
   menu: () => ({}),
   menu: () => ({}),
-  menuList: () => ({}),
+  menuList: ({ maxHeight }: { maxHeight: number }) => ({
+    maxHeight,
+  }),
   multiValue: () => ({}),
   multiValue: () => ({}),
   multiValueLabel: () => ({}),
   multiValueLabel: () => ({}),
   multiValueRemove: () => ({}),
   multiValueRemove: () => ({}),

+ 4 - 3
public/app/core/components/Switch/Switch.tsx

@@ -6,6 +6,7 @@ export interface Props {
   checked: boolean;
   checked: boolean;
   labelClass?: string;
   labelClass?: string;
   switchClass?: string;
   switchClass?: string;
+  transparent?: boolean;
   onChange: (event) => any;
   onChange: (event) => any;
 }
 }
 
 
@@ -24,11 +25,11 @@ export class Switch extends PureComponent<Props, State> {
   };
   };
 
 
   render() {
   render() {
-    const { labelClass = '', switchClass = '', label, checked } = this.props;
+    const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
 
 
     const labelId = `check-${this.state.id}`;
     const labelId = `check-${this.state.id}`;
-    const labelClassName = `gf-form-label ${labelClass} pointer`;
-    const switchClassName = `gf-form-switch ${switchClass}`;
+    const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
+    const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
 
 
     return (
     return (
       <label htmlFor={labelId} className="gf-form-switch-container">
       <label htmlFor={labelId} className="gf-form-switch-container">

+ 10 - 11
public/app/core/components/TagFilter/TagFilter.tsx

@@ -10,7 +10,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles';
 export interface Props {
 export interface Props {
   tags: string[];
   tags: string[];
   tagOptions: () => any;
   tagOptions: () => any;
-  onSelect: (tag: string) => void;
+  onChange: (tags: string[]) => void;
 }
 }
 
 
 export class TagFilter extends React.Component<Props, any> {
 export class TagFilter extends React.Component<Props, any> {
@@ -18,12 +18,9 @@ export class TagFilter extends React.Component<Props, any> {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-
-    this.searchTags = this.searchTags.bind(this);
-    this.onChange = this.onChange.bind(this);
   }
   }
 
 
-  searchTags(query) {
+  onLoadOptions = query => {
     return this.props.tagOptions().then(options => {
     return this.props.tagOptions().then(options => {
       return options.map(option => ({
       return options.map(option => ({
         value: option.term,
         value: option.term,
@@ -31,18 +28,20 @@ export class TagFilter extends React.Component<Props, any> {
         count: option.count,
         count: option.count,
       }));
       }));
     });
     });
-  }
+  };
 
 
-  onChange(newTags) {
-    this.props.onSelect(newTags);
-  }
+  onChange = (newTags: any[]) => {
+    this.props.onChange(newTags.map(tag => tag.value));
+  };
 
 
   render() {
   render() {
+    const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 }));
+
     const selectOptions = {
     const selectOptions = {
       classNamePrefix: 'gf-form-select-box',
       classNamePrefix: 'gf-form-select-box',
       isMulti: true,
       isMulti: true,
       defaultOptions: true,
       defaultOptions: true,
-      loadOptions: this.searchTags,
+      loadOptions: this.onLoadOptions,
       onChange: this.onChange,
       onChange: this.onChange,
       className: 'gf-form-input gf-form-input--form-dropdown',
       className: 'gf-form-input gf-form-input--form-dropdown',
       placeholder: 'Tags',
       placeholder: 'Tags',
@@ -50,7 +49,7 @@ export class TagFilter extends React.Component<Props, any> {
       noOptionsMessage: () => 'No tags found',
       noOptionsMessage: () => 'No tags found',
       getOptionValue: i => i.value,
       getOptionValue: i => i.value,
       getOptionLabel: i => i.label,
       getOptionLabel: i => i.label,
-      value: this.props.tags,
+      value: tags,
       styles: ResetStyles,
       styles: ResetStyles,
       components: {
       components: {
         Option: TagOption,
         Option: TagOption,

+ 18 - 40
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -1,48 +1,20 @@
-import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react';
+import React, { SFC, ReactNode, PureComponent } from 'react';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
 
 
 interface ToggleButtonGroupProps {
 interface ToggleButtonGroupProps {
-  onChange: (value) => void;
-  value?: any;
   label?: string;
   label?: string;
-  render: (props) => void;
-  stackedButtons?: boolean;
+  children: JSX.Element[];
+  transparent?: boolean;
 }
 }
 
 
 export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
 export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
-  static defaultProps = {
-    stackedButtons: false,
-  };
-
-  getValues() {
-    const { children } = this.props;
-    return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
-  }
-
-  smallChildren() {
-    const { children } = this.props;
-    return React.Children.toArray(children).every((c: ReactElement<any>) => c.props.className.includes('small'));
-  }
-
-  handleToggle(toggleValue) {
-    const { value, onChange } = this.props;
-    if (value && value === toggleValue) {
-      return;
-    }
-    onChange(toggleValue);
-  }
-
   render() {
   render() {
-    const { value, label, stackedButtons } = this.props;
-    const values = this.getValues();
-    const selectedValue = value || values[0];
-    const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`;
+    const { children, label, transparent } = this.props;
 
 
     return (
     return (
       <div className="gf-form">
       <div className="gf-form">
-        <div className={`toggle-button-group ${stackedButtons ? 'stacked' : ''}`}>
-          {label && <label className={labelClassName}>{label}</label>}
-          {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this), stackedButtons: stackedButtons })}
-        </div>
+        {label && <label className={`gf-form-label ${transparent ? 'gf-form-label--transparent' : ''}`}>{label}</label>}
+        <div className={`toggle-button-group ${transparent ? 'toggle-button-group--transparent' : ''}`}>{children}</div>
       </div>
       </div>
     );
     );
   }
   }
@@ -54,16 +26,16 @@ interface ToggleButtonProps {
   value: any;
   value: any;
   className?: string;
   className?: string;
   children: ReactNode;
   children: ReactNode;
-  stackedButtons?: boolean;
+  tooltip?: string;
 }
 }
 
 
 export const ToggleButton: SFC<ToggleButtonProps> = ({
 export const ToggleButton: SFC<ToggleButtonProps> = ({
   children,
   children,
   selected,
   selected,
   className = '',
   className = '',
-  value,
+  value = null,
+  tooltip,
   onChange,
   onChange,
-  stackedButtons,
 }) => {
 }) => {
   const handleChange = event => {
   const handleChange = event => {
     event.stopPropagation();
     event.stopPropagation();
@@ -72,10 +44,16 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
     }
     }
   };
   };
 
 
-  const btnClassName = `btn ${className} ${selected ? 'active' : ''} ${stackedButtons ? 'stacked' : ''}`;
-  return (
+  const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
+  const button = (
     <button className={btnClassName} onClick={handleChange}>
     <button className={btnClassName} onClick={handleChange}>
       <span>{children}</span>
       <span>{children}</span>
     </button>
     </button>
   );
   );
+
+  if (tooltip) {
+    return <Tooltip content={tooltip}>{button}</Tooltip>;
+  } else {
+    return button;
+  }
 };
 };

+ 1 - 1
public/app/core/components/colorpicker/SeriesColorPicker.tsx

@@ -44,7 +44,7 @@ export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
     const drop = new Drop({
     const drop = new Drop({
       target: this.pickerElem,
       target: this.pickerElem,
       content: dropContentElem,
       content: dropContentElem,
-      position: 'top center',
+      position: 'bottom center',
       classes: 'drop-popover',
       classes: 'drop-popover',
       openOn: 'hover',
       openOn: 'hover',
       hoverCloseDelay: 200,
       hoverCloseDelay: 200,

+ 1 - 1
public/app/core/components/search/search.html

@@ -41,7 +41,7 @@
           </a>
           </a>
         </div>
         </div>
 
 
-        <tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onSelect="ctrl.onTagSelect">
+        <tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onChange="ctrl.onTagFiltersChanged">
         </tag-filter>
         </tag-filter>
       </div>
       </div>
 
 

+ 6 - 8
public/app/core/components/search/search.ts

@@ -25,8 +25,6 @@ export class SearchCtrl {
     appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
     appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
 
 
     this.initialFolderFilterTitle = 'All';
     this.initialFolderFilterTitle = 'All';
-    this.getTags = this.getTags.bind(this);
-    this.onTagSelect = this.onTagSelect.bind(this);
     this.isEditor = contextSrv.isEditor;
     this.isEditor = contextSrv.isEditor;
     this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
     this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
   }
   }
@@ -162,7 +160,7 @@ export class SearchCtrl {
     const localSearchId = this.currentSearchId;
     const localSearchId = this.currentSearchId;
     const query = {
     const query = {
       ...this.query,
       ...this.query,
-      tag: this.query.tag.map(i => i.value),
+      tag: this.query.tag,
     };
     };
 
 
     return this.searchSrv.search(query).then(results => {
     return this.searchSrv.search(query).then(results => {
@@ -195,14 +193,14 @@ export class SearchCtrl {
     evt.preventDefault();
     evt.preventDefault();
   }
   }
 
 
-  getTags() {
+  getTags = () => {
     return this.searchSrv.getDashboardTags();
     return this.searchSrv.getDashboardTags();
-  }
+  };
 
 
-  onTagSelect(newTags) {
-    this.query.tag = newTags;
+  onTagFiltersChanged = (tags: string[]) => {
+    this.query.tag = tags;
     this.search();
     this.search();
-  }
+  };
 
 
   clearSearchFilter() {
   clearSearchFilter() {
     this.query.tag = [];
     this.query.tag = [];

+ 1 - 1
public/app/core/components/sidemenu/TopSectionItem.tsx

@@ -15,7 +15,7 @@ const TopSectionItem: SFC<Props> = props => {
           {link.img && <img src={link.img} />}
           {link.img && <img src={link.img} />}
         </span>
         </span>
       </a>
       </a>
-      {link.children && <SideMenuDropDown link={link} />}
+      <SideMenuDropDown link={link} />
     </div>
     </div>
   );
   );
 };
 };

+ 3 - 0
public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap

@@ -13,5 +13,8 @@ exports[`Render should render component 1`] = `
       <i />
       <i />
     </span>
     </span>
   </a>
   </a>
+  <SideMenuDropDown
+    link={Object {}}
+  />
 </div>
 </div>
 `;
 `;

+ 39 - 17
public/app/core/logs_model.ts

@@ -88,6 +88,13 @@ export interface LogsStreamLabels {
   [key: string]: string;
   [key: string]: string;
 }
 }
 
 
+export enum LogsDedupDescription {
+  none = 'No de-duplication',
+  exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
+  numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.',
+  signature = 'De-duplication of successive lines that have identical punctuation and whitespace.',
+}
+
 export enum LogsDedupStrategy {
 export enum LogsDedupStrategy {
   none = 'none',
   none = 'none',
   exact = 'exact',
   exact = 'exact',
@@ -242,32 +249,47 @@ export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSerie
   // Graph time series by log level
   // Graph time series by log level
   const seriesByLevel = {};
   const seriesByLevel = {};
   const bucketSize = intervalMs * 10;
   const bucketSize = intervalMs * 10;
+  const seriesList = [];
 
 
   for (const row of rows) {
   for (const row of rows) {
-    if (!seriesByLevel[row.logLevel]) {
-      seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel };
-    }
+    let series = seriesByLevel[row.logLevel];
 
 
-    const levelSeries = seriesByLevel[row.logLevel];
+    if (!series) {
+      seriesByLevel[row.logLevel] = series = {
+        lastTs: null,
+        datapoints: [],
+        alias: row.logLevel,
+        color: LogLevelColor[row.logLevel],
+      };
+
+      seriesList.push(series);
+    }
 
 
-    // Bucket to nearest minute
+    // align time to bucket size
     const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize;
     const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize;
 
 
     // Entry for time
     // Entry for time
-    if (time === levelSeries.lastTs) {
-      levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++;
+    if (time === series.lastTs) {
+      series.datapoints[series.datapoints.length - 1][0]++;
     } else {
     } else {
-      levelSeries.datapoints.push([1, time]);
-      levelSeries.lastTs = time;
+      series.datapoints.push([1, time]);
+      series.lastTs = time;
     }
     }
-  }
 
 
-  return Object.keys(seriesByLevel).reduce((acc, level) => {
-    if (seriesByLevel[level]) {
-      const gs = new TimeSeries(seriesByLevel[level]);
-      gs.setColor(LogLevelColor[level]);
-      acc.push(gs);
+    // add zero to other levels to aid stacking so each level series has same number of points
+    for (const other of seriesList) {
+      if (other !== series && other.lastTs !== time) {
+        other.datapoints.push([0, time]);
+        other.lastTs = time;
+      }
     }
     }
-    return acc;
-  }, []);
+  }
+
+  return seriesList.map(series => {
+    series.datapoints.sort((a, b) => {
+      return a[1] - b[1];
+    });
+
+    return new TimeSeries(series);
+  });
 }
 }

+ 100 - 73
public/app/features/dashboard/dashgrid/DataSourcePicker.tsx

@@ -1,97 +1,124 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import classNames from 'classnames';
 import classNames from 'classnames';
 import _ from 'lodash';
 import _ from 'lodash';
-
+import withKeyboardNavigation from './withKeyboardNavigation';
 import { DataSourceSelectItem } from 'app/types';
 import { DataSourceSelectItem } from 'app/types';
 
 
-interface Props {
+export interface Props {
   onChangeDataSource: (ds: any) => void;
   onChangeDataSource: (ds: any) => void;
   datasources: DataSourceSelectItem[];
   datasources: DataSourceSelectItem[];
+  selected?: number;
+  onKeyDown?: (evt: any, maxSelectedIndex: number, onEnterAction: () => void) => void;
+  onMouseEnter?: (select: number) => void;
 }
 }
 
 
 interface State {
 interface State {
   searchQuery: string;
   searchQuery: string;
 }
 }
 
 
-export class DataSourcePicker extends PureComponent<Props, State> {
-  searchInput: HTMLElement;
+export const DataSourcePicker = withKeyboardNavigation(
+  class DataSourcePicker extends PureComponent<Props, State> {
+    searchInput: HTMLElement;
 
 
-  constructor(props) {
-    super(props);
-    this.state = {
-      searchQuery: '',
-    };
-  }
+    constructor(props) {
+      super(props);
+      this.state = {
+        searchQuery: '',
+      };
+    }
 
 
-  getDataSources() {
-    const { searchQuery } = this.state;
-    const regex = new RegExp(searchQuery, 'i');
-    const { datasources } = this.props;
+    getDataSources() {
+      const { searchQuery } = this.state;
+      const regex = new RegExp(searchQuery, 'i');
+      const { datasources } = this.props;
 
 
-    const filtered = datasources.filter(item => {
-      return regex.test(item.name) || regex.test(item.meta.name);
-    });
+      const filtered = datasources.filter(item => {
+        return regex.test(item.name) || regex.test(item.meta.name);
+      });
 
 
-    return filtered;
-  }
+      return filtered;
+    }
+
+    get maxSelectedIndex() {
+      const filtered = this.getDataSources();
+      return filtered.length - 1;
+    }
 
 
-  renderDataSource = (ds: DataSourceSelectItem, index: number) => {
-    const { onChangeDataSource } = this.props;
-    const onClick = () => onChangeDataSource(ds);
-    const cssClass = classNames({
-      'ds-picker-list__item': true,
-    });
+    renderDataSource = (ds: DataSourceSelectItem, index: number) => {
+      const { onChangeDataSource, selected, onMouseEnter } = this.props;
+      const onClick = () => onChangeDataSource(ds);
+      const isSelected = selected === index;
+      const cssClass = classNames({
+        'ds-picker-list__item': true,
+        'ds-picker-list__item--selected': isSelected,
+      });
+      return (
+        <div
+          key={index}
+          className={cssClass}
+          title={ds.name}
+          onClick={onClick}
+          onMouseEnter={() => onMouseEnter(index)}
+        >
+          <img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
+          <div className="ds-picker-list__name">{ds.name}</div>
+        </div>
+      );
+    };
 
 
-    return (
-      <div key={index} className={cssClass} title={ds.name} onClick={onClick}>
-        <img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
-        <div className="ds-picker-list__name">{ds.name}</div>
-      </div>
-    );
-  };
+    componentDidMount() {
+      setTimeout(() => {
+        this.searchInput.focus();
+      }, 300);
+    }
 
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.searchInput.focus();
-    }, 300);
-  }
+    onSearchQueryChange = evt => {
+      const value = evt.target.value;
+      this.setState(prevState => ({
+        ...prevState,
+        searchQuery: value,
+      }));
+    };
 
 
-  onSearchQueryChange = evt => {
-    const value = evt.target.value;
-    this.setState(prevState => ({
-      ...prevState,
-      searchQuery: value,
-    }));
-  };
+    renderFilters() {
+      const { searchQuery } = this.state;
+      const { onKeyDown } = this.props;
+      return (
+        <>
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-13"
+              placeholder=""
+              ref={elem => (this.searchInput = elem)}
+              onChange={this.onSearchQueryChange}
+              value={searchQuery}
+              onKeyDown={evt => {
+                onKeyDown(evt, this.maxSelectedIndex, () => {
+                  const { onChangeDataSource, selected } = this.props;
+                  const ds = this.getDataSources()[selected];
+                  onChangeDataSource(ds);
+                });
+              }}
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+        </>
+      );
+    }
 
 
-  renderFilters() {
-    const { searchQuery } = this.state;
-    return (
-      <>
-        <label className="gf-form--has-input-icon">
-          <input
-            type="text"
-            className="gf-form-input width-13"
-            placeholder=""
-            ref={elem => (this.searchInput = elem)}
-            onChange={this.onSearchQueryChange}
-            value={searchQuery}
-          />
-          <i className="gf-form-input-icon fa fa-search" />
-        </label>
-      </>
-    );
+    render() {
+      return (
+        <>
+          <div className="cta-form__bar">
+            {this.renderFilters()}
+            <div className="gf-form--grow" />
+          </div>
+          <div className="ds-picker-list">{this.getDataSources().map(this.renderDataSource)}</div>
+        </>
+      );
+    }
   }
   }
+);
 
 
-  render() {
-    return (
-      <>
-        <div className="cta-form__bar">
-          {this.renderFilters()}
-          <div className="gf-form--grow" />
-        </div>
-        <div className="ds-picker-list">{this.getDataSources().map(this.renderDataSource)}</div>
-      </>
-    );
-  }
-}
+export default DataSourcePicker;

+ 1 - 1
public/app/features/dashboard/dashgrid/EditorTabBody.tsx

@@ -117,7 +117,7 @@ export class EditorTabBody extends PureComponent<Props, State> {
         </div>
         </div>
         <div className="panel-editor__scroll">
         <div className="panel-editor__scroll">
           <CustomScrollbar autoHide={false}>
           <CustomScrollbar autoHide={false}>
-            <FadeIn in={isOpen} duration={200}>
+            <FadeIn in={isOpen} duration={200} unmountOnExit={true}>
               <div className="panel-editor__toolbar-view">{openView && this.renderOpenView(openView)}</div>
               <div className="panel-editor__toolbar-view">{openView && this.renderOpenView(openView)}</div>
             </FadeIn>
             </FadeIn>
             <div className="panel-editor__content">
             <div className="panel-editor__content">

+ 1 - 15
public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx

@@ -1,4 +1,4 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent } from 'react';
 import { Switch } from 'app/core/components/Switch/Switch';
 import { Switch } from 'app/core/components/Switch/Switch';
 import { Input } from 'app/core/components/Form';
 import { Input } from 'app/core/components/Form';
 import { isValidTimeSpan } from 'app/core/utils/rangeutil';
 import { isValidTimeSpan } from 'app/core/utils/rangeutil';
@@ -64,12 +64,7 @@ export class TimeRangeOptions extends PureComponent<Props> {
 
 
         <div className="gf-form-group">
         <div className="gf-form-group">
           <div className="gf-form">
           <div className="gf-form">
-            <span className="gf-form-label">
-              <i className="fa fa-clock-o" />
-            </span>
-
             <span className="gf-form-label width-12">Override relative time</span>
             <span className="gf-form-label width-12">Override relative time</span>
-            <span className="gf-form-label width-6">Last</span>
             <Input
             <Input
               type="text"
               type="text"
               className="gf-form-input max-width-8"
               className="gf-form-input max-width-8"
@@ -81,11 +76,7 @@ export class TimeRangeOptions extends PureComponent<Props> {
           </div>
           </div>
 
 
           <div className="gf-form">
           <div className="gf-form">
-            <span className="gf-form-label">
-              <i className="fa fa-clock-o" />
-            </span>
             <span className="gf-form-label width-12">Add time shift</span>
             <span className="gf-form-label width-12">Add time shift</span>
-            <span className="gf-form-label width-6">Amount</span>
             <Input
             <Input
               type="text"
               type="text"
               className="gf-form-input max-width-8"
               className="gf-form-input max-width-8"
@@ -97,11 +88,6 @@ export class TimeRangeOptions extends PureComponent<Props> {
           </div>
           </div>
 
 
           <div className="gf-form-inline">
           <div className="gf-form-inline">
-            <div className="gf-form">
-              <span className="gf-form-label">
-                <i className="fa fa-clock-o" />
-              </span>
-            </div>
             <Switch label="Hide time override info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
             <Switch label="Hide time override info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
           </div>
           </div>
         </div>
         </div>

+ 62 - 16
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -1,9 +1,9 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import classNames from 'classnames';
 import _ from 'lodash';
 import _ from 'lodash';
 
 
 import config from 'app/core/config';
 import config from 'app/core/config';
 import { PanelPlugin } from 'app/types/plugins';
 import { PanelPlugin } from 'app/types/plugins';
+import VizTypePickerPlugin from './VizTypePickerPlugin';
 
 
 interface Props {
 interface Props {
   current: PanelPlugin;
   current: PanelPlugin;
@@ -12,6 +12,7 @@ interface Props {
 
 
 interface State {
 interface State {
   searchQuery: string;
   searchQuery: string;
+  selected: number;
 }
 }
 
 
 export class VizTypePicker extends PureComponent<Props, State> {
 export class VizTypePicker extends PureComponent<Props, State> {
@@ -23,9 +24,50 @@ export class VizTypePicker extends PureComponent<Props, State> {
 
 
     this.state = {
     this.state = {
       searchQuery: '',
       searchQuery: '',
+      selected: 0,
     };
     };
   }
   }
 
 
+  get maxSelectedIndex() {
+    const filteredPluginList = this.getFilteredPluginList();
+    return filteredPluginList.length - 1;
+  }
+
+  goRight = () => {
+    const nextIndex = this.state.selected >= this.maxSelectedIndex ? 0 : this.state.selected + 1;
+    this.setState({
+      selected: nextIndex,
+    });
+  };
+
+  goLeft = () => {
+    const nextIndex = this.state.selected <= 0 ? this.maxSelectedIndex : this.state.selected - 1;
+    this.setState({
+      selected: nextIndex,
+    });
+  };
+
+  onKeyDown = evt => {
+    if (evt.key === 'ArrowDown') {
+      evt.preventDefault();
+      this.goRight();
+    }
+    if (evt.key === 'ArrowUp') {
+      evt.preventDefault();
+      this.goLeft();
+    }
+    if (evt.key === 'Enter') {
+      const filteredPluginList = this.getFilteredPluginList();
+      this.props.onTypeChanged(filteredPluginList[this.state.selected]);
+    }
+  };
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.searchInput.focus();
+    }, 300);
+  }
+
   getPanelPlugins(filter): PanelPlugin[] {
   getPanelPlugins(filter): PanelPlugin[] {
     const panels = _.chain(config.panels)
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
       .filter({ hideFromList: false })
@@ -36,26 +78,29 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return _.sortBy(panels, 'sort');
     return _.sortBy(panels, 'sort');
   }
   }
 
 
-  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
-    const cssClass = classNames({
-      'viz-picker__item': true,
-      'viz-picker__item--selected': plugin.id === this.props.current.id,
+  onMouseEnter = (mouseEnterIndex: number) => {
+    this.setState({
+      selected: mouseEnterIndex,
     });
     });
+  };
 
 
+  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
+    const isSelected = this.state.selected === index;
+    const isCurrent = plugin.id === this.props.current.id;
     return (
     return (
-      <div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
-        <div className="viz-picker__item-name">{plugin.name}</div>
-        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
-      </div>
+      <VizTypePickerPlugin
+        key={plugin.id}
+        isSelected={isSelected}
+        isCurrent={isCurrent}
+        plugin={plugin}
+        onMouseEnter={() => {
+          this.onMouseEnter(index);
+        }}
+        onClick={() => this.props.onTypeChanged(plugin)}
+      />
     );
     );
   };
   };
 
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.searchInput.focus();
-    }, 300);
-  }
-
   getFilteredPluginList = (): PanelPlugin[] => {
   getFilteredPluginList = (): PanelPlugin[] => {
     const { searchQuery } = this.state;
     const { searchQuery } = this.state;
     const regex = new RegExp(searchQuery, 'i');
     const regex = new RegExp(searchQuery, 'i');
@@ -73,6 +118,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
     this.setState(prevState => ({
     this.setState(prevState => ({
       ...prevState,
       ...prevState,
       searchQuery: value,
       searchQuery: value,
+      selected: 0,
     }));
     }));
   };
   };
 
 
@@ -86,6 +132,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
             placeholder=""
             placeholder=""
             ref={elem => (this.searchInput = elem)}
             ref={elem => (this.searchInput = elem)}
             onChange={this.onSearchQueryChange}
             onChange={this.onSearchQueryChange}
+            onKeyDown={this.onKeyDown}
           />
           />
           <i className="gf-form-input-icon fa fa-search" />
           <i className="gf-form-input-icon fa fa-search" />
         </label>
         </label>
@@ -102,7 +149,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
           {this.renderFilters()}
           {this.renderFilters()}
           <div className="gf-form--grow" />
           <div className="gf-form--grow" />
         </div>
         </div>
-
         <div className="viz-picker">{filteredPluginList.map(this.renderVizPlugin)}</div>
         <div className="viz-picker">{filteredPluginList.map(this.renderVizPlugin)}</div>
       </>
       </>
     );
     );

+ 36 - 0
public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+import classNames from 'classnames';
+import { PanelPlugin } from 'app/types/plugins';
+
+interface Props {
+  isSelected: boolean;
+  isCurrent: boolean;
+  plugin: PanelPlugin;
+  onClick: () => void;
+  onMouseEnter: () => void;
+}
+
+const VizTypePickerPlugin = React.memo(
+  ({ isSelected, isCurrent, plugin, onClick, onMouseEnter }: Props) => {
+    const cssClass = classNames({
+      'viz-picker__item': true,
+      'viz-picker__item--selected': isSelected,
+      'viz-picker__item--current': isCurrent,
+    });
+
+    return (
+      <div className={cssClass} onClick={onClick} title={plugin.name} onMouseEnter={onMouseEnter}>
+        <div className="viz-picker__item-name">{plugin.name}</div>
+        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
+      </div>
+    );
+  },
+  (prevProps, nextProps) => {
+    if (prevProps.isSelected === nextProps.isSelected && prevProps.isCurrent === nextProps.isCurrent) {
+      return true;
+    }
+    return false;
+  }
+);
+
+export default VizTypePickerPlugin;

+ 65 - 0
public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+import { Props } from './DataSourcePicker';
+
+interface State {
+  selected: number;
+}
+
+const withKeyboardNavigation = WrappedComponent => {
+  return class extends React.Component<Props, State> {
+    constructor(props) {
+      super(props);
+
+      this.state = {
+        selected: 0,
+      };
+    }
+
+    goToNext = (maxSelectedIndex: number) => {
+      const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
+      this.setState({
+        selected: nextIndex,
+      });
+    };
+
+    goToPrev = (maxSelectedIndex: number) => {
+      const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
+      this.setState({
+        selected: nextIndex,
+      });
+    };
+
+    onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
+      if (evt.key === 'ArrowDown') {
+        evt.preventDefault();
+        this.goToNext(maxSelectedIndex);
+      }
+      if (evt.key === 'ArrowUp') {
+        evt.preventDefault();
+        this.goToPrev(maxSelectedIndex);
+      }
+      if (evt.key === 'Enter' && onEnterAction) {
+        onEnterAction();
+      }
+    };
+
+    onMouseEnter = (mouseEnterIndex: number) => {
+      this.setState({
+        selected: mouseEnterIndex,
+      });
+    };
+
+    render() {
+      return (
+        <WrappedComponent
+          selected={this.state.selected}
+          onKeyDown={this.onKeyDown}
+          onMouseEnter={this.onMouseEnter}
+          {...this.props}
+        />
+      );
+    }
+  };
+};
+
+export default withKeyboardNavigation;

+ 15 - 7
public/app/features/explore/Explore.tsx

@@ -40,8 +40,8 @@ import Graph from './Graph';
 import Logs from './Logs';
 import Logs from './Logs';
 import Table from './Table';
 import Table from './Table';
 import ErrorBoundary from './ErrorBoundary';
 import ErrorBoundary from './ErrorBoundary';
-import TimePicker from './TimePicker';
 import { Alert } from './Error';
 import { Alert } from './Error';
+import TimePicker, { parseTime } from './TimePicker';
 
 
 interface ExploreProps {
 interface ExploreProps {
   datasourceSrv: DatasourceSrv;
   datasourceSrv: DatasourceSrv;
@@ -119,7 +119,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     } else {
     } else {
       const { datasource, queries, range } = props.urlState as ExploreUrlState;
       const { datasource, queries, range } = props.urlState as ExploreUrlState;
       initialQueries = ensureQueries(queries);
       initialQueries = ensureQueries(queries);
-      const initialRange = range || { ...DEFAULT_RANGE };
+      const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE };
       // Millies step for helper bar charts
       // Millies step for helper bar charts
       const initialGraphInterval = 15 * 1000;
       const initialGraphInterval = 15 * 1000;
       this.state = {
       this.state = {
@@ -687,7 +687,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     }
     }
 
 
     this.setState(state => {
     this.setState(state => {
-      const { history, queryTransactions, scanning } = state;
+      const { history, queryTransactions } = state;
+      let { scanning } = state;
 
 
       // Transaction might have been discarded
       // Transaction might have been discarded
       const transaction = queryTransactions.find(qt => qt.id === transactionId);
       const transaction = queryTransactions.find(qt => qt.id === transactionId);
@@ -724,15 +725,21 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       const nextHistory = updateHistory(history, datasourceId, queries);
       const nextHistory = updateHistory(history, datasourceId, queries);
 
 
       // Keep scanning for results if this was the last scanning transaction
       // Keep scanning for results if this was the last scanning transaction
-      if (_.size(result) === 0 && scanning) {
-        const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
-        if (!other) {
-          this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
+      if (scanning) {
+        if (_.size(result) === 0) {
+          const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done);
+          if (!other) {
+            this.scanTimer = setTimeout(this.scanPreviousRange, 1000);
+          }
+        } else {
+          // We can stop scanning if we have a result
+          scanning = false;
         }
         }
       }
       }
 
 
       return {
       return {
         ...results,
         ...results,
+        scanning,
         history: nextHistory,
         history: nextHistory,
         queryTransactions: nextQueryTransactions,
         queryTransactions: nextQueryTransactions,
       };
       };
@@ -913,6 +920,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                 onChange={this.onChangeDatasource}
                 onChange={this.onChangeDatasource}
                 options={exploreDatasources}
                 options={exploreDatasources}
                 styles={ResetStyles}
                 styles={ResetStyles}
+                maxMenuHeight={500}
                 placeholder="Select datasource"
                 placeholder="Select datasource"
                 loadingMessage={() => 'Loading datasources...'}
                 loadingMessage={() => 'Loading datasources...'}
                 noOptionsMessage={() => 'No datasources found'}
                 noOptionsMessage={() => 'No datasources found'}

+ 9 - 8
public/app/features/explore/LogLabels.tsx

@@ -69,7 +69,7 @@ export class Stats extends PureComponent<{
 
 
 class Label extends PureComponent<
 class Label extends PureComponent<
   {
   {
-    allRows?: LogRow[];
+    getRows?: () => LogRow[];
     label: string;
     label: string;
     plain?: boolean;
     plain?: boolean;
     value: string;
     value: string;
@@ -98,13 +98,14 @@ class Label extends PureComponent<
       if (state.showStats) {
       if (state.showStats) {
         return { showStats: false, stats: null };
         return { showStats: false, stats: null };
       }
       }
-      const stats = calculateLogsLabelStats(this.props.allRows, this.props.label);
+      const allRows = this.props.getRows();
+      const stats = calculateLogsLabelStats(allRows, this.props.label);
       return { showStats: true, stats };
       return { showStats: true, stats };
     });
     });
   };
   };
 
 
   render() {
   render() {
-    const { allRows, label, plain, value } = this.props;
+    const { getRows, label, plain, value } = this.props;
     const { showStats, stats } = this.state;
     const { showStats, stats } = this.state;
     const tooltip = `${label}: ${value}`;
     const tooltip = `${label}: ${value}`;
     return (
     return (
@@ -115,12 +116,12 @@ class Label extends PureComponent<
         {!plain && (
         {!plain && (
           <span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" />
           <span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" />
         )}
         )}
-        {!plain && allRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />}
+        {!plain && getRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />}
         {showStats && (
         {showStats && (
           <span className="logs-label__stats">
           <span className="logs-label__stats">
             <Stats
             <Stats
               stats={stats}
               stats={stats}
-              rowCount={allRows.length}
+              rowCount={getRows().length}
               label={label}
               label={label}
               value={value}
               value={value}
               onClickClose={this.onClickClose}
               onClickClose={this.onClickClose}
@@ -133,15 +134,15 @@ class Label extends PureComponent<
 }
 }
 
 
 export default class LogLabels extends PureComponent<{
 export default class LogLabels extends PureComponent<{
-  allRows?: LogRow[];
+  getRows?: () => LogRow[];
   labels: LogsStreamLabels;
   labels: LogsStreamLabels;
   plain?: boolean;
   plain?: boolean;
   onClickLabel?: (label: string, value: string) => void;
   onClickLabel?: (label: string, value: string) => void;
 }> {
 }> {
   render() {
   render() {
-    const { allRows, labels, onClickLabel, plain } = this.props;
+    const { getRows, labels, onClickLabel, plain } = this.props;
     return Object.keys(labels).map(key => (
     return Object.keys(labels).map(key => (
-      <Label key={key} allRows={allRows} label={key} value={labels[key]} plain={plain} onClickLabel={onClickLabel} />
+      <Label key={key} getRows={getRows} label={key} value={labels[key]} plain={plain} onClickLabel={onClickLabel} />
     ));
     ));
   }
   }
 }
 }

+ 39 - 59
public/app/features/explore/Logs.tsx

@@ -6,6 +6,7 @@ import classnames from 'classnames';
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import { RawTimeRange } from 'app/types/series';
 import { RawTimeRange } from 'app/types/series';
 import {
 import {
+  LogsDedupDescription,
   LogsDedupStrategy,
   LogsDedupStrategy,
   LogsModel,
   LogsModel,
   dedupLogRows,
   dedupLogRows,
@@ -56,13 +57,13 @@ const FieldHighlight = onClick => props => {
 };
 };
 
 
 interface RowProps {
 interface RowProps {
-  allRows: LogRow[];
   highlighterExpressions?: string[];
   highlighterExpressions?: string[];
   row: LogRow;
   row: LogRow;
   showDuplicates: boolean;
   showDuplicates: boolean;
   showLabels: boolean | null; // Tristate: null means auto
   showLabels: boolean | null; // Tristate: null means auto
   showLocalTime: boolean;
   showLocalTime: boolean;
   showUtc: boolean;
   showUtc: boolean;
+  getRows: () => LogRow[];
   onClickLabel?: (label: string, value: string) => void;
   onClickLabel?: (label: string, value: string) => void;
 }
 }
 
 
@@ -107,11 +108,12 @@ class Row extends PureComponent<RowProps, RowState> {
   };
   };
 
 
   onClickHighlight = (fieldText: string) => {
   onClickHighlight = (fieldText: string) => {
-    const { allRows } = this.props;
+    const { getRows } = this.props;
     const { parser } = this.state;
     const { parser } = this.state;
 
 
     const fieldMatch = fieldText.match(parser.fieldRegex);
     const fieldMatch = fieldText.match(parser.fieldRegex);
     if (fieldMatch) {
     if (fieldMatch) {
+      const allRows = getRows();
       // Build value-agnostic row matcher based on the field label
       // Build value-agnostic row matcher based on the field label
       const fieldLabel = fieldMatch[1];
       const fieldLabel = fieldMatch[1];
       const fieldValue = fieldMatch[2];
       const fieldValue = fieldMatch[2];
@@ -151,7 +153,7 @@ class Row extends PureComponent<RowProps, RowState> {
 
 
   render() {
   render() {
     const {
     const {
-      allRows,
+      getRows,
       highlighterExpressions,
       highlighterExpressions,
       onClickLabel,
       onClickLabel,
       row,
       row,
@@ -193,7 +195,7 @@ class Row extends PureComponent<RowProps, RowState> {
         )}
         )}
         {showLabels && (
         {showLabels && (
           <div className="logs-row__labels">
           <div className="logs-row__labels">
-            <LogLabels allRows={allRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
+            <LogLabels getRows={getRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
           </div>
           </div>
         )}
         )}
         <div className="logs-row__message" onMouseEnter={this.onMouseOverMessage} onMouseLeave={this.onMouseOutMessage}>
         <div className="logs-row__message" onMouseEnter={this.onMouseOverMessage} onMouseLeave={this.onMouseOutMessage}>
@@ -393,29 +395,11 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
       }
       }
     }
     }
 
 
-    // Grid options
-    // const cssColumnSizes = [];
-    // if (showDuplicates) {
-    //   cssColumnSizes.push('max-content');
-    // }
-    // // Log-level indicator line
-    // cssColumnSizes.push('3px');
-    // if (showUtc) {
-    //   cssColumnSizes.push('minmax(220px, max-content)');
-    // }
-    // if (showLocalTime) {
-    //   cssColumnSizes.push('minmax(140px, max-content)');
-    // }
-    // if (showLabels) {
-    //   cssColumnSizes.push('fit-content(20%)');
-    // }
-    // cssColumnSizes.push('1fr');
-    // const logEntriesStyle = {
-    //   gridTemplateColumns: cssColumnSizes.join(' '),
-    // };
-
     const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
     const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
 
 
+    // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
+    const getRows = () => processedRows;
+
     return (
     return (
       <div className="logs-panel">
       <div className="logs-panel">
         <div className="logs-panel-graph">
         <div className="logs-panel-graph">
@@ -431,41 +415,37 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
         </div>
         </div>
         <div className="logs-panel-options">
         <div className="logs-panel-options">
           <div className="logs-panel-controls">
           <div className="logs-panel-controls">
-            <Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} />
-            <Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} />
-            <Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} />
-            <ToggleButtonGroup
-              label="Dedup"
-              onChange={this.onChangeDedup}
-              value={dedup}
-              render={({ selectedValue, onChange }) =>
-                Object.keys(LogsDedupStrategy).map((dedupType, i) => (
-                  <ToggleButton
-                    className="btn-small"
-                    key={i}
-                    value={dedupType}
-                    onChange={onChange}
-                    selected={selectedValue === dedupType}
-                  >
-                    {dedupType}
-                  </ToggleButton>
-                ))
-              }
-            />
-            {hasData &&
-              meta && (
-                <div className="logs-panel-meta">
-                  {meta.map(item => (
-                    <div className="logs-panel-meta__item" key={item.label}>
-                      <span className="logs-panel-meta__label">{item.label}:</span>
-                      <span className="logs-panel-meta__value">{renderMetaItem(item.value, item.kind)}</span>
-                    </div>
-                  ))}
-                </div>
-              )}
+            <Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} transparent />
+            <Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} transparent />
+            <Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} transparent />
+            <ToggleButtonGroup label="Dedup" transparent={true}>
+              {Object.keys(LogsDedupStrategy).map((dedupType, i) => (
+                <ToggleButton
+                  key={i}
+                  value={dedupType}
+                  onChange={this.onChangeDedup}
+                  selected={dedup === dedupType}
+                  tooltip={LogsDedupDescription[dedupType]}
+                >
+                  {dedupType}
+                </ToggleButton>
+              ))}
+            </ToggleButtonGroup>
           </div>
           </div>
         </div>
         </div>
 
 
+        {hasData &&
+          meta && (
+            <div className="logs-panel-meta">
+              {meta.map(item => (
+                <div className="logs-panel-meta__item" key={item.label}>
+                  <span className="logs-panel-meta__label">{item.label}:</span>
+                  <span className="logs-panel-meta__value">{renderMetaItem(item.value, item.kind)}</span>
+                </div>
+              ))}
+            </div>
+          )}
+
         <div className="logs-rows">
         <div className="logs-rows">
           {hasData &&
           {hasData &&
             !deferLogs &&
             !deferLogs &&
@@ -473,7 +453,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             firstRows.map(row => (
             firstRows.map(row => (
               <Row
               <Row
                 key={row.key + row.duplicates}
                 key={row.key + row.duplicates}
-                allRows={processedRows}
+                getRows={getRows}
                 highlighterExpressions={highlighterExpressions}
                 highlighterExpressions={highlighterExpressions}
                 row={row}
                 row={row}
                 showDuplicates={showDuplicates}
                 showDuplicates={showDuplicates}
@@ -489,7 +469,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             lastRows.map(row => (
             lastRows.map(row => (
               <Row
               <Row
                 key={row.key + row.duplicates}
                 key={row.key + row.duplicates}
-                allRows={processedRows}
+                getRows={getRows}
                 row={row}
                 row={row}
                 showDuplicates={showDuplicates}
                 showDuplicates={showDuplicates}
                 showLabels={showLabels}
                 showLabels={showLabels}

+ 18 - 5
public/app/features/explore/TimePicker.tsx

@@ -15,11 +15,14 @@ export const DEFAULT_RANGE = {
  * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
  * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
  * @param value Epoch or relative time
  * @param value Epoch or relative time
  */
  */
-export function parseTime(value: string, isUtc = false): string {
+export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment {
   if (moment.isMoment(value)) {
   if (moment.isMoment(value)) {
+    if (ensureString) {
+      return value.format(DATE_FORMAT);
+    }
     return value;
     return value;
   }
   }
-  if (value.indexOf('now') !== -1) {
+  if ((value as string).indexOf('now') !== -1) {
     return value;
     return value;
   }
   }
   let time: any = value;
   let time: any = value;
@@ -50,6 +53,16 @@ interface TimePickerState {
   toRaw: string;
   toRaw: string;
 }
 }
 
 
+/**
+ * TimePicker with dropdown menu for relative dates.
+ *
+ * Initialize with a range that is either based on relative time strings,
+ * or on Moment objects.
+ * Internally the component needs to keep a string representation in `fromRaw`
+ * and `toRaw` for the controlled inputs.
+ * When a time is picked, `onChangeTime` is called with the new range that
+ * is again based on relative time strings or Moment objects.
+ */
 export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
 export default class TimePicker extends PureComponent<TimePickerProps, TimePickerState> {
   dropdownEl: any;
   dropdownEl: any;
 
 
@@ -75,9 +88,9 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
     const from = props.range ? props.range.from : DEFAULT_RANGE.from;
     const from = props.range ? props.range.from : DEFAULT_RANGE.from;
     const to = props.range ? props.range.to : DEFAULT_RANGE.to;
     const to = props.range ? props.range.to : DEFAULT_RANGE.to;
 
 
-    // Ensure internal format
-    const fromRaw = parseTime(from, props.isUtc);
-    const toRaw = parseTime(to, props.isUtc);
+    // Ensure internal string format
+    const fromRaw = parseTime(from, props.isUtc, true);
+    const toRaw = parseTime(to, props.isUtc, true);
     const range = {
     const range = {
       from: fromRaw,
       from: fromRaw,
       to: toRaw,
       to: toRaw,

+ 11 - 4
public/app/features/panel/metrics_tab.ts

@@ -95,10 +95,17 @@ export class MetricsTabCtrl {
           target.datasource = config.defaultDatasource;
           target.datasource = config.defaultDatasource;
         }
         }
       });
       });
-    } else if (this.datasourceInstance && this.datasourceInstance.meta.mixed) {
-      _.each(this.panel.targets, target => {
-        delete target.datasource;
-      });
+    } else if (this.datasourceInstance) {
+      // if switching from mixed
+      if (this.datasourceInstance.meta.mixed) {
+        _.each(this.panel.targets, target => {
+          delete target.datasource;
+        });
+      } else if (this.datasourceInstance.meta.id !== datasource.meta.id) {
+        // we are changing data source type, clear queries
+        this.panel.targets = [{ refId: 'A' }];
+        this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
+      }
     }
     }
 
 
     this.datasourceInstance = datasource;
     this.datasourceInstance = datasource;

+ 6 - 1
public/app/features/panel/panel_editor_tab.ts

@@ -12,7 +12,12 @@ function panelEditorTab(dynamicDirectiveSrv) {
     },
     },
     directive: scope => {
     directive: scope => {
       const pluginId = scope.ctrl.pluginId;
       const pluginId = scope.ctrl.pluginId;
-      const tabName = scope.editorTab.title.toLowerCase().replace(' ', '-');
+      const tabName = scope.editorTab.title
+        .toLowerCase()
+        .replace(' ', '-')
+        .replace('&', '')
+        .replace(' ', '')
+        .replace(' ', '-');
 
 
       if (directiveCache[pluginId]) {
       if (directiveCache[pluginId]) {
         if (directiveCache[pluginId][tabName]) {
         if (directiveCache[pluginId][tabName]) {

+ 3 - 2
public/app/features/templating/custom_variable.ts

@@ -38,8 +38,9 @@ export class CustomVariable implements Variable {
   }
   }
 
 
   updateOptions() {
   updateOptions() {
-    // extract options in comma separated string
-    this.options = _.map(this.query.split(/[,]+/), text => {
+    // extract options in comma separated string (use backslash to escape wanted commas)
+    this.options = _.map(this.query.match(/(?:\\,|[^,])+/g), text => {
+      text = text.replace('\\,', ',');
       return { text: text.trim(), value: text.trim() };
       return { text: text.trim(), value: text.trim() };
     });
     });
 
 

+ 1 - 1
public/app/features/templating/partials/editor.html

@@ -151,7 +151,7 @@
 			<h5 class="section-heading">Custom Options</h5>
 			<h5 class="section-heading">Custom Options</h5>
 			<div class="gf-form">
 			<div class="gf-form">
 				<span class="gf-form-label width-14">Values separated by comma</span>
 				<span class="gf-form-label width-14">Values separated by comma</span>
-				<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue"
+				<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="1, 10, 20, myvalue, escaped\,value"
 				 required></input>
 				 required></input>
 			</div>
 			</div>
 		</div>
 		</div>

+ 4 - 2
public/app/features/templating/specs/variable_srv.test.ts

@@ -493,15 +493,17 @@ describe('VariableSrv', function(this: any) {
     scenario.setup(() => {
     scenario.setup(() => {
       scenario.variableModel = {
       scenario.variableModel = {
         type: 'custom',
         type: 'custom',
-        query: 'hej, hop, asd',
+        query: 'hej, hop, asd, escaped\\,var',
         name: 'test',
         name: 'test',
       };
       };
     });
     });
 
 
     it('should update options array', () => {
     it('should update options array', () => {
-      expect(scenario.variable.options.length).toBe(3);
+      expect(scenario.variable.options.length).toBe(4);
       expect(scenario.variable.options[0].text).toBe('hej');
       expect(scenario.variable.options[0].text).toBe('hej');
       expect(scenario.variable.options[1].value).toBe('hop');
       expect(scenario.variable.options[1].value).toBe('hop');
+      expect(scenario.variable.options[2].value).toBe('asd');
+      expect(scenario.variable.options[3].value).toBe('escaped,var');
     });
     });
   });
   });
 
 

+ 1 - 1
public/app/plugins/datasource/loki/plugin.json

@@ -7,7 +7,7 @@
   "annotations": false,
   "annotations": false,
   "logs": true,
   "logs": true,
   "explore": true,
   "explore": true,
-  "tables": true,
+  "tables": false,
   "info": {
   "info": {
     "description": "Loki Logging Data Source for Grafana",
     "description": "Loki Logging Data Source for Grafana",
     "author": {
     "author": {

+ 1 - 0
public/app/plugins/panel/graph/module.ts

@@ -138,6 +138,7 @@ class GraphCtrl extends MetricsPanelCtrl {
     this.addEditorTab('Display options', 'public/app/plugins/panel/graph/tab_display.html');
     this.addEditorTab('Display options', 'public/app/plugins/panel/graph/tab_display.html');
     this.addEditorTab('Axes', axesEditorComponent);
     this.addEditorTab('Axes', axesEditorComponent);
     this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html');
     this.addEditorTab('Legend', 'public/app/plugins/panel/graph/tab_legend.html');
+    this.addEditorTab('Thresholds & Time Regions', 'public/app/plugins/panel/graph/tab_thresholds_time_regions.html');
     this.subTabIndex = 0;
     this.subTabIndex = 0;
   }
   }
 
 

+ 63 - 0
public/app/plugins/panel/graph/specs/time_region_manager.test.ts

@@ -130,6 +130,33 @@ describe('TimeRegionManager', () => {
       });
       });
     });
     });
 
 
+    plotOptionsScenario('for time from/to region', ctx => {
+      const regions = [{ from: '00:00', to: '05:00', fill: true, colorMode: 'red' }];
+      const from = moment('2018-12-01T00:00+01:00');
+      const to = moment('2018-12-03T23:59+01:00');
+      ctx.setup(regions, from, to);
+
+      it('should add 3 markings', () => {
+        expect(ctx.options.grid.markings.length).toBe(3);
+      });
+
+      it('should add one fill between 00:00 and 05:00 each day', () => {
+        const markings = ctx.options.grid.markings;
+
+        expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-01T01:00:00+01:00').format());
+        expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-01T06:00:00+01:00').format());
+        expect(markings[0].color).toBe(colorModes.red.color.fill);
+
+        expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-12-02T01:00:00+01:00').format());
+        expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-12-02T06:00:00+01:00').format());
+        expect(markings[1].color).toBe(colorModes.red.color.fill);
+
+        expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-12-03T01:00:00+01:00').format());
+        expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-12-03T06:00:00+01:00').format());
+        expect(markings[2].color).toBe(colorModes.red.color.fill);
+      });
+    });
+
     plotOptionsScenario('for day of week from/to region', ctx => {
     plotOptionsScenario('for day of week from/to region', ctx => {
       const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
       const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
       const from = moment('2018-01-01T18:45:05+01:00');
       const from = moment('2018-01-01T18:45:05+01:00');
@@ -211,6 +238,42 @@ describe('TimeRegionManager', () => {
       });
       });
     });
     });
 
 
+    plotOptionsScenario('for day of week from/to time region', ctx => {
+      const regions = [{ fromDayOfWeek: 7, from: '23:00', toDayOfWeek: 1, to: '01:40', fill: true, colorMode: 'red' }];
+      const from = moment('2018-12-07T12:51:19+01:00');
+      const to = moment('2018-12-10T13:51:29+01:00');
+      ctx.setup(regions, from, to);
+
+      it('should add 1 marking', () => {
+        expect(ctx.options.grid.markings.length).toBe(1);
+      });
+
+      it('should add one fill between sunday 23:00 and monday 01:40', () => {
+        const markings = ctx.options.grid.markings;
+
+        expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-10T00:00:00+01:00').format());
+        expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-10T02:40:00+01:00').format());
+      });
+    });
+
+    plotOptionsScenario('for day of week from/to time region', ctx => {
+      const regions = [{ fromDayOfWeek: 6, from: '03:00', toDayOfWeek: 7, to: '02:00', fill: true, colorMode: 'red' }];
+      const from = moment('2018-12-07T12:51:19+01:00');
+      const to = moment('2018-12-10T13:51:29+01:00');
+      ctx.setup(regions, from, to);
+
+      it('should add 1 marking', () => {
+        expect(ctx.options.grid.markings.length).toBe(1);
+      });
+
+      it('should add one fill between saturday 03:00 and sunday 02:00', () => {
+        const markings = ctx.options.grid.markings;
+
+        expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-08T04:00:00+01:00').format());
+        expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-09T03:00:00+01:00').format());
+      });
+    });
+
     plotOptionsScenario('for day of week from/to time region with daylight saving time', ctx => {
     plotOptionsScenario('for day of week from/to time region with daylight saving time', ctx => {
       const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }];
       const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }];
       const from = moment('2018-03-17T06:00:00+01:00');
       const from = moment('2018-03-17T06:00:00+01:00');

+ 9 - 43
public/app/plugins/panel/graph/tab_display.html

@@ -1,28 +1,5 @@
-<div class="edit-tab-with-sidemenu">
-	<aside class="edit-sidemenu-aside">
-		<ul class="edit-sidemenu">
-			<li ng-class="{active: ctrl.subTabIndex === 0}">
-				<a ng-click="ctrl.subTabIndex = 0">Draw options</a>
-			</li>
-			<li ng-class="{active: ctrl.subTabIndex === 1}">
-				<a ng-click="ctrl.subTabIndex = 1">
-					Series overrides <span class="muted">({{ctrl.panel.seriesOverrides.length}})</span>
-				</a>
-			</li>
-			<li ng-class="{active: ctrl.subTabIndex === 2}">
-				<a ng-click="ctrl.subTabIndex = 2">
-					Thresholds <span class="muted">({{ctrl.panel.thresholds.length}})</span>
-				</a>
-			</li>
-			<li ng-class="{active: ctrl.subTabIndex === 3}">
-				<a ng-click="ctrl.subTabIndex = 3">
-					Time regions <span class="muted">({{ctrl.panel.timeRegions.length}})</span>
-				</a>
-			</li>
-		</ul>
-	</aside>
 
 
-	<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 0">
+	<div class="editor-row">
 		<div class="section gf-form-group">
 		<div class="section gf-form-group">
 			<h5 class="section-heading">Draw Modes</h5>
 			<h5 class="section-heading">Draw Modes</h5>
 			<gf-form-switch class="gf-form" label="Bars" label-class="width-5" checked="ctrl.panel.bars" on-change="ctrl.render()"></gf-form-switch>
 			<gf-form-switch class="gf-form" label="Bars" label-class="width-5" checked="ctrl.panel.bars" on-change="ctrl.render()"></gf-form-switch>
@@ -89,9 +66,7 @@
 		</div>
 		</div>
 	</div>
 	</div>
 
 
-	<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 1">
 		<div class="gf-form-group">
 		<div class="gf-form-group">
-			<h5>Series specific overrides <tip>Regex match example: /server[0-3]/i </tip></h5>
 			<div class="gf-form-inline" ng-repeat="override in ctrl.panel.seriesOverrides" ng-controller="SeriesOverridesCtrl">
 			<div class="gf-form-inline" ng-repeat="override in ctrl.panel.seriesOverrides" ng-controller="SeriesOverridesCtrl">
 				<div class="gf-form">
 				<div class="gf-form">
 					<label class="gf-form-label">alias or regex</label>
 					<label class="gf-form-label">alias or regex</label>
@@ -110,35 +85,26 @@
 						</span>
 						</span>
 					</label>
 					</label>
 				</div>
 				</div>
-
+	
 				<div class="gf-form">
 				<div class="gf-form">
 					<span class="dropdown" dropdown-typeahead="overrideMenu" dropdown-typeahead-on-select="setOverride($item, $subItem)">
 					<span class="dropdown" dropdown-typeahead="overrideMenu" dropdown-typeahead-on-select="setOverride($item, $subItem)">
 					</span>
 					</span>
 				</div>
 				</div>
-
+	
 				<div class="gf-form gf-form--grow">
 				<div class="gf-form gf-form--grow">
 					<div class="gf-form-label gf-form-label--grow"></div>
 					<div class="gf-form-label gf-form-label--grow"></div>
 				</div>
 				</div>
-
+	
 				<div class="gf-form">
 				<div class="gf-form">
 					<label class="gf-form-label">
 					<label class="gf-form-label">
 						<i class="fa fa-trash pointer" ng-click="ctrl.removeSeriesOverride(override)"></i>
 						<i class="fa fa-trash pointer" ng-click="ctrl.removeSeriesOverride(override)"></i>
 					</label>
 					</label>
 				</div>
 				</div>
 			</div>
 			</div>
+			<div class="gf-form-button-row">
+				<button class="btn btn-inverse" ng-click="ctrl.addSeriesOverride()">
+					<i class="fa fa-plus"></i>&nbsp;Add series override<tip>Regex match example: /server[0-3]/i </tip>
+				</button>
+			</div>
 		</div>
 		</div>
 
 
-		<button class="btn btn-inverse" ng-click="ctrl.addSeriesOverride()">
-			<i class="fa fa-plus"></i>&nbsp;Add override
-		</button>
-	</div>
-
-	<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 2">
-		<graph-threshold-form panel-ctrl="ctrl"></graph-threshold-form>
-	</div>
-
-	<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 3">
-		<graph-time-region-form panel-ctrl="ctrl"></graph-time-region-form>
-	</div>
-
-</div>

+ 2 - 0
public/app/plugins/panel/graph/tab_thresholds_time_regions.html

@@ -0,0 +1,2 @@
+<graph-threshold-form panel-ctrl="ctrl"></graph-threshold-form>
+<graph-time-region-form panel-ctrl="ctrl"></graph-time-region-form>

+ 0 - 1
public/app/plugins/panel/graph/thresholds_form.html

@@ -1,5 +1,4 @@
 <div class="gf-form-group">
 <div class="gf-form-group">
-  <h5>Thresholds</h5>
   <p class="muted" ng-show="ctrl.disabled">
   <p class="muted" ng-show="ctrl.disabled">
     Visual thresholds options <strong>disabled.</strong>
     Visual thresholds options <strong>disabled.</strong>
     Visit the Alert tab update your thresholds. <br>
     Visit the Alert tab update your thresholds. <br>

+ 18 - 10
public/app/plugins/panel/graph/time_region_manager.ts

@@ -87,6 +87,14 @@ export class TimeRegionManager {
         continue;
         continue;
       }
       }
 
 
+      if (timeRegion.from && !timeRegion.to) {
+        timeRegion.to = timeRegion.from;
+      }
+
+      if (!timeRegion.from && timeRegion.to) {
+        timeRegion.from = timeRegion.to;
+      }
+
       hRange = {
       hRange = {
         from: this.parseTimeRange(timeRegion.from),
         from: this.parseTimeRange(timeRegion.from),
         to: this.parseTimeRange(timeRegion.to),
         to: this.parseTimeRange(timeRegion.to),
@@ -108,21 +116,13 @@ export class TimeRegionManager {
         hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek);
         hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek);
       }
       }
 
 
-      if (!hRange.from.h && hRange.to.h) {
-        hRange.from = hRange.to;
-      }
-
-      if (hRange.from.h && !hRange.to.h) {
-        hRange.to = hRange.from;
-      }
-
-      if (hRange.from.dayOfWeek && !hRange.from.h && !hRange.from.m) {
+      if (hRange.from.dayOfWeek && hRange.from.h === null && hRange.from.m === null) {
         hRange.from.h = 0;
         hRange.from.h = 0;
         hRange.from.m = 0;
         hRange.from.m = 0;
         hRange.from.s = 0;
         hRange.from.s = 0;
       }
       }
 
 
-      if (hRange.to.dayOfWeek && !hRange.to.h && !hRange.to.m) {
+      if (hRange.to.dayOfWeek && hRange.to.h === null && hRange.to.m === null) {
         hRange.to.h = 23;
         hRange.to.h = 23;
         hRange.to.m = 59;
         hRange.to.m = 59;
         hRange.to.s = 59;
         hRange.to.s = 59;
@@ -169,8 +169,16 @@ export class TimeRegionManager {
             fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
             fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
           } else if (hRange.from.h + hRange.to.h < 23) {
           } else if (hRange.from.h + hRange.to.h < 23) {
             fromEnd.add(hRange.to.h, 'hours');
             fromEnd.add(hRange.to.h, 'hours');
+
+            while (fromEnd.hour() !== hRange.to.h) {
+              fromEnd.add(-1, 'hours');
+            }
           } else {
           } else {
             fromEnd.add(24 - hRange.from.h, 'hours');
             fromEnd.add(24 - hRange.from.h, 'hours');
+
+            while (fromEnd.hour() !== hRange.to.h) {
+              fromEnd.add(1, 'hours');
+            }
           }
           }
 
 
           fromEnd.set('minute', hRange.to.m);
           fromEnd.set('minute', hRange.to.m);

+ 1 - 2
public/app/plugins/panel/graph/time_regions_form.html

@@ -1,5 +1,4 @@
 <div class="gf-form-group">
 <div class="gf-form-group">
-  <h5>Time regions <tip>All configured time regions refers to UTC time</tip></h5>
   <div class="gf-form-inline" ng-repeat="timeRegion in ctrl.panel.timeRegions">
   <div class="gf-form-inline" ng-repeat="timeRegion in ctrl.panel.timeRegions">
     <div class="gf-form">
     <div class="gf-form">
       <label class="gf-form-label">T{{$index+1}}</label>
       <label class="gf-form-label">T{{$index+1}}</label>
@@ -58,7 +57,7 @@
 
 
   <div class="gf-form-button-row">
   <div class="gf-form-button-row">
     <button class="btn btn-inverse" ng-click="ctrl.addTimeRegion()">
     <button class="btn btn-inverse" ng-click="ctrl.addTimeRegion()">
-      <i class="fa fa-plus"></i>&nbsp;Add time region
+      <i class="fa fa-plus"></i>&nbsp;Add time region<tip>All configured time regions refers to UTC time</tip>
     </button>
     </button>
   </div>
   </div>
 </div>
 </div>

+ 5 - 2
public/app/plugins/panel/singlestat/module.ts

@@ -107,7 +107,10 @@ class SingleStatCtrl extends MetricsPanelCtrl {
   }
   }
 
 
   onDataReceived(dataList) {
   onDataReceived(dataList) {
-    const data: any = {};
+    const data: any = {
+      scopedVars: _.extend({}, this.panel.scopedVars),
+    };
+
     if (dataList.length > 0 && dataList[0].type === 'table') {
     if (dataList.length > 0 && dataList[0].type === 'table') {
       this.dataType = 'table';
       this.dataType = 'table';
       const tableData = dataList.map(this.tableHandler.bind(this));
       const tableData = dataList.map(this.tableHandler.bind(this));
@@ -117,6 +120,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       this.series = dataList.map(this.seriesHandler.bind(this));
       this.series = dataList.map(this.seriesHandler.bind(this));
       this.setValues(data);
       this.setValues(data);
     }
     }
+
     this.data = data;
     this.data = data;
     this.render();
     this.render();
   }
   }
@@ -320,7 +324,6 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       }
       }
 
 
       // Add $__name variable for using in prefix or postfix
       // Add $__name variable for using in prefix or postfix
-      data.scopedVars = _.extend({}, this.panel.scopedVars);
       data.scopedVars['__name'] = { value: this.series[0].label };
       data.scopedVars['__name'] = { value: this.series[0].label };
     }
     }
     this.setValueMapping(data);
     this.setValueMapping(data);

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

@@ -391,3 +391,8 @@ $panel-grid-placeholder-shadow: 0 0 4px $blue;
 
 
 // logs
 // logs
 $logs-color-unkown: $gray-2;
 $logs-color-unkown: $gray-2;
+
+// toggle-group
+$button-toggle-group-btn-active-bg: linear-gradient(90deg, $orange, $red);
+$button-toggle-group-btn-active-shadow: inset 0 0 4px $black;
+$button-toggle-group-btn-seperator-border: 1px solid $page-bg;

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

@@ -62,7 +62,7 @@ $critical: #ec2128;
 // -------------------------
 // -------------------------
 
 
 $body-bg: $gray-7;
 $body-bg: $gray-7;
-$page-bg: $gray-6;
+$page-bg: $gray-7;
 $body-color: $gray-1;
 $body-color: $gray-1;
 $text-color: $gray-1;
 $text-color: $gray-1;
 $text-color-strong: $dark-2;
 $text-color-strong: $dark-2;
@@ -400,3 +400,8 @@ $panel-grid-placeholder-shadow: 0 0 4px $blue-light;
 
 
 // logs
 // logs
 $logs-color-unkown: $gray-5;
 $logs-color-unkown: $gray-5;
+
+// toggle-group
+$button-toggle-group-btn-active-bg: $brand-primary;
+$button-toggle-group-btn-active-shadow: inset 0 0 4px $white;
+$button-toggle-group-btn-seperator-border: 1px solid $gray-6;

+ 5 - 5
public/sass/base/_reboot.scss

@@ -87,7 +87,7 @@ body {
 // might still respond to pointer events.
 // might still respond to pointer events.
 //
 //
 // Credit: https://github.com/suitcss/base
 // Credit: https://github.com/suitcss/base
-[tabindex="-1"]:focus {
+[tabindex='-1']:focus {
   outline: none !important;
   outline: none !important;
 }
 }
 
 
@@ -171,7 +171,7 @@ a {
   }
   }
 
 
   &:focus {
   &:focus {
-    @include tab-focus();
+    @include no-focus();
   }
   }
 }
 }
 
 
@@ -214,7 +214,7 @@ img {
 // for traditionally non-focusable elements with role="button"
 // for traditionally non-focusable elements with role="button"
 // see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
 // see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
 
 
-[role="button"] {
+[role='button'] {
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 
@@ -231,7 +231,7 @@ img {
 a,
 a,
 area,
 area,
 button,
 button,
-[role="button"],
+[role='button'],
 input,
 input,
 label,
 label,
 select,
 select,
@@ -320,7 +320,7 @@ legend {
   //  border: 0;
   //  border: 0;
 }
 }
 
 
-input[type="search"] {
+input[type='search'] {
   // This overrides the extra rounded corners on search inputs in iOS so that our
   // This overrides the extra rounded corners on search inputs in iOS so that our
   // `.form-control` class can properly style them. Note that this cannot simply
   // `.form-control` class can properly style them. Note that this cannot simply
   // be added to `.form-control` as it's not specific enough. For details, see
   // be added to `.form-control` as it's not specific enough. For details, see

+ 1 - 1
public/sass/components/_buttons.scss

@@ -23,7 +23,7 @@
   &.active {
   &.active {
     &:focus,
     &:focus,
     &.focus {
     &.focus {
-      @include tab-focus();
+      @include no-focus();
     }
     }
   }
   }
 
 

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

@@ -50,12 +50,8 @@
   }
   }
 
 
   .react-grid-item {
   .react-grid-item {
-    display: none;
-    transition-property: none !important;
-  }
-
-  .panel {
     display: block !important;
     display: block !important;
+    transition-property: none !important;
     position: unset !important;
     position: unset !important;
     width: 100% !important;
     width: 100% !important;
     transform: translate(0px, 0px) !important;
     transform: translate(0px, 0px) !important;

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

@@ -55,6 +55,11 @@ $select-input-bg-disabled: $input-bg-disabled;
   z-index: 2;
   z-index: 2;
 }
 }
 
 
+.gf-form-select-box__menu-list {
+  overflow-y: auto;
+  max-height: 300px;
+}
+
 .tag-filter .gf-form-select-box__menu {
 .tag-filter .gf-form-select-box__menu {
   width: 100%;
   width: 100%;
 }
 }

+ 5 - 3
public/sass/components/_gf-form.scss

@@ -117,9 +117,11 @@ $input-border: 1px solid $input-border-color;
     color: $critical;
     color: $critical;
   }
   }
 
 
-  &--small {
-    padding: ($input-padding-y / 2) ($input-padding-x / 2);
-    font-size: $font-size-xs;
+  &--transparent {
+    background-color: transparent;
+    border: 0;
+    text-align: right;
+    padding-left: 0px;
   }
   }
 
 
   &:disabled {
   &:disabled {

+ 8 - 14
public/sass/components/_panel_editor.scss

@@ -157,21 +157,15 @@
   padding-bottom: 6px;
   padding-bottom: 6px;
   transition: transform 1 ease;
   transition: transform 1 ease;
 
 
-  &:hover {
-    box-shadow: $panel-editor-viz-item-shadow-hover;
-    background: $panel-editor-viz-item-bg-hover;
-    border: $panel-editor-viz-item-border-hover;
-  }
-
-  &--selected {
+  &--current {
     box-shadow: 0 0 6px $orange;
     box-shadow: 0 0 6px $orange;
     border: 1px solid $orange;
     border: 1px solid $orange;
+  }
 
 
-    &:hover {
-      box-shadow: 0 0 6px $orange;
-      border: 1px solid $orange;
-      background: $panel-editor-viz-item-bg-hover-active;
-    }
+  &--selected {
+    box-shadow: $panel-editor-viz-item-shadow-hover;
+    background: $panel-editor-viz-item-bg-hover;
+    border: $panel-editor-viz-item-border-hover;
   }
   }
 }
 }
 
 
@@ -263,13 +257,13 @@
   align-items: center;
   align-items: center;
   height: 44px;
   height: 44px;
 
 
-  &:hover {
+  &--selected {
     background: $panel-editor-viz-item-bg-hover;
     background: $panel-editor-viz-item-bg-hover;
     border: $panel-editor-viz-item-border-hover;
     border: $panel-editor-viz-item-border-hover;
     box-shadow: $panel-editor-viz-item-shadow-hover;
     box-shadow: $panel-editor-viz-item-shadow-hover;
   }
   }
 
 
-  &--selected {
+  &--active {
     box-shadow: 0 0 6px $orange;
     box-shadow: 0 0 6px $orange;
     border: 1px solid $orange;
     border: 1px solid $orange;
 
 

+ 14 - 6
public/sass/components/_panel_logs.scss

@@ -1,15 +1,21 @@
 $column-horizontal-spacing: 10px;
 $column-horizontal-spacing: 10px;
 
 
-.logs-panel-controls {
+.logs-panel-options {
   display: flex;
   display: flex;
   background-color: $page-bg;
   background-color: $page-bg;
   padding: $panel-padding;
   padding: $panel-padding;
   padding-top: 10px;
   padding-top: 10px;
   border-radius: $border-radius;
   border-radius: $border-radius;
-  margin: 2*$panel-margin 0;
+  margin: 2*$panel-margin 0 $panel-margin;
   border: $panel-border;
   border: $panel-border;
+  flex-direction: column;
+}
+
+.logs-panel-controls {
+  display: flex;
   justify-items: flex-start;
   justify-items: flex-start;
-  align-items: flex-start;
+  align-items: center;
+  flex-wrap: wrap;
 
 
   > * {
   > * {
     margin-right: 1em;
     margin-right: 1em;
@@ -25,12 +31,14 @@ $column-horizontal-spacing: 10px;
 .logs-panel-meta {
 .logs-panel-meta {
   flex: 1;
   flex: 1;
   color: $text-color-weak;
   color: $text-color-weak;
-  // Align first line with controls labels
-  margin-top: -2px;
+  margin-bottom: 10px;
+  min-width: 30%;
+  display: flex;
 }
 }
 
 
 .logs-panel-meta__item {
 .logs-panel-meta__item {
   margin-right: 1em;
   margin-right: 1em;
+  display: flex;
 }
 }
 
 
 .logs-panel-meta__label {
 .logs-panel-meta__label {
@@ -131,7 +139,7 @@ $column-horizontal-spacing: 10px;
   &--warning,
   &--warning,
   &--warn {
   &--warn {
     &::after {
     &::after {
-      background-color: $warn;
+      background-color: $yellow;
     }
     }
   }
   }
 
 

+ 13 - 7
public/sass/components/_switch.scss

@@ -27,33 +27,39 @@ gf-form-switch[disabled] {
   border: 1px solid $input-border-color;
   border: 1px solid $input-border-color;
   border-left: none;
   border-left: none;
   border-radius: $input-border-radius;
   border-radius: $input-border-radius;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 
 
   input {
   input {
     opacity: 0;
     opacity: 0;
     width: 0;
     width: 0;
     height: 0;
     height: 0;
   }
   }
+
+  &--transparent {
+    background: transparent;
+    border: 0;
+    width: 40px;
+  }
 }
 }
 
 
 /* The slider */
 /* The slider */
 .gf-form-switch__slider {
 .gf-form-switch__slider {
-  position: absolute;
-  top: 8px;
-  left: 16px;
-  right: 14px;
-  bottom: 10px;
   background: $switch-slider-off-bg;
   background: $switch-slider-off-bg;
   border-radius: 8px;
   border-radius: 8px;
   height: 16px;
   height: 16px;
   width: 29px;
   width: 29px;
+  display: block;
+  position: relative;
 
 
   &::before {
   &::before {
     position: absolute;
     position: absolute;
     content: '';
     content: '';
     height: 12px;
     height: 12px;
     width: 12px;
     width: 12px;
-    left: 2px;
-    bottom: 2px;
+    left: 1px;
+    top: 2px;
     background: $switch-slider-color;
     background: $switch-slider-color;
     transition: 0.4s;
     transition: 0.4s;
     border-radius: 50%;
     border-radius: 50%;

+ 12 - 23
public/sass/components/_toggle_button_group.scss

@@ -1,28 +1,21 @@
 .toggle-button-group {
 .toggle-button-group {
   display: flex;
   display: flex;
 
 
-  .gf-form-label {
-    background-color: $input-label-bg;
-    &:first-child {
-      border-radius: $border-radius 0 0 $border-radius;
-      margin: 0;
-    }
-    &.small {
-      padding: ($input-padding-y / 2) ($input-padding-x / 2);
-      font-size: $font-size-xs;
-    }
-  }
-
-  &.stacked {
-    flex-direction: column;
-  }
-
   .btn {
   .btn {
-    background-color: $typeahead-selected-bg;
+    @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl, $btn-inverse-text-color, $btn-inverse-text-shadow);
+
+    padding: 7px 10px;
+    font-weight: $font-weight-semi-bold;
+    font-size: $font-size-sm;
     border-radius: 0;
     border-radius: 0;
-    color: $text-color;
+    border-right: $button-toggle-group-btn-seperator-border;
+
     &.active {
     &.active {
-      background-color: $input-bg;
+      background: $button-toggle-group-btn-active-bg;
+      box-shadow: $button-toggle-group-btn-active-shadow;
+      border-right: 0;
+      color: $white;
+
       &:hover {
       &:hover {
         cursor: default;
         cursor: default;
       }
       }
@@ -37,9 +30,5 @@
       border-radius: 0 $border-radius $border-radius 0;
       border-radius: 0 $border-radius $border-radius 0;
       margin-left: 0;
       margin-left: 0;
     }
     }
-
-    &.stacked {
-      border-radius: $border-radius;
-    }
   }
   }
 }
 }

+ 14 - 53
public/sass/mixins/_mixins.scss

@@ -1,6 +1,6 @@
 @mixin clearfix() {
 @mixin clearfix() {
   &::after {
   &::after {
-    content: "";
+    content: '';
     display: table;
     display: table;
     clear: both;
     clear: both;
   }
   }
@@ -19,6 +19,10 @@
   outline-offset: -2px;
   outline-offset: -2px;
 }
 }
 
 
+@mixin no-focus() {
+  outline: none;
+}
+
 // Center-align a block level element
 // Center-align a block level element
 // ----------------------------------
 // ----------------------------------
 @mixin center-block() {
 @mixin center-block() {
@@ -265,20 +269,10 @@
 // Add an alphatransparency value to any background or border color (via Elyse Holladay)
 // Add an alphatransparency value to any background or border color (via Elyse Holladay)
 #translucent {
 #translucent {
   @mixin background($color: $white, $alpha: 1) {
   @mixin background($color: $white, $alpha: 1) {
-    background-color: hsla(
-      hue($color),
-      saturation($color),
-      lightness($color),
-      $alpha
-    );
+    background-color: hsla(hue($color), saturation($color), lightness($color), $alpha);
   }
   }
   @mixin border($color: $white, $alpha: 1) {
   @mixin border($color: $white, $alpha: 1) {
-    border-color: hsla(
-      hue($color),
-      saturation($color),
-      lightness($color),
-      $alpha
-    );
+    border-color: hsla(hue($color), saturation($color), lightness($color), $alpha);
     @include background-clip(padding-box);
     @include background-clip(padding-box);
   }
   }
 }
 }
@@ -294,66 +288,37 @@
 // Gradients
 // Gradients
 @mixin gradient-horizontal($startColor: #555, $endColor: #333) {
 @mixin gradient-horizontal($startColor: #555, $endColor: #333) {
   background-color: $endColor;
   background-color: $endColor;
-  background-image: linear-gradient(
-    to right,
-    $startColor,
-    $endColor
-  ); // Standard, IE10
+  background-image: linear-gradient(to right, $startColor, $endColor); // Standard, IE10
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 
 
 @mixin gradient-vertical($startColor: #555, $endColor: #333) {
 @mixin gradient-vertical($startColor: #555, $endColor: #333) {
   background-color: mix($startColor, $endColor, 60%);
   background-color: mix($startColor, $endColor, 60%);
-  background-image: linear-gradient(
-    to bottom,
-    $startColor,
-    $endColor
-  ); // Standard, IE10
+  background-image: linear-gradient(to bottom, $startColor, $endColor); // Standard, IE10
   background-repeat: repeat-x;
   background-repeat: repeat-x;
 }
 }
 
 
 @mixin gradient-directional($startColor: #555, $endColor: #333, $deg: 45deg) {
 @mixin gradient-directional($startColor: #555, $endColor: #333, $deg: 45deg) {
   background-color: $endColor;
   background-color: $endColor;
   background-repeat: repeat-x;
   background-repeat: repeat-x;
-  background-image: linear-gradient(
-    $deg,
-    $startColor,
-    $endColor
-  ); // Standard, IE10
+  background-image: linear-gradient($deg, $startColor, $endColor); // Standard, IE10
 }
 }
 
 
 @mixin gradient-horizontal-three-colors($startColor: #00b3ee, $midColor: #7a43b6, $colorStop: 50%, $endColor: #c3325f) {
 @mixin gradient-horizontal-three-colors($startColor: #00b3ee, $midColor: #7a43b6, $colorStop: 50%, $endColor: #c3325f) {
   background-color: mix($midColor, $endColor, 80%);
   background-color: mix($midColor, $endColor, 80%);
-  background-image: linear-gradient(
-    to right,
-    $startColor,
-    $midColor $colorStop,
-    $endColor
-  );
+  background-image: linear-gradient(to right, $startColor, $midColor $colorStop, $endColor);
   background-repeat: no-repeat;
   background-repeat: no-repeat;
 }
 }
 
 
 @mixin gradient-vertical-three-colors($startColor: #00b3ee, $midColor: #7a43b6, $colorStop: 50%, $endColor: #c3325f) {
 @mixin gradient-vertical-three-colors($startColor: #00b3ee, $midColor: #7a43b6, $colorStop: 50%, $endColor: #c3325f) {
   background-color: mix($midColor, $endColor, 80%);
   background-color: mix($midColor, $endColor, 80%);
-  background-image: linear-gradient(
-    $startColor,
-    $midColor $colorStop,
-    $endColor
-  );
+  background-image: linear-gradient($startColor, $midColor $colorStop, $endColor);
   background-repeat: no-repeat;
   background-repeat: no-repeat;
 }
 }
 
 
 @mixin gradient-radial($innerColor: #555, $outerColor: #333) {
 @mixin gradient-radial($innerColor: #555, $outerColor: #333) {
   background-color: $outerColor;
   background-color: $outerColor;
-  background-image: -webkit-gradient(
-    radial,
-    center center,
-    0,
-    center center,
-    460,
-    from($innerColor),
-    to($outerColor)
-  );
+  background-image: -webkit-gradient(radial, center center, 0, center center, 460, from($innerColor), to($outerColor));
   background-image: -webkit-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -webkit-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -moz-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -moz-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -o-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -o-radial-gradient(circle, $innerColor, $outerColor);
@@ -380,11 +345,7 @@
 
 
 @mixin left-brand-border-gradient() {
 @mixin left-brand-border-gradient() {
   border: none;
   border: none;
-  border-image: linear-gradient(
-    rgba(255, 213, 0, 1) 0%,
-    rgba(255, 68, 0, 1) 99%,
-    rgba(255, 68, 0, 1) 100%
-  );
+  border-image: linear-gradient(rgba(255, 213, 0, 1) 0%, rgba(255, 68, 0, 1) 99%, rgba(255, 68, 0, 1) 100%);
   border-image-slice: 1;
   border-image-slice: 1;
   border-style: solid;
   border-style: solid;
   border-top: 0;
   border-top: 0;

+ 1 - 1
public/views/index-template.html

@@ -178,7 +178,7 @@
       </p>
       </p>
       <p>
       <p>
         1. This could be caused by your reverse proxy settings.<br /><br />
         1. This could be caused by your reverse proxy settings.<br /><br />
-        2. If you host grafana under subpath make sure your grafana.ini root_path setting includes subpath<br /> <br />
+        2. If you host grafana under subpath make sure your grafana.ini root_url setting includes subpath<br /> <br />
         3. If you have a local dev build make sure you build frontend using: npm run dev, npm run watch, or npm run
         3. If you have a local dev build make sure you build frontend using: npm run dev, npm run watch, or npm run
         build<br /> <br />
         build<br /> <br />
         4. Sometimes restarting grafana-server can help<br />
         4. Sometimes restarting grafana-server can help<br />