소스 검색

Merge branch 'master' into WPH95-feature/add_es_alerting

Marcus Efraimsson 7 년 전
부모
커밋
cde347bd3d
83개의 변경된 파일1238개의 추가작업 그리고 435개의 파일을 삭제
  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
 /conf/provisioning/**/custom.yaml
 profile.cov
 profile.cov
 /grafana
 /grafana
+/local
 .notouch
 .notouch
+/Makefile.local
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/debug
 /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)
 * **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)
 * **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)
 # 5.1.0 (2018-04-26)
 
 

+ 2 - 0
Makefile

@@ -1,3 +1,5 @@
+-include local/Makefile
+
 all: deps build
 all: deps build
 
 
 deps-go:
 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. 
 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. 
 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
   - Elasticsearch alerting
-  - First login registration view
-  - Backend plugins? (alert notifiers, auth)
   - Crossplatform builds
   - 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)
 ### Long term (4 - 8 months)
 
 

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

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 # http://localhost:3000 (Grafana running locally)
 #
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
 # 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:
   apacheproxy:
     build: blocks/apache_proxy
     build: blocks/apache_proxy

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

@@ -2,7 +2,7 @@
 # http://localhost:3000 (Grafana running locally)
 # http://localhost:3000 (Grafana running locally)
 #
 #
 # Please note that you'll need to change the root_url in the Grafana configuration:
 # 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:
   nginxproxy:
     build: blocks/nginx_proxy
     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
 Filter Option | Example | Raw | Interpolated | Description
 ------------ | ------------- | ------------- | -------------  | -------------
 ------------ | ------------- | ------------- | -------------  | -------------
 `glob` | ${servers:glob} |  `'test1', 'test2'` | `{test1,test2}` | Formats multi-value variable into a glob
 `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
 `csv`| ${servers:csv} |  `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
 
 
 ## Improved workflow for provisioned dashboards
 ## Improved workflow for provisioned dashboards
@@ -122,4 +122,4 @@ More information in the [Provisioning documentation](/features/datasources/prome
 ## Changelog
 ## Changelog
 
 
 Checkout the [CHANGELOG.md](https://github.com/grafana/grafana/blob/master/CHANGELOG.md) file for a complete list
 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
 ```bash
 [server]
 [server]
 domain = foo.bar
 domain = foo.bar
-root_url = %(protocol)s://%(domain)s:/grafana
+root_url = %(protocol)s://%(domain)s/grafana/
 ```
 ```
 
 
 #### Nginx configuration with sub path
 #### Nginx configuration with sub path
@@ -98,7 +98,7 @@ Given:
     ```bash
     ```bash
     [server]
     [server]
     domain = localhost:8080
     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:
 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.
 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>
 <hr>
 
 
 ## [session]
 ## [session]

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

@@ -15,7 +15,7 @@ weight = 1
 
 
 Description | Download
 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)
 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
 ```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 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
 <!-- ## Install Latest Beta

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

@@ -15,7 +15,7 @@ weight = 2
 
 
 Description | Download
 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)
 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.
 You can install Grafana using Yum directly.
 
 
 ```bash
 ```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
 <!-- ## Install Beta
@@ -42,15 +42,15 @@ Or install manually using `rpm`.
 #### On CentOS / Fedora / Redhat:
 #### On CentOS / Fedora / Redhat:
 
 
 ```bash
 ```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 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:
 #### On OpenSuse:
 
 
 ```bash
 ```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
 ## Install via YUM Repository

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

@@ -12,7 +12,7 @@ weight = 3
 
 
 Description | Download
 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)
 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]
 [menu.docs]
 name = "Developing App Plugins"
 name = "Developing App Plugins"
 parent = "developing"
 parent = "developing"
-weight = 6
+weight = 4
 +++
 +++
 
 
 # Grafana Apps
 # Grafana Apps

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

@@ -5,7 +5,7 @@ type = "docs"
 [menu.docs]
 [menu.docs]
 name = "Developing Datasource Plugins"
 name = "Developing Datasource Plugins"
 parent = "developing"
 parent = "developing"
-weight = 6
+weight = 5
 +++
 +++
 
 
 # Datasources
 # 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"
 type = "docs"
 [menu.docs]
 [menu.docs]
+name = "Developing Panel Plugins"
 parent = "developing"
 parent = "developing"
-weight = 1
+weight = 4
 +++
 +++
 
 
 
 
