Browse Source

Merge branch 'master' into WPH95-feature/add_es_alerting

Marcus Efraimsson 7 years ago
parent
commit
cde347bd3d
83 changed files with 1238 additions and 435 deletions
  1. 18 0
      .dockerignore
  2. 2 0
      .gitignore
  3. 15 0
      CHANGELOG.md
  4. 2 0
      Makefile
  5. 10 16
      ROADMAP.md
  6. 1 1
      docker/blocks/apache_proxy/docker-compose.yaml
  7. 1 1
      docker/blocks/nginx_proxy/docker-compose.yaml
  8. 3 3
      docs/sources/guides/whats-new-in-v5-1.md
  9. 2 2
      docs/sources/installation/behind_proxy.md
  10. 4 0
      docs/sources/installation/configuration.md
  11. 3 3
      docs/sources/installation/debian.md
  12. 5 5
      docs/sources/installation/rpm.md
  13. 1 1
      docs/sources/installation/windows.md
  14. 1 1
      docs/sources/plugins/developing/apps.md
  15. 1 1
      docs/sources/plugins/developing/datasources.md
  16. 19 10
      docs/sources/plugins/developing/panels.md
  17. 1 1
      docs/sources/plugins/developing/plugin.json.md
  18. 4 4
      docs/sources/tutorials/iis.md
  19. 19 10
      pkg/api/http_server.go
  20. 8 24
      pkg/cmd/grafana-server/main.go
  21. 47 33
      pkg/cmd/grafana-server/server.go
  22. 9 0
      pkg/components/null/float.go
  23. 14 0
      pkg/login/ldap_test.go
  24. 5 1
      pkg/login/ldap_user.go
  25. 0 38
      pkg/metrics/init.go
  26. 1 23
      pkg/metrics/metrics.go
  27. 71 0
      pkg/metrics/service.go
  28. 22 34
      pkg/metrics/settings.go
  29. 11 0
      pkg/middleware/auth_proxy.go
  30. 7 6
      pkg/models/notifications.go
  31. 12 12
      pkg/services/alerting/engine.go
  32. 173 0
      pkg/services/alerting/notifiers/discord.go
  33. 52 0
      pkg/services/alerting/notifiers/discord_test.go
  34. 7 6
      pkg/services/notifications/notifications.go
  35. 13 7
      pkg/services/notifications/webhook.go
  36. 1 1
      pkg/services/provisioning/dashboards/config_reader.go
  37. 2 5
      pkg/services/provisioning/dashboards/dashboard.go
  38. 23 13
      pkg/services/provisioning/provisioning.go
  39. 5 0
      pkg/services/sqlstore/migrations/user_auth_mig.go
  40. 12 8
      pkg/services/sqlstore/sqlstore.go
  41. 19 8
      pkg/setting/setting.go
  42. 17 2
      public/app/containers/Explore/Explore.tsx
  43. 15 4
      public/app/containers/Explore/QueryRows.tsx
  44. 1 0
      public/app/core/services/analytics.ts
  45. 13 1
      public/app/core/services/keybindingSrv.ts
  46. 79 18
      public/app/core/specs/file_export.jest.ts
  47. 62 12
      public/app/core/specs/kbn.jest.ts
  48. 14 0
      public/app/core/specs/time_series.jest.ts
  49. 8 2
      public/app/core/time_series2.ts
  50. 103 55
      public/app/core/utils/file_export.ts
  51. 7 6
      public/app/core/utils/kbn.ts
  52. 7 4
      public/app/core/utils/location_util.ts
  53. 5 1
      public/app/features/dashboard/dashboard_srv.ts
  54. 12 2
      public/app/features/dashboard/settings/settings.html
  55. 16 3
      public/app/features/dashboard/settings/settings.ts
  56. 14 3
      public/app/features/dashlinks/module.ts
  57. 19 0
      public/app/features/panel/metrics_panel_ctrl.ts
  58. 13 1
      public/app/features/panel/panel_ctrl.ts
  59. 10 0
      public/app/features/templating/specs/template_srv.jest.ts
  60. 8 5
      public/app/features/templating/template_srv.ts
  61. 1 0
      public/app/plugins/datasource/elasticsearch/datasource.ts
  62. 14 5
      public/app/plugins/datasource/influxdb/response_parser.ts
  63. 26 0
      public/app/plugins/datasource/influxdb/specs/response_parser.jest.ts
  64. 18 0
      public/app/plugins/datasource/prometheus/datasource.ts
  65. 3 3
      public/app/plugins/datasource/prometheus/result_transformer.ts
  66. 12 0
      public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts
  67. 1 1
      public/app/plugins/panel/graph/module.ts
  68. 1 1
      public/app/plugins/panel/pluginlist/module.ts
  69. 2 2
      public/app/plugins/panel/singlestat/module.ts
  70. 47 0
      public/app/plugins/panel/singlestat/specs/singlestat_specs.ts
  71. 5 0
      public/app/plugins/panel/table/module.ts
  72. 1 1
      public/app/plugins/panel/table/renderer.ts
  73. 1 0
      public/app/routes/ReactContainer.tsx
  74. 1 1
      public/app/routes/routes.ts
  75. 2 3
      public/img/graph404.svg
  76. 11 0
      public/sass/components/_dashboard_settings.scss
  77. 1 0
      public/sass/components/_form_select_box.scss
  78. 14 6
      public/sass/components/_timepicker.scss
  79. 2 1
      public/sass/pages/_dashboard.scss
  80. 22 11
      public/test/specs/helpers.ts
  81. 2 1
      scripts/webpack/webpack.common.js
  82. 1 1
      scripts/webpack/webpack.dev.js
  83. 1 1
      tools/phantomjs/render.js

+ 18 - 0
.dockerignore

@@ -0,0 +1,18 @@
+.awcache
+.dockerignore
+.git
+.gitignore
+.github
+data*
+dist
+docker
+docs
+dump.rdb
+node_modules
+/local
+/tmp
+/vendor
+*.yml
+*.md
+/vendor
+/tmp

+ 2 - 0
.gitignore

@@ -44,7 +44,9 @@ docker-compose.yaml
 /conf/provisioning/**/custom.yaml
 profile.cov
 /grafana
+/local
 .notouch
+/Makefile.local
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/debug

+ 15 - 0
CHANGELOG.md

