Browse Source

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

Peter Holmberg 7 years ago
parent
commit
c2b1f504a0
56 changed files with 967 additions and 486 deletions
  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)
 
+### 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
 
 * **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)
 * **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)
 
 * **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.
 
-### 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}
 

+ 3 - 3
packaging/publish/publish_both.sh

@@ -1,7 +1,7 @@
 #! /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/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/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/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",
 			Icon:     "fa fa-rocket",
 			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/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/SES":              {"Bounce", "Complaint", "Delivery", "Reject", "Send"},
+		"AWS/SES":              {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"},
 		"AWS/SNS":              {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"},
 		"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"},

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

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

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

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

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

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

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

@@ -6,6 +6,7 @@ export interface Props {
   checked: boolean;
   labelClass?: string;
   switchClass?: string;
+  transparent?: boolean;
   onChange: (event) => any;
 }
 
@@ -24,11 +25,11 @@ export class Switch extends PureComponent<Props, State> {
   };
 
   render() {
-    const { labelClass = '', switchClass = '', label, checked } = this.props;
+    const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
 
     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 (
       <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 {
   tags: string[];
   tagOptions: () => any;
-  onSelect: (tag: string) => void;
+  onChange: (tags: string[]) => void;
 }
 
 export class TagFilter extends React.Component<Props, any> {
@@ -18,12 +18,9 @@ export class TagFilter extends React.Component<Props, any> {
 
   constructor(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 options.map(option => ({
         value: option.term,
@@ -31,18 +28,20 @@ export class TagFilter extends React.Component<Props, any> {
         count: option.count,
       }));
     });
-  }
+  };
 
-  onChange(newTags) {
-    this.props.onSelect(newTags);
-  }
+  onChange = (newTags: any[]) => {
+    this.props.onChange(newTags.map(tag => tag.value));
+  };
 
   render() {
+    const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 }));
+
     const selectOptions = {
       classNamePrefix: 'gf-form-select-box',
       isMulti: true,
       defaultOptions: true,
-      loadOptions: this.searchTags,
+      loadOptions: this.onLoadOptions,
       onChange: this.onChange,
       className: 'gf-form-input gf-form-input--form-dropdown',
       placeholder: 'Tags',
@@ -50,7 +49,7 @@ export class TagFilter extends React.Component<Props, any> {
       noOptionsMessage: () => 'No tags found',
       getOptionValue: i => i.value,
       getOptionLabel: i => i.label,
-      value: this.props.tags,
+      value: tags,
       styles: ResetStyles,
       components: {
         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 {
-  onChange: (value) => void;
-  value?: any;
   label?: string;
-  render: (props) => void;
-  stackedButtons?: boolean;
+  children: JSX.Element[];
+  transparent?: boolean;
 }
 
 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() {
-    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 (
       <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>
     );
   }
@@ -54,16 +26,16 @@ interface ToggleButtonProps {
   value: any;
   className?: string;
   children: ReactNode;
-  stackedButtons?: boolean;
+  tooltip?: string;
 }
 
 export const ToggleButton: SFC<ToggleButtonProps> = ({
   children,
   selected,
   className = '',
-  value,
+  value = null,
+  tooltip,
   onChange,
-  stackedButtons,
 }) => {
   const handleChange = event => {
     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}>
       <span>{children}</span>
     </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({
       target: this.pickerElem,
       content: dropContentElem,
-      position: 'top center',
+      position: 'bottom center',
       classes: 'drop-popover',
       openOn: 'hover',
       hoverCloseDelay: 200,

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

@@ -41,7 +41,7 @@
           </a>
         </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>
       </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);
 
     this.initialFolderFilterTitle = 'All';
-    this.getTags = this.getTags.bind(this);
-    this.onTagSelect = this.onTagSelect.bind(this);
     this.isEditor = contextSrv.isEditor;
     this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
   }
@@ -162,7 +160,7 @@ export class SearchCtrl {
     const localSearchId = this.currentSearchId;
     const query = {
       ...this.query,
-      tag: this.query.tag.map(i => i.value),
+      tag: this.query.tag,
     };
 
     return this.searchSrv.search(query).then(results => {
@@ -195,14 +193,14 @@ export class SearchCtrl {
     evt.preventDefault();
   }
 
-  getTags() {
+  getTags = () => {
     return this.searchSrv.getDashboardTags();
-  }
+  };
 
-  onTagSelect(newTags) {
-    this.query.tag = newTags;
+  onTagFiltersChanged = (tags: string[]) => {
+    this.query.tag = tags;
     this.search();
-  }
+  };
 
   clearSearchFilter() {
     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} />}
         </span>
       </a>
-      {link.children && <SideMenuDropDown link={link} />}
+      <SideMenuDropDown link={link} />
     </div>
   );
 };

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

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

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

@@ -88,6 +88,13 @@ export interface LogsStreamLabels {
   [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 {
   none = 'none',
   exact = 'exact',
@@ -242,32 +249,47 @@ export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSerie
   // Graph time series by log level
   const seriesByLevel = {};
   const bucketSize = intervalMs * 10;
+  const seriesList = [];
 
   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;
 
     // 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 {
-      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 classNames from 'classnames';
 import _ from 'lodash';
-
+import withKeyboardNavigation from './withKeyboardNavigation';
 import { DataSourceSelectItem } from 'app/types';
 
-interface Props {
+export interface Props {
   onChangeDataSource: (ds: any) => void;
   datasources: DataSourceSelectItem[];
+  selected?: number;
+  onKeyDown?: (evt: any, maxSelectedIndex: number, onEnterAction: () => void) => void;
+  onMouseEnter?: (select: number) => void;
 }
 
 interface State {
   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 className="panel-editor__scroll">
           <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>
             </FadeIn>
             <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 { Input } from 'app/core/components/Form';
 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">
-            <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-6">Last</span>
             <Input
               type="text"
               className="gf-form-input max-width-8"
@@ -81,11 +76,7 @@ export class TimeRangeOptions extends PureComponent<Props> {
           </div>
 
           <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-6">Amount</span>
             <Input
               type="text"
               className="gf-form-input max-width-8"
@@ -97,11 +88,6 @@ export class TimeRangeOptions extends PureComponent<Props> {
           </div>
 
           <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} />
           </div>
         </div>

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

@@ -1,9 +1,9 @@
 import React, { PureComponent } from 'react';
-import classNames from 'classnames';
 import _ from 'lodash';
 
 import config from 'app/core/config';
 import { PanelPlugin } from 'app/types/plugins';
+import VizTypePickerPlugin from './VizTypePickerPlugin';
 
 interface Props {
   current: PanelPlugin;
@@ -12,6 +12,7 @@ interface Props {
 
 interface State {
   searchQuery: string;
+  selected: number;
 }
 
 export class VizTypePicker extends PureComponent<Props, State> {
@@ -23,9 +24,50 @@ export class VizTypePicker extends PureComponent<Props, State> {
 
     this.state = {
       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[] {
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
@@ -36,26 +78,29 @@ export class VizTypePicker extends PureComponent<Props, State> {
     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 (
-      <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[] => {
     const { searchQuery } = this.state;
     const regex = new RegExp(searchQuery, 'i');
@@ -73,6 +118,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
     this.setState(prevState => ({
       ...prevState,
       searchQuery: value,
+      selected: 0,
     }));
   };
 
@@ -86,6 +132,7 @@ export class VizTypePicker extends PureComponent<Props, State> {
             placeholder=""
             ref={elem => (this.searchInput = elem)}
             onChange={this.onSearchQueryChange}
+            onKeyDown={this.onKeyDown}
           />
           <i className="gf-form-input-icon fa fa-search" />
         </label>
@@ -102,7 +149,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
           {this.renderFilters()}
           <div className="gf-form--grow" />
         </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 Table from './Table';
 import ErrorBoundary from './ErrorBoundary';
-import TimePicker from './TimePicker';
 import { Alert } from './Error';
+import TimePicker, { parseTime } from './TimePicker';
 
 interface ExploreProps {
   datasourceSrv: DatasourceSrv;
@@ -119,7 +119,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     } else {
       const { datasource, queries, range } = props.urlState as ExploreUrlState;
       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
       const initialGraphInterval = 15 * 1000;
       this.state = {
@@ -687,7 +687,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     }
 
     this.setState(state => {
-      const { history, queryTransactions, scanning } = state;
+      const { history, queryTransactions } = state;
+      let { scanning } = state;
 
       // Transaction might have been discarded
       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);
 
       // 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 {
         ...results,
+        scanning,
         history: nextHistory,
         queryTransactions: nextQueryTransactions,
       };
@@ -913,6 +920,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                 onChange={this.onChangeDatasource}
                 options={exploreDatasources}
                 styles={ResetStyles}
+                maxMenuHeight={500}
                 placeholder="Select datasource"
                 loadingMessage={() => 'Loading datasources...'}
                 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<
   {
-    allRows?: LogRow[];
+    getRows?: () => LogRow[];
     label: string;
     plain?: boolean;
     value: string;
@@ -98,13 +98,14 @@ class Label extends PureComponent<
       if (state.showStats) {
         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 };
     });
   };
 
   render() {
-    const { allRows, label, plain, value } = this.props;
+    const { getRows, label, plain, value } = this.props;
     const { showStats, stats } = this.state;
     const tooltip = `${label}: ${value}`;
     return (
@@ -115,12 +116,12 @@ class Label extends PureComponent<
         {!plain && (
           <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 && (
           <span className="logs-label__stats">
             <Stats
               stats={stats}
-              rowCount={allRows.length}
+              rowCount={getRows().length}
               label={label}
               value={value}
               onClickClose={this.onClickClose}
@@ -133,15 +134,15 @@ class Label extends PureComponent<
 }
 
 export default class LogLabels extends PureComponent<{
-  allRows?: LogRow[];
+  getRows?: () => LogRow[];
   labels: LogsStreamLabels;
   plain?: boolean;
   onClickLabel?: (label: string, value: string) => void;
 }> {
   render() {
-    const { allRows, labels, onClickLabel, plain } = this.props;
+    const { getRows, labels, onClickLabel, plain } = this.props;
     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 { RawTimeRange } from 'app/types/series';
 import {
+  LogsDedupDescription,
   LogsDedupStrategy,
   LogsModel,
   dedupLogRows,
@@ -56,13 +57,13 @@ const FieldHighlight = onClick => props => {
 };
 
 interface RowProps {
-  allRows: LogRow[];
   highlighterExpressions?: string[];
   row: LogRow;
   showDuplicates: boolean;
   showLabels: boolean | null; // Tristate: null means auto
   showLocalTime: boolean;
   showUtc: boolean;
+  getRows: () => LogRow[];
   onClickLabel?: (label: string, value: string) => void;
 }
 
@@ -107,11 +108,12 @@ class Row extends PureComponent<RowProps, RowState> {
   };
 
   onClickHighlight = (fieldText: string) => {
-    const { allRows } = this.props;
+    const { getRows } = this.props;
     const { parser } = this.state;
 
     const fieldMatch = fieldText.match(parser.fieldRegex);
     if (fieldMatch) {
+      const allRows = getRows();
       // Build value-agnostic row matcher based on the field label
       const fieldLabel = fieldMatch[1];
       const fieldValue = fieldMatch[2];
@@ -151,7 +153,7 @@ class Row extends PureComponent<RowProps, RowState> {
 
   render() {
     const {
-      allRows,
+      getRows,
       highlighterExpressions,
       onClickLabel,
       row,
@@ -193,7 +195,7 @@ class Row extends PureComponent<RowProps, RowState> {
         )}
         {showLabels && (
           <div className="logs-row__labels">
-            <LogLabels allRows={allRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
+            <LogLabels getRows={getRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
           </div>
         )}
         <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...';
 
+    // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
+    const getRows = () => processedRows;
+
     return (
       <div className="logs-panel">
         <div className="logs-panel-graph">
@@ -431,41 +415,37 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
         </div>
         <div className="logs-panel-options">
           <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>
 
+        {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">
           {hasData &&
             !deferLogs &&
@@ -473,7 +453,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             firstRows.map(row => (
               <Row
                 key={row.key + row.duplicates}
-                allRows={processedRows}
+                getRows={getRows}
                 highlighterExpressions={highlighterExpressions}
                 row={row}
                 showDuplicates={showDuplicates}
@@ -489,7 +469,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             lastRows.map(row => (
               <Row
                 key={row.key + row.duplicates}
-                allRows={processedRows}
+                getRows={getRows}
                 row={row}
                 showDuplicates={showDuplicates}
                 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).
  * @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 (ensureString) {
+      return value.format(DATE_FORMAT);
+    }
     return value;
   }
-  if (value.indexOf('now') !== -1) {
+  if ((value as string).indexOf('now') !== -1) {
     return value;
   }
   let time: any = value;
@@ -50,6 +53,16 @@ interface TimePickerState {
   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> {
   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 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 = {
       from: fromRaw,
       to: toRaw,

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

@@ -95,10 +95,17 @@ export class MetricsTabCtrl {
           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;

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

@@ -12,7 +12,12 @@ function panelEditorTab(dynamicDirectiveSrv) {
     },
     directive: scope => {
       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][tabName]) {

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

@@ -38,8 +38,9 @@ export class CustomVariable implements Variable {
   }
 
   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() };
     });
 

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

@@ -151,7 +151,7 @@
 			<h5 class="section-heading">Custom Options</h5>
 			<div class="gf-form">
 				<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>
 			</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.variableModel = {
         type: 'custom',
-        query: 'hej, hop, asd',
+        query: 'hej, hop, asd, escaped\\,var',
         name: 'test',
       };
     });
 
     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[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,
   "logs": true,
   "explore": true,
-  "tables": true,
+  "tables": false,
   "info": {
     "description": "Loki Logging Data Source for Grafana",
     "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('Axes', axesEditorComponent);
     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;
   }
 

+ 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 => {
       const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
       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 => {
       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');

+ 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">
 			<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>
@@ -89,9 +66,7 @@
 		</div>
 	</div>
 
-	<div class="edit-tab-content" ng-if="ctrl.subTabIndex === 1">
 		<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">
 					<label class="gf-form-label">alias or regex</label>
@@ -110,35 +85,26 @@
 						</span>
 					</label>
 				</div>
-
+	
 				<div class="gf-form">
 					<span class="dropdown" dropdown-typeahead="overrideMenu" dropdown-typeahead-on-select="setOverride($item, $subItem)">
 					</span>
 				</div>
-
+	
 				<div class="gf-form gf-form--grow">
 					<div class="gf-form-label gf-form-label--grow"></div>
 				</div>
-
+	
 				<div class="gf-form">
 					<label class="gf-form-label">
 						<i class="fa fa-trash pointer" ng-click="ctrl.removeSeriesOverride(override)"></i>
 					</label>
 				</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>
 
-		<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">
-  <h5>Thresholds</h5>
   <p class="muted" ng-show="ctrl.disabled">
     Visual thresholds options <strong>disabled.</strong>
     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;
       }
 
+      if (timeRegion.from && !timeRegion.to) {
+        timeRegion.to = timeRegion.from;
+      }
+
+      if (!timeRegion.from && timeRegion.to) {
+        timeRegion.from = timeRegion.to;
+      }
+
       hRange = {
         from: this.parseTimeRange(timeRegion.from),
         to: this.parseTimeRange(timeRegion.to),
@@ -108,21 +116,13 @@ export class TimeRegionManager {
         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.m = 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.m = 59;
         hRange.to.s = 59;
@@ -169,8 +169,16 @@ export class TimeRegionManager {
             fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
           } else if (hRange.from.h + hRange.to.h < 23) {
             fromEnd.add(hRange.to.h, 'hours');
+
+            while (fromEnd.hour() !== hRange.to.h) {
+              fromEnd.add(-1, 'hours');
+            }
           } else {
             fromEnd.add(24 - hRange.from.h, 'hours');
+
+            while (fromEnd.hour() !== hRange.to.h) {
+              fromEnd.add(1, 'hours');
+            }
           }
 
           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">
-  <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">
       <label class="gf-form-label">T{{$index+1}}</label>
@@ -58,7 +57,7 @@
 
   <div class="gf-form-button-row">
     <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>
   </div>
 </div>

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

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

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

@@ -391,3 +391,8 @@ $panel-grid-placeholder-shadow: 0 0 4px $blue;
 
 // logs
 $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;
-$page-bg: $gray-6;
+$page-bg: $gray-7;
 $body-color: $gray-1;
 $text-color: $gray-1;
 $text-color-strong: $dark-2;
@@ -400,3 +400,8 @@ $panel-grid-placeholder-shadow: 0 0 4px $blue-light;
 
 // logs
 $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.
 //
 // Credit: https://github.com/suitcss/base
-[tabindex="-1"]:focus {
+[tabindex='-1']:focus {
   outline: none !important;
 }
 
@@ -171,7 +171,7 @@ a {
   }
 
   &:focus {
-    @include tab-focus();
+    @include no-focus();
   }
 }
 
@@ -214,7 +214,7 @@ img {
 // for traditionally non-focusable elements with role="button"
 // see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
 
-[role="button"] {
+[role='button'] {
   cursor: pointer;
 }
 
@@ -231,7 +231,7 @@ img {
 a,
 area,
 button,
-[role="button"],
+[role='button'],
 input,
 label,
 select,
@@ -320,7 +320,7 @@ legend {
   //  border: 0;
 }
 
-input[type="search"] {
+input[type='search'] {
   // 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
   // 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 {
     &:focus,
     &.focus {
-      @include tab-focus();
+      @include no-focus();
     }
   }
 

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

@@ -50,12 +50,8 @@
   }
 
   .react-grid-item {
-    display: none;
-    transition-property: none !important;
-  }
-
-  .panel {
     display: block !important;
+    transition-property: none !important;
     position: unset !important;
     width: 100% !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;
 }
 
+.gf-form-select-box__menu-list {
+  overflow-y: auto;
+  max-height: 300px;
+}
+
 .tag-filter .gf-form-select-box__menu {
   width: 100%;
 }

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

@@ -117,9 +117,11 @@ $input-border: 1px solid $input-border-color;
     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 {

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

@@ -157,21 +157,15 @@
   padding-bottom: 6px;
   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;
     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;
   height: 44px;
 
-  &:hover {
+  &--selected {
     background: $panel-editor-viz-item-bg-hover;
     border: $panel-editor-viz-item-border-hover;
     box-shadow: $panel-editor-viz-item-shadow-hover;
   }
 
-  &--selected {
+  &--active {
     box-shadow: 0 0 6px $orange;
     border: 1px solid $orange;
 

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

@@ -1,15 +1,21 @@
 $column-horizontal-spacing: 10px;
 
-.logs-panel-controls {
+.logs-panel-options {
   display: flex;
   background-color: $page-bg;
   padding: $panel-padding;
   padding-top: 10px;
   border-radius: $border-radius;
-  margin: 2*$panel-margin 0;
+  margin: 2*$panel-margin 0 $panel-margin;
   border: $panel-border;
+  flex-direction: column;
+}
+
+.logs-panel-controls {
+  display: flex;
   justify-items: flex-start;
-  align-items: flex-start;
+  align-items: center;
+  flex-wrap: wrap;
 
   > * {
     margin-right: 1em;
@@ -25,12 +31,14 @@ $column-horizontal-spacing: 10px;
 .logs-panel-meta {
   flex: 1;
   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 {
   margin-right: 1em;
+  display: flex;
 }
 
 .logs-panel-meta__label {
@@ -131,7 +139,7 @@ $column-horizontal-spacing: 10px;
   &--warning,
   &--warn {
     &::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-left: none;
   border-radius: $input-border-radius;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 
   input {
     opacity: 0;
     width: 0;
     height: 0;
   }
+
+  &--transparent {
+    background: transparent;
+    border: 0;
+    width: 40px;
+  }
 }
 
 /* The slider */
 .gf-form-switch__slider {
-  position: absolute;
-  top: 8px;
-  left: 16px;
-  right: 14px;
-  bottom: 10px;
   background: $switch-slider-off-bg;
   border-radius: 8px;
   height: 16px;
   width: 29px;
+  display: block;
+  position: relative;
 
   &::before {
     position: absolute;
     content: '';
     height: 12px;
     width: 12px;
-    left: 2px;
-    bottom: 2px;
+    left: 1px;
+    top: 2px;
     background: $switch-slider-color;
     transition: 0.4s;
     border-radius: 50%;

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

@@ -1,28 +1,21 @@
 .toggle-button-group {
   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 {
-    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;
-    color: $text-color;
+    border-right: $button-toggle-group-btn-seperator-border;
+
     &.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 {
         cursor: default;
       }
@@ -37,9 +30,5 @@
       border-radius: 0 $border-radius $border-radius 0;
       margin-left: 0;
     }
-
-    &.stacked {
-      border-radius: $border-radius;
-    }
   }
 }

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

@@ -1,6 +1,6 @@
 @mixin clearfix() {
   &::after {
-    content: "";
+    content: '';
     display: table;
     clear: both;
   }
@@ -19,6 +19,10 @@
   outline-offset: -2px;
 }
 
+@mixin no-focus() {
+  outline: none;
+}
+
 // Center-align a block level element
 // ----------------------------------
 @mixin center-block() {
@@ -265,20 +269,10 @@
 // Add an alphatransparency value to any background or border color (via Elyse Holladay)
 #translucent {
   @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) {
-    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);
   }
 }
@@ -294,66 +288,37 @@
 // Gradients
 @mixin gradient-horizontal($startColor: #555, $endColor: #333) {
   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;
 }
 
 @mixin gradient-vertical($startColor: #555, $endColor: #333) {
   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;
 }
 
 @mixin gradient-directional($startColor: #555, $endColor: #333, $deg: 45deg) {
   background-color: $endColor;
   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) {
   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;
 }
 
 @mixin gradient-vertical-three-colors($startColor: #00b3ee, $midColor: #7a43b6, $colorStop: 50%, $endColor: #c3325f) {
   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;
 }
 
 @mixin gradient-radial($innerColor: #555, $outerColor: #333) {
   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: -moz-radial-gradient(circle, $innerColor, $outerColor);
   background-image: -o-radial-gradient(circle, $innerColor, $outerColor);
@@ -380,11 +345,7 @@
 
 @mixin left-brand-border-gradient() {
   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-style: solid;
   border-top: 0;

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

@@ -178,7 +178,7 @@
       </p>
       <p>
         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
         build<br /> <br />
         4. Sometimes restarting grafana-server can help<br />