@@ -20,7 +15,21 @@ Panels are the main building blocks of dashboards.
 
 
 ## Panel development
 ## 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)
 - [clock-panel](https://github.com/grafana/clock-panel)
 - [singlestat-panel](https://github.com/grafana/grafana/blob/master/public/app/plugins/panel/singlestat/module.ts)
 - [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]
 [menu.docs]
 name = "plugin.json Schema"
 name = "plugin.json Schema"
 parent = "developing"
 parent = "developing"
-weight = 6
+weight = 8
 +++
 +++
 
 
 # Plugin.json
 # Plugin.json

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

@@ -16,7 +16,7 @@ Example:
 - Parent site: http://localhost:8080
 - Parent site: http://localhost:8080
 - Grafana: http://localhost:3000
 - Grafana: http://localhost:3000
 
 
-Grafana as a subpath: http://localhost:8080/grafana 
+Grafana as a subpath: http://localhost:8080/grafana
 
 
 ## Setup
 ## Setup
 
 
@@ -33,7 +33,7 @@ Given that the subpath should be `grafana` and the parent site is `localhost:808
  ```bash
  ```bash
 [server]
 [server]
 domain = localhost:8080
 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.
 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):
 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:
 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(/)?(.*)`
     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/middleware"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/plugins"
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
+func init() {
+	registry.RegisterService(&HTTPServer{})
+}
+
 type HTTPServer struct {
 type HTTPServer struct {
 	log           log.Logger
 	log           log.Logger
 	macaron       *macaron.Macaron
 	macaron       *macaron.Macaron
@@ -41,12 +46,14 @@ type HTTPServer struct {
 	Bus           bus.Bus       `inject:""`
 	Bus           bus.Bus       `inject:""`
 }
 }
 
 
-func (hs *HTTPServer) Init() {
+func (hs *HTTPServer) Init() error {
 	hs.log = log.New("http.server")
 	hs.log = log.New("http.server")
 	hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
 	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
 	var err error
 
 
 	hs.context = ctx
 	hs.context = ctx
@@ -57,17 +64,18 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	hs.streamManager.Run(ctx)
 	hs.streamManager.Run(ctx)
 
 
 	listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
 	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}
 	hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron}
 
 
 	// handle http shutdown on server context done
 	// handle http shutdown on server context done
 	go func() {
 	go func() {
 		<-ctx.Done()
 		<-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 {
 		if err := hs.httpSrv.Shutdown(context.Background()); err != nil {
 			hs.log.Error("Failed to shutdown server", "error", err)
 			hs.log.Error("Failed to shutdown server", "error", err)
 		}
 		}
-		hs.log.Info("Stopped HTTP Server")
 	}()
 	}()
 
 
 	switch setting.Protocol {
 	switch setting.Protocol {
@@ -106,12 +114,6 @@ func (hs *HTTPServer) Start(ctx context.Context) error {
 	return err
 	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 {
 func (hs *HTTPServer) listenAndServeTLS(certfile, keyfile string) error {
 	if certfile == "" {
 	if certfile == "" {
 		return fmt.Errorf("cert_file cannot be empty when using HTTPS")
 		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, route.Directory, "", pluginRoute)
 	}
 	}
 
 
+	hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
 	hs.mapStatic(m, setting.StaticRootPath, "", "public")
 	hs.mapStatic(m, setting.StaticRootPath, "", "public")
 	hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")
 	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")
 		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 {
 	if setting.Env == setting.DEV {
 		headers = func(c *macaron.Context) {
 		headers = func(c *macaron.Context) {
 			c.Resp.Header().Set("Cache-Control", "max-age=0, must-revalidate, no-cache")
 			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 configFile = flag.String("config", "", "path to config file")
 var homePath = flag.String("homepath", "", "path to grafana install/home path, defaults to working directory")
 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 pidFile = flag.String("pidfile", "", "path to pid file")
-var exitChan = make(chan int)
 
 
 func main() {
 func main() {
 	v := flag.Bool("v", false, "prints current version and exits")
 	v := flag.Bool("v", false, "prints current version and exits")
@@ -82,29 +81,20 @@ func main() {
 	setting.Enterprise, _ = strconv.ParseBool(enterprise)
 	setting.Enterprise, _ = strconv.ParseBool(enterprise)
 
 
 	metrics.M_Grafana_Version.WithLabelValues(version).Set(1)
 	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()
 	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)
 	signalChan := make(chan os.Signal, 1)
 	ignoreChan := make(chan os.Signal, 1)
 	ignoreChan := make(chan os.Signal, 1)
 
 
@@ -113,12 +103,6 @@ func listenToSystemSignals(server *GrafanaServerImpl, shutdownCompleted chan int
 
 
 	select {
 	select {
 	case sig := <-signalChan:
 	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/middleware"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/dashboards"
 	"github.com/grafana/grafana/pkg/services/dashboards"
-	"github.com/grafana/grafana/pkg/services/provisioning"
 
 
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sync/errgroup"
 
 
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/api"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/login"
 	"github.com/grafana/grafana/pkg/login"
-	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/services/sqlstore"
 	"github.com/grafana/grafana/pkg/services/sqlstore"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 
 
@@ -33,10 +31,12 @@ import (
 
 
 	// self registering services
 	// self registering services
 	_ "github.com/grafana/grafana/pkg/extensions"
 	_ "github.com/grafana/grafana/pkg/extensions"
+	_ "github.com/grafana/grafana/pkg/metrics"
 	_ "github.com/grafana/grafana/pkg/plugins"
 	_ "github.com/grafana/grafana/pkg/plugins"
 	_ "github.com/grafana/grafana/pkg/services/alerting"
 	_ "github.com/grafana/grafana/pkg/services/alerting"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "github.com/grafana/grafana/pkg/services/cleanup"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
 	_ "github.com/grafana/grafana/pkg/services/notifications"
+	_ "github.com/grafana/grafana/pkg/services/provisioning"
 	_ "github.com/grafana/grafana/pkg/services/search"
 	_ "github.com/grafana/grafana/pkg/services/search"
 )
 )
 
 
@@ -54,17 +54,19 @@ func NewGrafanaServer() *GrafanaServerImpl {
 }
 }
 
 
 type GrafanaServerImpl struct {
 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:""`
 	RouteRegister api.RouteRegister `inject:""`
 	HttpServer    *api.HTTPServer   `inject:""`
 	HttpServer    *api.HTTPServer   `inject:""`
 }
 }
 
 
-func (g *GrafanaServerImpl) Start() error {
+func (g *GrafanaServerImpl) Run() error {
 	g.loadConfiguration()
 	g.loadConfiguration()
 	g.writePIDFile()
 	g.writePIDFile()
 
 
@@ -72,14 +74,9 @@ func (g *GrafanaServerImpl) Start() error {
 	sqlstore.NewEngine() // TODO: this should return an error
 	sqlstore.NewEngine() // TODO: this should return an error
 	sqlstore.EnsureAdminUser()
 	sqlstore.EnsureAdminUser()
 
 
-	metrics.Init(g.cfg.Raw)
 	login.Init()
 	login.Init()
 	social.NewOAuthService()
 	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)
 	tracingCloser, err := tracing.Init(g.cfg.Raw)
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("Tracing settings is not valid. error: %v", err)
 		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: g.cfg})
 	serviceGraph.Provide(&inject.Object{Value: dashboards.NewProvisioningService()})
 	serviceGraph.Provide(&inject.Object{Value: dashboards.NewProvisioningService()})
 	serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
 	serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
-	serviceGraph.Provide(&inject.Object{Value: api.HTTPServer{}})
 
 
 	// self registered services
 	// self registered services
 	services := registry.GetServices()
 	services := registry.GetServices()
@@ -117,7 +113,7 @@ func (g *GrafanaServerImpl) Start() error {
 		g.log.Info("Initializing " + reflect.TypeOf(service).Elem().Name())
 		g.log.Info("Initializing " + reflect.TypeOf(service).Elem().Name())
 
 
 		if err := service.Init(); err != nil {
 		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 {
 		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)
 			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
 			return err
 		})
 		})
 	}
 	}
 
 
 	sendSystemdNotification("READY=1")
 	sendSystemdNotification("READY=1")
-	return g.startHttpServer()
+
+	return g.childRoutines.Wait()
 }
 }
 
 
 func (g *GrafanaServerImpl) loadConfiguration() {
 func (g *GrafanaServerImpl) loadConfiguration() {
@@ -159,28 +172,29 @@ func (g *GrafanaServerImpl) loadConfiguration() {
 	g.cfg.LogConfigSources()
 	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
 	// call cancel func on root context
 	g.shutdownFn()
 	g.shutdownFn()
 
 
 	// wait for child routines
 	// 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() {
 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)
 	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.
 // SetValid changes this Float's value and also sets it to be non-null.
 func (f *Float) SetValid(n float64) {
 func (f *Float) SetValid(n float64) {
 	f.Float64 = n
 	f.Float64 = n

+ 14 - 0
pkg/login/ldap_test.go

@@ -53,6 +53,20 @@ func TestLdapAuther(t *testing.T) {
 			So(result, ShouldEqual, user1)
 			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) {
 		ldapAutherScenario("Given no existing grafana user", func(sc *scenarioContext) {
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 			ldapAuther := NewLdapAuthenticator(&LdapServerConf{
 				LdapGroups: []*LdapGroupToOrgRole{
 				LdapGroups: []*LdapGroupToOrgRole{

+ 5 - 1
pkg/login/ldap_user.go

@@ -1,5 +1,9 @@
 package login
 package login
 
 
+import (
+	"strings"
+)
+
 type LdapUserInfo struct {
 type LdapUserInfo struct {
 	DN        string
 	DN        string
 	FirstName string
 	FirstName string
@@ -15,7 +19,7 @@ func (u *LdapUserInfo) isMemberOf(group string) bool {
 	}
 	}
 
 
 	for _, member := range u.MemberOf {
 	for _, member := range u.MemberOf {
-		if member == group {
+		if strings.EqualFold(member, group) {
 			return true
 			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"})
 	}, []string{"version"})
 }
 }
 
 
-func initMetricVars(settings *MetricSettings) {
+func initMetricVars() {
 	prometheus.MustRegister(
 	prometheus.MustRegister(
 		M_Instance_Start,
 		M_Instance_Start,
 		M_Page_Status,
 		M_Page_Status,
@@ -316,28 +316,6 @@ func initMetricVars(settings *MetricSettings) {
 		M_StatTotal_Playlists,
 		M_StatTotal_Playlists,
 		M_Grafana_Version)
 		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() {
 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
 package metrics
 
 
 import (
 import (
+	"fmt"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
 	"github.com/grafana/grafana/pkg/metrics/graphitebridge"
 	"github.com/grafana/grafana/pkg/metrics/graphitebridge"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/prometheus/client_golang/prometheus"
 	"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 {
 	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
 		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 {
 	if err != nil {
-		return nil, nil
+		return nil
 	}
 	}
 
 
 	address := graphiteSection.Key("address").String()
 	address := graphiteSection.Key("address").String()
 	if address == "" {
 	if address == "" {
-		return nil, nil
+		return nil
 	}
 	}
 
 
-	cfg := &graphitebridge.Config{
+	bridgeCfg := &graphitebridge.Config{
 		URL:             address,
 		URL:             address,
 		Prefix:          graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
 		Prefix:          graphiteSection.Key("prefix").MustString("prod.grafana.%(instance_name)s"),
 		CountersAsDelta: true,
 		CountersAsDelta: true,
 		Gatherer:        prometheus.DefaultGatherer,
 		Gatherer:        prometheus.DefaultGatherer,
-		Interval:        time.Duration(settings.IntervalSeconds) * time.Second,
+		Interval:        time.Duration(im.intervalSeconds) * time.Second,
 		Timeout:         10 * time.Second,
 		Timeout:         10 * time.Second,
 		Logger:          &logWrapper{logger: metricsLogger},
 		Logger:          &logWrapper{logger: metricsLogger},
 		ErrorHandling:   graphitebridge.ContinueOnError,
 		ErrorHandling:   graphitebridge.ContinueOnError,
@@ -74,6 +60,8 @@ func parseGraphiteSettings(settings *MetricSettings, file *ini.File) (*graphiteb
 		prefix = "prod.grafana.%(instance_name)s."
 		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"
 	"fmt"
 	"net"
 	"net"
 	"net/mail"
 	"net/mail"
+	"reflect"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -111,6 +112,16 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
 			return true
 			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
 		// add/update user in grafana
 		cmd := &m.UpsertUserCommand{
 		cmd := &m.UpsertUserCommand{
 			ReqContext:    ctx,
 			ReqContext:    ctx,

+ 7 - 6
pkg/models/notifications.go

@@ -19,12 +19,13 @@ type SendEmailCommandSync struct {
 }
 }
 
 
 type SendWebhookSync 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 {
 type SendResetPasswordEmailCommand struct {

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

@@ -16,7 +16,7 @@ import (
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sync/errgroup"
 )
 )
 
 
-type Engine struct {
+type AlertingService struct {
 	execQueue chan *Job
 	execQueue chan *Job
 	//clock         clock.Clock
 	//clock         clock.Clock
 	ticker        *Ticker
 	ticker        *Ticker
@@ -28,20 +28,20 @@ type Engine struct {
 }
 }
 
 
 func init() {
 func init() {
-	registry.RegisterService(&Engine{})
+	registry.RegisterService(&AlertingService{})
 }
 }
 
 
-func NewEngine() *Engine {
-	e := &Engine{}
+func NewEngine() *AlertingService {
+	e := &AlertingService{}
 	e.Init()
 	e.Init()
 	return e
 	return e
 }
 }
 
 
-func (e *Engine) IsDisabled() bool {
+func (e *AlertingService) IsDisabled() bool {
 	return !setting.AlertingEnabled || !setting.ExecuteAlerts
 	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.ticker = NewTicker(time.Now(), time.Second*0, clock.New())
 	e.execQueue = make(chan *Job, 1000)
 	e.execQueue = make(chan *Job, 1000)
 	e.scheduler = NewScheduler()
 	e.scheduler = NewScheduler()
@@ -52,7 +52,7 @@ func (e *Engine) Init() error {
 	return nil
 	return nil
 }
 }
 
 
-func (e *Engine) Run(ctx context.Context) error {
+func (e *AlertingService) Run(ctx context.Context) error {
 	alertGroup, ctx := errgroup.WithContext(ctx)
 	alertGroup, ctx := errgroup.WithContext(ctx)
 	alertGroup.Go(func() error { return e.alertingTicker(ctx) })
 	alertGroup.Go(func() error { return e.alertingTicker(ctx) })
 	alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
 	alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
@@ -61,7 +61,7 @@ func (e *Engine) Run(ctx context.Context) error {
 	return err
 	return err
 }
 }
 
 
-func (e *Engine) alertingTicker(grafanaCtx context.Context) error {
+func (e *AlertingService) alertingTicker(grafanaCtx context.Context) error {
 	defer func() {
 	defer func() {
 		if err := recover(); err != nil {
 		if err := recover(); err != nil {
 			e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1))
 			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)
 	dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx)
 
 
 	for {
 	for {
@@ -106,7 +106,7 @@ var (
 	alertMaxAttempts = 3
 	alertMaxAttempts = 3
 )
 )
 
 
-func (e *Engine) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
+func (e *AlertingService) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
 	defer func() {
 	defer func() {
 		if err := recover(); err != nil {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
 			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
 	job.Running = false
 	close(cancelChan)
 	close(cancelChan)
 	for cancelFn := range cancelChan {
 	for cancelFn := range cancelChan {
@@ -150,7 +150,7 @@ func (e *Engine) endJob(err error, cancelChan chan context.CancelFunc, job *Job)
 	return err
 	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() {
 	defer func() {
 		if err := recover(); err != nil {
 		if err := recover(); err != nil {
 			e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
 			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 {
 func (ns *NotificationService) SendWebhookSync(ctx context.Context, cmd *m.SendWebhookSync) error {
 	return ns.sendWebRequestSync(ctx, &Webhook{
 	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 {
 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{
 var netTransport = &http.Transport{
@@ -48,8 +49,13 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
 		return err
 		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")
 	request.Header.Add("User-Agent", "Grafana")
+
 	if webhook.User != "" && webhook.Password != "" {
 	if webhook.User != "" && webhook.Password != "" {
 		request.Header.Add("Authorization", util.GetBasicAuthHeader(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)
 		parsedDashboards, err := cr.parseConfigs(file)
 		if err != nil {
 		if err != nil {
-
+			return nil, err
 		}
 		}
 
 
 		if len(parsedDashboards) > 0 {
 		if len(parsedDashboards) > 0 {

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

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

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

@@ -2,30 +2,40 @@ package provisioning
 
 
 import (
 import (
 	"context"
 	"context"
+	"fmt"
 	"path"
 	"path"
-	"path/filepath"
 
 
+	"github.com/grafana/grafana/pkg/registry"
 	"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
 	"github.com/grafana/grafana/pkg/services/provisioning/dashboards"
 	"github.com/grafana/grafana/pkg/services/provisioning/datasources"
 	"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 {
 	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))
 	mg.AddMigration("create user auth table", NewAddTableMigration(userAuthV1))
 	// add indices
 	// add indices
 	addTableIndicesMigrations(mg, "v1", userAuthV1)
 	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",
 		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" {
 		if DbCfg.SslMode == "true" || DbCfg.SslMode == "skip-verify" {
 			tlsCert, err := makeCert("custom", DbCfg)
 			tlsCert, err := makeCert("custom", DbCfg)
@@ -142,13 +142,17 @@ func getEngine() (*xorm.Engine, error) {
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 		if len(fields) > 1 && len(strings.TrimSpace(fields[1])) > 0 {
 			port = fields[1]
 			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":
 	case "sqlite3":
 		if !filepath.IsAbs(DbCfg.Path) {
 		if !filepath.IsAbs(DbCfg.Path) {
 			DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)
 			DbCfg.Path = filepath.Join(setting.DataPath, DbCfg.Path)

+ 19 - 8
pkg/setting/setting.go

@@ -52,12 +52,11 @@ var (
 	ApplicationName string
 	ApplicationName string
 
 
 	// Paths
 	// 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.
 	// Log settings.
 	LogModes   []string
 	LogModes   []string
@@ -125,6 +124,7 @@ var (
 	AuthProxyAutoSignUp     bool
 	AuthProxyAutoSignUp     bool
 	AuthProxyLdapSyncTtl    int
 	AuthProxyLdapSyncTtl    int
 	AuthProxyWhitelist      string
 	AuthProxyWhitelist      string
+	AuthProxyHeaders        map[string]string
 
 
 	// Basic Auth
 	// Basic Auth
 	BasicAuthEnabled bool
 	BasicAuthEnabled bool
@@ -187,6 +187,9 @@ var (
 type Cfg struct {
 type Cfg struct {
 	Raw *ini.File
 	Raw *ini.File
 
 
+	// Paths
+	ProvisioningPath string
+
 	// SMTP email settings
 	// SMTP email settings
 	Smtp SmtpSettings
 	Smtp SmtpSettings
 
 
@@ -516,7 +519,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	Env = iniFile.Section("").Key("app_mode").MustString("development")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	InstanceName = iniFile.Section("").Key("instance_name").MustString("unknown_instance_name")
 	PluginsPath = makeAbsolute(iniFile.Section("paths").Key("plugins").String(), HomePath)
 	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")
 	server := iniFile.Section("server")
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
 
 
@@ -611,6 +614,14 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	AuthProxyLdapSyncTtl = authProxy.Key("ldap_sync_ttl").MustInt()
 	AuthProxyLdapSyncTtl = authProxy.Key("ldap_sync_ttl").MustInt()
 	AuthProxyWhitelist = authProxy.Key("whitelist").String()
 	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
 	// basic auth
 	authBasic := iniFile.Section("auth.basic")
 	authBasic := iniFile.Section("auth.basic")
 	BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
 	BasicAuthEnabled = authBasic.Key("enabled").MustBool(true)
@@ -719,6 +730,6 @@ func (cfg *Cfg) LogConfigSources() {
 	logger.Info("Path Data", "path", DataPath)
 	logger.Info("Path Data", "path", DataPath)
 	logger.Info("Path Logs", "path", LogsPath)
 	logger.Info("Path Logs", "path", LogsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
-	logger.Info("Path Provisioning", "path", ProvisioningPath)
+	logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
 	logger.Info("App mode " + Env)
 	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 Table from './Table';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
 import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
+import { decodePathComponent } from 'app/core/utils/location_util';
 
 
 function makeTimeSeriesList(dataList, options) {
 function makeTimeSeriesList(dataList, options) {
   return dataList.map((seriesData, index) => {
   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 {
 interface IExploreState {
   datasource: any;
   datasource: any;
   datasourceError: any;
   datasourceError: any;
@@ -58,6 +72,7 @@ export class Explore extends React.Component<any, IExploreState> {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    const initialQueries = parseInitialQueries(props.routeParams.initial);
     this.state = {
     this.state = {
       datasource: null,
       datasource: null,
       datasourceError: null,
       datasourceError: null,
@@ -65,7 +80,7 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult: null,
       graphResult: null,
       latency: 0,
       latency: 0,
       loading: false,
       loading: false,
-      queries: ensureQueries(),
+      queries: ensureQueries(initialQueries),
       requestOptions: null,
       requestOptions: null,
       showingGraph: true,
       showingGraph: true,
       showingTable: true,
       showingTable: true,
@@ -77,7 +92,7 @@ export class Explore extends React.Component<any, IExploreState> {
     const datasource = await this.props.datasourceSrv.get();
     const datasource = await this.props.datasourceSrv.get();
     const testResult = await datasource.testDatasource();
     const testResult = await datasource.testDatasource();
     if (testResult.status === 'success') {
     if (testResult.status === 'success') {
-      this.setState({ datasource, datasourceError: null, datasourceLoading: false });
+      this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
     } else {
     } else {
       this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
       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) {
   constructor(props) {
     super(props);
     super(props);
     this.state = {
     this.state = {
-      query: '',
+      edited: false,
+      query: props.query || '',
     };
     };
   }
   }
 
 
   handleChangeQuery = value => {
   handleChangeQuery = value => {
     const { index, onChangeQuery } = this.props;
     const { index, onChangeQuery } = this.props;
-    this.setState({ query: value });
+    const { query } = this.state;
+    const edited = query !== value;
+    this.setState({ edited, query: value });
     if (onChangeQuery) {
     if (onChangeQuery) {
       onChangeQuery(value, index);
       onChangeQuery(value, index);
     }
     }
@@ -41,6 +44,7 @@ class QueryRow extends PureComponent<any, any> {
 
 
   render() {
   render() {
     const { request } = this.props;
     const { request } = this.props;
+    const { edited, query } = this.state;
     return (
     return (
       <div className="query-row">
       <div className="query-row">
         <div className="query-row-tools">
         <div className="query-row-tools">
@@ -52,7 +56,12 @@ class QueryRow extends PureComponent<any, any> {
           </button>
           </button>
         </div>
         </div>
         <div className="query-field-wrapper">
         <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>
       </div>
       </div>
     );
     );
@@ -63,7 +72,9 @@ export default class QueryRows extends PureComponent<any, any> {
   render() {
   render() {
     const { className = '', queries, ...handlers } = this.props;
     const { className = '', queries, ...handlers } = this.props;
     return (
     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.l = +new Date();
     ga('create', (<any>config).googleAnalyticsId, 'auto');
     ga('create', (<any>config).googleAnalyticsId, 'auto');
+    ga('set', 'anonymizeIp', true);
     return ga;
     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 coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
 import appEvents from 'app/core/app_events';
+import { encodePathComponent } from 'app/core/utils/location_util';
 
 
 import Mousetrap from 'mousetrap';
 import Mousetrap from 'mousetrap';
 import 'mousetrap-global-bind';
 import 'mousetrap-global-bind';
@@ -13,7 +14,7 @@ export class KeybindingSrv {
   timepickerOpen = false;
   timepickerOpen = false;
 
 
   /** @ngInject */
   /** @ngInject */
-  constructor(private $rootScope, private $location) {
+  constructor(private $rootScope, private $location, private datasourceSrv) {
     // clear out all shortcuts on route change
     // clear out all shortcuts on route change
     $rootScope.$on('$routeChangeSuccess', () => {
     $rootScope.$on('$routeChangeSuccess', () => {
       Mousetrap.reset();
       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
     // delete panel
     this.bind('p r', () => {
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
       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', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
       let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
       const expectedText =
       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);
       expect(text).toBe(expectedText);
     });
     });
@@ -50,15 +50,76 @@ describe('file_export', () => {
     it('should export points in proper order', () => {
     it('should export points in proper order', () => {
       let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
       let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
       const expectedText =
       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);
       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');
 describeValueFormat('d', 2456, 10, 0, '6.73 year');
 
 
 describe('date time formats', function() {
 describe('date time formats', function() {
+  const epoch = 1505634997920;
+  const utcTime = moment.utc(epoch);
+  const browserTime = moment(epoch);
+
   it('should format as iso date', function() {
   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() {
   it('should format as iso date and skip date when today', function() {
     var now = moment();
     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() {
   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() {
   it('should format as US date and skip date when today', function() {
     var now = moment();
     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() {
   it('should format as from now with days', function() {
     var daysAgo = moment().add(-7, 'd');
     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() {
   it('should format as from now with minutes', function() {
     var daysAgo = moment().add(-2, 'm');
     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);
         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() {
   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.alias = opts.alias;
     this.aliasEscaped = _.escape(opts.alias);
     this.aliasEscaped = _.escape(opts.alias);
     this.color = opts.color;
     this.color = opts.color;
+    this.bars = { fillColor: opts.color };
     this.valueFormater = kbn.valueFormats.none;
     this.valueFormater = kbn.valueFormats.none;
     this.stats = {};
     this.stats = {};
     this.legend = true;
     this.legend = true;
@@ -112,11 +113,11 @@ export default class TimeSeries {
       dashLength: [],
       dashLength: [],
     };
     };
     this.points = {};
     this.points = {};
-    this.bars = {};
     this.yaxis = 1;
     this.yaxis = 1;
     this.zindex = 0;
     this.zindex = 0;
     this.nullPointMode = null;
     this.nullPointMode = null;
     delete this.stack;
     delete this.stack;
+    delete this.bars.show;
 
 
     for (var i = 0; i < overrides.length; i++) {
     for (var i = 0; i < overrides.length; i++) {
       var override = overrides[i];
       var override = overrides[i];
@@ -168,7 +169,7 @@ export default class TimeSeries {
         this.fillBelowTo = override.fillBelowTo;
         this.fillBelowTo = override.fillBelowTo;
       }
       }
       if (override.color !== void 0) {
       if (override.color !== void 0) {
-        this.color = override.color;
+        this.setColor(override.color);
       }
       }
       if (override.transform !== void 0) {
       if (override.transform !== void 0) {
         this.transform = override.transform;
         this.transform = override.transform;
@@ -346,4 +347,9 @@ export default class TimeSeries {
 
 
     return false;
     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 moment from 'moment';
 import { saveAs } from 'file-saver';
 import { saveAs } from 'file-saver';
+import { isNullOrUndefined } from 'util';
 
 
 const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
 const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
 const POINT_TIME_INDEX = 1;
 const POINT_TIME_INDEX = 1;
 const POINT_VALUE_INDEX = 0;
 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) {
 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;
   return text;
 }
 }
 
 
 export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
 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) {
 export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
-  let text = (excel ? 'sep=;\n' : '') + 'Time;';
   // add header
   // 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
   // process data
   seriesList = mergeSeriesByTime(seriesList);
   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
   // 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;
   return text;
@@ -71,15 +120,15 @@ function mergeSeriesByTime(seriesList) {
       timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
       timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
     }
     }
   }
   }
-  timestamps = _.sortedUniq(timestamps.sort());
+  timestamps = sortedUniq(timestamps.sort());
 
 
   for (let i = 0; i < seriesList.length; i++) {
   for (let i = 0; i < seriesList.length; i++) {
     let seriesPoints = seriesList[i].datapoints;
     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 extendedSeries = [];
     let pointIndex;
     let pointIndex;
     for (let j = 0; j < timestamps.length; j++) {
     for (let j = 0; j < timestamps.length; j++) {
-      pointIndex = _.sortedIndexOf(seriesTimestamps, timestamps[j]);
+      pointIndex = sortedIndexOf(seriesTimestamps, timestamps[j]);
       if (pointIndex !== -1) {
       if (pointIndex !== -1) {
         extendedSeries.push(seriesPoints[pointIndex]);
         extendedSeries.push(seriesPoints[pointIndex]);
       } else {
       } else {
@@ -93,27 +142,26 @@ function mergeSeriesByTime(seriesList) {
 
 
 export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
 export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
   let text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel);
   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
   // 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) {
 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);
   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);
   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')) {
   if (moment().isSame(epoch, 'day')) {
     return time.format('HH:mm:ss');
     return time.format('HH:mm:ss');
@@ -825,8 +825,8 @@ kbn.valueFormats.dateTimeAsIso = function(epoch) {
   return time.format('YYYY-MM-DD HH:mm:ss');
   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')) {
   if (moment().isSame(epoch, 'day')) {
     return time.format('h:mm:ss a');
     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');
   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 /////
 ///// FORMAT MENU /////

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

@@ -1,6 +1,11 @@
 import config from 'app/core/config';
 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 appSubUrl = config.appSubUrl;
   const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
   const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
   const urlWithoutBase =
   const urlWithoutBase =
@@ -9,6 +14,4 @@ const _stripBaseFromUrl = url => {
   return urlWithoutBase;
   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));
       .catch(this.handleSaveDashboardError.bind(this, clone, options));
   }
   }
 
 
-  saveDashboard(options, clone) {
+  saveDashboard(options?, clone?) {
     if (clone) {
     if (clone) {
       this.setCurrent(this.create(clone, this.dash.meta));
       this.setCurrent(this.create(clone, this.dash.meta));
     }
     }
@@ -124,6 +124,10 @@ export class DashboardSrv {
     return this.save(this.dash.getSaveModelClone(), options);
     return this.save(this.dash.getSaveModelClone(), options);
   }
   }
 
 
+  saveJSONDashboard(json: string) {
+    return this.save(JSON.parse(json), {});
+  }
+
   showDashboardProvisionedModal() {
   showDashboardProvisionedModal() {
     this.$rootScope.appEvent('show-modal', {
     this.$rootScope.appEvent('show-modal', {
       templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-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>
 	<gf-dashboard-history dashboard="dashboard"></gf-dashboard-history>
 </div>
 </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">
 	<div class="gf-form">
 		<code-editor content="ctrl.json" data-mode="json" data-max-lines=30 ></code-editor>
 		<code-editor content="ctrl.json" data-mode="json" data-max-lines=30 ></code-editor>
 	</div>
 	</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>
 
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
 <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;
   hasUnsavedFolderChange: boolean;
 
 
   /** @ngInject */
   /** @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
     // temp hack for annotations and variables editors
     // that rely on inherited scope
     // that rely on inherited scope
     $scope.dashboard = this.dashboard;
     $scope.dashboard = this.dashboard;
@@ -93,8 +100,8 @@ export class SettingsCtrl {
     }
     }
 
 
     this.sections.push({
     this.sections.push({
-      title: 'View JSON',
-      id: 'view_json',
+      title: 'JSON Model',
+      id: 'dashboard_json',
       icon: 'gicon gicon-json',
       icon: 'gicon gicon-json',
     });
     });
 
 
@@ -137,6 +144,12 @@ export class SettingsCtrl {
     this.dashboardSrv.saveDashboard();
     this.dashboardSrv.saveDashboard();
   }
   }
 
 
+  saveDashboardJson() {
+    this.dashboardSrv.saveJSONDashboard(this.json).then(() => {
+      this.$route.reload();
+    });
+  }
+
   onPostSave() {
   onPostSave() {
     this.hasUnsavedFolderChange = false;
     this.hasUnsavedFolderChange = false;
   }
   }

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

@@ -15,7 +15,7 @@ function dashLinksContainer() {
 }
 }
 
 
 /** @ngInject */
 /** @ngInject */
-function dashLink($compile, linkSrv) {
+function dashLink($compile, $sanitize, linkSrv) {
   return {
   return {
     restrict: 'E',
     restrict: 'E',
     link: function(scope, elem) {
     link: function(scope, elem) {
@@ -49,10 +49,21 @@ function dashLink($compile, linkSrv) {
         var linkInfo = linkSrv.getAnchorInfo(link);
         var linkInfo = linkSrv.getAnchorInfo(link);
         span.text(linkInfo.title);
         span.text(linkInfo.title);
         anchor.attr('href', linkInfo.href);
         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);
       icon.attr('class', 'fa fa-fw ' + scope.link.icon);
       anchor.attr('target', scope.link.target);
       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 rangeUtil from 'app/core/utils/rangeutil';
 import * as dateMath from 'app/core/utils/datemath';
 import * as dateMath from 'app/core/utils/datemath';
+import { encodePathComponent } from 'app/core/utils/location_util';
 
 
 import { metricsTabDirective } from './metrics_tab';
 import { metricsTabDirective } from './metrics_tab';
 
 
@@ -309,6 +310,24 @@ class MetricsPanelCtrl extends PanelCtrl {
     this.refresh();
     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) {
   addQuery(target) {
     target.refId = this.dashboard.getNextQueryLetter(this.panel);
     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;
   editorTabs: any;
   $scope: any;
   $scope: any;
   $injector: any;
   $injector: any;
+  $location: any;
   $timeout: any;
   $timeout: any;
   fullscreen: boolean;
   fullscreen: boolean;
   inspector: any;
   inspector: any;
@@ -35,6 +36,7 @@ export class PanelCtrl {
 
 
   constructor($scope, $injector) {
   constructor($scope, $injector) {
     this.$injector = $injector;
     this.$injector = $injector;
+    this.$location = $injector.get('$location');
     this.$scope = $scope;
     this.$scope = $scope;
     this.$timeout = $injector.get('$timeout');
     this.$timeout = $injector.get('$timeout');
     this.editorTabIndex = 0;
     this.editorTabIndex = 0;
@@ -161,6 +163,9 @@ export class PanelCtrl {
       shortcut: 'p s',
       shortcut: 'p s',
     });
     });
 
 
+    // Additional items from sub-class
+    menu.push(...this.getAdditionalMenuItems());
+
     let extendedMenu = this.getExtendedMenu();
     let extendedMenu = this.getExtendedMenu();
     menu.push({
     menu.push({
       text: 'More ...',
       text: 'More ...',
@@ -209,6 +214,11 @@ export class PanelCtrl {
     return menu;
     return menu;
   }
   }
 
 
+  // Override in sub-class to add items before extended menu
+  getAdditionalMenuItems() {
+    return [];
+  }
+
   otherPanelInFullscreenMode() {
   otherPanelInFullscreenMode() {
     return this.dashboard.meta.fullscreen && !this.fullscreen;
     return this.dashboard.meta.fullscreen && !this.fullscreen;
   }
   }
@@ -314,6 +324,7 @@ export class PanelCtrl {
     }
     }
 
 
     var linkSrv = this.$injector.get('linkSrv');
     var linkSrv = this.$injector.get('linkSrv');
+    var sanitize = this.$injector.get('$sanitize');
     var templateSrv = this.$injector.get('templateSrv');
     var templateSrv = this.$injector.get('templateSrv');
     var interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
     var interpolatedMarkdown = templateSrv.replace(markdown, this.panel.scopedVars);
     var html = '<div class="markdown-html">';
     var html = '<div class="markdown-html">';
@@ -336,7 +347,8 @@ export class PanelCtrl {
       html += '</ul>';
       html += '</ul>';
     }
     }
 
 
-    return html + '</div>';
+    html += '</div>';
+    return sanitize(html);
   }
   }
 
 
   openInspector() {
   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}', {});
       var target = _templateSrv.replace('this=${test:pipe}', {});
       expect(target).toBe('this=value1|value2');
       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() {
   describe('variable with all option', function() {
@@ -164,6 +169,11 @@ describe('templateSrv', function() {
       var target = _templateSrv.replace('this.${test:glob}.filters', {});
       var target = _templateSrv.replace('this.${test:glob}.filters', {});
       expect(target).toBe('this.{value1,value2}.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() {
   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') {
     if (typeof value === 'string') {
       return luceneEscape(value);
       return luceneEscape(value);
     }
     }
+    if (value instanceof Array && value.length === 0) {
+      return '__empty__';
+    }
     var quotedValues = _.map(value, function(val) {
     var quotedValues = _.map(value, function(val) {
       return '"' + luceneEscape(val) + '"';
       return '"' + luceneEscape(val) + '"';
     });
     });
@@ -179,16 +182,16 @@ export class TemplateSrv {
       return target;
       return target;
     }
     }
 
 
-    var variable, systemValue, value;
+    var variable, systemValue, value, fmt;
     this.regex.lastIndex = 0;
     this.regex.lastIndex = 0;
 
 
     return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
     return target.replace(this.regex, (match, var1, var2, fmt2, var3, fmt3) => {
       variable = this.index[var1 || var2 || var3];
       variable = this.index[var1 || var2 || var3];
-      format = fmt2 || fmt3 || format;
+      fmt = fmt2 || fmt3 || format;
       if (scopedVars) {
       if (scopedVars) {
         value = scopedVars[var1 || var2 || var3];
         value = scopedVars[var1 || var2 || var3];
         if (value) {
         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];
       systemValue = this.grafanaVariables[variable.current.value];
       if (systemValue) {
       if (systemValue) {
-        return this.formatValue(systemValue, format, variable);
+        return this.formatValue(systemValue, fmt, variable);
       }
       }
 
 
       value = variable.current.value;
       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;
       return res;
     });
     });
   }
   }

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

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

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

@@ -11,14 +11,23 @@ export default class ResponseParser {
       return [];
       return [];
     }
     }
 
 
-    var influxdb11format = query.toLowerCase().indexOf('show tag values') >= 0;
-
     var res = {};
     var res = {};
     _.each(influxResults.series, serie => {
     _.each(influxResults.series, serie => {
       _.each(serie.values, value => {
       _.each(serie.values, value => {
         if (_.isArray(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 {
           } else {
             addUnique(res, value[0]);
             addUnique(res, value[0]);
           }
           }
@@ -29,7 +38,7 @@ export default class ResponseParser {
     });
     });
 
 
     return _.map(res, value => {
     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', () => {
   describe('SHOW FIELD response', () => {
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     var query = 'SHOW FIELD KEYS FROM "cpu"';
     describe('response from 0.10.0', () => {
     describe('response from 0.10.0', () => {

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

@@ -19,6 +19,7 @@ export class PrometheusDatasource {
   type: string;
   type: string;
   editorSrc: string;
   editorSrc: string;
   name: string;
   name: string;
+  supportsExplore: boolean;
   supportMetrics: boolean;
   supportMetrics: boolean;
   url: string;
   url: string;
   directUrl: string;
   directUrl: string;
@@ -34,6 +35,7 @@ export class PrometheusDatasource {
     this.type = 'prometheus';
     this.type = 'prometheus';
     this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
     this.editorSrc = 'app/features/prometheus/partials/query.editor.html';
     this.name = instanceSettings.name;
     this.name = instanceSettings.name;
+    this.supportsExplore = true;
     this.supportMetrics = true;
     this.supportMetrics = true;
     this.url = instanceSettings.url;
     this.url = instanceSettings.url;
     this.directUrl = instanceSettings.directUrl;
     this.directUrl = instanceSettings.directUrl;
@@ -153,6 +155,7 @@ export class PrometheusDatasource {
           end: end,
           end: end,
           responseListLength: responseList.length,
           responseListLength: responseList.length,
           responseIndex: index,
           responseIndex: index,
+          refId: activeTargets[index].refId,
         };
         };
 
 
         this.resultTransformer.transform(result, response, transformerOptions);
         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) {
   getPrometheusTime(date, roundUp) {
     if (_.isString(date)) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);
       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;
     let prometheusResult = response.data.data.result;
 
 
     if (options.format === 'table') {
     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') {
     } else if (options.format === 'heatmap') {
       let seriesList = [];
       let seriesList = [];
       prometheusResult.sort(sortSeriesByLabel);
       prometheusResult.sort(sortSeriesByLabel);
@@ -58,7 +58,7 @@ export class ResultTransformer {
     return { target: metricLabel, datapoints: dps };
     return { target: metricLabel, datapoints: dps };
   }
   }
 
 
-  transformMetricDataToTable(md, resultCount: number, resultIndex: number) {
+  transformMetricDataToTable(md, resultCount: number, refId: string) {
     var table = new TableModel();
     var table = new TableModel();
     var i, j;
     var i, j;
     var metricLabels = {};
     var metricLabels = {};
@@ -83,7 +83,7 @@ export class ResultTransformer {
       metricLabels[label] = labelIndex + 1;
       metricLabels[label] = labelIndex + 1;
       table.columns.push({ text: label });
       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 });
     table.columns.push({ text: valueText });
 
 
     // Populate rows, set value to empty string when label not present.
     // 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' },
         { 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', () => {
   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) {
   changeSeriesColor(series, color) {
-    series.color = color;
+    series.setColor(color);
     this.panel.aliasColors[series.alias] = series.color;
     this.panel.aliasColors[series.alias] = series.color;
     this.render();
     this.render();
   }
   }

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

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

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

@@ -77,7 +77,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
   };
   };
 
 
   /** @ngInject */
   /** @ngInject */
-  constructor($scope, $injector, private $location, private linkSrv) {
+  constructor($scope, $injector, private linkSrv) {
     super($scope, $injector);
     super($scope, $injector);
     _.defaults(this.panel, this.panelDefaults);
     _.defaults(this.panel, this.panelDefaults);
 
 
@@ -308,7 +308,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
         let formatFunc = kbn.valueFormats[this.panel.format];
         let formatFunc = kbn.valueFormats[this.panel.format];
         data.value = lastPoint[1];
         data.value = lastPoint[1];
         data.valueRounded = data.value;
         data.valueRounded = data.value;
-        data.valueFormatted = formatFunc(data.value, 0, 0);
+        data.valueFormatted = formatFunc(data.value, this.dashboard.isTimezoneUtc());
       } else {
       } else {
         data.value = this.series[0].stats[this.panel.valueName];
         data.value = this.series[0].stats[this.panel.valueName];
         data.flotpairs = this.series[0].flotpairs;
         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) {
   singleStatScenario('showing last us time instead of value', function(ctx) {
     ctx.setup(function() {
     ctx.setup(function() {
       ctx.data = [{ target: 'test.cpu1', datapoints: [[10, 12], [20, 1505634997920]] }];
       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) {
   singleStatScenario('showing last time from now instead of value', function(ctx) {
     beforeEach(() => {
     beforeEach(() => {
       clock = sinon.useFakeTimers(epoch);
       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(
   singleStatScenario('MainValue should use same number for decimals as displayed when checking thresholds', function(
     ctx
     ctx
   ) {
   ) {

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

@@ -154,6 +154,11 @@ class TablePanelCtrl extends MetricsPanelCtrl {
     this.render();
     this.render();
   }
   }
 
 
+  moveQuery(target, direction) {
+    super.moveQuery(target, direction);
+    super.refresh();
+  }
+
   exportCsv() {
   exportCsv() {
     var scope = this.$scope.$new(true);
     var scope = this.$scope.$new(true);
     scope.tableData = this.renderer.render_values();
     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);
       var scopedVars = this.renderRowVariables(rowIndex);
       scopedVars['__cell'] = { value: value };
       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 cellLinkTooltip = this.templateSrv.replace(column.style.linkTooltip, scopedVars);
       var cellTarget = column.style.linkTargetBlank ? '_blank' : '';
       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 = {
       const props = {
         backendSrv: backendSrv,
         backendSrv: backendSrv,
         datasourceSrv: datasourceSrv,
         datasourceSrv: datasourceSrv,
+        routeParams: $route.current.params,
       };
       };
 
 
       ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
       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',
       controller: 'FolderDashboardsCtrl',
       controllerAs: 'ctrl',
       controllerAs: 'ctrl',
     })
     })
-    .when('/explore', {
+    .when('/explore/:initial?', {
       template: '<react-container />',
       template: '<react-container />',
       resolve: {
       resolve: {
         component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
         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"?>
   <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        ">
 <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#">
    <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
@@ -58,7 +58,6 @@
       }
       }
     </style>
     </style>
   </defs>
   </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_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_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"/>
   <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_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_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" 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>
 </svg>

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

@@ -53,6 +53,13 @@
   margin-bottom: $spacer*2;
   margin-bottom: $spacer*2;
 }
 }
 
 
+.dashboard-settings__subheader {
+  color: $text-muted;
+  font-style: italic;
+  position: relative;
+  top: -1.5rem;
+}
+
 .dashboard-settings__nav-item {
 .dashboard-settings__nav-item {
   padding: 7px 12px;
   padding: 7px 12px;
   color: $text-color;
   color: $text-color;
@@ -85,3 +92,7 @@
     margin-bottom: 10px;
     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 {
 .gf-form-input--form-dropdown-right {
   .Select-menu-outer {
   .Select-menu-outer {
     right: 0;
     right: 0;
+    left: unset;
   }
   }
 }
 }

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

@@ -71,21 +71,29 @@
   td {
   td {
     padding: 1px;
     padding: 1px;
   }
   }
-  button.btn-sm {
+  button {
     @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl);
     @include buttonBackground($btn-inverse-bg, $btn-inverse-bg-hl);
-    font-size: $font-size-sm;
     background-image: none;
     background-image: none;
     border: none;
     border: none;
-    padding: 5px 11px;
     color: $text-color;
     color: $text-color;
     &.active span {
     &.active span {
-      color: $blue;
+      color: $query-blue;
       font-weight: bold;
       font-weight: bold;
     }
     }
     .text-info {
     .text-info {
       color: $orange;
       color: $orange;
       font-weight: bold;
       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 {
 .fa-chevron-left::before {
-  content: "\f053";
+  content: '\f053';
 }
 }
 .fa-chevron-right::before {
 .fa-chevron-right::before {
-  content: "\f054";
+  content: '\f054';
 }
 }
 
 
 .glyphicon-chevron-right {
 .glyphicon-chevron-right {

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

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

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

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

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

@@ -12,7 +12,8 @@ module.exports = {
   output: {
   output: {
     path: path.resolve(__dirname, '../../public/build'),
     path: path.resolve(__dirname, '../../public/build'),
     filename: '[name].[hash].js',
     filename: '[name].[hash].js',
-    publicPath: "/public/build/",
+    // Keep publicPath relative for host.com/grafana/ deployments
+    publicPath: "public/build/",
   },
   },
   resolve: {
   resolve: {
     extensions: ['.ts', '.tsx', '.es6', '.js', '.json'],
     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')({
       require('./sass.rule.js')({
-        sourceMap: true, minimize: false, preserveUrl: true
+        sourceMap: true, minimize: false, preserveUrl: HOT
       }, extractSass),
       }, extractSass),
       {
       {
         test: /\.(ttf|eot|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
         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');
           var rootScope = body.injector().get('$rootScope');
           if (!rootScope) {return false;}
           if (!rootScope) {return false;}
-          var panels = angular.element('div.panel:visible').length;
+          var panels = angular.element('plugin-component').length;
           return rootScope.panelsRendered >= panels;
           return rootScope.panelsRendered >= panels;
         });
         });