@@ -4,6 +4,21 @@
 
 * **Graph**: Show invisible highest value bucket in histogram [#11498](https://github.com/grafana/grafana/issues/11498)
 * **Dashboard**: Enable "Save As..." if user has edit permission [#11625](https://github.com/grafana/grafana/issues/11625)
+* **Prometheus**: Table columns order now changes when rearrange queries [#11690](https://github.com/grafana/grafana/issues/11690), thx [@mtanda](https://github.com/mtanda)
+* **Variables**: Fix variable interpolation when using multiple formatting types [#11800](https://github.com/grafana/grafana/issues/11800), thx [@svenklemm](https://github.com/svenklemm)
+* **Dashboard**: Fix date selector styling for dark/light theme in time picker control [#11616](https://github.com/grafana/grafana/issues/11616)
+* **Discord**: Alert notification channel type for Discord, [#7964](https://github.com/grafana/grafana/issues/7964) thx [@jereksel](https://github.com/jereksel),
+* **InfluxDB**: Support SELECT queries in templating query, [#5013](https://github.com/grafana/grafana/issues/5013)
+* **Dashboard**: JSON Model under dashboard settings can now be updated & changes saved, [#1429](https://github.com/grafana/grafana/issues/1429), thx [@jereksel](https://github.com/jereksel)
+* **Security**: Fix XSS vulnerabilities in dashboard links [#11813](https://github.com/grafana/grafana/pull/11813)
+* **Singlestat**: Fix "time of last point" shows local time when dashboard timezone set to UTC [#10338](https://github.com/grafana/grafana/issues/10338)
+
+# 5.1.1 (2018-05-07)
+
+* **LDAP**: LDAP login with MariaDB/MySQL database and dn>100 chars not possible [#11754](https://github.com/grafana/grafana/issues/11754)
+* **Build**: AppVeyor Windows build missing version and commit info [#11758](https://github.com/grafana/grafana/issues/11758)
+* **Scroll**: Scroll can't start in graphs on Chrome mobile [#11710](https://github.com/grafana/grafana/issues/11710)
+* **Units**: Revert renaming of unit key ppm [#11743](https://github.com/grafana/grafana/issues/11743)
 
 # 5.1.0 (2018-04-26)
 

+ 2 - 0
Makefile

@@ -1,3 +1,5 @@
+-include local/Makefile
+
 all: deps build
 
 deps-go:

+ 10 - 16
ROADMAP.md

@@ -1,26 +1,20 @@
-# Roadmap (2018-02-22)
+# Roadmap (2018-05-06)
 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 But it will give you an idea of our current vision and plan. 
-
-### Short term (1-2 months)
-
-- v5.1
-  - Build speed improvements & integration test execution
-  - Kubernetes friendly docker container
-  - Enterprise LDAP
-  - Provisioning workflow
-  - MSSQL datasource
   
-### Mid term (2-4 months)
+### Short term (1-2 months)
 
-- v5.2
-  - Azure monitor backend rewrite
   - Elasticsearch alerting
-  - First login registration view
-  - Backend plugins? (alert notifiers, auth)
   - Crossplatform builds
-  - IFQL Initial support
+  - Backend service refactorings
+  - Explore UI 
+  - First login registration view
+  
+### Mid term (2-4 months)
+  - Multi-Stat panel
+  - React Panels 
+  - Templating Query Editor UI Plugin hook
   
 ### Long term (4 - 8 months)
 

+ 1 - 1
docker/blocks/apache_proxy/docker-compose.yaml

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
-# root_url = %(protocol)s://%(domain)s:/grafana/
+# root_url = %(protocol)s://%(domain)s:10081/grafana/
 
   apacheproxy:
     build: blocks/apache_proxy

+ 1 - 1
docker/blocks/nginx_proxy/docker-compose.yaml

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
-# root_url = %(protocol)s://%(domain)s:/grafana/
+# root_url = %(protocol)s://%(domain)s:10080/grafana/
 
   nginxproxy:
     build: blocks/nginx_proxy

+ 3 - 3
docs/sources/guides/whats-new-in-v5-1.md

@@ -100,8 +100,8 @@ In the table below you can see some examples and you can find all different opti
 Filter Option | Example | Raw | Interpolated | Description
 ------------ | ------------- | ------------- | -------------  | -------------
 `glob` | ${servers:glob} |  `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob
-`regex` | ${servers:regex} | `'test.', 'test2'` |  `(test\\.|test2)` | Formats multi-value variable into a regex string
-`pipe` | ${servers:pipe} | `'test.', 'test2'` |  `test.|test2` | Formats multi-value variable into a pipe-separated string
+`regex` | ${servers:regex} | `'test.', 'test2'` |  <code>(test\.&#124;test2)</code> | Formats multi-value variable into a regex string
+`pipe` | ${servers:pipe} | `'test.', 'test2'` |  <code>test.&#124;test2</code> | Formats multi-value variable into a pipe-separated string
 `csv`| ${servers:csv} |  `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
 
 ## Improved workflow for provisioned dashboards
@@ -122,4 +122,4 @@ More information in the [Provisioning documentation](/features/datasources/prome
 ## Changelog
 
 Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
-of new features, changes, and bug fixes.
+of new features, changes, and bug fixes.

+ 2 - 2
docs/sources/installation/behind_proxy.md

@@ -53,7 +53,7 @@ server {
 ```bash
 [server]
 domain = foo.bar
-root_url = %(protocol)s://%(domain)s:/grafana
+root_url = %(protocol)s://%(domain)s/grafana/
 ```
 
 #### Nginx configuration with sub path
@@ -98,7 +98,7 @@ Given:
     ```bash
     [server]
     domain = localhost:8080
-    root_url = %(protocol)s://%(domain)s:/grafana
+    root_url = %(protocol)s://%(domain)s/grafana/
     ```
 
 Create an Inbound Rule for the parent website (localhost:8080 in this example) in IIS Manager with the following settings:

+ 4 - 0
docs/sources/installation/configuration.md

@@ -659,6 +659,10 @@ Set to `true` to enable auto sign up of users who do not exist in Grafana DB. De
 
 Limit where auth proxy requests come from by configuring a list of IP addresses. This can be used to prevent users spoofing the X-WEBAUTH-USER header.
 
+### headers
+
+Used to define additional headers for `Name`, `Email` and/or `Login`, for example if the user's name is sent in the X-WEBAUTH-NAME header and their email address in the X-WEBAUTH-EMAIL header, set `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL`.
+
 <hr>
 
 ## [session]

+ 3 - 3
docs/sources/installation/debian.md

@@ -15,7 +15,7 @@ weight = 1
 
 Description | Download
 ------------ | -------------
-Stable for Debian-based Linux | [grafana_5.1.0_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0_amd64.deb)
+Stable for Debian-based Linux | [grafana_5.1.1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.1_amd64.deb)
 <!--
 Beta for Debian-based Linux | [grafana_5.1.0-beta1_amd64.deb](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0-beta1_amd64.deb)
 -->
@@ -27,9 +27,9 @@ installation.
 
 
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.0_amd64.deb
+wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.1_amd64.deb
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.1.0_amd64.deb
+sudo dpkg -i grafana_5.1.1_amd64.deb
 ```
 
 <!-- ## Install Latest Beta

+ 5 - 5
docs/sources/installation/rpm.md

@@ -15,7 +15,7 @@ weight = 2
 
 Description | Download
 ------------ | -------------
-Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm)
+Stable for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm)
 <!--
 Latest Beta for CentOS / Fedora / OpenSuse / Redhat Linux | [5.1.0-beta1 (x86-64 rpm)](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-beta1.x86_64.rpm)
 -->
@@ -28,7 +28,7 @@ installation.
 You can install Grafana using Yum directly.
 
 ```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm
+$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm
 ```
 
 <!-- ## Install Beta
@@ -42,15 +42,15 @@ Or install manually using `rpm`.
 #### On CentOS / Fedora / Redhat:
 
 ```bash
-$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0-1.x86_64.rpm
+$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1-1.x86_64.rpm
 $ sudo yum install initscripts fontconfig
-$ sudo rpm -Uvh grafana-5.1.0-1.x86_64.rpm
+$ sudo rpm -Uvh grafana-5.1.1-1.x86_64.rpm
 ```
 
 #### On OpenSuse:
 
 ```bash
-$ sudo rpm -i --nodeps grafana-5.1.0-1.x86_64.rpm
+$ sudo rpm -i --nodeps grafana-5.1.1-1.x86_64.rpm
 ```
 
 ## Install via YUM Repository

+ 1 - 1
docs/sources/installation/windows.md

@@ -12,7 +12,7 @@ weight = 3
 
 Description | Download
 ------------ | -------------
-Latest stable package for Windows | [grafana-5.1.0.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.0.windows-x64.zip)
+Latest stable package for Windows | [grafana-5.1.1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.1.windows-x64.zip)
 
 <!--
 Latest beta package for Windows | [grafana.5.1.0-beta1.windows-x64.zip](https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.0.0-beta5.windows-x64.zip)

+ 1 - 1
docs/sources/plugins/developing/apps.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "Developing App Plugins"
 parent = "developing"
-weight = 6
+weight = 4
 +++
 
 # Grafana Apps

+ 1 - 1
docs/sources/plugins/developing/datasources.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "Developing Datasource Plugins"
 parent = "developing"
-weight = 6
+weight = 5
 +++
 
 # Datasources

+ 19 - 10
docs/sources/plugins/developing/panels.md

@@ -1,16 +1,11 @@
----
-page_title: Plugin panel
-page_description: Panel plugins for Grafana
-page_keywords: grafana, plugins, documentation
----
-
-
 +++
-title = "Installing Plugins"
+title = "Developing Panel Plugins"
+keywords = ["grafana", "plugins", "panel", "documentation"]
 type = "docs"
 [menu.docs]
+name = "Developing Panel Plugins"
 parent = "developing"
-weight = 1
+weight = 4
 +++
 
 
@@ -20,7 +15,21 @@ Panels are the main building blocks of dashboards.
 
 ## Panel development
 
-Examples
+
+### Scrolling
+The grafana dashboard framework controls the panel height.  To enable a scrollbar within the panel the PanelCtrl needs to set the scrollable static variable:
+
+```javascript
+export class MyPanelCtrl extends PanelCtrl {
+  static scrollable = true;
+  ...
+```
+
+In this case, make sure the template has a single `<div>...</div>` root.  The plugin loader will modifiy that element adding a scrollbar.
+
+
+
+### Examples
 
 - [clock-panel](https://github.com/grafana/clock-panel)
 - [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts)

+ 1 - 1
docs/sources/plugins/developing/plugin.json.md

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 name = "plugin.json Schema"
 parent = "developing"
-weight = 6
+weight = 8
 +++
 
 # Plugin.json

+ 4 - 4
docs/sources/tutorials/iis.md

@@ -16,7 +16,7 @@ Example:
 - Parent site: http://localhost:8080
 - Grafana: http://localhost:3000
 
-Grafana as a subpath: http://localhost:8080/grafana 
+Grafana as a subpath: http://localhost:8080/grafana
 
 ## Setup
 
@@ -33,7 +33,7 @@ Given that the subpath should be `grafana` and the parent site is `localhost:808
  ```bash
 [server]
 domain = localhost:8080
-root_url = %(protocol)s://%(domain)s:/grafana
+root_url = %(protocol)s://%(domain)s/grafana/
 ```
 
 Restart the Grafana server after changing the config file.
@@ -74,11 +74,11 @@ When navigating to the grafana url (`http://localhost:8080/grafana` in the examp
 
 1. The `root_url` setting in the Grafana config file does not match the parent url with subpath. This could happen if the root_url is commented out by mistake (`;` is used for commenting out a line in .ini files):
 
-    `; root_url = %(protocol)s://%(domain)s:/grafana`
+    `; root_url = %(protocol)s://%(domain)s/grafana/`
 
 2. or if the subpath in the `root_url` setting does not match the subpath used in the pattern in the Inbound Rule in IIS:
 
-    `root_url = %(protocol)s://%(domain)s:/grafana`
+    `root_url = %(protocol)s://%(domain)s/grafana/`
 
     pattern in Inbound Rule: `wrongsubpath(/)?(.*)`
 

+ 19 - 10
pkg/api/http_server.go

@@ -26,9 +26,14 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/setting"
 )
 
+func init() {
+	registry.RegisterService(&HTTPServer{})
+}
+
 type HTTPServer struct {
 	log           log.Logger
 	macaron       *macaron.Macaron
@@ -41,12 +46,14 @@ type HTTPServer struct {
 	Bus           bus.Bus       `inject:""`
 }
 
-func (hs *HTTPServer) Init() {
+func (hs *HTTPServer) Init() error {
 	hs.log = log.New("http.server")
 	hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
+
+	return nil
 }
 
-func (hs *HTTPServer) Start(ctx context.Context) error {
+func (hs *HTTPServer) Run(ctx context.Context) error {
 	var err error
 
 	hs.context = ctx
@@ -57,17 +64,18 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	hs.streamManager.Run(ctx)
 
 	listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
-	hs.log.Info("Initializing HTTP Server", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
+	hs.log.Info("HTTP Server Listen", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
 
 	hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron}
 
 	// handle http shutdown on server context done
 	go func() {
 		<-ctx.Done()
+		// Hacky fix for race condition between ListenAndServe and Shutdown
+		time.Sleep(time.Millisecond * 100)
 		if err := hs.httpSrv.Shutdown(context.Background()); err != nil {
 			hs.log.Error("Failed to shutdown server", "error", err)
 		}
-		hs.log.Info("Stopped HTTP Server")
 	}()
 
 	switch setting.Protocol {
@@ -106,12 +114,6 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	return err
 }
 
-func (hs *HTTPServer) Shutdown(ctx context.Context) error {
-	err := hs.httpSrv.Shutdown(ctx)
-	hs.log.Info("Stopped HTTP server")
-	return err
-}
-
 func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error {
 	if certfile == "" {
 		return fmt.Errorf("cert_file cannot be empty when using HTTPS")
@@ -172,6 +174,7 @@ func (hs *HTTPServer) newMacaron() *macaron.Macaron {
 		hs.mapStatic(m, route.Directory, "", pluginRoute)
 	}
 
+	hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
 	hs.mapStatic(m, setting.StaticRootPath, "", "public")
 	hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
 
@@ -239,6 +242,12 @@ func (hs *HTTPServer) mapStatic(m *macaron.Macaron, rootDir string, dir string,
 		c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
 	}
 
+	if prefix == "public/build" {
+		headers = func(c *macaron.Context) {
+			c.Resp.Header().Set("Cache-Control", "public, max-age=31536000")
+		}
+	}
+
 	if setting.Env == setting.DEV {
 		headers = func(c *macaron.Context) {
 			c.Resp.Header().Set("Cache-Control", "max-age=0, must-revalidate, no-cache")

+ 8 - 24
pkg/cmd/grafana-server/main.go

@@ -40,7 +40,6 @@ var enterprise string
 var configFile = flag.String("config", "", "path to config file")
 var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
 var pidFile = flag.String("pidfile", "", "path to pid file")
-var exitChan = make(chan int)
 
 func main() {
 	v := flag.Bool("v", false, "prints current version and exits")
@@ -82,29 +81,20 @@ func main() {
 	setting.Enterprise, _ = strconv.ParseBool(enterprise)
 
 	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
-	shutdownCompleted := make(chan int)
-	server := NewGrafanaServer()
 
-	go listenToSystemSignals(server, shutdownCompleted)
+	server := NewGrafanaServer()
 
-	go func() {
-		code := 0
-		if err := server.Start(); err != nil {
-			log.Error2("Startup failed", "error", err)
-			code = 1
-		}
+	go listenToSystemSignals(server)
 
-		exitChan <- code
-	}()
+	err := server.Run()
 
-	code := <-shutdownCompleted
-	log.Info2("Grafana shutdown completed.", "code", code)
+	trace.Stop()
 	log.Close()
-	os.Exit(code)
+
+	server.Exit(err)
 }
 
-func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int) {
-	var code int
+func listenToSystemSignals(server *GrafanaServerImpl) {
 	signalChan := make(chan os.Signal, 1)
 	ignoreChan := make(chan os.Signal, 1)
 
@@ -113,12 +103,6 @@ func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int
 
 	select {
 	case sig := <-signalChan:
-		trace.Stop() // Stops trace if profiling has been enabled
-		server.Shutdown(0, fmt.Sprintf("system signal: %s", sig))
-		shutdownCompleted <- 0
-	case code = <-exitChan:
-		trace.Stop() // Stops trace if profiling has been enabled
-		server.Shutdown(code, "startup error")
-		shutdownCompleted <- code
+		server.Shutdown(fmt.Sprintf("System signal: %s", sig))
 	}
 }

+ 47 - 33
pkg/cmd/grafana-server/server.go

@@ -17,14 +17,12 @@ import (
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/dashboards"
-	"github.com/grafana/grafana/pkg/services/provisioning"
 
 	"golang.org/x/sync/errgroup"
 
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
-	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/services/sqlstore"
 	"github.com/grafana/grafana/pkg/setting"
 
@@ -33,10 +31,12 @@ import (
 
 	// self registering services
 	_ "github.com/grafana/grafana/pkg/extensions"
+	_ "github.com/grafana/grafana/pkg/metrics"
 	_ "github.com/grafana/grafana/pkg/plugins"
 	_ "github.com/grafana/grafana/pkg/services/alerting"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
+	_ "github.com/grafana/grafana/pkg/services/provisioning"
 	_ "github.com/grafana/grafana/pkg/services/search"
 )
 
@@ -54,17 +54,19 @@ func NewGrafanaServer() *GrafanaServerImpl {
 }
 
 type GrafanaServerImpl struct {
-	context       context.Context
-	shutdownFn    context.CancelFunc
-	childRoutines *errgroup.Group
-	log           log.Logger
-	cfg           *setting.Cfg
+	context            context.Context
+	shutdownFn         context.CancelFunc
+	childRoutines      *errgroup.Group
+	log                log.Logger
+	cfg                *setting.Cfg
+	shutdownReason     string
+	shutdownInProgress bool
 
 	RouteRegister api.RouteRegister `inject:""`
 	HttpServer    *api.HTTPServer   `inject:""`
 }
 
-func (g *GrafanaServerImpl) Start() error {
+func (g *GrafanaServerImpl) Run() error {
 	g.loadConfiguration()
 	g.writePIDFile()
 
@@ -72,14 +74,9 @@ func (g *GrafanaServerImpl) Start() error {
 	sqlstore.NewEngine() // TODO: this should return an error
 	sqlstore.EnsureAdminUser()
 
-	metrics.Init(g.cfg.Raw)
 	login.Init()
 	social.NewOAuthService()
 
-	if err := provisioning.Init(g.context, setting.HomePath, g.cfg.Raw); err != nil {
-		return fmt.Errorf("Failed to provision Grafana from config. error: %v", err)
-	}
-
 	tracingCloser, err := tracing.Init(g.cfg.Raw)
 	if err != nil {
 		return fmt.Errorf("Tracing settings is not valid. error: %v", err)
@@ -91,7 +88,6 @@ func (g *GrafanaServerImpl) Start() error {
 	serviceGraph.Provide(&inject.Object{Value: g.cfg})
 	serviceGraph.Provide(&inject.Object{Value: dashboards.NewProvisioningService()})
 	serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
-	serviceGraph.Provide(&inject.Object{Value: api.HTTPServer{}})
 
 	// self registered services
 	services := registry.GetServices()
@@ -117,7 +113,7 @@ func (g *GrafanaServerImpl) Start() error {
 		g.log.Info("Initializing " + reflect.TypeOf(service).Elem().Name())
 
 		if err := service.Init(); err != nil {
-			return fmt.Errorf("Service init failed %v", err)
+			return fmt.Errorf("Service init failed: %v", err)
 		}
 	}
 
@@ -133,14 +129,31 @@ func (g *GrafanaServerImpl) Start() error {
 		}
 
 		g.childRoutines.Go(func() error {
+			// Skip starting new service when shutting down
+			// Can happen when service stop/return during startup
+			if g.shutdownInProgress {
+				return nil
+			}
+
 			err := service.Run(g.context)
-			g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+
+			// If error is not canceled then the service crashed
+			if err != context.Canceled && err != nil {
+				g.log.Error("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+			} else {
+				g.log.Info("Stopped "+reflect.TypeOf(service).Elem().Name(), "reason", err)
+			}
+
+			// Mark that we are in shutdown mode
+			// So more services are not started
+			g.shutdownInProgress = true
 			return err
 		})
 	}
 
 	sendSystemdNotification("READY=1")
-	return g.startHttpServer()
+
+	return g.childRoutines.Wait()
 }
 
 func (g *GrafanaServerImpl) loadConfiguration() {
@@ -159,28 +172,29 @@ func (g *GrafanaServerImpl) loadConfiguration() {
 	g.cfg.LogConfigSources()
 }
 
-func (g *GrafanaServerImpl) startHttpServer() error {
-	g.HttpServer.Init()
-
-	err := g.HttpServer.Start(g.context)
-
-	if err != nil {
-		return fmt.Errorf("Fail to start server. error: %v", err)
-	}
-
-	return nil
-}
-
-func (g *GrafanaServerImpl) Shutdown(code int, reason string) {
-	g.log.Info("Shutdown started", "code", code, "reason", reason)
+func (g *GrafanaServerImpl) Shutdown(reason string) {
+	g.log.Info("Shutdown started", "reason", reason)
+	g.shutdownReason = reason
+	g.shutdownInProgress = true
 
 	// call cancel func on root context
 	g.shutdownFn()
 
 	// wait for child routines
-	if err := g.childRoutines.Wait(); err != nil && err != context.Canceled {
-		g.log.Error("Server shutdown completed", "error", err)
+	g.childRoutines.Wait()
+}
+
+func (g *GrafanaServerImpl) Exit(reason error) {
+	// default exit code is 1
+	code := 1
+
+	if reason == context.Canceled && g.shutdownReason != "" {
+		reason = fmt.Errorf(g.shutdownReason)
+		code = 0
 	}
+
+	g.log.Error("Server shutdown", "reason", reason)
+	os.Exit(code)
 }
 
 func (g *GrafanaServerImpl) writePIDFile() {

+ 9 - 0
pkg/components/null/float.go

@@ -106,6 +106,15 @@ func (f Float) String() string {
 	return fmt.Sprintf("%1.3f", f.Float64)
 }
 
+// FullString returns float as string in full precision
+func (f Float) FullString() string {
+	if !f.Valid {
+		return "null"
+	}
+
+	return fmt.Sprintf("%f", f.Float64)
+}
+
 // SetValid changes this Float's value and also sets it to be non-null.
 func (f *Float) SetValid(n float64) {
 	f.Float64 = n

+ 14 - 0
pkg/login/ldap_test.go

@@ -53,6 +53,20 @@ func TestLdapAuther(t *testing.T) {
 			So(result, ShouldEqual, user1)
 		})
 
+		ldapAutherScenario("Given group match with different case", func(sc *scenarioContext) {
+			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
+				LdapGroups: []*LdapGroupToOrgRole{
+					{GroupDN: "cn=users", OrgRole: "Admin"},
+				},
+			})
+
+			sc.userQueryReturns(user1)
+
+			result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"CN=users"}})
+			So(err, ShouldBeNil)
+			So(result, ShouldEqual, user1)
+		})
+
 		ldapAutherScenario("Given no existing grafana user", func(sc *scenarioContext) {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{

+ 5 - 1
pkg/login/ldap_user.go

@@ -1,5 +1,9 @@
 package login
 
+import (
+	"strings"
+)
+
 type LdapUserInfo struct {
 	DN        string
 	FirstName string
@@ -15,7 +19,7 @@ func (u *LdapUserInfo) isMemberOf(group string) bool {
 	}
 
 	for _, member := range u.MemberOf {
-		if member == group {
+		if strings.EqualFold(member, group) {
 			return true
 		}
 	}

+ 0 - 38
pkg/metrics/init.go

@@ -1,38 +0,0 @@
-package metrics
-
-import (
-	"context"
-
-	ini "gopkg.in/ini.v1"
-
-	"github.com/grafana/grafana/pkg/log"
-	"github.com/grafana/grafana/pkg/metrics/graphitebridge"
-)
-
-var metricsLogger log.Logger = log.New("metrics")
-
-type logWrapper struct {
-	logger log.Logger
-}
-
-func (lw *logWrapper) Println(v ...interface{}) {
-	lw.logger.Info("graphite metric bridge", v...)
-}
-
-func Init(file *ini.File) {
-	cfg := ReadSettings(file)
-	internalInit(cfg)
-}
-
-func internalInit(settings *MetricSettings) {
-	initMetricVars(settings)
-
-	if settings.GraphiteBridgeConfig != nil {
-		bridge, err := graphitebridge.NewBridge(settings.GraphiteBridgeConfig)
-		if err != nil {
-			metricsLogger.Error("failed to create graphite bridge", "error", err)
-		} else {
-			go bridge.Run(context.Background())
-		}
-	}
-}

+ 1 - 23
pkg/metrics/metrics.go

@@ -279,7 +279,7 @@ func init() {
 	}, []string{"version"})
 }
 
-func initMetricVars(settings *MetricSettings) {
+func initMetricVars() {
 	prometheus.MustRegister(
 		M_Instance_Start,
 		M_Page_Status,
@@ -316,28 +316,6 @@ func initMetricVars(settings *MetricSettings) {
 		M_StatTotal_Playlists,
 		M_Grafana_Version)
 
-	go instrumentationLoop(settings)
-}
-
-func instrumentationLoop(settings *MetricSettings) chan struct{} {
-	M_Instance_Start.Inc()
-
-	// set the total stats gauges before we publishing metrics
-	updateTotalStats()
-
-	onceEveryDayTick := time.NewTicker(time.Hour * 24)
-	everyMinuteTicker := time.NewTicker(time.Minute)
-	defer onceEveryDayTick.Stop()
-	defer everyMinuteTicker.Stop()
-
-	for {
-		select {
-		case <-onceEveryDayTick.C:
-			sendUsageStats()
-		case <-everyMinuteTicker.C:
-			updateTotalStats()
-		}
-	}
 }
 
 func updateTotalStats() {

+ 71 - 0
pkg/metrics/service.go

@@ -0,0 +1,71 @@
+package metrics
+
+import (
+	"context"
+	"time"
+
+	"github.com/grafana/grafana/pkg/log"
+	"github.com/grafana/grafana/pkg/metrics/graphitebridge"
+	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+var metricsLogger log.Logger = log.New("metrics")
+
+type logWrapper struct {
+	logger log.Logger
+}
+
+func (lw *logWrapper) Println(v ...interface{}) {
+	lw.logger.Info("graphite metric bridge", v...)
+}
+
+func init() {
+	registry.RegisterService(&InternalMetricsService{})
+	initMetricVars()
+}
+
+type InternalMetricsService struct {
+	Cfg *setting.Cfg `inject:""`
+
+	enabled         bool
+	intervalSeconds int64
+	graphiteCfg     *graphitebridge.Config
+}
+
+func (im *InternalMetricsService) Init() error {
+	return im.readSettings()
+}
+
+func (im *InternalMetricsService) Run(ctx context.Context) error {
+	// Start Graphite Bridge
+	if im.graphiteCfg != nil {
+		bridge, err := graphitebridge.NewBridge(im.graphiteCfg)
+		if err != nil {
+			metricsLogger.Error("failed to create graphite bridge", "error", err)
+		} else {
+			go bridge.Run(ctx)
+		}
+	}
+
+	M_Instance_Start.Inc()
+
+	// set the total stats gauges before we publishing metrics
+	updateTotalStats()
+
+	onceEveryDayTick := time.NewTicker(time.Hour * 24)
+	everyMinuteTicker := time.NewTicker(time.Minute)
+	defer onceEveryDayTick.Stop()
+	defer everyMinuteTicker.Stop()
+
+	for {
+		select {
+		case <-onceEveryDayTick.C:
+			sendUsageStats()
+		case <-everyMinuteTicker.C:
+			updateTotalStats()
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}

+ 22 - 34
pkg/metrics/settings.go

@@ -1,67 +1,53 @@
 package metrics
 
 import (
+	"fmt"
 	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/metrics/graphitebridge"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/prometheus/client_golang/prometheus"
-	ini "gopkg.in/ini.v1"
 )
 
-type MetricSettings struct {
-	Enabled              bool
-	IntervalSeconds      int64
-	GraphiteBridgeConfig *graphitebridge.Config
-}
-
-func ReadSettings(file *ini.File) *MetricSettings {
-	var settings = &MetricSettings{
-		Enabled: false,
-	}
-
-	var section, err = file.GetSection("metrics")
+func (im *InternalMetricsService) readSettings() error {
+	var section, err = im.Cfg.Raw.GetSection("metrics")
 	if err != nil {
-		metricsLogger.Crit("Unable to find metrics config section", "error", err)
-		return nil
+		return fmt.Errorf("Unable to find metrics config section %v", err)
 	}
 
-	settings.Enabled = section.Key("enabled").MustBool(false)
-	settings.IntervalSeconds = section.Key("interval_seconds").MustInt64(10)
-
-	if !settings.Enabled {
-		return settings
-	}
+	im.enabled = section.Key("enabled").MustBool(false)
+	im.intervalSeconds = section.Key("interval_seconds").MustInt64(10)
 
-	cfg, err := parseGraphiteSettings(settings, file)
-	if err != nil {
-		metricsLogger.Crit("Unable to parse metrics graphite section", "error", err)
+	if !im.enabled {
 		return nil
 	}
 
-	settings.GraphiteBridgeConfig = cfg
+	if err := im.parseGraphiteSettings(); err != nil {
+		return fmt.Errorf("Unable to parse metrics graphite section, %v", err)
+	}
 
-	return settings
+	return nil
 }
 
-func parseGraphiteSettings(settings *MetricSettings, file *ini.File) (*graphitebridge.Config, error) {
-	graphiteSection, err := setting.Raw.GetSection("metrics.graphite")
+func (im *InternalMetricsService) parseGraphiteSettings() error {
+	graphiteSection, err := im.Cfg.Raw.GetSection("metrics.graphite")
+
 	if err != nil {
-		return nil, nil
+		return nil
 	}
 
 	address := graphiteSection.Key("address").String()
 	if address == "" {
-		return nil, nil
+		return nil
 	}
 
-	cfg := &graphitebridge.Config{
+	bridgeCfg := &graphitebridge.Config{
 		URL:             address,
 		Prefix:          graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
 		CountersAsDelta: true,
 		Gatherer:        prometheus.DefaultGatherer,
-		Interval:        time.Duration(settings.IntervalSeconds) * time.Second,
+		Interval:        time.Duration(im.intervalSeconds) * time.Second,
 		Timeout:         10 * time.Second,
 		Logger:          &logWrapper{logger: metricsLogger},
 		ErrorHandling:   graphitebridge.ContinueOnError,
@@ -74,6 +60,8 @@ func parseGraphiteSettings(settings *MetricSettings, file *ini.File) (*graphiteb
 		prefix = "prod.grafana.%(instance_name)s."
 	}
 
-	cfg.Prefix = strings.Replace(prefix, "%(instance_name)s", safeInstanceName, -1)
-	return cfg, nil
+	bridgeCfg.Prefix = strings.Replace(prefix, "%(instance_name)s", safeInstanceName, -1)
+
+	im.graphiteCfg = bridgeCfg
+	return nil
 }

+ 11 - 0
pkg/middleware/auth_proxy.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net"
 	"net/mail"
+	"reflect"
 	"strings"
 	"time"
 
@@ -111,6 +112,16 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 			return true
 		}
 
+		for _, field := range []string{"Name", "Email", "Login"} {
+			if setting.AuthProxyHeaders[field] == "" {
+				continue
+			}
+
+			if val := ctx.Req.Header.Get(setting.AuthProxyHeaders[field]); val != "" {
+				reflect.ValueOf(extUser).Elem().FieldByName(field).SetString(val)
+			}
+		}
+
 		// add/update user in grafana
 		cmd := &m.UpsertUserCommand{
 			ReqContext:    ctx,

+ 7 - 6
pkg/models/notifications.go

@@ -19,12 +19,13 @@ type SendEmailCommandSync struct {
 }
 
 type SendWebhookSync struct {
-	Url        string
-	User       string
-	Password   string
-	Body       string
-	HttpMethod string
-	HttpHeader map[string]string
+	Url         string
+	User        string
+	Password    string
+	Body        string
+	HttpMethod  string
+	HttpHeader  map[string]string
+	ContentType string
 }
 
 type SendResetPasswordEmailCommand struct {

+ 12 - 12
pkg/services/alerting/engine.go

@@ -16,7 +16,7 @@ import (
 	"golang.org/x/sync/errgroup"
 )
 
-type Engine struct {
+type AlertingService struct {
 	execQueue chan *Job
 	//clock         clock.Clock
 	ticker        *Ticker
@@ -28,20 +28,20 @@ type Engine struct {
 }
 
 func init() {
-	registry.RegisterService(&Engine{})
+	registry.RegisterService(&AlertingService{})
 }
 
-func NewEngine() *Engine {
-	e := &Engine{}
+func NewEngine() *AlertingService {
+	e := &AlertingService{}
 	e.Init()
 	return e
 }
 
-func (e *Engine) IsDisabled() bool {
+func (e *AlertingService) IsDisabled() bool {
 	return !setting.AlertingEnabled || !setting.ExecuteAlerts
 }
 
-func (e *Engine) Init() error {
+func (e *AlertingService) Init() error {
 	e.ticker = NewTicker(time.Now(), time.Second*0, clock.New())
 	e.execQueue = make(chan *Job, 1000)
 	e.scheduler = NewScheduler()
@@ -52,7 +52,7 @@ func (e *Engine) Init() error {
 	return nil
 }
 
-func (e *Engine) Run(ctx context.Context) error {
+func (e *AlertingService) Run(ctx context.Context) error {
 	alertGroup, ctx := errgroup.WithContext(ctx)
 	alertGroup.Go(func() error { return e.alertingTicker(ctx) })
 	alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
@@ -61,7 +61,7 @@ func (e *Engine) Run(ctx context.Context) error {
 	return err
 }
 
-func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
+func (e *AlertingService) alertingTicker(grafanaCtx context.Context) error {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1))
@@ -86,7 +86,7 @@ func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
 	}
 }
 
-func (e *Engine) runJobDispatcher(grafanaCtx context.Context) error {
+func (e *AlertingService) runJobDispatcher(grafanaCtx context.Context) error {
 	dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx)
 
 	for {
@@ -106,7 +106,7 @@ var (
 	alertMaxAttempts = 3
 )
 
-func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
+func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
@@ -141,7 +141,7 @@ func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error
 	}
 }
 
-func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
+func (e *AlertingService) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
 	job.Running = false
 	close(cancelChan)
 	for cancelFn := range cancelChan {
@@ -150,7 +150,7 @@ func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job)
 	return err
 }
 
-func (e *Engine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
+func (e *AlertingService) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
 	defer func() {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))

+ 173 - 0
pkg/services/alerting/notifiers/discord.go

@@ -0,0 +1,173 @@
+package notifiers
+
+import (
+	"bytes"
+	"io"
+	"mime/multipart"
+	"os"
+	"strconv"
+	"strings"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	"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:        "discord",
+		Name:        "Discord",
+		Description: "Sends notifications to Discord",
+		Factory:     NewDiscordNotifier,
+		OptionsTemplate: `
+      <h3 class="page-heading">Discord settings</h3>
+      <div class="gf-form">
+        <span class="gf-form-label width-14">Webhook URL</span>
+        <input type="text" required class="gf-form-input max-width-22" ng-model="ctrl.model.settings.url" placeholder="Discord webhook URL"></input>
+      </div>
+    `,
+	})
+}
+
+func NewDiscordNotifier(model *m.AlertNotification) (alerting.Notifier, error) {
+	url := model.Settings.Get("url").MustString()
+	if url == "" {
+		return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"}
+	}
+
+	return &DiscordNotifier{
+		NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings),
+		WebhookURL:   url,
+		log:          log.New("alerting.notifier.discord"),
+	}, nil
+}
+
+type DiscordNotifier struct {
+	NotifierBase
+	WebhookURL string
+	log        log.Logger
+}
+
+func (this *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
+	this.log.Info("Sending alert notification to", "webhook_url", this.WebhookURL)
+
+	ruleUrl, err := evalContext.GetRuleUrl()
+	if err != nil {
+		this.log.Error("Failed get rule link", "error", err)
+		return err
+	}
+
+	bodyJSON := simplejson.New()
+	bodyJSON.Set("username", "Grafana")
+
+	fields := make([]map[string]interface{}, 0)
+
+	for _, evt := range evalContext.EvalMatches {
+
+		fields = append(fields, map[string]interface{}{
+			"name":   evt.Metric,
+			"value":  evt.Value.FullString(),
+			"inline": true,
+		})
+	}
+
+	footer := map[string]interface{}{
+		"text":     "Grafana v" + setting.BuildVersion,
+		"icon_url": "https://grafana.com/assets/img/fav32.png",
+	}
+
+	color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0)
+
+	embed := simplejson.New()
+	embed.Set("title", evalContext.GetNotificationTitle())
+	//Discord takes integer for color
+	embed.Set("color", color)
+	embed.Set("url", ruleUrl)
+	embed.Set("description", evalContext.Rule.Message)
+	embed.Set("type", "rich")
+	embed.Set("fields", fields)
+	embed.Set("footer", footer)
+
+	var image map[string]interface{}
+	var embeddedImage = false
+
+	if evalContext.ImagePublicUrl != "" {
+		image = map[string]interface{}{
+			"url": evalContext.ImagePublicUrl,
+		}
+		embed.Set("image", image)
+	} else {
+		image = map[string]interface{}{
+			"url": "attachment://graph.png",
+		}
+		embed.Set("image", image)
+		embeddedImage = true
+	}
+
+	bodyJSON.Set("embeds", []interface{}{embed})
+
+	json, _ := bodyJSON.MarshalJSON()
+
+	content_type := "application/json"
+
+	var body []byte
+
+	if embeddedImage {
+
+		var b bytes.Buffer
+
+		w := multipart.NewWriter(&b)
+
+		f, err := os.Open(evalContext.ImageOnDiskPath)
+
+		if err != nil {
+			this.log.Error("Can't open graph file", err)
+			return err
+		}
+
+		defer f.Close()
+
+		fw, err := w.CreateFormField("payload_json")
+		if err != nil {
+			return err
+		}
+
+		if _, err = fw.Write([]byte(string(json))); err != nil {
+			return err
+		}
+
+		fw, err = w.CreateFormFile("file", "graph.png")
+		if err != nil {
+			return err
+		}
+
+		if _, err = io.Copy(fw, f); err != nil {
+			return err
+		}
+
+		w.Close()
+
+		body = b.Bytes()
+		content_type = w.FormDataContentType()
+
+	} else {
+		body = json
+	}
+
+	cmd := &m.SendWebhookSync{
+		Url:         this.WebhookURL,
+		Body:        string(body),
+		HttpMethod:  "POST",
+		ContentType: content_type,
+	}
+
+	if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil {
+		this.log.Error("Failed to send notification to Discord", "error", err)
+		return err
+	}
+
+	return nil
+}

+ 52 - 0
pkg/services/alerting/notifiers/discord_test.go

@@ -0,0 +1,52 @@
+package notifiers
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDiscordNotifier(t *testing.T) {
+	Convey("Telegram 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:     "discord_testing",
+					Type:     "discord",
+					Settings: settingsJSON,
+				}
+
+				_, err := NewDiscordNotifier(model)
+				So(err, ShouldNotBeNil)
+			})
+
+			Convey("settings should trigger incident", func() {
+				json := `
+				{
+          "url": "https://web.hook/"
+				}`
+
+				settingsJSON, _ := simplejson.NewJson([]byte(json))
+				model := &m.AlertNotification{
+					Name:     "discord_testing",
+					Type:     "discord",
+					Settings: settingsJSON,
+				}
+
+				not, err := NewDiscordNotifier(model)
+				discordNotifier := not.(*DiscordNotifier)
+
+				So(err, ShouldBeNil)
+				So(discordNotifier.Name, ShouldEqual, "discord_testing")
+				So(discordNotifier.Type, ShouldEqual, "discord")
+				So(discordNotifier.WebhookURL, ShouldEqual, "https://web.hook/")
+			})
+		})
+	})
+}

+ 7 - 6
pkg/services/notifications/notifications.go

@@ -104,12 +104,13 @@ func (ns *NotificationService) Run(ctx context.Context) error {
 
 func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {
 	return ns.sendWebRequestSync(ctx, &Webhook{
-		Url:        cmd.Url,
-		User:       cmd.User,
-		Password:   cmd.Password,
-		Body:       cmd.Body,
-		HttpMethod: cmd.HttpMethod,
-		HttpHeader: cmd.HttpHeader,
+		Url:         cmd.Url,
+		User:        cmd.User,
+		Password:    cmd.Password,
+		Body:        cmd.Body,
+		HttpMethod:  cmd.HttpMethod,
+		HttpHeader:  cmd.HttpHeader,
+		ContentType: cmd.ContentType,
 	})
 }
 

+ 13 - 7
pkg/services/notifications/webhook.go

@@ -15,12 +15,13 @@ import (
 )
 
 type Webhook struct {
-	Url        string
-	User       string
-	Password   string
-	Body       string
-	HttpMethod string
-	HttpHeader map[string]string
+	Url         string
+	User        string
+	Password    string
+	Body        string
+	HttpMethod  string
+	HttpHeader  map[string]string
+	ContentType string
 }
 
 var netTransport = &http.Transport{
@@ -48,8 +49,13 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
 		return err
 	}
 
-	request.Header.Add("Content-Type", "application/json")
+	if webhook.ContentType == "" {
+		webhook.ContentType = "application/json"
+	}
+
+	request.Header.Add("Content-Type", webhook.ContentType)
 	request.Header.Add("User-Agent", "Grafana")
+
 	if webhook.User != "" && webhook.Password != "" {
 		request.Header.Add("Authorization", util.GetBasicAuthHeader(webhook.User, webhook.Password))
 	}

+ 1 - 1
pkg/services/provisioning/dashboards/config_reader.go

@@ -69,7 +69,7 @@ func (cr *configReader) readConfig() ([]*DashboardsAsConfig, error) {
 
 		parsedDashboards, err := cr.parseConfigs(file)
 		if err != nil {
-
+			return nil, err
 		}
 
 		if len(parsedDashboards) > 0 {

+ 2 - 5
pkg/services/provisioning/dashboards/dashboard.go

@@ -10,19 +10,16 @@ import (
 type DashboardProvisioner struct {
 	cfgReader *configReader
 	log       log.Logger
-	ctx       context.Context
 }
 
-func Provision(ctx context.Context, configDirectory string) (*DashboardProvisioner, error) {
+func NewDashboardProvisioner(configDirectory string) *DashboardProvisioner {
 	log := log.New("provisioning.dashboard")
 	d := &DashboardProvisioner{
 		cfgReader: &configReader{path: configDirectory, log: log},
 		log:       log,
-		ctx:       ctx,
 	}
 
-	err := d.Provision(ctx)
-	return d, err
+	return d
 }
 
 func (provider *DashboardProvisioner) Provision(ctx context.Context) error {

+ 23 - 13
pkg/services/provisioning/provisioning.go

@@ -2,30 +2,40 @@ package provisioning
 
 import (
 	"context"
+	"fmt"
 	"path"
-	"path/filepath"
 
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
 	"github.com/grafana/grafana/pkg/services/provisioning/datasources"
-	ini "gopkg.in/ini.v1"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
-func Init(ctx context.Context, homePath string, cfg *ini.File) error {
-	provisioningPath := makeAbsolute(cfg.Section("paths").Key("provisioning").String(), homePath)
+func init() {
+	registry.RegisterService(&ProvisioningService{})
+}
+
+type ProvisioningService struct {
+	Cfg *setting.Cfg `inject:""`
+}
 
-	datasourcePath := path.Join(provisioningPath, "datasources")
+func (ps *ProvisioningService) Init() error {
+	datasourcePath := path.Join(ps.Cfg.ProvisioningPath, "datasources")
 	if err := datasources.Provision(datasourcePath); err != nil {
-		return err
+		return fmt.Errorf("Datasource provisioning error: %v", err)
 	}
 
-	dashboardPath := path.Join(provisioningPath, "dashboards")
-	_, err := dashboards.Provision(ctx, dashboardPath)
-	return err
+	return nil
 }
 
-func makeAbsolute(path string, root string) string {
-	if filepath.IsAbs(path) {
-		return path
+func (ps *ProvisioningService) Run(ctx context.Context) error {
+	dashboardPath := path.Join(ps.Cfg.ProvisioningPath, "dashboards")
+	dashProvisioner := dashboards.NewDashboardProvisioner(dashboardPath)
+
+	if err := dashProvisioner.Provision(ctx); err != nil {
+		return err
 	}
-	return filepath.Join(root, path)
+
+	<-ctx.Done()
+	return ctx.Err()
 }

+ 5 - 0
pkg/services/sqlstore/migrations/user_auth_mig.go

@@ -21,4 +21,9 @@ func addUserAuthMigrations(mg *Migrator) {
 	mg.AddMigration("create user auth table", NewAddTableMigration(userAuthV1))
 	// add indices
 	addTableIndicesMigrations(mg, "v1", userAuthV1)
+
+	mg.AddMigration("alter user_auth.auth_id to length 190", new(RawSqlMigration).
+		Sqlite("SELECT 0 WHERE 0;").
+		Postgres("ALTER TABLE user_auth ALTER COLUMN auth_id TYPE VARCHAR(190);").
+		Mysql("ALTER TABLE user_auth MODIFY auth_id VARCHAR(190);"))
 }

+ 12 - 8
pkg/services/sqlstore/sqlstore.go

@@ -123,7 +123,7 @@ func getEngine() (*xorm.Engine, error) {
 		}
 
 		cnnstr = fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&allowNativePasswords=true",
-			DbCfg.User, DbCfg.Pwd, protocol, DbCfg.Host, DbCfg.Name)
+			url.QueryEscape(DbCfg.User), url.QueryEscape(DbCfg.Pwd), protocol, DbCfg.Host, url.PathEscape(DbCfg.Name))
 
 		if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" {
 			tlsCert, err := makeCert("custom", DbCfg)
@@ -142,13 +142,17 @@ func getEngine() (*xorm.Engine, error) {
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 			port = fields[1]
 		}
-		if DbCfg.Pwd == "" {
-			DbCfg.Pwd = "''"
-		}
-		if DbCfg.User == "" {
-			DbCfg.User = "''"
-		}
-		cnnstr = fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=%s sslcert=%s sslkey=%s sslrootcert=%s", DbCfg.User, DbCfg.Pwd, host, port, DbCfg.Name, DbCfg.SslMode, DbCfg.ClientCertPath, DbCfg.ClientKeyPath, DbCfg.CaCertPath)
+		cnnstr = fmt.Sprintf("user='%s' password='%s' host='%s' port='%s' dbname='%s' sslmode='%s' sslcert='%s' sslkey='%s' sslrootcert='%s'",
+			strings.Replace(DbCfg.User, `'`, `\'`, -1),
+			strings.Replace(DbCfg.Pwd, `'`, `\'`, -1),
+			strings.Replace(host, `'`, `\'`, -1),
+			strings.Replace(port, `'`, `\'`, -1),
+			strings.Replace(DbCfg.Name, `'`, `\'`, -1),
+			strings.Replace(DbCfg.SslMode, `'`, `\'`, -1),
+			strings.Replace(DbCfg.ClientCertPath, `'`, `\'`, -1),
+			strings.Replace(DbCfg.ClientKeyPath, `'`, `\'`, -1),
+			strings.Replace(DbCfg.CaCertPath, `'`, `\'`, -1),
+		)
 	case "sqlite3":
 		if !filepath.IsAbs(DbCfg.Path) {
 			DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)

+ 19 - 8
pkg/setting/setting.go

@@ -52,12 +52,11 @@ var (
 	ApplicationName string
 
 	// Paths
-	LogsPath         string
-	HomePath         string
-	DataPath         string
-	PluginsPath      string
-	ProvisioningPath string
-	CustomInitPath   = "conf/custom.ini"
+	LogsPath       string
+	HomePath       string
+	DataPath       string
+	PluginsPath    string
+	CustomInitPath = "conf/custom.ini"
 
 	// Log settings.
 	LogModes   []string
@@ -125,6 +124,7 @@ var (
 	AuthProxyAutoSignUp     bool
 	AuthProxyLdapSyncTtl    int
 	AuthProxyWhitelist      string
+	AuthProxyHeaders        map[string]string
 
 	// Basic Auth
 	BasicAuthEnabled bool
@@ -187,6 +187,9 @@ var (
 type Cfg struct {
 	Raw *ini.File
 
+	// Paths
+	ProvisioningPath string
+
 	// SMTP email settings
 	Smtp SmtpSettings
 
@@ -516,7 +519,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
-	ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
+	cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
 	server := iniFile.Section("server")
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
 
@@ -611,6 +614,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	AuthProxyLdapSyncTtl = authProxy.Key("ldap_sync_ttl").MustInt()
 	AuthProxyWhitelist = authProxy.Key("whitelist").String()
 
+	AuthProxyHeaders = make(map[string]string)
+	for _, propertyAndHeader := range util.SplitString(authProxy.Key("headers").String()) {
+		split := strings.SplitN(propertyAndHeader, ":", 2)
+		if len(split) == 2 {
+			AuthProxyHeaders[split[0]] = split[1]
+		}
+	}
+
 	// basic auth
 	authBasic := iniFile.Section("auth.basic")
 	BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
@@ -719,6 +730,6 @@ func (cfg *Cfg) LogConfigSources() {
 	logger.Info("Path Data", "path", DataPath)
 	logger.Info("Path Logs", "path", LogsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
-	logger.Info("Path Provisioning", "path", ProvisioningPath)
+	logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
 	logger.Info("App mode " + Env)
 }

+ 17 - 2
public/app/containers/Explore/Explore.tsx

@@ -10,6 +10,7 @@ import Graph from './Graph';
 import Table from './Table';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
+import { decodePathComponent } from 'app/core/utils/location_util';
 
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
@@ -38,6 +39,19 @@ function makeTimeSeriesList(dataList, options) {
   });
 }
 
+function parseInitialQueries(initial) {
+  if (!initial) {
+    return [];
+  }
+  try {
+    const parsed = JSON.parse(decodePathComponent(initial));
+    return parsed.queries.map(q => q.query);
+  } catch (e) {
+    console.error(e);
+    return [];
+  }
+}
+
 interface IExploreState {
   datasource: any;
   datasourceError: any;
@@ -58,6 +72,7 @@ export class Explore extends React.Component<any, IExploreState> {
 
   constructor(props) {
     super(props);
+    const initialQueries = parseInitialQueries(props.routeParams.initial);
     this.state = {
       datasource: null,
       datasourceError: null,
@@ -65,7 +80,7 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult: null,
       latency: 0,
       loading: false,
-      queries: ensureQueries(),
+      queries: ensureQueries(initialQueries),
       requestOptions: null,
       showingGraph: true,
       showingTable: true,
@@ -77,7 +92,7 @@ export class Explore extends React.Component<any, IExploreState> {
     const datasource = await this.props.datasourceSrv.get();
     const testResult = await datasource.testDatasource();
     if (testResult.status === 'success') {
-      this.setState({ datasource, datasourceError: null, datasourceLoading: false });
+      this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
     } else {
       this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
     }

+ 15 - 4
public/app/containers/Explore/QueryRows.tsx

@@ -6,13 +6,16 @@ class QueryRow extends PureComponent<any, any> {
   constructor(props) {
     super(props);
     this.state = {
-      query: '',
+      edited: false,
+      query: props.query || '',
     };
   }
 
   handleChangeQuery = value => {
     const { index, onChangeQuery } = this.props;
-    this.setState({ query: value });
+    const { query } = this.state;
+    const edited = query !== value;
+    this.setState({ edited, query: value });
     if (onChangeQuery) {
       onChangeQuery(value, index);
     }
@@ -41,6 +44,7 @@ class QueryRow extends PureComponent<any, any> {
 
   render() {
     const { request } = this.props;
+    const { edited, query } = this.state;
     return (
       <div className="query-row">
         <div className="query-row-tools">
@@ -52,7 +56,12 @@ class QueryRow extends PureComponent<any, any> {
           </button>
         </div>
         <div className="query-field-wrapper">
-          <QueryField onPressEnter={this.handlePressEnter} onQueryChange={this.handleChangeQuery} request={request} />
+          <QueryField
+            initialQuery={edited ? null : query}
+            onPressEnter={this.handlePressEnter}
+            onQueryChange={this.handleChangeQuery}
+            request={request}
+          />
         </div>
       </div>
     );
@@ -63,7 +72,9 @@ export default class QueryRows extends PureComponent<any, any> {
   render() {
     const { className = '', queries, ...handlers } = this.props;
     return (
-      <div className={className}>{queries.map((q, index) => <QueryRow key={q.key} index={index} {...handlers} />)}</div>
+      <div className={className}>
+        {queries.map((q, index) => <QueryRow key={q.key} index={index} query={q.query} {...handlers} />)}
+      </div>
     );
   }
 }

+ 1 - 0
public/app/core/services/analytics.ts

@@ -19,6 +19,7 @@ export class Analytics {
       });
     ga.l = +new Date();
     ga('create', (<any>config).googleAnalyticsId, 'auto');
+    ga('set', 'anonymizeIp', true);
     return ga;
   }
 

+ 13 - 1
public/app/core/services/keybindingSrv.ts

@@ -3,6 +3,7 @@ import _ from 'lodash';
 
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
+import { encodePathComponent } from 'app/core/utils/location_util';
 
 import Mousetrap from 'mousetrap';
 import 'mousetrap-global-bind';
@@ -13,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
 
   /** @ngInject */
-  constructor(private $rootScope, private $location) {
+  constructor(private $rootScope, private $location, private datasourceSrv) {
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
@@ -176,6 +177,17 @@ export class KeybindingSrv {
       }
     });
 
+    this.bind('x', async () => {
+      if (dashboard.meta.focusPanelId) {
+        const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
+        const datasource = await this.datasourceSrv.get(panel.datasource);
+        if (datasource && datasource.supportsExplore) {
+          const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
+          this.$location.url(`/explore/${exploreState}`);
+        }
+      }
+    });
+
     // delete panel
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {

+ 79 - 18
public/app/core/specs/file_export.jest.ts

@@ -30,17 +30,17 @@ describe('file_export', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
       const expectedText =
-        'Series;Time;Value\n' +
-        'series_1;1500026100;1\n' +
-        'series_1;1500026200;2\n' +
-        'series_1;1500026300;null\n' +
-        'series_1;1500026400;null\n' +
-        'series_1;1500026500;null\n' +
-        'series_1;1500026600;6\n' +
-        'series_2;1500026100;11\n' +
-        'series_2;1500026200;12\n' +
-        'series_2;1500026300;13\n' +
-        'series_2;1500026500;15\n';
+        '"Series";"Time";"Value"\r\n' +
+        '"series_1";"1500026100";1\r\n' +
+        '"series_1";"1500026200";2\r\n' +
+        '"series_1";"1500026300";null\r\n' +
+        '"series_1";"1500026400";null\r\n' +
+        '"series_1";"1500026500";null\r\n' +
+        '"series_1";"1500026600";6\r\n' +
+        '"series_2";"1500026100";11\r\n' +
+        '"series_2";"1500026200";12\r\n' +
+        '"series_2";"1500026300";13\r\n' +
+        '"series_2";"1500026500";15';
 
       expect(text).toBe(expectedText);
     });
@@ -50,15 +50,76 @@ describe('file_export', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
       const expectedText =
-        'Time;series_1;series_2\n' +
-        '1500026100;1;11\n' +
-        '1500026200;2;12\n' +
-        '1500026300;null;13\n' +
-        '1500026400;null;null\n' +
-        '1500026500;null;15\n' +
-        '1500026600;6;null\n';
+        '"Time";"series_1";"series_2"\r\n' +
+        '"1500026100";1;11\r\n' +
+        '"1500026200";2;12\r\n' +
+        '"1500026300";null;13\r\n' +
+        '"1500026400";null;null\r\n' +
+        '"1500026500";null;15\r\n' +
+        '"1500026600";6;null';
 
       expect(text).toBe(expectedText);
     });
   });
+
+  describe('when exporting table data to csv', () => {
+    it('should properly escape special characters and quote all string values', () => {
+      const inputTable = {
+        columns: [
+          { title: 'integer_value' },
+          { text: 'string_value' },
+          { title: 'float_value' },
+          { text: 'boolean_value' },
+        ],
+        rows: [
+          [123, 'some_string', 1.234, true],
+          [0o765, 'some string with " in the middle', 1e-2, false],
+          [0o765, 'some string with "" in the middle', 1e-2, false],
+          [0o765, 'some string with """ in the middle', 1e-2, false],
+          [0o765, '"some string with " at the beginning', 1e-2, false],
+          [0o765, 'some string with " at the end"', 1e-2, false],
+          [0x123, 'some string with \n in the middle', 10.01, false],
+          [0b1011, 'some string with ; in the middle', -12.34, true],
+          [123, 'some string with ;; in the middle', -12.34, true],
+        ],
+      };
+
+      const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
+
+      const expectedText =
+        '"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
+        '123;"some_string";1.234;true\r\n' +
+        '501;"some string with "" in the middle";0.01;false\r\n' +
+        '501;"some string with """" in the middle";0.01;false\r\n' +
+        '501;"some string with """""" in the middle";0.01;false\r\n' +
+        '501;"""some string with "" at the beginning";0.01;false\r\n' +
+        '501;"some string with "" at the end""";0.01;false\r\n' +
+        '291;"some string with \n in the middle";10.01;false\r\n' +
+        '11;"some string with ; in the middle";-12.34;true\r\n' +
+        '123;"some string with ;; in the middle";-12.34;true';
+
+      expect(returnedText).toBe(expectedText);
+    });
+
+    it('should decode HTML encoded characters', function() {
+      const inputTable = {
+        columns: [{ text: 'string_value' }],
+        rows: [
+          ['&quot;&amp;&auml;'],
+          ['<strong>&quot;some html&quot;</strong>'],
+          ['<a href="http://something/index.html">some text</a>'],
+        ],
+      };
+
+      const returnedText = fileExport.convertTableDataToCsv(inputTable, false);
+
+      const expectedText =
+        '"string_value"\r\n' +
+        '"""&ä"\r\n' +
+        '"<strong>""some html""</strong>"\r\n' +
+        '"<a href=""http://something/index.html"">some text</a>"';
+
+      expect(returnedText).toBe(expectedText);
+    });
+  });
 });

+ 62 - 12
public/app/core/specs/kbn.jest.ts

@@ -101,38 +101,88 @@ describeValueFormat('d', 245, 100, 0, '35 week');
 describeValueFormat('d', 2456, 10, 0, '6.73 year');
 
 describe('date time formats', function() {
+  const epoch = 1505634997920;
+  const utcTime = moment.utc(epoch);
+  const browserTime = moment(epoch);
+
   it('should format as iso date', function() {
-    var str = kbn.valueFormats.dateTimeAsIso(1505634997920, 1);
-    expect(str).toBe(moment(1505634997920).format('YYYY-MM-DD HH:mm:ss'));
+    var expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
+    var actual = kbn.valueFormats.dateTimeAsIso(epoch);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as iso date (in UTC)', function() {
+    var expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
+    var actual = kbn.valueFormats.dateTimeAsIso(epoch, true);
+    expect(actual).toBe(expected);
   });
 
   it('should format as iso date and skip date when today', function() {
     var now = moment();
-    var str = kbn.valueFormats.dateTimeAsIso(now.valueOf(), 1);
-    expect(str).toBe(now.format('HH:mm:ss'));
+    var expected = now.format('HH:mm:ss');
+    var actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as iso date (in UTC) and skip date when today', function() {
+    var now = moment.utc();
+    var expected = now.format('HH:mm:ss');
+    var actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), true);
+    expect(actual).toBe(expected);
   });
 
   it('should format as US date', function() {
-    var str = kbn.valueFormats.dateTimeAsUS(1505634997920, 1);
-    expect(str).toBe(moment(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
+    var expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
+    var actual = kbn.valueFormats.dateTimeAsUS(epoch, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date (in UTC)', function() {
+    var expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
+    var actual = kbn.valueFormats.dateTimeAsUS(epoch, true);
+    expect(actual).toBe(expected);
   });
 
   it('should format as US date and skip date when today', function() {
     var now = moment();
-    var str = kbn.valueFormats.dateTimeAsUS(now.valueOf(), 1);
-    expect(str).toBe(now.format('h:mm:ss a'));
+    var expected = now.format('h:mm:ss a');
+    var actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date (in UTC) and skip date when today', function() {
+    var now = moment.utc();
+    var expected = now.format('h:mm:ss a');
+    var actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), true);
+    expect(actual).toBe(expected);
   });
 
   it('should format as from now with days', function() {
     var daysAgo = moment().add(-7, 'd');
-    var str = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), 1);
-    expect(str).toBe('7 days ago');
+    var expected = '7 days ago';
+    var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with days (in UTC)', function() {
+    var daysAgo = moment.utc().add(-7, 'd');
+    var expected = '7 days ago';
+    var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
+    expect(actual).toBe(expected);
   });
 
   it('should format as from now with minutes', function() {
     var daysAgo = moment().add(-2, 'm');
-    var str = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), 1);
-    expect(str).toBe('2 minutes ago');
+    var expected = '2 minutes ago';
+    var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with minutes (in UTC)', function() {
+    var daysAgo = moment.utc().add(-2, 'm');
+    var expected = '2 minutes ago';
+    var actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
+    expect(actual).toBe(expected);
   });
 });
 

+ 14 - 0
public/app/core/specs/time_series.jest.ts

@@ -281,6 +281,20 @@ describe('TimeSeries', function() {
         expect(series.zindex).toBe(2);
       });
     });
+
+    describe('override color', function() {
+      beforeEach(function() {
+        series.applySeriesOverrides([{ alias: 'test', color: '#112233' }]);
+      });
+
+      it('should set color', function() {
+        expect(series.color).toBe('#112233');
+      });
+
+      it('should set bars.fillColor', function() {
+        expect(series.bars.fillColor).toBe('#112233');
+      });
+    });
   });
 
   describe('value formatter', function() {

+ 8 - 2
public/app/core/time_series2.ts

@@ -99,6 +99,7 @@ export default class TimeSeries {
     this.alias = opts.alias;
     this.aliasEscaped = _.escape(opts.alias);
     this.color = opts.color;
+    this.bars = { fillColor: opts.color };
     this.valueFormater = kbn.valueFormats.none;
     this.stats = {};
     this.legend = true;
@@ -112,11 +113,11 @@ export default class TimeSeries {
       dashLength: [],
     };
     this.points = {};
-    this.bars = {};
     this.yaxis = 1;
     this.zindex = 0;
     this.nullPointMode = null;
     delete this.stack;
+    delete this.bars.show;
 
     for (var i = 0; i < overrides.length; i++) {
       var override = overrides[i];
@@ -168,7 +169,7 @@ export default class TimeSeries {
         this.fillBelowTo = override.fillBelowTo;
       }
       if (override.color !== void 0) {
-        this.color = override.color;
+        this.setColor(override.color);
       }
       if (override.transform !== void 0) {
         this.transform = override.transform;
@@ -346,4 +347,9 @@ export default class TimeSeries {
 
     return false;
   }
+
+  setColor(color) {
+    this.color = color;
+    this.bars.fillColor = color;
+  }
 }

+ 103 - 55
public/app/core/utils/file_export.ts

@@ -1,59 +1,108 @@
-import _ from 'lodash';
+import { isBoolean, isNumber, sortedUniq, sortedIndexOf, unescape as htmlUnescaped } from 'lodash';
 import moment from 'moment';
 import { saveAs } from 'file-saver';
+import { isNullOrUndefined } from 'util';
 
 const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
 const POINT_TIME_INDEX = 1;
 const POINT_VALUE_INDEX = 0;
 
+const END_COLUMN = ';';
+const END_ROW = '\r\n';
+const QUOTE = '"';
+const EXPORT_FILENAME = 'grafana_data_export.csv';
+
+function csvEscaped(text) {
+  if (!text) {
+    return text;
+  }
+
+  return text.split(QUOTE).join(QUOTE + QUOTE);
+}
+
+const domParser = new DOMParser();
+function htmlDecoded(text) {
+  if (!text) {
+    return text;
+  }
+
+  const regexp = /&[^;]+;/g;
+  function htmlDecoded(value) {
+    const parsedDom = domParser.parseFromString(value, 'text/html');
+    return parsedDom.body.textContent;
+  }
+  return text.replace(regexp, htmlDecoded).replace(regexp, htmlDecoded);
+}
+
+function formatSpecialHeader(useExcelHeader) {
+  return useExcelHeader ? `sep=${END_COLUMN}${END_ROW}` : '';
+}
+
+function formatRow(row, addEndRowDelimiter = true) {
+  let text = '';
+  for (let i = 0; i < row.length; i += 1) {
+    if (isBoolean(row[i]) || isNullOrUndefined(row[i])) {
+      text += row[i];
+    } else if (isNumber(row[i])) {
+      text += row[i].toLocaleString();
+    } else {
+      text += `${QUOTE}${csvEscaped(htmlUnescaped(htmlDecoded(row[i])))}${QUOTE}`;
+    }
+
+    if (i < row.length - 1) {
+      text += END_COLUMN;
+    }
+  }
+  return addEndRowDelimiter ? text + END_ROW : text;
+}
+
 export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
-  var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
-  _.each(seriesList, function(series) {
-    _.each(series.datapoints, function(dp) {
-      text +=
-        series.alias + ';' + moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat) + ';' + dp[POINT_VALUE_INDEX] + '\n';
-    });
-  });
+  let text = formatSpecialHeader(excel) + formatRow(['Series', 'Time', 'Value']);
+  for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex += 1) {
+    for (let i = 0; i < seriesList[seriesIndex].datapoints.length; i += 1) {
+      text += formatRow(
+        [
+          seriesList[seriesIndex].alias,
+          moment(seriesList[seriesIndex].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat),
+          seriesList[seriesIndex].datapoints[i][POINT_VALUE_INDEX],
+        ],
+        i < seriesList[seriesIndex].datapoints.length - 1 || seriesIndex < seriesList.length - 1
+      );
+    }
+  }
   return text;
 }
 
 export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
-  var text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
-  saveSaveBlob(text, 'grafana_data_export.csv');
+  let text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
+  saveSaveBlob(text, EXPORT_FILENAME);
 }
 
 export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
-  let text = (excel ? 'sep=;\n' : '') + 'Time;';
   // add header
-  _.each(seriesList, function(series) {
-    text += series.alias + ';';
-  });
-  text = text.substring(0, text.length - 1);
-  text += '\n';
-
+  let text =
+    formatSpecialHeader(excel) +
+    formatRow(
+      ['Time'].concat(
+        seriesList.map(function(val) {
+          return val.alias;
+        })
+      )
+    );
   // process data
   seriesList = mergeSeriesByTime(seriesList);
-  var dataArr = [[]];
-  var sIndex = 1;
-  _.each(seriesList, function(series) {
-    var cIndex = 0;
-    dataArr.push([]);
-    _.each(series.datapoints, function(dp) {
-      dataArr[0][cIndex] = moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat);
-      dataArr[sIndex][cIndex] = dp[POINT_VALUE_INDEX];
-      cIndex++;
-    });
-    sIndex++;
-  });
 
   // make text
-  for (var i = 0; i < dataArr[0].length; i++) {
-    text += dataArr[0][i] + ';';
-    for (var j = 1; j < dataArr.length; j++) {
-      text += dataArr[j][i] + ';';
-    }
-    text = text.substring(0, text.length - 1);
-    text += '\n';
+  for (let i = 0; i < seriesList[0].datapoints.length; i += 1) {
+    const timestamp = moment(seriesList[0].datapoints[i][POINT_TIME_INDEX]).format(dateTimeFormat);
+    text += formatRow(
+      [timestamp].concat(
+        seriesList.map(function(series) {
+          return series.datapoints[i][POINT_VALUE_INDEX];
+        })
+      ),
+      i < seriesList[0].datapoints.length - 1
+    );
   }
 
   return text;
@@ -71,15 +120,15 @@ function mergeSeriesByTime(seriesList) {
       timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
     }
   }
-  timestamps = _.sortedUniq(timestamps.sort());
+  timestamps = sortedUniq(timestamps.sort());
 
   for (let i = 0; i < seriesList.length; i++) {
     let seriesPoints = seriesList[i].datapoints;
-    let seriesTimestamps = _.map(seriesPoints, p => p[POINT_TIME_INDEX]);
+    let seriesTimestamps = seriesPoints.map(p => p[POINT_TIME_INDEX]);
     let extendedSeries = [];
     let pointIndex;
     for (let j = 0; j < timestamps.length; j++) {
-      pointIndex = _.sortedIndexOf(seriesTimestamps, timestamps[j]);
+      pointIndex = sortedIndexOf(seriesTimestamps, timestamps[j]);
       if (pointIndex !== -1) {
         extendedSeries.push(seriesPoints[pointIndex]);
       } else {
@@ -93,27 +142,26 @@ function mergeSeriesByTime(seriesList) {
 
 export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
   let text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel);
-  saveSaveBlob(text, 'grafana_data_export.csv');
+  saveSaveBlob(text, EXPORT_FILENAME);
 }
 
-export function exportTableDataToCsv(table, excel = false) {
-  var text = excel ? 'sep=;\n' : '';
-  // add header
-  _.each(table.columns, function(column) {
-    text += (column.title || column.text) + ';';
-  });
-  text += '\n';
+export function convertTableDataToCsv(table, excel = false) {
+  let text = formatSpecialHeader(excel);
+  // add headline
+  text += formatRow(table.columns.map(val => val.title || val.text));
   // process data
-  _.each(table.rows, function(row) {
-    _.each(row, function(value) {
-      text += value + ';';
-    });
-    text += '\n';
-  });
-  saveSaveBlob(text, 'grafana_data_export.csv');
+  for (let i = 0; i < table.rows.length; i += 1) {
+    text += formatRow(table.rows[i], i < table.rows.length - 1);
+  }
+  return text;
+}
+
+export function exportTableDataToCsv(table, excel = false) {
+  let text = convertTableDataToCsv(table, excel);
+  saveSaveBlob(text, EXPORT_FILENAME);
 }
 
 export function saveSaveBlob(payload, fname) {
-  var blob = new Blob([payload], { type: 'text/csv;charset=utf-8' });
+  let blob = new Blob([payload], { type: 'text/csv;charset=utf-8;header=present;' });
   saveAs(blob, fname);
 }

+ 7 - 6
public/app/core/utils/kbn.ts

@@ -816,8 +816,8 @@ kbn.valueFormats.timeticks = function(size, decimals, scaledDecimals) {
   return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
 };
 
-kbn.valueFormats.dateTimeAsIso = function(epoch) {
-  var time = moment(epoch);
+kbn.valueFormats.dateTimeAsIso = function(epoch, isUtc) {
+  var time = isUtc ? moment.utc(epoch) : moment(epoch);
 
   if (moment().isSame(epoch, 'day')) {
     return time.format('HH:mm:ss');
@@ -825,8 +825,8 @@ kbn.valueFormats.dateTimeAsIso = function(epoch) {
   return time.format('YYYY-MM-DD HH:mm:ss');
 };
 
-kbn.valueFormats.dateTimeAsUS = function(epoch) {
-  var time = moment(epoch);
+kbn.valueFormats.dateTimeAsUS = function(epoch, isUtc) {
+  var time = isUtc ? moment.utc(epoch) : moment(epoch);
 
   if (moment().isSame(epoch, 'day')) {
     return time.format('h:mm:ss a');
@@ -834,8 +834,9 @@ kbn.valueFormats.dateTimeAsUS = function(epoch) {
   return time.format('MM/DD/YYYY h:mm:ss a');
 };
 
-kbn.valueFormats.dateTimeFromNow = function(epoch) {
-  return moment(epoch).fromNow();
+kbn.valueFormats.dateTimeFromNow = function(epoch, isUtc) {
+  var time = isUtc ? moment.utc(epoch) : moment(epoch);
+  return time.fromNow();
 };
 
 ///// FORMAT MENU /////

+ 7 - 4
public/app/core/utils/location_util.ts

@@ -1,6 +1,11 @@
 import config from 'app/core/config';
 
-const _stripBaseFromUrl = url => {
+// Slash encoding for angular location provider, see https://github.com/angular/angular.js/issues/10479
+const SLASH = '<SLASH>';
+export const decodePathComponent = (pc: string) => decodeURIComponent(pc).replace(new RegExp(SLASH, 'g'), '/');
+export const encodePathComponent = (pc: string) => encodeURIComponent(pc.replace(/\//g, SLASH));
+
+export const stripBaseFromUrl = url => {
   const appSubUrl = config.appSubUrl;
   const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
   const urlWithoutBase =
@@ -9,6 +14,4 @@ const _stripBaseFromUrl = url => {
   return urlWithoutBase;
 };
 
-export default {
-  stripBaseFromUrl: _stripBaseFromUrl,
-};
+export default { stripBaseFromUrl };

+ 5 - 1
public/app/features/dashboard/dashboard_srv.ts

@@ -100,7 +100,7 @@ export class DashboardSrv {
       .catch(this.handleSaveDashboardError.bind(this, clone, options));
   }
 
-  saveDashboard(options, clone) {
+  saveDashboard(options?, clone?) {
     if (clone) {
       this.setCurrent(this.create(clone, this.dash.meta));
     }
@@ -124,6 +124,10 @@ export class DashboardSrv {
     return this.save(this.dash.getSaveModelClone(), options);
   }
 
+  saveJSONDashboard(json: string) {
+    return this.save(JSON.parse(json), {});
+  }
+
   showDashboardProvisionedModal() {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',

+ 12 - 2
public/app/features/dashboard/settings/settings.html

@@ -87,12 +87,22 @@
 	<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>
 </div>
 
-<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'view_json'" >
-	<h3 class="dashboard-settings__header">View JSON</h3>
+<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'dashboard_json'" >
+	<h3 class="dashboard-settings__header">JSON Model</h3>
+  <div class="dashboard-settings__subheader">
+    The JSON Model below is data structure that defines the dashboard. Including settings, panel settings & layout,
+    queries etc.
+  </div>
 
 	<div class="gf-form">
 		<code-editor content="ctrl.json" data-mode="json" data-max-lines=30 ></code-editor>
 	</div>
+
+  <div class="gf-form-button-row">
+    <button class="btn btn-success" ng-click="ctrl.saveDashboardJson()" ng-show="ctrl.canSave">
+      <i class="fa fa-save"></i> Save Changes
+    </button>
+  </div>
 </div>
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >

+ 16 - 3
public/app/features/dashboard/settings/settings.ts

@@ -17,7 +17,14 @@ export class SettingsCtrl {
   hasUnsavedFolderChange: boolean;
 
   /** @ngInject */
-  constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
+  constructor(
+    private $scope,
+    private $route,
+    private $location,
+    private $rootScope,
+    private backendSrv,
+    private dashboardSrv
+  ) {
     // temp hack for annotations and variables editors
     // that rely on inherited scope
     $scope.dashboard = this.dashboard;
@@ -93,8 +100,8 @@ export class SettingsCtrl {
     }
 
     this.sections.push({
-      title: 'View JSON',
-      id: 'view_json',
+      title: 'JSON Model',
+      id: 'dashboard_json',
       icon: 'gicon gicon-json',
     });
 
@@ -137,6 +144,12 @@ export class SettingsCtrl {
     this.dashboardSrv.saveDashboard();
   }
 
+  saveDashboardJson() {
+    this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
+      this.$route.reload();
+    });
+  }
+
   onPostSave() {
     this.hasUnsavedFolderChange = false;
   }

+ 14 - 3
public/app/features/dashlinks/module.ts

@@ -15,7 +15,7 @@ function dashLinksContainer() {
 }
 
 /** @ngInject */
-function dashLink($compile, linkSrv) {
+function dashLink($compile, $sanitize, linkSrv) {
   return {
     restrict: 'E',
     link: function(scope, elem) {
@@ -49,10 +49,21 @@ function dashLink($compile, linkSrv) {
         var linkInfo = linkSrv.getAnchorInfo(link);
         span.text(linkInfo.title);
         anchor.attr('href', linkInfo.href);
+        sanitizeAnchor();
+
+        // tooltip
+        elem.find('a').tooltip({
+          title: $sanitize(scope.link.tooltip),
+          html: true,
+          container: 'body',
+        });
+      }
+
+      function sanitizeAnchor() {
+        const anchorSanitized = $sanitize(anchor.parent().html());
+        anchor.parent().html(anchorSanitized);
       }
 
-      // tooltip
-      elem.find('a').tooltip({ title: scope.link.tooltip, html: true, container: 'body' });
       icon.attr('class', 'fa fa-fw ' + scope.link.icon);
       anchor.attr('target', scope.link.target);
 

+ 19 - 0
public/app/features/panel/metrics_panel_ctrl.ts

@@ -6,6 +6,7 @@ import { PanelCtrl } from 'app/features/panel/panel_ctrl';
 
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import * as dateMath from 'app/core/utils/datemath';
+import { encodePathComponent } from 'app/core/utils/location_util';
 
 import { metricsTabDirective } from './metrics_tab';
 
@@ -309,6 +310,24 @@ class MetricsPanelCtrl extends PanelCtrl {
     this.refresh();
   }
 
+  getAdditionalMenuItems() {
+    const items = [];
+    if (this.datasource.supportsExplore) {
+      items.push({
+        text: 'Explore',
+        click: 'ctrl.explore();',
+        icon: 'fa fa-fw fa-rocket',
+        shortcut: 'x',
+      });
+    }
+    return items;
+  }
+
+  explore() {
+    const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
+    this.$location.url(`/explore/${exploreState}`);
+  }
+
   addQuery(target) {
     target.refId = this.dashboard.getNextQueryLetter(this.panel);
 

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

@@ -22,6 +22,7 @@ export class PanelCtrl {
   editorTabs: any;
   $scope: any;
   $injector: any;
+  $location: any;
   $timeout: any;
   fullscreen: boolean;
   inspector: any;
@@ -35,6 +36,7 @@ export class PanelCtrl {
 
   constructor($scope, $injector) {
     this.$injector = $injector;
+    this.$location = $injector.get('$location');
     this.$scope = $scope;
     this.$timeout = $injector.get('$timeout');
     this.editorTabIndex = 0;
@@ -161,6 +163,9 @@ export class PanelCtrl {
       shortcut: 'p s',
     });
 
+    // Additional items from sub-class
+    menu.push(...this.getAdditionalMenuItems());
+
     let extendedMenu = this.getExtendedMenu();
     menu.push({
       text: 'More ...',
@@ -209,6 +214,11 @@ export class PanelCtrl {
     return menu;
   }
 
+  // Override in sub-class to add items before extended menu
+  getAdditionalMenuItems() {
+    return [];
+  }
+
   otherPanelInFullscreenMode() {
     return this.dashboard.meta.fullscreen && !this.fullscreen;
   }
@@ -314,6 +324,7 @@ export class PanelCtrl {
     }
 
     var linkSrv = this.$injector.get('linkSrv');
+    var sanitize = this.$injector.get('$sanitize');
     var templateSrv = this.$injector.get('templateSrv');
     var interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
     var html = '<div class="markdown-html">';
@@ -336,7 +347,8 @@ export class PanelCtrl {
       html += '</ul>';
     }
 
-    return html + '</div>';
+    html += '</div>';
+    return sanitize(html);
   }
 
   openInspector() {

+ 10 - 0
public/app/features/templating/specs/template_srv.jest.ts

@@ -136,6 +136,11 @@ describe('templateSrv', function() {
       var target = _templateSrv.replace('this=${test:pipe}', {});
       expect(target).toBe('this=value1|value2');
     });
+
+    it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
+      var target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
+      expect(target).toBe('value1|value2,{value1,value2}');
+    });
   });
 
   describe('variable with all option', function() {
@@ -164,6 +169,11 @@ describe('templateSrv', function() {
       var target = _templateSrv.replace('this.${test:glob}.filters', {});
       expect(target).toBe('this.{value1,value2}.filters');
     });
+
+    it('should replace ${test:pipe} with piped value and $test with globbed value', function() {
+      var target = _templateSrv.replace('${test:pipe},$test', {}, 'glob');
+      expect(target).toBe('value1|value2,{value1,value2}');
+    });
   });
 
   describe('variable with all option and custom value', function() {

+ 8 - 5
public/app/features/templating/template_srv.ts

@@ -74,6 +74,9 @@ export class TemplateSrv {
     if (typeof value === 'string') {
       return luceneEscape(value);
     }
+    if (value instanceof Array && value.length === 0) {
+      return '__empty__';
+    }
     var quotedValues = _.map(value, function(val) {
       return '"' + luceneEscape(val) + '"';
     });
@@ -179,16 +182,16 @@ export class TemplateSrv {
       return target;
     }
 
-    var variable, systemValue, value;
+    var variable, systemValue, value, fmt;
     this.regex.lastIndex = 0;
 
     return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
       variable = this.index[var1 || var2 || var3];
-      format = fmt2 || fmt3 || format;
+      fmt = fmt2 || fmt3 || format;
       if (scopedVars) {
         value = scopedVars[var1 || var2 || var3];
         if (value) {
-          return this.formatValue(value.value, format, variable);
+          return this.formatValue(value.value, fmt, variable);
         }
       }
 
@@ -198,7 +201,7 @@ export class TemplateSrv {
 
       systemValue = this.grafanaVariables[variable.current.value];
       if (systemValue) {
-        return this.formatValue(systemValue, format, variable);
+        return this.formatValue(systemValue, fmt, variable);
       }
 
       value = variable.current.value;
@@ -210,7 +213,7 @@ export class TemplateSrv {
         }
       }
 
-      var res = this.formatValue(value, format, variable);
+      var res = this.formatValue(value, fmt, variable);
       return res;
     });
   }

+ 1 - 0
public/app/plugins/datasource/elasticsearch/datasource.ts

@@ -395,6 +395,7 @@ export class ElasticDatasource {
     }
 
     if (query.find === 'terms') {
+      query.field = this.templateSrv.replace(query.field, {}, 'lucene');
       query.query = this.templateSrv.replace(query.query || '*', {}, 'lucene');
       return this.getTerms(query);
     }

+ 14 - 5
public/app/plugins/datasource/influxdb/response_parser.ts

@@ -11,14 +11,23 @@ export default class ResponseParser {
       return [];
     }
 
-    var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
-
     var res = {};
     _.each(influxResults.series, serie => {
       _.each(serie.values, value => {
         if (_.isArray(value)) {
-          if (influxdb11format) {
-            addUnique(res, value[1] || value[0]);
+          // In general, there are 2 possible shapes for the returned value.
+          // The first one is a two-element array,
+          // where the first element is somewhat a metadata value:
+          // the tag name for SHOW TAG VALUES queries,
+          // the time field for SELECT queries, etc.
+          // The second shape is an one-element array,
+          // that is containing an immediate value.
+          // For example, SHOW FIELD KEYS queries return such shape.
+          // Note, pre-0.11 versions return
+          // the second shape for SHOW TAG VALUES queries
+          // (while the newer versions—first).
+          if (value[1] !== undefined) {
+            addUnique(res, value[1]);
           } else {
             addUnique(res, value[0]);
           }
@@ -29,7 +38,7 @@ export default class ResponseParser {
     });
 
     return _.map(res, value => {
-      return { text: value };
+      return { text: value.toString() };
     });
   }
 }

+ 26 - 0
public/app/plugins/datasource/influxdb/specs/response_parser.jest.ts

@@ -85,6 +85,32 @@ describe('influxdb response parser', () => {
     });
   });
 
+  describe('SELECT response', () => {
+    var query = 'SELECT "usage_iowait" FROM "cpu" LIMIT 10';
+    var response = {
+      results: [
+        {
+          series: [
+            {
+              name: 'cpu',
+              columns: ['time', 'usage_iowait'],
+              values: [[1488465190006040638, 0.0], [1488465190006040638, 15.0], [1488465190006040638, 20.2]],
+            },
+          ],
+        },
+      ],
+    };
+
+    var result = parser.parse(query, response);
+
+    it('should return second column', () => {
+      expect(_.size(result)).toBe(3);
+      expect(result[0].text).toBe('0');
+      expect(result[1].text).toBe('15');
+      expect(result[2].text).toBe('20.2');
+    });
+  });
+
   describe('SHOW FIELD response', () => {
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     describe('response from 0.10.0', () => {

+ 18 - 0
public/app/plugins/datasource/prometheus/datasource.ts

@@ -19,6 +19,7 @@ export class PrometheusDatasource {
   type: string;
   editorSrc: string;
   name: string;
+  supportsExplore: boolean;
   supportMetrics: boolean;
   url: string;
   directUrl: string;
@@ -34,6 +35,7 @@ export class PrometheusDatasource {
     this.type = 'prometheus';
     this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
     this.name = instanceSettings.name;
+    this.supportsExplore = true;
     this.supportMetrics = true;
     this.url = instanceSettings.url;
     this.directUrl = instanceSettings.directUrl;
@@ -153,6 +155,7 @@ export class PrometheusDatasource {
           end: end,
           responseListLength: responseList.length,
           responseIndex: index,
+          refId: activeTargets[index].refId,
         };
 
         this.resultTransformer.transform(result, response, transformerOptions);
@@ -323,6 +326,21 @@ export class PrometheusDatasource {
     });
   }
 
+  getExploreState(panel) {
+    let state = {};
+    if (panel.targets) {
+      const queries = panel.targets.map(t => ({
+        query: this.templateSrv.replace(t.expr, {}, this.interpolateQueryExpr),
+        format: t.format,
+      }));
+      state = {
+        ...state,
+        queries,
+      };
+    }
+    return state;
+  }
+
   getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);

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

@@ -8,7 +8,7 @@ export class ResultTransformer {
     let prometheusResult = response.data.data.result;
 
     if (options.format === 'table') {
-      result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.responseIndex));
+      result.push(this.transformMetricDataToTable(prometheusResult, options.responseListLength, options.refId));
     } else if (options.format === 'heatmap') {
       let seriesList = [];
       prometheusResult.sort(sortSeriesByLabel);
@@ -58,7 +58,7 @@ export class ResultTransformer {
     return { target: metricLabel, datapoints: dps };
   }
 
-  transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
+  transformMetricDataToTable(md, resultCount: number, refId: string) {
     var table = new TableModel();
     var i, j;
     var metricLabels = {};
@@ -83,7 +83,7 @@ export class ResultTransformer {
       metricLabels[label] = labelIndex + 1;
       table.columns.push({ text: label });
     });
-    let valueText = resultCount > 1 ? `Value #${String.fromCharCode(65 + resultIndex)}` : 'Value';
+    let valueText = resultCount > 1 ? `Value #${refId}` : 'Value';
     table.columns.push({ text: valueText });
 
     // Populate rows, set value to empty string when label not present.

+ 12 - 0
public/app/plugins/datasource/prometheus/specs/result_transformer.jest.ts

@@ -47,6 +47,18 @@ describe('Prometheus Result Transformer', () => {
         { text: 'Value' },
       ]);
     });
+
+    it('should column title include refId if response count is more than 2', () => {
+      var table = ctx.resultTransformer.transformMetricDataToTable(response.data.result, 2, 'B');
+      expect(table.type).toBe('table');
+      expect(table.columns).toEqual([
+        { text: 'Time', type: 'time' },
+        { text: '__name__' },
+        { text: 'instance' },
+        { text: 'job' },
+        { text: 'Value #B' },
+      ]);
+    });
   });
 
   describe('When resultFormat is table and instant = true', () => {

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

@@ -235,7 +235,7 @@ class GraphCtrl extends MetricsPanelCtrl {
   }
 
   changeSeriesColor(series, color) {
-    series.color = color;
+    series.setColor(color);
     this.panel.aliasColors[series.alias] = series.color;
     this.render();
   }

+ 1 - 1
public/app/plugins/panel/pluginlist/module.ts

@@ -12,7 +12,7 @@ class PluginListCtrl extends PanelCtrl {
   panelDefaults = {};
 
   /** @ngInject */
-  constructor($scope, $injector, private backendSrv, private $location) {
+  constructor($scope, $injector, private backendSrv) {
     super($scope, $injector);
 
     _.defaults(this.panel, this.panelDefaults);

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

@@ -77,7 +77,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
   };
 
   /** @ngInject */
-  constructor($scope, $injector, private $location, private linkSrv) {
+  constructor($scope, $injector, private linkSrv) {
     super($scope, $injector);
     _.defaults(this.panel, this.panelDefaults);
 
@@ -308,7 +308,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
         let formatFunc = kbn.valueFormats[this.panel.format];
         data.value = lastPoint[1];
         data.valueRounded = data.value;
-        data.valueFormatted = formatFunc(data.value, 0, 0);
+        data.valueFormatted = formatFunc(data.value, this.dashboard.isTimezoneUtc());
       } else {
         data.value = this.series[0].stats[this.panel.valueName];
         data.flotpairs = this.series[0].flotpairs;

+ 47 - 0
public/app/plugins/panel/singlestat/specs/singlestat_specs.ts

@@ -82,6 +82,19 @@ describe('SingleStatCtrl', function() {
     });
   });
 
+  singleStatScenario('showing last iso time instead of value (in UTC)', function(ctx) {
+    ctx.setup(function() {
+      ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
+      ctx.ctrl.panel.valueName = 'last_time';
+      ctx.ctrl.panel.format = 'dateTimeAsIso';
+      ctx.setIsUtc(true);
+    });
+
+    it('should set formatted value', function() {
+      expect(ctx.data.valueFormatted).to.be(moment.utc(1505634997920).format('YYYY-MM-DD HH:mm:ss'));
+    });
+  });
+
   singleStatScenario('showing last us time instead of value', function(ctx) {
     ctx.setup(function() {
       ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
@@ -99,6 +112,19 @@ describe('SingleStatCtrl', function() {
     });
   });
 
+  singleStatScenario('showing last us time instead of value (in UTC)', function(ctx) {
+    ctx.setup(function() {
+      ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
+      ctx.ctrl.panel.valueName = 'last_time';
+      ctx.ctrl.panel.format = 'dateTimeAsUS';
+      ctx.setIsUtc(true);
+    });
+
+    it('should set formatted value', function() {
+      expect(ctx.data.valueFormatted).to.be(moment.utc(1505634997920).format('MM/DD/YYYY h:mm:ss a'));
+    });
+  });
+
   singleStatScenario('showing last time from now instead of value', function(ctx) {
     beforeEach(() => {
       clock = sinon.useFakeTimers(epoch);
@@ -124,6 +150,27 @@ describe('SingleStatCtrl', function() {
     });
   });
 
+  singleStatScenario('showing last time from now instead of value (in UTC)', function(ctx) {
+    beforeEach(() => {
+      clock = sinon.useFakeTimers(epoch);
+    });
+
+    ctx.setup(function() {
+      ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
+      ctx.ctrl.panel.valueName = 'last_time';
+      ctx.ctrl.panel.format = 'dateTimeFromNow';
+      ctx.setIsUtc(true);
+    });
+
+    it('should set formatted value', function() {
+      expect(ctx.data.valueFormatted).to.be('2 days ago');
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+  });
+
   singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', function(
     ctx
   ) {

+ 5 - 0
public/app/plugins/panel/table/module.ts

@@ -154,6 +154,11 @@ class TablePanelCtrl extends MetricsPanelCtrl {
     this.render();
   }
 
+  moveQuery(target, direction) {
+    super.moveQuery(target, direction);
+    super.refresh();
+  }
+
   exportCsv() {
     var scope = this.$scope.$new(true);
     scope.tableData = this.renderer.render_values();

+ 1 - 1
public/app/plugins/panel/table/renderer.ts

@@ -247,7 +247,7 @@ export class TableRenderer {
       var scopedVars = this.renderRowVariables(rowIndex);
       scopedVars['__cell'] = { value: value };
 
-      var cellLink = this.templateSrv.replace(column.style.linkUrl, scopedVars);
+      var cellLink = this.templateSrv.replace(column.style.linkUrl, scopedVars, encodeURIComponent);
       var cellLinkTooltip = this.templateSrv.replace(column.style.linkTooltip, scopedVars);
       var cellTarget = column.style.linkTargetBlank ? '_blank' : '';
 

+ 1 - 0
public/app/routes/ReactContainer.tsx

@@ -29,6 +29,7 @@ export function reactContainer($route, $location, backendSrv: BackendSrv, dataso
       const props = {
         backendSrv: backendSrv,
         datasourceSrv: datasourceSrv,
+        routeParams: $route.current.params,
       };
 
       ReactDOM.render(WrapInProvider(store, component, props), elem[0]);

+ 1 - 1
public/app/routes/routes.ts

@@ -111,7 +111,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'FolderDashboardsCtrl',
       controllerAs: 'ctrl',
     })
-    .when('/explore', {
+    .when('/explore/:initial?', {
       template: '<react-container />',
       resolve: {
         component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),

+ 2 - 3
public/img/graph404.svg

@@ -1,4 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="500" viewBox="0 0 800 500">
+<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
   <metadata><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
 <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01        ">
    <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@@ -58,7 +58,6 @@
       }
     </style>
   </defs>
-  <image id="Lager_1" data-name="Lager 1" width="800" height="500" xlink:href="data:img/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAH0AQAAAADtO3TVAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAHdElNRQfhCQ8OGxT9zSJNAAAAxUlEQVR42u3NMQEAAAwCIPuX1hTbBQVIH0QikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikUgkEolEIpFIJBKJRCKRSCQSiUQikVwZyReYD18j2sEAAAAASUVORK5CYII="/>
   <path id="Form_3" data-name="Form 3" class="cls-1" d="M0,2V500H800"/>
   <path id="Form_4" data-name="Form 4" class="cls-1" d="M160,2V500"/>
   <path id="Form_5" data-name="Form 5" class="cls-1" d="M320,1V500"/>
@@ -69,5 +68,5 @@
   <path id="Form_10" data-name="Form 10" class="cls-1" d="M0,300H798"/>
   <path id="Form_11" data-name="Form 11" class="cls-1" d="M0,400H800"/>
   <path id="Form_12" data-name="Form 12" class="cls-2" d="M0,0C0,299.762,320.7,500,800,500"/>
-  <path id="Form_12_kopiera" data-name="Form 12 kopiera" class="cls-3" d="M800,500C320.7,500,0,299.762,0,0,0-234.869,0,500,0,500H800Z"/>
+  <path id="Form_12_kopiera" data-name="Form 12 kopiera" class="cls-3" d="M800,500C320.7,500,0,299.762,0,0V500H800Z"/>
 </svg>

+ 11 - 0
public/sass/components/_dashboard_settings.scss

@@ -53,6 +53,13 @@
   margin-bottom: $spacer*2;
 }
 
+.dashboard-settings__subheader {
+  color: $text-muted;
+  font-style: italic;
+  position: relative;
+  top: -1.5rem;
+}
+
 .dashboard-settings__nav-item {
   padding: 7px 12px;
   color: $text-color;
@@ -85,3 +92,7 @@
     margin-bottom: 10px;
   }
 }
+
+.dashboard-settings__json-save-button {
+  margin-top: $spacer;
+}

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

@@ -102,5 +102,6 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
 .gf-form-input--form-dropdown-right {
   .Select-menu-outer {
     right: 0;
+    left: unset;
   }
 }

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

@@ -71,21 +71,29 @@
   td {
     padding: 1px;
   }
-  button.btn-sm {
+  button {
     @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl);
-    font-size: $font-size-sm;
     background-image: none;
     border: none;
-    padding: 5px 11px;
     color: $text-color;
     &.active span {
-      color: $blue;
+      color: $query-blue;
       font-weight: bold;
     }
     .text-info {
       color: $orange;
       font-weight: bold;
     }
+    &.btn-sm {
+      font-size: $font-size-sm;
+      padding: 5px 11px;
+    }
+    &:hover {
+      color: $text-color-strong;
+    }
+    &[disabled] {
+      color: $text-color;
+    }
   }
 }
 
@@ -103,10 +111,10 @@
 }
 
 .fa-chevron-left::before {
-  content: "\f053";
+  content: '\f053';
 }
 .fa-chevron-right::before {
-  content: "\f054";
+  content: '\f054';
 }
 
 .glyphicon-chevron-right {

+ 2 - 1
public/sass/pages/_dashboard.scss

@@ -44,7 +44,8 @@ div.flot-text {
   padding: $panel-padding;
   height: calc(100% - 27px);
   position: relative;
-  overflow: hidden;
+  // Fixes scrolling on mobile devices
+  overflow: auto;
 }
 
 .panel-title-container {

+ 22 - 11
public/test/specs/helpers.ts

@@ -1,14 +1,15 @@
 import _ from 'lodash';
 import config from 'app/core/config';
 import * as dateMath from 'app/core/utils/datemath';
-import {angularMocks, sinon} from '../lib/common';
-import {PanelModel} from 'app/features/dashboard/panel_model';
+import { angularMocks, sinon } from '../lib/common';
+import { PanelModel } from 'app/features/dashboard/panel_model';
 
 export function ControllerTestContext() {
   var self = this;
 
   this.datasource = {};
   this.$element = {};
+  this.$sanitize = {};
   this.annotationsSrv = {};
   this.timeSrv = new TimeSrvStub();
   this.templateSrv = new TemplateSrvStub();
@@ -22,6 +23,7 @@ export function ControllerTestContext() {
       };
     },
   };
+  this.isUtc = false;
 
   this.providePhase = function(mocks) {
     return angularMocks.module(function($provide) {
@@ -30,6 +32,7 @@ export function ControllerTestContext() {
       $provide.value('timeSrv', self.timeSrv);
       $provide.value('templateSrv', self.templateSrv);
       $provide.value('$element', self.$element);
+      $provide.value('$sanitize', self.$sanitize);
       _.each(mocks, function(value, key) {
         $provide.value(key, value);
       });
@@ -42,8 +45,12 @@ export function ControllerTestContext() {
       self.$location = $location;
       self.$browser = $browser;
       self.$q = $q;
-      self.panel = new PanelModel({type: 'test'});
-      self.dashboard = {meta: {}};
+      self.panel = new PanelModel({ type: 'test' });
+      self.dashboard = { meta: {} };
+      self.isUtc = false;
+      self.dashboard.isTimezoneUtc = function() {
+        return self.isUtc;
+      };
 
       $rootScope.appEvent = sinon.spy();
       $rootScope.onAppEvent = sinon.spy();
@@ -53,14 +60,14 @@ export function ControllerTestContext() {
         $rootScope.colors.push('#' + i);
       }
 
-      config.panels['test'] = {info: {}};
+      config.panels['test'] = { info: {} };
       self.ctrl = $controller(
         Ctrl,
-        {$scope: self.scope},
+        { $scope: self.scope },
         {
           panel: self.panel,
           dashboard: self.dashboard,
-        },
+        }
       );
     });
   };
@@ -72,7 +79,7 @@ export function ControllerTestContext() {
       self.$browser = $browser;
       self.scope.contextSrv = {};
       self.scope.panel = {};
-      self.scope.dashboard = {meta: {}};
+      self.scope.dashboard = { meta: {} };
       self.scope.dashboardMeta = {};
       self.scope.dashboardViewState = new DashboardViewStateStub();
       self.scope.appEvent = sinon.spy();
@@ -91,6 +98,10 @@ export function ControllerTestContext() {
       });
     });
   };
+
+  this.setIsUtc = function(isUtc = false) {
+    self.isUtc = isUtc;
+  };
 }
 
 export function ServiceTestContext() {
@@ -131,7 +142,7 @@ export function DashboardViewStateStub() {
 
 export function TimeSrvStub() {
   this.init = sinon.spy();
-  this.time = {from: 'now-1h', to: 'now'};
+  this.time = { from: 'now-1h', to: 'now' };
   this.timeRange = function(parse) {
     if (parse === false) {
       return this.time;
@@ -159,7 +170,7 @@ export function ContextSrvStub() {
 
 export function TemplateSrvStub() {
   this.variables = [];
-  this.templateSettings = {interpolate: /\[\[([\s\S]+?)\]\]/g};
+  this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g };
   this.data = {};
   this.replace = function(text) {
     return _.template(text, this.templateSettings)(this.data);
@@ -188,7 +199,7 @@ var allDeps = {
   TimeSrvStub: TimeSrvStub,
   ControllerTestContext: ControllerTestContext,
   ServiceTestContext: ServiceTestContext,
-  DashboardViewStateStub: DashboardViewStateStub
+  DashboardViewStateStub: DashboardViewStateStub,
 };
 
 // for legacy

+ 2 - 1
scripts/webpack/webpack.common.js

@@ -12,7 +12,8 @@ module.exports = {
   output: {
     path: path.resolve(__dirname, '../../public/build'),
     filename: '[name].[hash].js',
-    publicPath: "/public/build/",
+    // Keep publicPath relative for host.com/grafana/ deployments
+    publicPath: "public/build/",
   },
   resolve: {
     extensions: ['.ts', '.tsx', '.es6', '.js', '.json'],

+ 1 - 1
scripts/webpack/webpack.dev.js

@@ -85,7 +85,7 @@ module.exports = merge(common, {
         ]
       },
       require('./sass.rule.js')({
-        sourceMap: true, minimize: false, preserveUrl: true
+        sourceMap: true, minimize: false, preserveUrl: HOT
       }, extractSass),
       {
         test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,

+ 1 - 1
tools/phantomjs/render.js

@@ -57,7 +57,7 @@
 
           var rootScope = body.injector().get('$rootScope');
           if (!rootScope) {return false;}
-          var panels = angular.element('div.panel:visible').length;
+          var panels = angular.element('plugin-component').length;
           return rootScope.panelsRendered >= panels;
         });