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

Merge branch 'master' into 14773-light-theme-page-bg

ijin08 пре 7 година
родитељ
комит
7133b79928
100 измењених фајлова са 1407 додато и 346 уклоњено
  1. 5 5
      .circleci/config.yml
  2. 9 1
      CHANGELOG.md
  3. 1 1
      Dockerfile
  4. 1 1
      README.md
  5. 1 1
      appveyor.yml
  6. 4 0
      conf/defaults.ini
  7. 7 0
      conf/sample.ini
  8. 7 0
      devenv/docker/blocks/alert_webhook_listener/Dockerfile
  9. 5 0
      devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml
  10. 24 0
      devenv/docker/blocks/alert_webhook_listener/main.go
  11. 2 1
      docs/sources/auth/auth-proxy.md
  12. 12 1
      docs/sources/auth/generic-oauth.md
  13. 3 3
      docs/sources/http_api/admin.md
  14. 6 0
      docs/sources/installation/configuration.md
  15. 8 11
      docs/sources/installation/debian.md
  16. 15 7
      docs/sources/installation/rpm.md
  17. 3 1
      jest.config.js
  18. 41 16
      package.json
  19. 4 0
      packages/grafana-build/README.md
  20. 13 0
      packages/grafana-build/package.json
  21. 3 0
      packages/grafana-ui/README.md
  22. 33 0
      packages/grafana-ui/package.json
  23. 5 4
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.test.tsx
  24. 10 10
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  25. 0 0
      packages/grafana-ui/src/components/DeleteButton/_DeleteButton.scss
  26. 1 0
      packages/grafana-ui/src/components/index.scss
  27. 1 0
      packages/grafana-ui/src/components/index.ts
  28. 23 0
      packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx
  29. 1 0
      packages/grafana-ui/src/forms/index.ts
  30. 1 0
      packages/grafana-ui/src/index.scss
  31. 5 0
      packages/grafana-ui/src/index.ts
  32. 3 0
      packages/grafana-ui/src/types/index.ts
  33. 17 0
      packages/grafana-ui/src/types/jquery.d.ts
  34. 31 0
      packages/grafana-ui/src/types/panel.ts
  35. 53 0
      packages/grafana-ui/src/types/series.ts
  36. 17 0
      packages/grafana-ui/src/types/time.ts
  37. 1 0
      packages/grafana-ui/src/utils/index.ts
  38. 174 0
      packages/grafana-ui/src/utils/processTimeSeries.ts
  39. 9 6
      packages/grafana-ui/src/visualizations/Graph/Graph.tsx
  40. 1 0
      packages/grafana-ui/src/visualizations/index.ts
  41. 18 0
      packages/grafana-ui/tsconfig.json
  42. 3 0
      packages/grafana-ui/tslint.json
  43. 112 8
      pkg/api/dashboard_snapshot.go
  44. 87 0
      pkg/api/dashboard_snapshot_test.go
  45. 8 0
      pkg/api/plugins.go
  46. 20 29
      pkg/components/dashdiffs/formatter_json.go
  47. 2 0
      pkg/login/ldap.go
  48. 21 7
      pkg/middleware/auth_proxy.go
  49. 90 0
      pkg/middleware/middleware_test.go
  50. 13 9
      pkg/models/dashboard_snapshot.go
  51. 0 12
      pkg/plugins/datasource_plugin.go
  52. 4 2
      pkg/services/alerting/notifier.go
  53. 1 1
      pkg/services/alerting/test_notification.go
  54. 3 1
      pkg/services/dashboards/dashboard_service.go
  55. 5 1
      pkg/services/notifications/webhook.go
  56. 27 12
      pkg/services/session/mysql.go
  57. 12 10
      pkg/services/sqlstore/dashboard_snapshot.go
  58. 2 2
      pkg/services/sqlstore/datasource.go
  59. 4 4
      pkg/services/sqlstore/login_attempt.go
  60. 4 0
      pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go
  61. 38 3
      pkg/services/sqlstore/migrator/conditions.go
  62. 7 1
      pkg/services/sqlstore/migrator/dialect.go
  63. 9 13
      pkg/services/sqlstore/migrator/migrations.go
  64. 17 7
      pkg/services/sqlstore/migrator/migrator.go
  65. 12 6
      pkg/services/sqlstore/migrator/mysql_dialect.go
  66. 3 3
      pkg/services/sqlstore/migrator/postgres_dialect.go
  67. 4 3
      pkg/services/sqlstore/migrator/sqlite_dialect.go
  68. 19 10
      pkg/services/sqlstore/sqlstore.go
  69. 6 1
      pkg/services/sqlstore/user.go
  70. 22 1
      pkg/services/sqlstore/user_test.go
  71. 15 14
      pkg/setting/setting_oauth.go
  72. 22 16
      pkg/social/social.go
  73. 1 2
      pkg/tsdb/cloudwatch/credentials.go
  74. 2 0
      pkg/tsdb/influxdb/model_parser.go
  75. 2 0
      pkg/tsdb/influxdb/model_parser_test.go
  76. 1 0
      pkg/tsdb/influxdb/models.go
  77. 10 0
      pkg/tsdb/influxdb/query.go
  78. 14 0
      pkg/tsdb/influxdb/query_test.go
  79. 3 3
      pkg/tsdb/postgres/macros.go
  80. 20 3
      pkg/tsdb/postgres/macros_test.go
  81. 4 2
      public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
  82. 4 4
      public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  83. 8 6
      public/app/core/components/EmptyListCTA/EmptyListCTA.tsx
  84. 44 0
      public/app/core/components/ErrorBoundary/ErrorBoundary.tsx
  85. 0 43
      public/app/core/components/Form/Element.tsx
  86. 0 19
      public/app/core/components/Form/Label.tsx
  87. 0 2
      public/app/core/components/Form/index.ts
  88. 83 0
      public/app/core/components/PluginHelp/PluginHelp.tsx
  89. 5 1
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  90. 10 2
      public/app/core/components/Tooltip/Popper.tsx
  91. 3 2
      public/app/core/components/Tooltip/withPopper.tsx
  92. 2 2
      public/app/core/components/code_editor/code_editor.ts
  93. 1 1
      public/app/core/components/json_explorer/helpers.ts
  94. 1 1
      public/app/core/components/json_explorer/json_explorer.ts
  95. 1 1
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  96. 1 1
      public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
  97. 2 2
      public/app/core/directives/tags.ts
  98. 1 1
      public/app/core/live/live_srv.ts
  99. 12 1
      public/app/core/logs_model.ts
  100. 2 2
      public/app/core/services/backend_srv.ts

+ 5 - 5
.circleci/config.yml

@@ -19,7 +19,7 @@ version: 2
 jobs:
 jobs:
   mysql-integration-test:
   mysql-integration-test:
     docker:
     docker:
-      - image: circleci/golang:1.11
+      - image: circleci/golang:1.11.4
       - image: circleci/mysql:5.6-ram
       - image: circleci/mysql:5.6-ram
         environment:
         environment:
           MYSQL_ROOT_PASSWORD: rootpass
           MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
 
 
   postgres-integration-test:
   postgres-integration-test:
     docker:
     docker:
-      - image: circleci/golang:1.11
+      - image: circleci/golang:1.11.4
       - image: circleci/postgres:9.3-ram
       - image: circleci/postgres:9.3-ram
         environment:
         environment:
           POSTGRES_USER: grafanatest
           POSTGRES_USER: grafanatest
@@ -74,7 +74,7 @@ jobs:
 
 
   gometalinter:
   gometalinter:
     docker:
     docker:
-      - image: circleci/golang:1.11
+      - image: circleci/golang:1.11.4
         environment:
         environment:
           # we need CGO because of go-sqlite3
           # we need CGO because of go-sqlite3
           CGO_ENABLED: 1
           CGO_ENABLED: 1
@@ -117,7 +117,7 @@ jobs:
 
 
   test-backend:
   test-backend:
     docker:
     docker:
-      - image: circleci/golang:1.11
+      - image: circleci/golang:1.11.4
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout
@@ -175,7 +175,7 @@ jobs:
 
 
   build:
   build:
     docker:
     docker:
-     - image: grafana/build-container:1.2.1
+     - image: grafana/build-container:1.2.2
     working_directory: /go/src/github.com/grafana/grafana
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     steps:
       - checkout
       - checkout

+ 9 - 1
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 
 ### New Features
 ### New Features
 * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
 * **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster)
+* **Snapshots**: Enable deletion of public snapshot [#14109](https://github.com/grafana/grafana/issues/14109)
 
 
 ### Minor
 ### Minor
 
 
@@ -13,12 +14,19 @@
 * **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
 * **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
 * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
 * **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
 * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
 * **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
+* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
+* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
+* **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
+* **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+
+### Bug fixes
+* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
 
 
 # 5.4.2 (2018-12-13)
 # 5.4.2 (2018-12-13)
 
 
 * **Datasource admin**: Fix for issue creating new data source when same name exists [#14467](https://github.com/grafana/grafana/issues/14467)
 * **Datasource admin**: Fix for issue creating new data source when same name exists [#14467](https://github.com/grafana/grafana/issues/14467)
 * **OAuth**: Fix for oauth auto login setting, can now be set using env variable [#14435](https://github.com/grafana/grafana/issues/14435)
 * **OAuth**: Fix for oauth auto login setting, can now be set using env variable [#14435](https://github.com/grafana/grafana/issues/14435)
-* **Dashboard search**: Fix for searching tags in tags filter dropdown. 
+* **Dashboard search**: Fix for searching tags in tags filter dropdown.
 
 
 # 5.4.1 (2018-12-10)
 # 5.4.1 (2018-12-10)
 
 

+ 1 - 1
Dockerfile

@@ -1,5 +1,5 @@
 # Golang build container
 # Golang build container
-FROM golang:1.11
+FROM golang:1.11.4
 
 
 WORKDIR $GOPATH/src/github.com/grafana/grafana
 WORKDIR $GOPATH/src/github.com/grafana/grafana
 
 

+ 1 - 1
README.md

@@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
 
 
 The resulting image will be tagged as `grafana/grafana:dev`
 The resulting image will be tagged as `grafana/grafana:dev`
 
 
-Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Perferences -> Advanced), otherwize you may faild at `grunt build`
+Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build`
 
 
 ### Dev config
 ### Dev config
 
 

+ 1 - 1
appveyor.yml

@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
 environment:
 environment:
   nodejs_version: "8"
   nodejs_version: "8"
   GOPATH: C:\gopath
   GOPATH: C:\gopath
-  GOVERSION: 1.11
+  GOVERSION: 1.11.4
 
 
 install:
 install:
   - rmdir c:\go /s /q
   - rmdir c:\go /s /q

+ 4 - 0
conf/defaults.ini

@@ -103,6 +103,9 @@ server_cert_name =
 # For "sqlite3" only, path relative to data_path setting
 # For "sqlite3" only, path relative to data_path setting
 path = grafana.db
 path = grafana.db
 
 
+# For "sqlite3" only. cache mode setting used for connecting to the database
+cache_mode = private
+
 #################################### Session #############################
 #################################### Session #############################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -335,6 +338,7 @@ tls_skip_verify_insecure = false
 tls_client_cert =
 tls_client_cert =
 tls_client_key =
 tls_client_key =
 tls_client_ca =
 tls_client_ca =
+send_client_credentials_via_post = false
 
 
 #################################### Basic Auth ##########################
 #################################### Basic Auth ##########################
 [auth.basic]
 [auth.basic]

+ 7 - 0
conf/sample.ini

@@ -99,6 +99,9 @@
 # Set to true to log the sql calls and execution times.
 # Set to true to log the sql calls and execution times.
 log_queries =
 log_queries =
 
 
+# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
+;cache_mode = private
+
 #################################### Session ####################################
 #################################### Session ####################################
 [session]
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -284,6 +287,10 @@ log_queries =
 ;tls_client_key =
 ;tls_client_key =
 ;tls_client_ca =
 ;tls_client_ca =
 
 
+; Set to true to enable sending client_id and client_secret via POST body instead of Basic authentication HTTP header
+; This might be required if the OAuth provider is not RFC6749 compliant, only supporting credentials passed via POST payload
+;send_client_credentials_via_post = false
+
 #################################### Grafana.com Auth ####################
 #################################### Grafana.com Auth ####################
 [auth.grafana_com]
 [auth.grafana_com]
 ;enabled = false
 ;enabled = false

+ 7 - 0
devenv/docker/blocks/alert_webhook_listener/Dockerfile

@@ -0,0 +1,7 @@
+
+FROM golang:latest 
+ADD main.go /
+WORKDIR /
+RUN go build -o main . 
+EXPOSE 3010
+ENTRYPOINT ["/main"]

+ 5 - 0
devenv/docker/blocks/alert_webhook_listener/docker-compose.yaml

@@ -0,0 +1,5 @@
+  alert_webhook_listener:
+    build: docker/blocks/alert_webhook_listener
+    network_mode: host
+    ports:
+      - "3010:3010"

+ 24 - 0
devenv/docker/blocks/alert_webhook_listener/main.go

@@ -0,0 +1,24 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+)
+
+func hello(w http.ResponseWriter, r *http.Request) {
+	body, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		return
+	}
+
+	line := fmt.Sprintf("webbhook: -> %s", string(body))
+	fmt.Println(line)
+	io.WriteString(w, line)
+}
+
+func main() {
+	http.HandleFunc("/", hello)
+	http.ListenAndServe(":3010", nil)
+}

+ 2 - 1
docs/sources/auth/auth-proxy.md

@@ -31,9 +31,10 @@ auto_sign_up = true
 ldap_sync_ttl = 60
 ldap_sync_ttl = 60
 # Limit where auth proxy requests come from by configuring a list of IP addresses.
 # 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.
 # This can be used to prevent users spoofing the X-WEBAUTH-USER header.
+# Example `whitelist = 192.168.1.1, 192.168.1.0/24, 2001::23, 2001::0/120`
 whitelist =
 whitelist =
 # Optionally define more headers to sync other user attributes
 # Optionally define more headers to sync other user attributes
-# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL``
+# Example `headers = Name:X-WEBAUTH-NAME Email:X-WEBAUTH-EMAIL`
 headers =
 headers =
 ```
 ```
 
 

+ 12 - 1
docs/sources/auth/generic-oauth.md

@@ -17,7 +17,7 @@ can find examples using Okta, BitBucket, OneLogin and Azure.
 
 
 This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
 This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
 
 
-You may have to set the `root_url` option of `[server]` for the callback URL to be 
+You may have to set the `root_url` option of `[server]` for the callback URL to be
 correct. For example in case you are serving Grafana behind a proxy.
 correct. For example in case you are serving Grafana behind a proxy.
 
 
 Example config:
 Example config:
@@ -209,6 +209,17 @@ allowed_organizations =
     token_url = https://<your domain>.my.centrify.com/OAuth2/Token/<Application ID>
     token_url = https://<your domain>.my.centrify.com/OAuth2/Token/<Application ID>
     ```
     ```
 
 
+## Set up OAuth2 with non-compliant providers
+
+Some OAuth2 providers might not support `client_id` and `client_secret` passed via Basic Authentication HTTP header, which
+results in `invalid_client` error. To allow Grafana to authenticate via these type of providers, the client identifiers must be
+send via POST body, which can be enabled via the following settings:
+
+    ```bash
+    [auth.generic_oauth]
+    send_client_credentials_via_post = true
+    ```
+
 <hr>
 <hr>
 
 
 
 

+ 3 - 3
docs/sources/http_api/admin.md

@@ -285,7 +285,7 @@ Content-Type: application/json
 HTTP/1.1 200
 HTTP/1.1 200
 Content-Type: application/json
 Content-Type: application/json
 
 
-{message: "User permissions updated"}
+{"message": "User permissions updated"}
 ```
 ```
 
 
 ## Delete global User
 ## Delete global User
@@ -308,7 +308,7 @@ Content-Type: application/json
 HTTP/1.1 200
 HTTP/1.1 200
 Content-Type: application/json
 Content-Type: application/json
 
 
-{message: "User deleted"}
+{"message": "User deleted"}
 ```
 ```
 
 
 ## Pause all alerts
 ## Pause all alerts
@@ -339,5 +339,5 @@ JSON Body schema:
 HTTP/1.1 200
 HTTP/1.1 200
 Content-Type: application/json
 Content-Type: application/json
 
 
-{state: "new state", message: "alerts pause/un paused", "alertsAffected": 100}
+{"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100}
 ```
 ```

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

@@ -250,6 +250,12 @@ Sets the maximum amount of time a connection may be reused. The default is 14400
 
 
 Set to `true` to log the sql calls and execution times.
 Set to `true` to log the sql calls and execution times.
 
 
+### cache_mode
+
+For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
+Defaults to private.
+
+
 <hr />
 <hr />
 
 
 ## [security]
 ## [security]

+ 8 - 11
docs/sources/installation/debian.md

@@ -34,32 +34,29 @@ sudo dpkg -i grafana_<version>_amd64.deb
 Example:
 Example:
 
 
 ```bash
 ```bash
-wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_5.1.4_amd64.deb
+wget https://dl.grafana.com/oss/release/grafana_5.4.2_amd64.deb
 sudo apt-get install -y adduser libfontconfig
 sudo apt-get install -y adduser libfontconfig
-sudo dpkg -i grafana_5.1.4_amd64.deb
+sudo dpkg -i grafana_5.4.2_amd64.deb
 ```
 ```
 
 
 ## APT Repository
 ## APT Repository
 
 
-Add the following line to your `/etc/apt/sources.list` file.
+Create a file `/etc/apt/sources.list.d/grafana.list` and add the following to it.
 
 
 ```bash
 ```bash
-deb https://packagecloud.io/grafana/stable/debian/ stretch main
+deb https://packages.grafana.com/oss/deb stable main
 ```
 ```
 
 
-Use the above line even if you are on Ubuntu or another Debian version.
-There is also a testing repository if you want beta or release
-candidates.
+There is a separate repository if you want beta releases.
 
 
 ```bash
 ```bash
-deb https://packagecloud.io/grafana/testing/debian/ stretch main
+deb https://packages.grafana.com/oss/deb beta main
 ```
 ```
 
 
-Then add the [Package Cloud](https://packagecloud.io/grafana) key. This
-allows you to install signed packages.
+Use the above line even if you are on Ubuntu or another Debian version. Then add our gpg key. This allows you to install signed packages.
 
 
 ```bash
 ```bash
-curl https://packagecloud.io/gpg.key | sudo apt-key add -
+curl https://packages.grafana.com/gpg.key | sudo apt-key add -
 ```
 ```
 
 
 Update your Apt repositories and install Grafana
 Update your Apt repositories and install Grafana

+ 15 - 7
docs/sources/installation/rpm.md

@@ -32,7 +32,7 @@ $ sudo yum install <rpm package url>
 Example:
 Example:
 
 
 ```bash
 ```bash
-$ sudo yum install https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
+$ sudo yum install https://dl.grafana.com/oss/release/grafana-5.4.2-1.x86_64.rpm
 ```
 ```
 
 
 Or install manually using `rpm`. First execute
 Or install manually using `rpm`. First execute
@@ -44,7 +44,7 @@ $ wget <rpm package url>
 Example:
 Example:
 
 
 ```bash
 ```bash
-$ wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.1.4-1.x86_64.rpm
+$ wget https://dl.grafana.com/oss/release/grafana-5.4.2-1.x86_64.rpm
 ```
 ```
 
 
 ### On CentOS / Fedora / Redhat:
 ### On CentOS / Fedora / Redhat:
@@ -67,19 +67,27 @@ Add the following to a new file at `/etc/yum.repos.d/grafana.repo`
 ```bash
 ```bash
 [grafana]
 [grafana]
 name=grafana
 name=grafana
-baseurl=https://packagecloud.io/grafana/stable/el/7/$basearch
+baseurl=https://packages.grafana.com/oss/rpm
 repo_gpgcheck=1
 repo_gpgcheck=1
 enabled=1
 enabled=1
 gpgcheck=1
 gpgcheck=1
-gpgkey=https://packagecloud.io/gpg.key https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana
+gpgkey=https://packages.grafana.com/gpg.key
 sslverify=1
 sslverify=1
 sslcacert=/etc/pki/tls/certs/ca-bundle.crt
 sslcacert=/etc/pki/tls/certs/ca-bundle.crt
 ```
 ```
 
 
-There is also a testing repository if you want beta or release candidates.
+There is a separate repository if you want beta releases.
 
 
 ```bash
 ```bash
-baseurl=https://packagecloud.io/grafana/testing/el/7/$basearch
+[grafana]
+name=grafana
+baseurl=https://packages.grafana.com/oss/rpm-beta
+repo_gpgcheck=1
+enabled=1
+gpgcheck=1
+gpgkey=https://packages.grafana.com/gpg.key
+sslverify=1
+sslcacert=/etc/pki/tls/certs/ca-bundle.crt
 ```
 ```
 
 
 Then install Grafana via the `yum` command.
 Then install Grafana via the `yum` command.
@@ -91,7 +99,7 @@ $ sudo yum install grafana
 ### RPM GPG Key
 ### RPM GPG Key
 
 
 The RPMs are signed, you can verify the signature with this [public GPG
 The RPMs are signed, you can verify the signature with this [public GPG
-key](https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana).
+key](https://packages.grafana.com/gpg.key).
 
 
 ## Package details
 ## Package details
 
 

+ 3 - 1
jest.config.js

@@ -6,7 +6,9 @@ module.exports = {
   },
   },
   "moduleDirectories": ["node_modules", "public"],
   "moduleDirectories": ["node_modules", "public"],
   "roots": [
   "roots": [
-    "<rootDir>/public"
+    "<rootDir>/public/app",
+    "<rootDir>/public/test",
+    "<rootDir>/packages"
   ],
   ],
   "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$",
   "testRegex": "(\\.|/)(test)\\.(jsx?|tsx?)$",
   "moduleFileExtensions": [
   "moduleFileExtensions": [

+ 41 - 16
package.json

@@ -1,4 +1,5 @@
 {
 {
+  "private": true,
   "author": {
   "author": {
     "name": "Torkel Ödegaard",
     "name": "Torkel Ödegaard",
     "company": "Grafana Labs"
     "company": "Grafana Labs"
@@ -11,14 +12,16 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@babel/core": "^7.1.2",
     "@babel/core": "^7.1.2",
-    "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
     "@babel/plugin-syntax-dynamic-import": "^7.0.0",
     "@babel/plugin-syntax-dynamic-import": "^7.0.0",
     "@babel/preset-env": "^7.1.0",
     "@babel/preset-env": "^7.1.0",
     "@babel/preset-react": "^7.0.0",
     "@babel/preset-react": "^7.0.0",
     "@babel/preset-typescript": "^7.1.0",
     "@babel/preset-typescript": "^7.1.0",
+    "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
+    "@types/classnames": "^2.2.6",
     "@types/d3": "^4.10.1",
     "@types/d3": "^4.10.1",
     "@types/enzyme": "^3.1.13",
     "@types/enzyme": "^3.1.13",
     "@types/jest": "^23.3.2",
     "@types/jest": "^23.3.2",
+    "@types/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
     "@types/node": "^8.0.31",
     "@types/react": "^16.7.6",
     "@types/react": "^16.7.6",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-custom-scrollbars": "^4.0.5",
@@ -49,15 +52,12 @@
     "grunt-cli": "~1.2.0",
     "grunt-cli": "~1.2.0",
     "grunt-contrib-clean": "~1.0.0",
     "grunt-contrib-clean": "~1.0.0",
     "grunt-contrib-compress": "^1.3.0",
     "grunt-contrib-compress": "^1.3.0",
-    "grunt-contrib-concat": "^1.0.1",
     "grunt-contrib-copy": "~1.0.0",
     "grunt-contrib-copy": "~1.0.0",
-    "grunt-contrib-cssmin": "~1.0.2",
     "grunt-exec": "^1.0.1",
     "grunt-exec": "^1.0.1",
     "grunt-newer": "^1.3.0",
     "grunt-newer": "^1.3.0",
     "grunt-notify": "^0.4.5",
     "grunt-notify": "^0.4.5",
     "grunt-postcss": "^0.8.0",
     "grunt-postcss": "^0.8.0",
-    "grunt-sass": "^2.0.0",
-    "grunt-sass-lint": "^0.2.2",
+    "grunt-sass-lint": "^0.2.4",
     "grunt-usemin": "3.1.1",
     "grunt-usemin": "3.1.1",
     "grunt-webpack": "^3.0.2",
     "grunt-webpack": "^3.0.2",
     "html-loader": "^0.5.1",
     "html-loader": "^0.5.1",
@@ -73,6 +73,7 @@
     "ng-annotate-webpack-plugin": "^0.3.0",
     "ng-annotate-webpack-plugin": "^0.3.0",
     "ngtemplate-loader": "^2.0.1",
     "ngtemplate-loader": "^2.0.1",
     "npm": "^5.4.2",
     "npm": "^5.4.2",
+    "node-sass": "^4.11.0",
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "phantomjs-prebuilt": "^2.1.15",
     "phantomjs-prebuilt": "^2.1.15",
     "postcss-browser-reporter": "^0.5.0",
     "postcss-browser-reporter": "^0.5.0",
@@ -92,6 +93,7 @@
     "tslib": "^1.9.3",
     "tslib": "^1.9.3",
     "tslint": "^5.8.0",
     "tslint": "^5.8.0",
     "tslint-loader": "^3.5.3",
     "tslint-loader": "^3.5.3",
+    "tslint-react": "^3.6.0",
     "typescript": "^3.0.3",
     "typescript": "^3.0.3",
     "uglifyjs-webpack-plugin": "^1.2.7",
     "uglifyjs-webpack-plugin": "^1.2.7",
     "webpack": "4.19.1",
     "webpack": "4.19.1",
@@ -108,15 +110,30 @@
     "watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js",
     "watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js",
     "build": "grunt build",
     "build": "grunt build",
     "test": "grunt test",
     "test": "grunt test",
-    "lint": "tslint -c tslint.json --project tsconfig.json",
+    "tslint": "tslint -c tslint.json --project tsconfig.json",
+    "typecheck": "tsc --noEmit",
     "jest": "jest --notify --watch",
     "jest": "jest --notify --watch",
     "api-tests": "jest --notify --watch --config=tests/api/jest.js",
     "api-tests": "jest --notify --watch --config=tests/api/jest.js",
-    "precommit": "lint-staged && grunt precommit"
+    "precommit": "grunt precommit"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged && grunt precommit"
+    }
   },
   },
   "lint-staged": {
   "lint-staged": {
-    "*.{ts,tsx}": ["prettier --write", "git add"],
-    "*.scss": ["prettier --write", "git add"],
-    "*pkg/**/*.go": ["gofmt -w -s", "git add"]
+    "*.{ts,tsx}": [
+      "prettier --write",
+      "git add"
+    ],
+    "*.scss": [
+      "prettier --write",
+      "git add"
+    ],
+    "*pkg/**/*.go": [
+      "gofmt -w -s",
+      "git add"
+    ]
   },
   },
   "prettier": {
   "prettier": {
     "trailingComma": "es5",
     "trailingComma": "es5",
@@ -126,6 +143,7 @@
   "license": "Apache-2.0",
   "license": "Apache-2.0",
   "dependencies": {
   "dependencies": {
     "@babel/polyfill": "^7.0.0",
     "@babel/polyfill": "^7.0.0",
+    "@torkelo/react-select": "2.1.1",
     "angular": "1.6.6",
     "angular": "1.6.6",
     "angular-bindonce": "0.3.1",
     "angular-bindonce": "0.3.1",
     "angular-native-dragdrop": "1.2.2",
     "angular-native-dragdrop": "1.2.2",
@@ -133,7 +151,7 @@
     "angular-sanitize": "1.6.6",
     "angular-sanitize": "1.6.6",
     "baron": "^3.0.3",
     "baron": "^3.0.3",
     "brace": "^0.10.0",
     "brace": "^0.10.0",
-    "classnames": "^2.2.5",
+    "classnames": "^2.2.6",
     "clipboard": "^1.7.1",
     "clipboard": "^1.7.1",
     "d3": "^4.11.0",
     "d3": "^4.11.0",
     "d3-scale-chromatic": "^1.3.0",
     "d3-scale-chromatic": "^1.3.0",
@@ -152,10 +170,9 @@
     "react-custom-scrollbars": "^4.2.1",
     "react-custom-scrollbars": "^4.2.1",
     "react-dom": "^16.6.3",
     "react-dom": "^16.6.3",
     "react-grid-layout": "0.16.6",
     "react-grid-layout": "0.16.6",
-    "react-popper": "^1.3.0",
     "react-highlight-words": "0.11.0",
     "react-highlight-words": "0.11.0",
+    "react-popper": "^1.3.0",
     "react-redux": "^5.0.7",
     "react-redux": "^5.0.7",
-    "@torkelo/react-select": "2.1.1",
     "react-sizeme": "^2.3.6",
     "react-sizeme": "^2.3.6",
     "react-table": "^6.8.6",
     "react-table": "^6.8.6",
     "react-transition-group": "^2.2.1",
     "react-transition-group": "^2.2.1",
@@ -165,18 +182,26 @@
     "redux-thunk": "^2.3.0",
     "redux-thunk": "^2.3.0",
     "remarkable": "^1.7.1",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rst2html": "github:thoward/rst2html#990cb89",
-    "rxjs": "^5.4.3",
+    "rxjs": "^6.3.3",
     "slate": "^0.33.4",
     "slate": "^0.33.4",
     "slate-plain-serializer": "^0.5.10",
     "slate-plain-serializer": "^0.5.10",
     "slate-prism": "^0.5.0",
     "slate-prism": "^0.5.0",
     "slate-react": "^0.12.4",
     "slate-react": "^0.12.4",
     "tether": "^1.4.0",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
-    "tinycolor2": "^1.4.1",
-    "tslint-react": "^3.6.0"
+    "tinycolor2": "^1.4.1"
   },
   },
   "resolutions": {
   "resolutions": {
     "caniuse-db": "1.0.30000772",
     "caniuse-db": "1.0.30000772",
     "**/@types/react": "16.7.6"
     "**/@types/react": "16.7.6"
+  },
+  "workspaces": {
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": [
+      "**/@types/*",
+      "**/@types/*/**"
+    ]
   }
   }
 }
 }

+ 4 - 0
packages/grafana-build/README.md

@@ -0,0 +1,4 @@
+# Shared build scripts
+
+Shared build scripts for plugins & internal packages.
+

+ 13 - 0
packages/grafana-build/package.json

@@ -0,0 +1,13 @@
+{
+  "name": "@grafana/build",
+  "private": true,
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "tslint": "echo \"Nothing to do\"",
+    "typecheck": "echo \"Nothing to do\""
+  },
+  "author": "",
+  "license": "ISC"
+}

+ 3 - 0
packages/grafana-ui/README.md

@@ -0,0 +1,3 @@
+# Grafana (WIP) shared component library
+
+Used by internal & external plugins.

+ 33 - 0
packages/grafana-ui/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@grafana/ui",
+  "version": "1.0.0",
+  "description": "",
+  "main": "src/index.ts",
+  "scripts": {
+    "tslint": "tslint -c tslint.json --project tsconfig.json",
+    "typecheck": "tsc --noEmit"
+  },
+  "author": "",
+  "license": "ISC",
+  "dependencies": {
+    "@torkelo/react-select": "2.1.1",
+    "classnames": "^2.2.5",
+    "jquery": "^3.2.1",
+    "lodash": "^4.17.10",
+    "moment": "^2.22.2",
+    "react": "^16.6.3",
+    "react-dom": "^16.6.3",
+    "react-highlight-words": "0.11.0",
+    "react-popper": "^1.3.0",
+    "react-transition-group": "^2.2.1",
+    "react-virtualized": "^9.21.0"
+  },
+  "devDependencies": {
+    "@types/jest": "^23.3.2",
+    "@types/lodash": "^4.14.119",
+    "@types/react": "^16.7.6",
+    "@types/classnames": "^2.2.6",
+    "@types/jquery": "^1.10.35",
+    "typescript": "^3.2.2"
+  }
+}

+ 5 - 4
public/app/core/components/DeleteButton/DeleteButton.test.tsx → packages/grafana-ui/src/components/DeleteButton/DeleteButton.test.tsx

@@ -1,10 +1,10 @@
 import React from 'react';
 import React from 'react';
-import DeleteButton from './DeleteButton';
+import { DeleteButton } from './DeleteButton';
 import { shallow } from 'enzyme';
 import { shallow } from 'enzyme';
 
 
 describe('DeleteButton', () => {
 describe('DeleteButton', () => {
-  let wrapper;
-  let deleted;
+  let wrapper: any;
+  let deleted: any;
 
 
   beforeAll(() => {
   beforeAll(() => {
     deleted = false;
     deleted = false;
@@ -12,7 +12,8 @@ describe('DeleteButton', () => {
     function deleteItem() {
     function deleteItem() {
       deleted = true;
       deleted = true;
     }
     }
-    wrapper = shallow(<DeleteButton onConfirmDelete={() => deleteItem()} />);
+
+    wrapper = shallow(<DeleteButton onConfirm={() => deleteItem()} />);
   });
   });
 
 
   it('should show confirm delete when clicked', () => {
   it('should show confirm delete when clicked', () => {

+ 10 - 10
public/app/core/components/DeleteButton/DeleteButton.tsx → packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx

@@ -1,19 +1,19 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent, SyntheticEvent } from 'react';
 
 
-export interface DeleteButtonProps {
-  onConfirmDelete();
+interface Props {
+  onConfirm(): void;
 }
 }
 
 
-export interface DeleteButtonStates {
+interface State {
   showConfirm: boolean;
   showConfirm: boolean;
 }
 }
 
 
-export default class DeleteButton extends PureComponent<DeleteButtonProps, DeleteButtonStates> {
-  state: DeleteButtonStates = {
+export class DeleteButton extends PureComponent<Props, State> {
+  state: State = {
     showConfirm: false,
     showConfirm: false,
   };
   };
 
 
-  onClickDelete = event => {
+  onClickDelete = (event: SyntheticEvent) => {
     if (event) {
     if (event) {
       event.preventDefault();
       event.preventDefault();
     }
     }
@@ -23,7 +23,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
     });
     });
   };
   };
 
 
-  onClickCancel = event => {
+  onClickCancel = (event: SyntheticEvent) => {
     if (event) {
     if (event) {
       event.preventDefault();
       event.preventDefault();
     }
     }
@@ -33,7 +33,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
   };
   };
 
 
   render() {
   render() {
-    const onClickConfirm = this.props.onConfirmDelete;
+    const { onConfirm } = this.props;
     let showConfirm;
     let showConfirm;
     let showDeleteButton;
     let showDeleteButton;
 
 
@@ -55,7 +55,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
             <a className="btn btn-small" onClick={this.onClickCancel}>
             <a className="btn btn-small" onClick={this.onClickCancel}>
               Cancel
               Cancel
             </a>
             </a>
-            <a className="btn btn-danger btn-small" onClick={onClickConfirm}>
+            <a className="btn btn-danger btn-small" onClick={onConfirm}>
               Confirm Delete
               Confirm Delete
             </a>
             </a>
           </span>
           </span>

+ 0 - 0
public/sass/components/_delete_button.scss → packages/grafana-ui/src/components/DeleteButton/_DeleteButton.scss


+ 1 - 0
packages/grafana-ui/src/components/index.scss

@@ -0,0 +1 @@
+@import 'DeleteButton/DeleteButton';

+ 1 - 0
packages/grafana-ui/src/components/index.ts

@@ -0,0 +1 @@
+export { DeleteButton } from './DeleteButton/DeleteButton';

+ 23 - 0
packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx

@@ -0,0 +1,23 @@
+import React, { SFC, ReactNode } from 'react';
+import classNames from 'classnames';
+
+interface Props {
+  children: ReactNode;
+  htmlFor?: string;
+  className?: string;
+  isFocused?: boolean;
+  isInvalid?: boolean;
+}
+
+export const GfFormLabel: SFC<Props> = ({ children, isFocused, isInvalid, className, htmlFor, ...rest }) => {
+  const classes = classNames('gf-form-label', className, {
+    'gf-form-label--is-focused': isFocused,
+    'gf-form-label--is-invalid': isInvalid,
+  });
+
+  return (
+    <label className={classes} {...rest} htmlFor={htmlFor}>
+      {children}
+    </label>
+  );
+};

+ 1 - 0
packages/grafana-ui/src/forms/index.ts

@@ -0,0 +1 @@
+export { GfFormLabel } from './GfFormLabel/GfFormLabel';

+ 1 - 0
packages/grafana-ui/src/index.scss

@@ -0,0 +1 @@
+@import 'components/index';

+ 5 - 0
packages/grafana-ui/src/index.ts

@@ -0,0 +1,5 @@
+export * from './components';
+export * from './visualizations';
+export * from './types';
+export * from './utils';
+export * from './forms';

+ 3 - 0
packages/grafana-ui/src/types/index.ts

@@ -0,0 +1,3 @@
+export * from './series';
+export * from './time';
+export * from './panel';

+ 17 - 0
packages/grafana-ui/src/types/jquery.d.ts

@@ -0,0 +1,17 @@
+interface JQueryPlot {
+  (element: HTMLElement | JQuery, data: any, options: any): void;
+  plugins: any[];
+}
+
+interface JQueryStatic {
+  plot: JQueryPlot;
+}
+
+interface JQuery {
+  place_tt: any;
+  modal: any;
+  tagsinput: any;
+  typeahead: any;
+  accessKey: any;
+  tooltip: any;
+}

+ 31 - 0
packages/grafana-ui/src/types/panel.ts

@@ -0,0 +1,31 @@
+import { TimeSeries, LoadingState } from './series';
+import { TimeRange } from './time';
+
+export interface PanelProps<T = any> {
+  timeSeries: TimeSeries[];
+  timeRange: TimeRange;
+  loading: LoadingState;
+  options: T;
+  renderCounter: number;
+  width: number;
+  height: number;
+}
+
+export interface PanelOptionsProps<T = any> {
+  options: T;
+  onChange: (options: T) => void;
+}
+
+export interface PanelSize {
+  width: number;
+  height: number;
+}
+
+export interface PanelMenuItem {
+  type?: 'submenu' | 'divider';
+  text?: string;
+  iconClassName?: string;
+  onClick?: () => void;
+  shortcut?: string;
+  subMenu?: PanelMenuItem[];
+}

+ 53 - 0
packages/grafana-ui/src/types/series.ts

@@ -0,0 +1,53 @@
+export enum LoadingState {
+  NotStarted = 'NotStarted',
+  Loading = 'Loading',
+  Done = 'Done',
+  Error = 'Error',
+}
+
+export type TimeSeriesValue = number | null;
+
+export type TimeSeriesPoints = TimeSeriesValue[][];
+
+export interface TimeSeries {
+  target: string;
+  datapoints: TimeSeriesPoints;
+  unit?: string;
+}
+
+/** View model projection of a time series */
+export interface TimeSeriesVM {
+  label: string;
+  color: string;
+  data: TimeSeriesValue[][];
+  stats: TimeSeriesStats;
+}
+
+export interface TimeSeriesStats {
+  total: number | null;
+  max: number | null;
+  min: number | null;
+  logmin: number;
+  avg: number | null;
+  current: number | null;
+  first: number | null;
+  delta: number;
+  diff: number | null;
+  range: number | null;
+  timeStep: number;
+  count: number;
+  allIsNull: boolean;
+  allIsZero: boolean;
+}
+
+export enum NullValueMode {
+  Null = 'null',
+  Ignore = 'connected',
+  AsZero = 'null as zero',
+}
+
+/** View model projection of many time series */
+export interface TimeSeriesVMs {
+  [index: number]: TimeSeriesVM;
+  length: number;
+}

+ 17 - 0
packages/grafana-ui/src/types/time.ts

@@ -0,0 +1,17 @@
+import { Moment } from 'moment';
+
+export interface RawTimeRange {
+  from: Moment | string;
+  to: Moment | string;
+}
+
+export interface TimeRange {
+  from: Moment;
+  to: Moment;
+  raw: RawTimeRange;
+}
+
+export interface IntervalValues {
+  interval: string; // 10s,5m
+  intervalMs: number;
+}

+ 1 - 0
packages/grafana-ui/src/utils/index.ts

@@ -0,0 +1 @@
+export * from './processTimeSeries';

+ 174 - 0
packages/grafana-ui/src/utils/processTimeSeries.ts

@@ -0,0 +1,174 @@
+// Libraries
+import _ from 'lodash';
+
+// Types
+import { TimeSeries, TimeSeriesVMs, NullValueMode, TimeSeriesValue } from '../types';
+
+interface Options {
+  timeSeries: TimeSeries[];
+  nullValueMode: NullValueMode;
+  colorPalette: string[];
+}
+
+export function processTimeSeries({ timeSeries, nullValueMode, colorPalette }: Options): TimeSeriesVMs {
+  const vmSeries = timeSeries.map((item, index) => {
+    const colorIndex = index % colorPalette.length;
+    const label = item.target;
+    const result = [];
+
+    // stat defaults
+    let total = 0;
+    let max: TimeSeriesValue = -Number.MAX_VALUE;
+    let min: TimeSeriesValue = Number.MAX_VALUE;
+    let logmin = Number.MAX_VALUE;
+    let avg: TimeSeriesValue = null;
+    let current: TimeSeriesValue = null;
+    let first: TimeSeriesValue = null;
+    let delta: TimeSeriesValue = 0;
+    let diff: TimeSeriesValue = null;
+    let range: TimeSeriesValue = null;
+    let timeStep = Number.MAX_VALUE;
+    let allIsNull = true;
+    let allIsZero = true;
+
+    const ignoreNulls = nullValueMode === NullValueMode.Ignore;
+    const nullAsZero = nullValueMode === NullValueMode.AsZero;
+
+    let currentTime: TimeSeriesValue = null;
+    let currentValue: TimeSeriesValue = null;
+    let nonNulls = 0;
+    let previousTime: TimeSeriesValue = null;
+    let previousValue = 0;
+    let previousDeltaUp = true;
+
+    for (let i = 0; i < item.datapoints.length; i++) {
+      currentValue = item.datapoints[i][0];
+      currentTime = item.datapoints[i][1];
+
+      if (typeof currentTime !== 'number') {
+        continue;
+      }
+
+      if (typeof currentValue !== 'number') {
+        continue;
+      }
+
+      // Due to missing values we could have different timeStep all along the series
+      // so we have to find the minimum one (could occur with aggregators such as ZimSum)
+      if (previousTime !== null && currentTime !== null) {
+        const currentStep = currentTime - previousTime;
+        if (currentStep < timeStep) {
+          timeStep = currentStep;
+        }
+      }
+
+      previousTime = currentTime;
+
+      if (currentValue === null) {
+        if (ignoreNulls) {
+          continue;
+        }
+        if (nullAsZero) {
+          currentValue = 0;
+        }
+      }
+
+      if (currentValue !== null) {
+        if (_.isNumber(currentValue)) {
+          total += currentValue;
+          allIsNull = false;
+          nonNulls++;
+        }
+
+        if (currentValue > max) {
+          max = currentValue;
+        }
+
+        if (currentValue < min) {
+          min = currentValue;
+        }
+
+        if (first === null) {
+          first = currentValue;
+        } else {
+          if (previousValue > currentValue) {
+            // counter reset
+            previousDeltaUp = false;
+            if (i === item.datapoints.length - 1) {
+              // reset on last
+              delta += currentValue;
+            }
+          } else {
+            if (previousDeltaUp) {
+              delta += currentValue - previousValue; // normal increment
+            } else {
+              delta += currentValue; // account for counter reset
+            }
+            previousDeltaUp = true;
+          }
+        }
+        previousValue = currentValue;
+
+        if (currentValue < logmin && currentValue > 0) {
+          logmin = currentValue;
+        }
+
+        if (currentValue !== 0) {
+          allIsZero = false;
+        }
+      }
+
+      result.push([currentTime, currentValue]);
+    }
+
+    if (max === -Number.MAX_VALUE) {
+      max = null;
+    }
+
+    if (min === Number.MAX_VALUE) {
+      min = null;
+    }
+
+    if (result.length && !allIsNull) {
+      avg = total / nonNulls;
+      current = result[result.length - 1][1];
+      if (current === null && result.length > 1) {
+        current = result[result.length - 2][1];
+      }
+    }
+
+    if (max !== null && min !== null) {
+      range = max - min;
+    }
+
+    if (current !== null && first !== null) {
+      diff = current - first;
+    }
+
+    const count = result.length;
+
+    return {
+      data: result,
+      label: label,
+      color: colorPalette[colorIndex],
+      stats: {
+        total,
+        min,
+        max,
+        current,
+        logmin,
+        avg,
+        diff,
+        delta,
+        timeStep,
+        range,
+        count,
+        first,
+        allIsZero,
+        allIsNull,
+      },
+    };
+  });
+
+  return vmSeries;
+}

+ 9 - 6
public/app/viz/Graph.tsx → packages/grafana-ui/src/visualizations/Graph/Graph.tsx

@@ -1,11 +1,9 @@
 // Libraries
 // Libraries
 import $ from 'jquery';
 import $ from 'jquery';
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import 'vendor/flot/jquery.flot';
-import 'vendor/flot/jquery.flot.time';
 
 
 // Types
 // Types
-import { TimeRange, TimeSeriesVMs } from 'app/types';
+import { TimeRange, TimeSeriesVMs } from '../../types';
 
 
 interface GraphProps {
 interface GraphProps {
   timeSeries: TimeSeriesVMs;
   timeSeries: TimeSeriesVMs;
@@ -24,7 +22,7 @@ export class Graph extends PureComponent<GraphProps> {
     showBars: false,
     showBars: false,
   };
   };
 
 
-  element: HTMLElement;
+  element: HTMLElement | null;
 
 
   componentDidUpdate() {
   componentDidUpdate() {
     this.draw();
     this.draw();
@@ -35,6 +33,10 @@ export class Graph extends PureComponent<GraphProps> {
   }
   }
 
 
   draw() {
   draw() {
+    if (this.element === null) {
+      return;
+    }
+
     const { width, timeSeries, timeRange, showLines, showBars, showPoints } = this.props;
     const { width, timeSeries, timeRange, showLines, showBars, showPoints } = this.props;
 
 
     if (!width) {
     if (!width) {
@@ -76,7 +78,7 @@ export class Graph extends PureComponent<GraphProps> {
         max: max,
         max: max,
         label: 'Datetime',
         label: 'Datetime',
         ticks: ticks,
         ticks: ticks,
-        timeformat: time_format(ticks, min, max),
+        timeformat: timeFormat(ticks, min, max),
       },
       },
       grid: {
       grid: {
         minBorderMargin: 0,
         minBorderMargin: 0,
@@ -96,6 +98,7 @@ export class Graph extends PureComponent<GraphProps> {
       $.plot(this.element, timeSeries, flotOptions);
       $.plot(this.element, timeSeries, flotOptions);
     } catch (err) {
     } catch (err) {
       console.log('Graph rendering error', err, flotOptions, timeSeries);
       console.log('Graph rendering error', err, flotOptions, timeSeries);
+      throw new Error('Error rendering panel');
     }
     }
   }
   }
 
 
@@ -109,7 +112,7 @@ export class Graph extends PureComponent<GraphProps> {
 }
 }
 
 
 // Copied from graph.ts
 // Copied from graph.ts
-function time_format(ticks, min, max) {
+function timeFormat(ticks: number, min: number, max: number): string {
   if (min && max && ticks) {
   if (min && max && ticks) {
     const range = max - min;
     const range = max - min;
     const secPerTick = range / ticks / 1000;
     const secPerTick = range / ticks / 1000;

+ 1 - 0
packages/grafana-ui/src/visualizations/index.ts

@@ -0,0 +1 @@
+export { Graph } from './Graph/Graph';

+ 18 - 0
packages/grafana-ui/tsconfig.json

@@ -0,0 +1,18 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": [
+    "src/**/*.ts",
+    "src/**/*.tsx"
+  ],
+  "exclude": [
+    "dist"
+  ],
+  "compilerOptions": {
+    "rootDir": ".",
+    "module": "esnext",
+    "outDir": "dist",
+    "declaration": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true
+  }
+}

+ 3 - 0
packages/grafana-ui/tslint.json

@@ -0,0 +1,3 @@
+{
+  "extends": "../../tslint.json"
+}

+ 112 - 8
pkg/api/dashboard_snapshot.go

@@ -1,10 +1,15 @@
 package api
 package api
 
 
 import (
 import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
 	"time"
 	"time"
 
 
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/services/guardian"
@@ -12,6 +17,11 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
+var client = &http.Client{
+	Timeout:   time.Second * 5,
+	Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
+}
+
 func GetSharingOptions(c *m.ReqContext) {
 func GetSharingOptions(c *m.ReqContext) {
 	c.JSON(200, util.DynMap{
 	c.JSON(200, util.DynMap{
 		"externalSnapshotURL":  setting.ExternalSnapshotUrl,
 		"externalSnapshotURL":  setting.ExternalSnapshotUrl,
@@ -20,26 +30,79 @@ func GetSharingOptions(c *m.ReqContext) {
 	})
 	})
 }
 }
 
 
+type CreateExternalSnapshotResponse struct {
+	Key       string `json:"key"`
+	DeleteKey string `json:"deleteKey"`
+	Url       string `json:"url"`
+	DeleteUrl string `json:"deleteUrl"`
+}
+
+func createExternalDashboardSnapshot(cmd m.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) {
+	var createSnapshotResponse CreateExternalSnapshotResponse
+	message := map[string]interface{}{
+		"name":      cmd.Name,
+		"expires":   cmd.Expires,
+		"dashboard": cmd.Dashboard,
+	}
+
+	messageBytes, err := simplejson.NewFromAny(message).Encode()
+	if err != nil {
+		return nil, err
+	}
+
+	response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
+	if err != nil {
+		return nil, err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != 200 {
+		return nil, fmt.Errorf("Create external snapshot response status code %d", response.StatusCode)
+	}
+
+	if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil {
+		return nil, err
+	}
+
+	return &createSnapshotResponse, nil
+}
+
+// POST /api/snapshots
 func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotCommand) {
 func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotCommand) {
 	if cmd.Name == "" {
 	if cmd.Name == "" {
 		cmd.Name = "Unnamed snapshot"
 		cmd.Name = "Unnamed snapshot"
 	}
 	}
 
 
+	var url string
+	cmd.ExternalUrl = ""
+	cmd.OrgId = c.OrgId
+	cmd.UserId = c.UserId
+
 	if cmd.External {
 	if cmd.External {
-		// external snapshot ref requires key and delete key
-		if cmd.Key == "" || cmd.DeleteKey == "" {
-			c.JsonApiErr(400, "Missing key and delete key for external snapshot", nil)
+		if !setting.ExternalEnabled {
+			c.JsonApiErr(403, "External dashboard creation is disabled", nil)
+			return
+		}
+
+		response, err := createExternalDashboardSnapshot(cmd)
+		if err != nil {
+			c.JsonApiErr(500, "Failed to create external snaphost", err)
 			return
 			return
 		}
 		}
 
 
-		cmd.OrgId = -1
-		cmd.UserId = -1
+		url = response.Url
+		cmd.Key = response.Key
+		cmd.DeleteKey = response.DeleteKey
+		cmd.ExternalUrl = response.Url
+		cmd.ExternalDeleteUrl = response.DeleteUrl
+		cmd.Dashboard = simplejson.New()
+
 		metrics.M_Api_Dashboard_Snapshot_External.Inc()
 		metrics.M_Api_Dashboard_Snapshot_External.Inc()
 	} else {
 	} else {
 		cmd.Key = util.GetRandomString(32)
 		cmd.Key = util.GetRandomString(32)
 		cmd.DeleteKey = util.GetRandomString(32)
 		cmd.DeleteKey = util.GetRandomString(32)
-		cmd.OrgId = c.OrgId
-		cmd.UserId = c.UserId
+		url = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
+
 		metrics.M_Api_Dashboard_Snapshot_Create.Inc()
 		metrics.M_Api_Dashboard_Snapshot_Create.Inc()
 	}
 	}
 
 
@@ -51,7 +114,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma
 	c.JSON(200, util.DynMap{
 	c.JSON(200, util.DynMap{
 		"key":       cmd.Key,
 		"key":       cmd.Key,
 		"deleteKey": cmd.DeleteKey,
 		"deleteKey": cmd.DeleteKey,
-		"url":       setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key),
+		"url":       url,
 		"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
 		"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
 	})
 	})
 }
 }
@@ -91,6 +154,33 @@ func GetDashboardSnapshot(c *m.ReqContext) {
 	c.JSON(200, dto)
 	c.JSON(200, dto)
 }
 }
 
 
+func deleteExternalDashboardSnapshot(externalUrl string) error {
+	response, err := client.Get(externalUrl)
+	if err != nil {
+		return err
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode == 200 {
+		return nil
+	}
+
+	// Gracefully ignore "snapshot not found" errors as they could have already
+	// been removed either via the cleanup script or by request.
+	if response.StatusCode == 500 {
+		var respJson map[string]interface{}
+		if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil {
+			return err
+		}
+
+		if respJson["message"] == "Failed to get dashboard snapshot" {
+			return nil
+		}
+	}
+
+	return fmt.Errorf("Unexpected response when deleting external snapshot. Status code: %d", response.StatusCode)
+}
+
 // GET /api/snapshots-delete/:deleteKey
 // GET /api/snapshots-delete/:deleteKey
 func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 	key := c.Params(":deleteKey")
 	key := c.Params(":deleteKey")
@@ -102,6 +192,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 		return Error(500, "Failed to get dashboard snapshot", err)
 		return Error(500, "Failed to get dashboard snapshot", err)
 	}
 	}
 
 
+	if query.Result.External {
+		err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl)
+		if err != nil {
+			return Error(500, "Failed to delete external dashboard", err)
+		}
+	}
+
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	if err := bus.Dispatch(cmd); err != nil {
@@ -138,6 +235,13 @@ func DeleteDashboardSnapshot(c *m.ReqContext) Response {
 		return Error(403, "Access denied to this snapshot", nil)
 		return Error(403, "Access denied to this snapshot", nil)
 	}
 	}
 
 
+	if query.Result.External {
+		err := deleteExternalDashboardSnapshot(query.Result.ExternalDeleteUrl)
+		if err != nil {
+			return Error(500, "Failed to delete external dashboard", err)
+		}
+	}
+
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: query.Result.DeleteKey}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	if err := bus.Dispatch(cmd); err != nil {

+ 87 - 0
pkg/api/dashboard_snapshot_test.go

@@ -1,6 +1,9 @@
 package api
 package api
 
 
 import (
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -13,13 +16,17 @@ import (
 
 
 func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 	Convey("Given a single snapshot", t, func() {
 	Convey("Given a single snapshot", t, func() {
+		var externalRequest *http.Request
 		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
 		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
 
 
 		mockSnapshotResult := &m.DashboardSnapshot{
 		mockSnapshotResult := &m.DashboardSnapshot{
 			Id:        1,
 			Id:        1,
+			Key:       "12345",
+			DeleteKey: "54321",
 			Dashboard: jsonModel,
 			Dashboard: jsonModel,
 			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
 			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
 			UserId:    999999,
 			UserId:    999999,
+			External:  true,
 		}
 		}
 
 
 		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
@@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 			return nil
 			return nil
 		})
 		})
 
 
+		setupRemoteServer := func(fn func(http.ResponseWriter, *http.Request)) *httptest.Server {
+			return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+				fn(rw, r)
+			}))
+		}
+
 		Convey("When user has editor role and is not in the ACL", func() {
 		Convey("When user has editor role and is not in the ACL", func() {
 			Convey("Should not be able to delete snapshot", func() {
 			Convey("Should not be able to delete snapshot", func() {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						externalRequest = req
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
 					sc.handlerFunc = DeleteDashboardSnapshot
 					sc.handlerFunc = DeleteDashboardSnapshot
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 403)
 					So(sc.resp.Code, ShouldEqual, 403)
+					So(externalRequest, ShouldBeNil)
 				})
 				})
 			})
 			})
 		})
 		})
@@ -59,6 +78,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 		Convey("When user is anonymous", func() {
 		Convey("When user is anonymous", func() {
 			Convey("Should be able to delete snapshot by deleteKey", func() {
 			Convey("Should be able to delete snapshot by deleteKey", func() {
 				anonymousUserScenario("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", func(sc *scenarioContext) {
 				anonymousUserScenario("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.WriteHeader(200)
+						externalRequest = req
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
 					sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey
 					sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
 
 
@@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 
 
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+
+					So(externalRequest.Method, ShouldEqual, http.MethodGet)
+					So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL)
+					So(externalRequest.URL.EscapedPath(), ShouldEqual, "/")
 				})
 				})
 			})
 			})
 		})
 		})
@@ -79,6 +108,12 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 
 
 			Convey("Should be able to delete a snapshot", func() {
 			Convey("Should be able to delete a snapshot", func() {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.WriteHeader(200)
+						externalRequest = req
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
 					sc.handlerFunc = DeleteDashboardSnapshot
 					sc.handlerFunc = DeleteDashboardSnapshot
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
 
@@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 					So(err, ShouldBeNil)
 
 
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
 					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+					So(fmt.Sprintf("http://%s", externalRequest.Host), ShouldEqual, ts.URL)
+					So(externalRequest.URL.EscapedPath(), ShouldEqual, "/")
 				})
 				})
 			})
 			})
 		})
 		})
@@ -94,6 +131,7 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 		Convey("When user is editor and is the creator of the snapshot", func() {
 		Convey("When user is editor and is the creator of the snapshot", func() {
 			aclMockResp = []*m.DashboardAclInfoDTO{}
 			aclMockResp = []*m.DashboardAclInfoDTO{}
 			mockSnapshotResult.UserId = TestUserID
 			mockSnapshotResult.UserId = TestUserID
+			mockSnapshotResult.External = false
 
 
 			Convey("Should be able to delete a snapshot", func() {
 			Convey("Should be able to delete a snapshot", func() {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
 				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
@@ -108,5 +146,54 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 		})
 		})
+
+		Convey("When deleting an external snapshot", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{}
+			mockSnapshotResult.UserId = TestUserID
+
+			Convey("Should gracefully delete local snapshot when remote snapshot has already been removed", func() {
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.Write([]byte(`{"message":"Failed to get dashboard snapshot"}`))
+						rw.WriteHeader(500)
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+				})
+			})
+
+			Convey("Should fail to delete local snapshot when an unexpected 500 error occurs", func() {
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.WriteHeader(500)
+						rw.Write([]byte(`{"message":"Unexpected"}`))
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 500)
+				})
+			})
+
+			Convey("Should fail to delete local snapshot when an unexpected remote error occurs", func() {
+				loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.WriteHeader(404)
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 500)
+				})
+			})
+		})
 	})
 	})
 }
 }

+ 8 - 0
pkg/api/plugins.go

@@ -164,6 +164,14 @@ func GetPluginMarkdown(c *m.ReqContext) Response {
 		return Error(500, "Could not get markdown file", err)
 		return Error(500, "Could not get markdown file", err)
 	}
 	}
 
 
+	// fallback try readme
+	if len(content) == 0 {
+		content, err = plugins.GetPluginMarkdown(pluginID, "readme")
+		if err != nil {
+			return Error(501, "Could not get markdown file", err)
+		}
+	}
+
 	resp := Respond(200, content)
 	resp := Respond(200, content)
 	resp.Header("Content-Type", "text/plain; charset=utf-8")
 	resp.Header("Content-Type", "text/plain; charset=utf-8")
 	return resp
 	return resp

+ 20 - 29
pkg/components/dashdiffs/formatter_json.go

@@ -206,10 +206,9 @@ func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []di
 
 
 	// Added
 	// Added
 	for _, delta := range deltas {
 	for _, delta := range deltas {
-		switch delta.(type) {
+		switch delta := delta.(type) {
 		case *diff.Added:
 		case *diff.Added:
-			d := delta.(*diff.Added)
-			f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
+			f.printRecursive(delta.Position.String(), delta.Value, ChangeAdded)
 		}
 		}
 	}
 	}
 
 
@@ -222,9 +221,8 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 	if len(matchedDeltas) > 0 {
 	if len(matchedDeltas) > 0 {
 		for _, matchedDelta := range matchedDeltas {
 		for _, matchedDelta := range matchedDeltas {
 
 
-			switch matchedDelta.(type) {
+			switch matchedDelta := matchedDelta.(type) {
 			case *diff.Object:
 			case *diff.Object:
-				d := matchedDelta.(*diff.Object)
 				switch value.(type) {
 				switch value.(type) {
 				case map[string]interface{}:
 				case map[string]interface{}:
 					//ok
 					//ok
@@ -238,7 +236,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 				f.print("{")
 				f.print("{")
 				f.closeLine()
 				f.closeLine()
 				f.push(positionStr, len(o), false)
 				f.push(positionStr, len(o), false)
-				f.processObject(o, d.Deltas)
+				f.processObject(o, matchedDelta.Deltas)
 				f.pop()
 				f.pop()
 				f.newLine(ChangeNil)
 				f.newLine(ChangeNil)
 				f.print("}")
 				f.print("}")
@@ -246,7 +244,6 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 				f.closeLine()
 				f.closeLine()
 
 
 			case *diff.Array:
 			case *diff.Array:
-				d := matchedDelta.(*diff.Array)
 				switch value.(type) {
 				switch value.(type) {
 				case []interface{}:
 				case []interface{}:
 					//ok
 					//ok
@@ -260,7 +257,7 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 				f.print("[")
 				f.print("[")
 				f.closeLine()
 				f.closeLine()
 				f.push(positionStr, len(a), true)
 				f.push(positionStr, len(a), true)
-				f.processArray(a, d.Deltas)
+				f.processArray(a, matchedDelta.Deltas)
 				f.pop()
 				f.pop()
 				f.newLine(ChangeNil)
 				f.newLine(ChangeNil)
 				f.print("]")
 				f.print("]")
@@ -268,27 +265,23 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 				f.closeLine()
 				f.closeLine()
 
 
 			case *diff.Added:
 			case *diff.Added:
-				d := matchedDelta.(*diff.Added)
-				f.printRecursive(positionStr, d.Value, ChangeAdded)
+				f.printRecursive(positionStr, matchedDelta.Value, ChangeAdded)
 				f.size[len(f.size)-1]++
 				f.size[len(f.size)-1]++
 
 
 			case *diff.Modified:
 			case *diff.Modified:
-				d := matchedDelta.(*diff.Modified)
 				savedSize := f.size[len(f.size)-1]
 				savedSize := f.size[len(f.size)-1]
-				f.printRecursive(positionStr, d.OldValue, ChangeOld)
+				f.printRecursive(positionStr, matchedDelta.OldValue, ChangeOld)
 				f.size[len(f.size)-1] = savedSize
 				f.size[len(f.size)-1] = savedSize
-				f.printRecursive(positionStr, d.NewValue, ChangeNew)
+				f.printRecursive(positionStr, matchedDelta.NewValue, ChangeNew)
 
 
 			case *diff.TextDiff:
 			case *diff.TextDiff:
 				savedSize := f.size[len(f.size)-1]
 				savedSize := f.size[len(f.size)-1]
-				d := matchedDelta.(*diff.TextDiff)
-				f.printRecursive(positionStr, d.OldValue, ChangeOld)
+				f.printRecursive(positionStr, matchedDelta.OldValue, ChangeOld)
 				f.size[len(f.size)-1] = savedSize
 				f.size[len(f.size)-1] = savedSize
-				f.printRecursive(positionStr, d.NewValue, ChangeNew)
+				f.printRecursive(positionStr, matchedDelta.NewValue, ChangeNew)
 
 
 			case *diff.Deleted:
 			case *diff.Deleted:
-				d := matchedDelta.(*diff.Deleted)
-				f.printRecursive(positionStr, d.Value, ChangeDeleted)
+				f.printRecursive(positionStr, matchedDelta.Value, ChangeDeleted)
 
 
 			default:
 			default:
 				return errors.New("Unknown Delta type detected")
 				return errors.New("Unknown Delta type detected")
@@ -305,13 +298,13 @@ func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, posi
 func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, position diff.Position) (results []diff.Delta) {
 func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, position diff.Position) (results []diff.Delta) {
 	results = make([]diff.Delta, 0)
 	results = make([]diff.Delta, 0)
 	for _, delta := range deltas {
 	for _, delta := range deltas {
-		switch delta.(type) {
+		switch typedDelta := delta.(type) {
 		case diff.PostDelta:
 		case diff.PostDelta:
-			if delta.(diff.PostDelta).PostPosition() == position {
+			if typedDelta.PostPosition() == position {
 				results = append(results, delta)
 				results = append(results, delta)
 			}
 			}
 		case diff.PreDelta:
 		case diff.PreDelta:
-			if delta.(diff.PreDelta).PrePosition() == position {
+			if typedDelta.PrePosition() == position {
 				results = append(results, delta)
 				results = append(results, delta)
 			}
 			}
 		default:
 		default:
@@ -417,20 +410,19 @@ func (f *JSONFormatter) print(a string) {
 }
 }
 
 
 func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
 func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
-	switch value.(type) {
+	switch value := value.(type) {
 	case map[string]interface{}:
 	case map[string]interface{}:
 		f.newLine(change)
 		f.newLine(change)
 		f.printKey(name)
 		f.printKey(name)
 		f.print("{")
 		f.print("{")
 		f.closeLine()
 		f.closeLine()
 
 
-		m := value.(map[string]interface{})
-		size := len(m)
+		size := len(value)
 		f.push(name, size, false)
 		f.push(name, size, false)
 
 
-		keys := sortKeys(m)
+		keys := sortKeys(value)
 		for _, key := range keys {
 		for _, key := range keys {
-			f.printRecursive(key, m[key], change)
+			f.printRecursive(key, value[key], change)
 		}
 		}
 		f.pop()
 		f.pop()
 
 
@@ -445,10 +437,9 @@ func (f *JSONFormatter) printRecursive(name string, value interface{}, change Ch
 		f.print("[")
 		f.print("[")
 		f.closeLine()
 		f.closeLine()
 
 
-		s := value.([]interface{})
-		size := len(s)
+		size := len(value)
 		f.push("", size, true)
 		f.push("", size, true)
-		for _, item := range s {
+		for _, item := range value {
 			f.printRecursive("", item, change)
 			f.printRecursive("", item, change)
 		}
 		}
 		f.pop()
 		f.pop()

+ 2 - 0
pkg/login/ldap.go

@@ -292,6 +292,8 @@ func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) {
 			Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
 			Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1),
 		}
 		}
 
 
+		a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
+
 		searchResult, err = a.conn.Search(&searchReq)
 		searchResult, err = a.conn.Search(&searchReq)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err

+ 21 - 7
pkg/middleware/auth_proxy.go

@@ -198,17 +198,31 @@ func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error
 	}
 	}
 
 
 	proxies := strings.Split(setting.AuthProxyWhitelist, ",")
 	proxies := strings.Split(setting.AuthProxyWhitelist, ",")
-	sourceIP, _, err := net.SplitHostPort(remoteAddr)
-	if err != nil {
-		return err
+	var proxyObjs []*net.IPNet
+	for _, proxy := range proxies {
+		proxyObjs = append(proxyObjs, coerceProxyAddress(proxy))
 	}
 	}
 
 
-	// Compare allowed IP addresses to actual address
-	for _, proxyIP := range proxies {
-		if sourceIP == strings.TrimSpace(proxyIP) {
+	sourceIP, _, _ := net.SplitHostPort(remoteAddr)
+	sourceObj := net.ParseIP(sourceIP)
+
+	for _, proxyObj := range proxyObjs {
+		if proxyObj.Contains(sourceObj) {
 			return nil
 			return nil
 		}
 		}
 	}
 	}
-
 	return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
 	return fmt.Errorf("Request for user (%s) from %s is not from the authentication proxy", proxyHeaderValue, sourceIP)
 }
 }
+
+func coerceProxyAddress(proxyAddr string) *net.IPNet {
+	proxyAddr = strings.TrimSpace(proxyAddr)
+	if !strings.Contains(proxyAddr, "/") {
+		proxyAddr = strings.Join([]string{proxyAddr, "32"}, "/")
+	}
+
+	_, network, err := net.ParseCIDR(proxyAddr)
+	if err != nil {
+		fmt.Println(err)
+	}
+	return network
+}

+ 90 - 0
pkg/middleware/middleware_test.go

@@ -271,6 +271,23 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
+		middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) {
+			setting.AuthProxyEnabled = true
+			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+			setting.AuthProxyHeaderProperty = "username"
+			setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
+
+			sc.fakeReq("GET", "/")
+			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
+			sc.req.RemoteAddr = "192.168.3.1:12345"
+			sc.exec()
+
+			Convey("should return 407 status code", func() {
+				So(sc.resp.Code, ShouldEqual, 407)
+				So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 192.168.3.1 is not from the authentication proxy")
+			})
+		})
+
 		middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) {
 		middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not trusted", func(sc *scenarioContext) {
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
@@ -288,6 +305,23 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
+		middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is not within trusted CIDR block", func(sc *scenarioContext) {
+			setting.AuthProxyEnabled = true
+			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+			setting.AuthProxyHeaderProperty = "username"
+			setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
+
+			sc.fakeReq("GET", "/")
+			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
+			sc.req.RemoteAddr = "[2001:23]:12345"
+			sc.exec()
+
+			Convey("should return 407 status code", func() {
+				So(sc.resp.Code, ShouldEqual, 407)
+				So(sc.resp.Body.String(), ShouldContainSubstring, "Request for user (torkelo) from 2001:23 is not from the authentication proxy")
+			})
+		})
+
 		middlewareScenario("When auth_proxy is enabled and request RemoteAddr is trusted", func(sc *scenarioContext) {
 		middlewareScenario("When auth_proxy is enabled and request RemoteAddr is trusted", func(sc *scenarioContext) {
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
@@ -316,6 +350,62 @@ func TestMiddlewareContext(t *testing.T) {
 			})
 			})
 		})
 		})
 
 
+		middlewareScenario("When auth_proxy is enabled and IPv4 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) {
+			setting.AuthProxyEnabled = true
+			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+			setting.AuthProxyHeaderProperty = "username"
+			setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
+
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
+				cmd.Result = &m.User{Id: 33}
+				return nil
+			})
+
+			sc.fakeReq("GET", "/")
+			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
+			sc.req.RemoteAddr = "192.168.1.10:12345"
+			sc.exec()
+
+			Convey("Should init context with user info", func() {
+				So(sc.context.IsSignedIn, ShouldBeTrue)
+				So(sc.context.UserId, ShouldEqual, 33)
+				So(sc.context.OrgId, ShouldEqual, 4)
+			})
+		})
+
+		middlewareScenario("When auth_proxy is enabled and IPv6 request RemoteAddr is within trusted CIDR block", func(sc *scenarioContext) {
+			setting.AuthProxyEnabled = true
+			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
+			setting.AuthProxyHeaderProperty = "username"
+			setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
+
+			bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
+				query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
+				cmd.Result = &m.User{Id: 33}
+				return nil
+			})
+
+			sc.fakeReq("GET", "/")
+			sc.req.Header.Add("X-WEBAUTH-USER", "torkelo")
+			sc.req.RemoteAddr = "[2001::23]:12345"
+			sc.exec()
+
+			Convey("Should init context with user info", func() {
+				So(sc.context.IsSignedIn, ShouldBeTrue)
+				So(sc.context.UserId, ShouldEqual, 33)
+				So(sc.context.OrgId, ShouldEqual, 4)
+			})
+		})
+
 		middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) {
 		middlewareScenario("When session exists for previous user, create a new session", func(sc *scenarioContext) {
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"

+ 13 - 9
pkg/models/dashboard_snapshot.go

@@ -8,14 +8,15 @@ import (
 
 
 // DashboardSnapshot model
 // DashboardSnapshot model
 type DashboardSnapshot struct {
 type DashboardSnapshot struct {
-	Id          int64
-	Name        string
-	Key         string
-	DeleteKey   string
-	OrgId       int64
-	UserId      int64
-	External    bool
-	ExternalUrl string
+	Id                int64
+	Name              string
+	Key               string
+	DeleteKey         string
+	OrgId             int64
+	UserId            int64
+	External          bool
+	ExternalUrl       string
+	ExternalDeleteUrl string
 
 
 	Expires time.Time
 	Expires time.Time
 	Created time.Time
 	Created time.Time
@@ -48,7 +49,10 @@ type CreateDashboardSnapshotCommand struct {
 	Expires   int64            `json:"expires"`
 	Expires   int64            `json:"expires"`
 
 
 	// these are passed when storing an external snapshot ref
 	// these are passed when storing an external snapshot ref
-	External  bool   `json:"external"`
+	External          bool   `json:"external"`
+	ExternalUrl       string `json:"-"`
+	ExternalDeleteUrl string `json:"-"`
+
 	Key       string `json:"key"`
 	Key       string `json:"key"`
 	DeleteKey string `json:"deleteKey"`
 	DeleteKey string `json:"deleteKey"`
 
 

+ 0 - 12
pkg/plugins/datasource_plugin.go

@@ -3,10 +3,8 @@ package plugins
 import (
 import (
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
-	"os"
 	"os/exec"
 	"os/exec"
 	"path"
 	"path"
-	"path/filepath"
 	"time"
 	"time"
 
 
 	"github.com/grafana/grafana-plugin-model/go/datasource"
 	"github.com/grafana/grafana-plugin-model/go/datasource"
@@ -29,7 +27,6 @@ type DataSourcePlugin struct {
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	Mixed        bool              `json:"mixed,omitempty"`
 	Mixed        bool              `json:"mixed,omitempty"`
-	HasQueryHelp bool              `json:"hasQueryHelp,omitempty"`
 	Routes       []*AppPluginRoute `json:"routes"`
 	Routes       []*AppPluginRoute `json:"routes"`
 
 
 	Backend    bool   `json:"backend,omitempty"`
 	Backend    bool   `json:"backend,omitempty"`
@@ -48,15 +45,6 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error {
 		return err
 		return err
 	}
 	}
 
 
-	// look for help markdown
-	helpPath := filepath.Join(p.PluginDir, "QUERY_HELP.md")
-	if _, err := os.Stat(helpPath); os.IsNotExist(err) {
-		helpPath = filepath.Join(p.PluginDir, "query_help.md")
-	}
-	if _, err := os.Stat(helpPath); err == nil {
-		p.HasQueryHelp = true
-	}
-
 	DataSources[p.Id] = p
 	DataSources[p.Id] = p
 	return nil
 	return nil
 }
 }

+ 4 - 2
pkg/services/alerting/notifier.go

@@ -166,7 +166,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
 
 
 	var result notifierStateSlice
 	var result notifierStateSlice
 	for _, notification := range query.Result {
 	for _, notification := range query.Result {
-		not, err := n.createNotifierFor(notification)
+		not, err := InitNotifier(notification)
 		if err != nil {
 		if err != nil {
 			n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
 			n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
 			continue
 			continue
@@ -195,7 +195,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
 	return result, nil
 	return result, nil
 }
 }
 
 
-func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Notifier, error) {
+// InitNotifier instantiate a new notifier based on the model
+func InitNotifier(model *m.AlertNotification) (Notifier, error) {
 	notifierPlugin, found := notifierFactories[model.Type]
 	notifierPlugin, found := notifierFactories[model.Type]
 	if !found {
 	if !found {
 		return nil, errors.New("Unsupported notification type")
 		return nil, errors.New("Unsupported notification type")
@@ -208,6 +209,7 @@ type NotifierFactory func(notification *m.AlertNotification) (Notifier, error)
 
 
 var notifierFactories = make(map[string]*NotifierPlugin)
 var notifierFactories = make(map[string]*NotifierPlugin)
 
 
+// RegisterNotifier register an notifier
 func RegisterNotifier(plugin *NotifierPlugin) {
 func RegisterNotifier(plugin *NotifierPlugin) {
 	notifierFactories[plugin.Type] = plugin
 	notifierFactories[plugin.Type] = plugin
 }
 }

+ 1 - 1
pkg/services/alerting/test_notification.go

@@ -32,7 +32,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
 		Settings: cmd.Settings,
 		Settings: cmd.Settings,
 	}
 	}
 
 
-	notifiers, err := notifier.createNotifierFor(model)
+	notifiers, err := InitNotifier(model)
 
 
 	if err != nil {
 	if err != nil {
 		log.Error2("Failed to create notifier", "error", err.Error())
 		log.Error2("Failed to create notifier", "error", err.Error())

+ 3 - 1
pkg/services/dashboards/dashboard_service.go

@@ -76,7 +76,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
 		return nil, models.ErrDashboardFolderCannotHaveParent
 		return nil, models.ErrDashboardFolderCannotHaveParent
 	}
 	}
 
 
-	if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(models.RootFolderName) {
+	if dash.IsFolder && strings.EqualFold(dash.Title, models.RootFolderName) {
 		return nil, models.ErrDashboardFolderNameExists
 		return nil, models.ErrDashboardFolderNameExists
 	}
 	}
 
 
@@ -175,7 +175,9 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 	dto.User = &models.SignedInUser{
 	dto.User = &models.SignedInUser{
 		UserId:  0,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 		OrgRole: models.ROLE_ADMIN,
+		OrgId:   dto.OrgId,
 	}
 	}
+
 	cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
 	cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err

+ 5 - 1
pkg/services/notifications/webhook.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
 	"fmt"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"io/ioutil"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
@@ -69,11 +70,14 @@ func (ns *NotificationService) sendWebRequestSync(ctx context.Context, webhook *
 		return err
 		return err
 	}
 	}
 
 
+	defer resp.Body.Close()
+
 	if resp.StatusCode/100 == 2 {
 	if resp.StatusCode/100 == 2 {
+		// flushing the body enables the transport to reuse the same connection
+		io.Copy(ioutil.Discard, resp.Body)
 		return nil
 		return nil
 	}
 	}
 
 
-	defer resp.Body.Close()
 	body, err := ioutil.ReadAll(resp.Body)
 	body, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 27 - 12
pkg/services/session/mysql.go

@@ -29,18 +29,22 @@ import (
 
 
 // MysqlStore represents a mysql session store implementation.
 // MysqlStore represents a mysql session store implementation.
 type MysqlStore struct {
 type MysqlStore struct {
-	c    *sql.DB
-	sid  string
-	lock sync.RWMutex
-	data map[interface{}]interface{}
+	c      *sql.DB
+	sid    string
+	lock   sync.RWMutex
+	data   map[interface{}]interface{}
+	expiry int64
+	dirty  bool
 }
 }
 
 
 // NewMysqlStore creates and returns a mysql session store.
 // NewMysqlStore creates and returns a mysql session store.
-func NewMysqlStore(c *sql.DB, sid string, kv map[interface{}]interface{}) *MysqlStore {
+func NewMysqlStore(c *sql.DB, sid string, kv map[interface{}]interface{}, expiry int64) *MysqlStore {
 	return &MysqlStore{
 	return &MysqlStore{
-		c:    c,
-		sid:  sid,
-		data: kv,
+		c:      c,
+		sid:    sid,
+		data:   kv,
+		expiry: expiry,
+		dirty:  false,
 	}
 	}
 }
 }
 
 
@@ -50,6 +54,7 @@ func (s *MysqlStore) Set(key, val interface{}) error {
 	defer s.lock.Unlock()
 	defer s.lock.Unlock()
 
 
 	s.data[key] = val
 	s.data[key] = val
+	s.dirty = true
 	return nil
 	return nil
 }
 }
 
 
@@ -67,6 +72,7 @@ func (s *MysqlStore) Delete(key interface{}) error {
 	defer s.lock.Unlock()
 	defer s.lock.Unlock()
 
 
 	delete(s.data, key)
 	delete(s.data, key)
+	s.dirty = true
 	return nil
 	return nil
 }
 }
 
 
@@ -77,13 +83,20 @@ func (s *MysqlStore) ID() string {
 
 
 // Release releases resource and save data to provider.
 // Release releases resource and save data to provider.
 func (s *MysqlStore) Release() error {
 func (s *MysqlStore) Release() error {
+	newExpiry := time.Now().Unix()
+	if !s.dirty && (s.expiry+60) >= newExpiry {
+		return nil
+	}
+
 	data, err := session.EncodeGob(s.data)
 	data, err := session.EncodeGob(s.data)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	_, err = s.c.Exec("UPDATE session SET data=?, expiry=? WHERE `key`=?",
 	_, err = s.c.Exec("UPDATE session SET data=?, expiry=? WHERE `key`=?",
-		data, time.Now().Unix(), s.sid)
+		data, newExpiry, s.sid)
+	s.dirty = false
+	s.expiry = newExpiry
 	return err
 	return err
 }
 }
 
 
@@ -93,6 +106,7 @@ func (s *MysqlStore) Flush() error {
 	defer s.lock.Unlock()
 	defer s.lock.Unlock()
 
 
 	s.data = make(map[interface{}]interface{})
 	s.data = make(map[interface{}]interface{})
+	s.dirty = true
 	return nil
 	return nil
 }
 }
 
 
@@ -117,11 +131,12 @@ func (p *MysqlProvider) Init(expire int64, connStr string) (err error) {
 
 
 // Read returns raw session store by session ID.
 // Read returns raw session store by session ID.
 func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
 func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
+	expiry := time.Now().Unix()
 	var data []byte
 	var data []byte
-	err := p.c.QueryRow("SELECT data FROM session WHERE `key`=?", sid).Scan(&data)
+	err := p.c.QueryRow("SELECT data,expiry FROM session WHERE `key`=?", sid).Scan(&data, &expiry)
 	if err == sql.ErrNoRows {
 	if err == sql.ErrNoRows {
 		_, err = p.c.Exec("INSERT INTO session(`key`,data,expiry) VALUES(?,?,?)",
 		_, err = p.c.Exec("INSERT INTO session(`key`,data,expiry) VALUES(?,?,?)",
-			sid, "", time.Now().Unix())
+			sid, "", expiry)
 	}
 	}
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -137,7 +152,7 @@ func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
 		}
 		}
 	}
 	}
 
 
-	return NewMysqlStore(p.c, sid, kv), nil
+	return NewMysqlStore(p.c, sid, kv, expiry), nil
 }
 }
 
 
 // Exist returns true if session with given ID exists.
 // Exist returns true if session with given ID exists.

+ 12 - 10
pkg/services/sqlstore/dashboard_snapshot.go

@@ -47,16 +47,18 @@ func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error {
 		}
 		}
 
 
 		snapshot := &m.DashboardSnapshot{
 		snapshot := &m.DashboardSnapshot{
-			Name:      cmd.Name,
-			Key:       cmd.Key,
-			DeleteKey: cmd.DeleteKey,
-			OrgId:     cmd.OrgId,
-			UserId:    cmd.UserId,
-			External:  cmd.External,
-			Dashboard: cmd.Dashboard,
-			Expires:   expires,
-			Created:   time.Now(),
-			Updated:   time.Now(),
+			Name:              cmd.Name,
+			Key:               cmd.Key,
+			DeleteKey:         cmd.DeleteKey,
+			OrgId:             cmd.OrgId,
+			UserId:            cmd.UserId,
+			External:          cmd.External,
+			ExternalUrl:       cmd.ExternalUrl,
+			ExternalDeleteUrl: cmd.ExternalDeleteUrl,
+			Dashboard:         cmd.Dashboard,
+			Expires:           expires,
+			Created:           time.Now(),
+			Updated:           time.Now(),
 		}
 		}
 
 
 		_, err := sess.Insert(snapshot)
 		_, err := sess.Insert(snapshot)

+ 2 - 2
pkg/services/sqlstore/datasource.go

@@ -53,14 +53,14 @@ func GetDataSourceByName(query *m.GetDataSourceByNameQuery) error {
 }
 }
 
 
 func GetDataSources(query *m.GetDataSourcesQuery) error {
 func GetDataSources(query *m.GetDataSourcesQuery) error {
-	sess := x.Limit(1000, 0).Where("org_id=?", query.OrgId).Asc("name")
+	sess := x.Limit(5000, 0).Where("org_id=?", query.OrgId).Asc("name")
 
 
 	query.Result = make([]*m.DataSource, 0)
 	query.Result = make([]*m.DataSource, 0)
 	return sess.Find(&query.Result)
 	return sess.Find(&query.Result)
 }
 }
 
 
 func GetAllDataSources(query *m.GetAllDataSourcesQuery) error {
 func GetAllDataSources(query *m.GetAllDataSourcesQuery) error {
-	sess := x.Limit(1000, 0).Asc("name")
+	sess := x.Limit(5000, 0).Asc("name")
 
 
 	query.Result = make([]*m.DataSource, 0)
 	query.Result = make([]*m.DataSource, 0)
 	return sess.Find(&query.Result)
 	return sess.Find(&query.Result)

+ 4 - 4
pkg/services/sqlstore/login_attempt.go

@@ -78,14 +78,14 @@ func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error {
 }
 }
 
 
 func toInt64(i interface{}) int64 {
 func toInt64(i interface{}) int64 {
-	switch i.(type) {
+	switch i := i.(type) {
 	case []byte:
 	case []byte:
-		n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64)
+		n, _ := strconv.ParseInt(string(i), 10, 64)
 		return n
 		return n
 	case int:
 	case int:
-		return int64(i.(int))
+		return int64(i)
 	case int64:
 	case int64:
-		return i.(int64)
+		return i
 	}
 	}
 	return 0
 	return 0
 }
 }

+ 4 - 0
pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go

@@ -60,4 +60,8 @@ func addDashboardSnapshotMigrations(mg *Migrator) {
 		{Name: "external_url", Type: DB_NVarchar, Length: 255, Nullable: false},
 		{Name: "external_url", Type: DB_NVarchar, Length: 255, Nullable: false},
 		{Name: "dashboard", Type: DB_MediumText, Nullable: false},
 		{Name: "dashboard", Type: DB_MediumText, Nullable: false},
 	}))
 	}))
+
+	mg.AddMigration("Add column external_delete_url to dashboard_snapshots table", NewAddColumnMigration(snapshotV5, &Column{
+		Name: "external_delete_url", Type: DB_NVarchar, Length: 255, Nullable: true,
+	}))
 }
 }

+ 38 - 3
pkg/services/sqlstore/migrator/conditions.go

@@ -2,12 +2,47 @@ package migrator
 
 
 type MigrationCondition interface {
 type MigrationCondition interface {
 	Sql(dialect Dialect) (string, []interface{})
 	Sql(dialect Dialect) (string, []interface{})
+	IsFulfilled(results []map[string][]byte) bool
 }
 }
 
 
-type IfTableExistsCondition struct {
+type ExistsMigrationCondition struct{}
+
+func (c *ExistsMigrationCondition) IsFulfilled(results []map[string][]byte) bool {
+	return len(results) >= 1
+}
+
+type NotExistsMigrationCondition struct{}
+
+func (c *NotExistsMigrationCondition) IsFulfilled(results []map[string][]byte) bool {
+	return len(results) == 0
+}
+
+type IfIndexExistsCondition struct {
+	ExistsMigrationCondition
 	TableName string
 	TableName string
+	IndexName string
+}
+
+func (c *IfIndexExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
+	return dialect.IndexCheckSql(c.TableName, c.IndexName)
+}
+
+type IfIndexNotExistsCondition struct {
+	NotExistsMigrationCondition
+	TableName string
+	IndexName string
+}
+
+func (c *IfIndexNotExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
+	return dialect.IndexCheckSql(c.TableName, c.IndexName)
+}
+
+type IfColumnNotExistsCondition struct {
+	NotExistsMigrationCondition
+	TableName  string
+	ColumnName string
 }
 }
 
 
-func (c *IfTableExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
-	return dialect.TableCheckSql(c.TableName)
+func (c *IfColumnNotExistsCondition) Sql(dialect Dialect) (string, []interface{}) {
+	return dialect.ColumnCheckSql(c.TableName, c.ColumnName)
 }
 }

+ 7 - 1
pkg/services/sqlstore/migrator/dialect.go

@@ -29,10 +29,12 @@ type Dialect interface {
 	DropTable(tableName string) string
 	DropTable(tableName string) string
 	DropIndexSql(tableName string, index *Index) string
 	DropIndexSql(tableName string, index *Index) string
 
 
-	TableCheckSql(tableName string) (string, []interface{})
 	RenameTable(oldName string, newName string) string
 	RenameTable(oldName string, newName string) string
 	UpdateTableSql(tableName string, columns []*Column) string
 	UpdateTableSql(tableName string, columns []*Column) string
 
 
+	IndexCheckSql(tableName, indexName string) (string, []interface{})
+	ColumnCheckSql(tableName, columnName string) (string, []interface{})
+
 	ColString(*Column) string
 	ColString(*Column) string
 	ColStringNoPk(*Column) string
 	ColStringNoPk(*Column) string
 
 
@@ -182,6 +184,10 @@ func (db *BaseDialect) RenameTable(oldName string, newName string) string {
 	return fmt.Sprintf("ALTER TABLE %s RENAME TO %s", quote(oldName), quote(newName))
 	return fmt.Sprintf("ALTER TABLE %s RENAME TO %s", quote(oldName), quote(newName))
 }
 }
 
 
+func (db *BaseDialect) ColumnCheckSql(tableName, columnName string) (string, []interface{}) {
+	return "", nil
+}
+
 func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string {
 func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string {
 	quote := db.dialect.Quote
 	quote := db.dialect.Quote
 	name := index.XName(tableName)
 	name := index.XName(tableName)

+ 9 - 13
pkg/services/sqlstore/migrator/migrations.go

@@ -85,7 +85,9 @@ type AddColumnMigration struct {
 }
 }
 
 
 func NewAddColumnMigration(table Table, col *Column) *AddColumnMigration {
 func NewAddColumnMigration(table Table, col *Column) *AddColumnMigration {
-	return &AddColumnMigration{tableName: table.Name, column: col}
+	m := &AddColumnMigration{tableName: table.Name, column: col}
+	m.Condition = &IfColumnNotExistsCondition{TableName: table.Name, ColumnName: col.Name}
+	return m
 }
 }
 
 
 func (m *AddColumnMigration) Table(tableName string) *AddColumnMigration {
 func (m *AddColumnMigration) Table(tableName string) *AddColumnMigration {
@@ -109,7 +111,9 @@ type AddIndexMigration struct {
 }
 }
 
 
 func NewAddIndexMigration(table Table, index *Index) *AddIndexMigration {
 func NewAddIndexMigration(table Table, index *Index) *AddIndexMigration {
-	return &AddIndexMigration{tableName: table.Name, index: index}
+	m := &AddIndexMigration{tableName: table.Name, index: index}
+	m.Condition = &IfIndexNotExistsCondition{TableName: table.Name, IndexName: index.XName(table.Name)}
+	return m
 }
 }
 
 
 func (m *AddIndexMigration) Table(tableName string) *AddIndexMigration {
 func (m *AddIndexMigration) Table(tableName string) *AddIndexMigration {
@@ -128,7 +132,9 @@ type DropIndexMigration struct {
 }
 }
 
 
 func NewDropIndexMigration(table Table, index *Index) *DropIndexMigration {
 func NewDropIndexMigration(table Table, index *Index) *DropIndexMigration {
-	return &DropIndexMigration{tableName: table.Name, index: index}
+	m := &DropIndexMigration{tableName: table.Name, index: index}
+	m.Condition = &IfIndexExistsCondition{TableName: table.Name, IndexName: index.XName(table.Name)}
+	return m
 }
 }
 
 
 func (m *DropIndexMigration) Sql(dialect Dialect) string {
 func (m *DropIndexMigration) Sql(dialect Dialect) string {
@@ -179,11 +185,6 @@ func NewRenameTableMigration(oldName string, newName string) *RenameTableMigrati
 	return &RenameTableMigration{oldName: oldName, newName: newName}
 	return &RenameTableMigration{oldName: oldName, newName: newName}
 }
 }
 
 
-func (m *RenameTableMigration) IfTableExists(tableName string) *RenameTableMigration {
-	m.Condition = &IfTableExistsCondition{TableName: tableName}
-	return m
-}
-
 func (m *RenameTableMigration) Rename(oldName string, newName string) *RenameTableMigration {
 func (m *RenameTableMigration) Rename(oldName string, newName string) *RenameTableMigration {
 	m.oldName = oldName
 	m.oldName = oldName
 	m.newName = newName
 	m.newName = newName
@@ -212,11 +213,6 @@ func NewCopyTableDataMigration(targetTable string, sourceTable string, colMap ma
 	return m
 	return m
 }
 }
 
 
-func (m *CopyTableDataMigration) IfTableExists(tableName string) *CopyTableDataMigration {
-	m.Condition = &IfTableExistsCondition{TableName: tableName}
-	return m
-}
-
 func (m *CopyTableDataMigration) Sql(d Dialect) string {
 func (m *CopyTableDataMigration) Sql(d Dialect) string {
 	return d.CopyTableData(m.sourceTable, m.targetTable, m.sourceCols, m.targetCols)
 	return d.CopyTableData(m.sourceTable, m.targetTable, m.sourceCols, m.targetCols)
 }
 }

+ 17 - 7
pkg/services/sqlstore/migrator/migrator.go

@@ -94,8 +94,6 @@ func (mg *Migrator) Start() error {
 			Timestamp:   time.Now(),
 			Timestamp:   time.Now(),
 		}
 		}
 
 
-		mg.Logger.Debug("Executing", "sql", sql)
-
 		err := mg.inTransaction(func(sess *xorm.Session) error {
 		err := mg.inTransaction(func(sess *xorm.Session) error {
 			err := mg.exec(m, sess)
 			err := mg.exec(m, sess)
 			if err != nil {
 			if err != nil {
@@ -123,18 +121,30 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 	condition := m.GetCondition()
 	condition := m.GetCondition()
 	if condition != nil {
 	if condition != nil {
 		sql, args := condition.Sql(mg.Dialect)
 		sql, args := condition.Sql(mg.Dialect)
-		results, err := sess.SQL(sql).Query(args...)
-		if err != nil || len(results) == 0 {
-			mg.Logger.Debug("Skipping migration condition not fulfilled", "id", m.Id())
-			return sess.Rollback()
+
+		if sql != "" {
+			mg.Logger.Debug("Executing migration condition sql", "id", m.Id(), "sql", sql, "args", args)
+			results, err := sess.SQL(sql, args...).Query()
+			if err != nil {
+				mg.Logger.Error("Executing migration condition failed", "id", m.Id(), "error", err)
+				return err
+			}
+
+			if !condition.IsFulfilled(results) {
+				mg.Logger.Warn("Skipping migration: Already executed, but not recorded in migration log", "id", m.Id())
+				return nil
+			}
 		}
 		}
 	}
 	}
 
 
 	var err error
 	var err error
 	if codeMigration, ok := m.(CodeMigration); ok {
 	if codeMigration, ok := m.(CodeMigration); ok {
+		mg.Logger.Debug("Executing code migration", "id", m.Id())
 		err = codeMigration.Exec(sess, mg)
 		err = codeMigration.Exec(sess, mg)
 	} else {
 	} else {
-		_, err = sess.Exec(m.Sql(mg.Dialect))
+		sql := m.Sql(mg.Dialect)
+		mg.Logger.Debug("Executing sql migration", "id", m.Id(), "sql", sql)
+		_, err = sess.Exec(sql)
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {

+ 12 - 6
pkg/services/sqlstore/migrator/mysql_dialect.go

@@ -90,12 +90,6 @@ func (db *Mysql) SqlType(c *Column) string {
 	return res
 	return res
 }
 }
 
 
-func (db *Mysql) TableCheckSql(tableName string) (string, []interface{}) {
-	args := []interface{}{"grafana", tableName}
-	sql := "SELECT `TABLE_NAME` from `INFORMATION_SCHEMA`.`TABLES` WHERE `TABLE_SCHEMA`=? and `TABLE_NAME`=?"
-	return sql, args
-}
-
 func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
 func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
 	var statements = []string{}
 	var statements = []string{}
 
 
@@ -108,6 +102,18 @@ func (db *Mysql) UpdateTableSql(tableName string, columns []*Column) string {
 	return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";"
 	return "ALTER TABLE " + db.Quote(tableName) + " " + strings.Join(statements, ", ") + ";"
 }
 }
 
 
+func (db *Mysql) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
+	args := []interface{}{tableName, indexName}
+	sql := "SELECT 1 FROM " + db.Quote("INFORMATION_SCHEMA") + "." + db.Quote("STATISTICS") + " WHERE " + db.Quote("TABLE_SCHEMA") + " = DATABASE() AND " + db.Quote("TABLE_NAME") + "=? AND " + db.Quote("INDEX_NAME") + "=?"
+	return sql, args
+}
+
+func (db *Mysql) ColumnCheckSql(tableName, columnName string) (string, []interface{}) {
+	args := []interface{}{tableName, columnName}
+	sql := "SELECT 1 FROM " + db.Quote("INFORMATION_SCHEMA") + "." + db.Quote("COLUMNS") + " WHERE " + db.Quote("TABLE_SCHEMA") + " = DATABASE() AND " + db.Quote("TABLE_NAME") + "=? AND " + db.Quote("COLUMN_NAME") + "=?"
+	return sql, args
+}
+
 func (db *Mysql) CleanDB() error {
 func (db *Mysql) CleanDB() error {
 	tables, _ := db.engine.DBMetas()
 	tables, _ := db.engine.DBMetas()
 	sess := db.engine.NewSession()
 	sess := db.engine.NewSession()

+ 3 - 3
pkg/services/sqlstore/migrator/postgres_dialect.go

@@ -101,9 +101,9 @@ func (db *Postgres) SqlType(c *Column) string {
 	return res
 	return res
 }
 }
 
 
-func (db *Postgres) TableCheckSql(tableName string) (string, []interface{}) {
-	args := []interface{}{"grafana", tableName}
-	sql := "SELECT table_name FROM information_schema.tables WHERE table_schema=? and table_name=?"
+func (db *Postgres) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
+	args := []interface{}{tableName, indexName}
+	sql := "SELECT 1 FROM " + db.Quote("pg_indexes") + " WHERE" + db.Quote("tablename") + "=? AND " + db.Quote("indexname") + "=?"
 	return sql, args
 	return sql, args
 }
 }
 
 

+ 4 - 3
pkg/services/sqlstore/migrator/sqlite_dialect.go

@@ -68,9 +68,10 @@ func (db *Sqlite3) SqlType(c *Column) string {
 	}
 	}
 }
 }
 
 
-func (db *Sqlite3) TableCheckSql(tableName string) (string, []interface{}) {
-	args := []interface{}{tableName}
-	return "SELECT name FROM sqlite_master WHERE type='table' and name = ?", args
+func (db *Sqlite3) IndexCheckSql(tableName, indexName string) (string, []interface{}) {
+	args := []interface{}{tableName, indexName}
+	sql := "SELECT 1 FROM " + db.Quote("sqlite_master") + " WHERE " + db.Quote("type") + "='index' AND " + db.Quote("tbl_name") + "=? AND " + db.Quote("name") + "=?"
+	return sql, args
 }
 }
 
 
 func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {
 func (db *Sqlite3) DropIndexSql(tableName string, index *Index) string {

+ 19 - 10
pkg/services/sqlstore/sqlstore.go

@@ -243,7 +243,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
 			ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path)
 			ss.dbCfg.Path = filepath.Join(ss.Cfg.DataPath, ss.dbCfg.Path)
 		}
 		}
 		os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
 		os.MkdirAll(path.Dir(ss.dbCfg.Path), os.ModePerm)
-		cnnstr = "file:" + ss.dbCfg.Path + "?cache=shared&mode=rwc"
+		cnnstr = fmt.Sprintf("file:%s?cache=%s&mode=rwc", ss.dbCfg.Path, ss.dbCfg.CacheMode)
 	default:
 	default:
 		return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
 		return "", fmt.Errorf("Unknown database type: %s", ss.dbCfg.Type)
 	}
 	}
@@ -319,6 +319,8 @@ func (ss *SqlStore) readConfig() {
 	ss.dbCfg.ClientCertPath = sec.Key("client_cert_path").String()
 	ss.dbCfg.ClientCertPath = sec.Key("client_cert_path").String()
 	ss.dbCfg.ServerCertName = sec.Key("server_cert_name").String()
 	ss.dbCfg.ServerCertName = sec.Key("server_cert_name").String()
 	ss.dbCfg.Path = sec.Key("path").MustString("data/grafana.db")
 	ss.dbCfg.Path = sec.Key("path").MustString("data/grafana.db")
+
+	ss.dbCfg.CacheMode = sec.Key("cache_mode").MustString("private")
 }
 }
 
 
 func InitTestDB(t *testing.T) *SqlStore {
 func InitTestDB(t *testing.T) *SqlStore {
@@ -391,13 +393,20 @@ func IsTestDbPostgres() bool {
 }
 }
 
 
 type DatabaseConfig struct {
 type DatabaseConfig struct {
-	Type, Host, Name, User, Pwd, Path, SslMode string
-	CaCertPath                                 string
-	ClientKeyPath                              string
-	ClientCertPath                             string
-	ServerCertName                             string
-	ConnectionString                           string
-	MaxOpenConn                                int
-	MaxIdleConn                                int
-	ConnMaxLifetime                            int
+	Type             string
+	Host             string
+	Name             string
+	User             string
+	Pwd              string
+	Path             string
+	SslMode          string
+	CaCertPath       string
+	ClientKeyPath    string
+	ClientCertPath   string
+	ServerCertName   string
+	ConnectionString string
+	MaxOpenConn      int
+	MaxIdleConn      int
+	ConnMaxLifetime  int
+	CacheMode        string
 }
 }

+ 6 - 1
pkg/services/sqlstore/user.go

@@ -345,8 +345,12 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error {
 	return err
 	return err
 }
 }
 
 
+func newSignedInUserCacheKey(orgID, userID int64) string {
+	return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID)
+}
+
 func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error {
 func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error {
-	cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId)
+	cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId)
 	if cached, found := ss.CacheService.Get(cacheKey); found {
 	if cached, found := ss.CacheService.Get(cacheKey); found {
 		query.Result = cached.(*m.SignedInUser)
 		query.Result = cached.(*m.SignedInUser)
 		return nil
 		return nil
@@ -357,6 +361,7 @@ func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) erro
 		return err
 		return err
 	}
 	}
 
 
+	cacheKey = newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
 	ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
 	ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
 	return nil
 	return nil
 }
 }

+ 22 - 1
pkg/services/sqlstore/user_test.go

@@ -13,7 +13,7 @@ import (
 func TestUserDataAccess(t *testing.T) {
 func TestUserDataAccess(t *testing.T) {
 
 
 	Convey("Testing DB", t, func() {
 	Convey("Testing DB", t, func() {
-		InitTestDB(t)
+		ss := InitTestDB(t)
 
 
 		Convey("Creating a user", func() {
 		Convey("Creating a user", func() {
 			cmd := &m.CreateUserCommand{
 			cmd := &m.CreateUserCommand{
@@ -153,6 +153,27 @@ func TestUserDataAccess(t *testing.T) {
 						So(prefsQuery.Result.UserId, ShouldEqual, 0)
 						So(prefsQuery.Result.UserId, ShouldEqual, 0)
 					})
 					})
 				})
 				})
+
+				Convey("when retreiving signed in user for orgId=0 result should return active org id", func() {
+					ss.CacheService.Flush()
+
+					query := &m.GetSignedInUserQuery{OrgId: users[1].OrgId, UserId: users[1].Id}
+					err := ss.GetSignedInUserWithCache(query)
+					So(err, ShouldBeNil)
+					So(query.Result, ShouldNotBeNil)
+					So(query.OrgId, ShouldEqual, users[1].OrgId)
+					err = SetUsingOrg(&m.SetUsingOrgCommand{UserId: users[1].Id, OrgId: users[0].OrgId})
+					So(err, ShouldBeNil)
+					query = &m.GetSignedInUserQuery{OrgId: 0, UserId: users[1].Id}
+					err = ss.GetSignedInUserWithCache(query)
+					So(err, ShouldBeNil)
+					So(query.Result, ShouldNotBeNil)
+					So(query.Result.OrgId, ShouldEqual, users[0].OrgId)
+
+					cacheKey := newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
+					_, found := ss.CacheService.Get(cacheKey)
+					So(found, ShouldBeTrue)
+				})
 			})
 			})
 		})
 		})
 
 

+ 15 - 14
pkg/setting/setting_oauth.go

@@ -1,20 +1,21 @@
 package setting
 package setting
 
 
 type OAuthInfo struct {
 type OAuthInfo struct {
-	ClientId, ClientSecret string
-	Scopes                 []string
-	AuthUrl, TokenUrl      string
-	Enabled                bool
-	EmailAttributeName     string
-	AllowedDomains         []string
-	HostedDomain           string
-	ApiUrl                 string
-	AllowSignup            bool
-	Name                   string
-	TlsClientCert          string
-	TlsClientKey           string
-	TlsClientCa            string
-	TlsSkipVerify          bool
+	ClientId, ClientSecret       string
+	Scopes                       []string
+	AuthUrl, TokenUrl            string
+	Enabled                      bool
+	EmailAttributeName           string
+	AllowedDomains               []string
+	HostedDomain                 string
+	ApiUrl                       string
+	AllowSignup                  bool
+	Name                         string
+	TlsClientCert                string
+	TlsClientKey                 string
+	TlsClientCa                  string
+	TlsSkipVerify                bool
+	SendClientCredentialsViaPost bool
 }
 }
 
 
 type OAuther struct {
 type OAuther struct {

+ 22 - 16
pkg/social/social.go

@@ -63,28 +63,34 @@ func NewOAuthService() {
 	for _, name := range allOauthes {
 	for _, name := range allOauthes {
 		sec := setting.Raw.Section("auth." + name)
 		sec := setting.Raw.Section("auth." + name)
 		info := &setting.OAuthInfo{
 		info := &setting.OAuthInfo{
-			ClientId:           sec.Key("client_id").String(),
-			ClientSecret:       sec.Key("client_secret").String(),
-			Scopes:             util.SplitString(sec.Key("scopes").String()),
-			AuthUrl:            sec.Key("auth_url").String(),
-			TokenUrl:           sec.Key("token_url").String(),
-			ApiUrl:             sec.Key("api_url").String(),
-			Enabled:            sec.Key("enabled").MustBool(),
-			EmailAttributeName: sec.Key("email_attribute_name").String(),
-			AllowedDomains:     util.SplitString(sec.Key("allowed_domains").String()),
-			HostedDomain:       sec.Key("hosted_domain").String(),
-			AllowSignup:        sec.Key("allow_sign_up").MustBool(),
-			Name:               sec.Key("name").MustString(name),
-			TlsClientCert:      sec.Key("tls_client_cert").String(),
-			TlsClientKey:       sec.Key("tls_client_key").String(),
-			TlsClientCa:        sec.Key("tls_client_ca").String(),
-			TlsSkipVerify:      sec.Key("tls_skip_verify_insecure").MustBool(),
+			ClientId:                     sec.Key("client_id").String(),
+			ClientSecret:                 sec.Key("client_secret").String(),
+			Scopes:                       util.SplitString(sec.Key("scopes").String()),
+			AuthUrl:                      sec.Key("auth_url").String(),
+			TokenUrl:                     sec.Key("token_url").String(),
+			ApiUrl:                       sec.Key("api_url").String(),
+			Enabled:                      sec.Key("enabled").MustBool(),
+			EmailAttributeName:           sec.Key("email_attribute_name").String(),
+			AllowedDomains:               util.SplitString(sec.Key("allowed_domains").String()),
+			HostedDomain:                 sec.Key("hosted_domain").String(),
+			AllowSignup:                  sec.Key("allow_sign_up").MustBool(),
+			Name:                         sec.Key("name").MustString(name),
+			TlsClientCert:                sec.Key("tls_client_cert").String(),
+			TlsClientKey:                 sec.Key("tls_client_key").String(),
+			TlsClientCa:                  sec.Key("tls_client_ca").String(),
+			TlsSkipVerify:                sec.Key("tls_skip_verify_insecure").MustBool(),
+			SendClientCredentialsViaPost: sec.Key("send_client_credentials_via_post").MustBool(),
 		}
 		}
 
 
 		if !info.Enabled {
 		if !info.Enabled {
 			continue
 			continue
 		}
 		}
 
 
+		// handle the clients that do not properly support Basic auth headers and require passing client_id/client_secret via POST payload
+		if info.SendClientCredentialsViaPost {
+			oauth2.RegisterBrokenAuthHeaderProvider(info.TokenUrl)
+		}
+
 		if name == "grafananet" {
 		if name == "grafananet" {
 			name = grafanaCom
 			name = grafanaCom
 		}
 		}

+ 1 - 2
pkg/tsdb/cloudwatch/credentials.go

@@ -3,7 +3,6 @@ package cloudwatch
 import (
 import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
-	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
@@ -43,7 +42,7 @@ func GetCredentials(dsInfo *DatasourceInfo) (*credentials.Credentials, error) {
 	secretAccessKey := ""
 	secretAccessKey := ""
 	sessionToken := ""
 	sessionToken := ""
 	var expiration *time.Time = nil
 	var expiration *time.Time = nil
-	if dsInfo.AuthType == "arn" && strings.Index(dsInfo.AssumeRoleArn, "arn:aws:iam:") == 0 {
+	if dsInfo.AuthType == "arn" {
 		params := &sts.AssumeRoleInput{
 		params := &sts.AssumeRoleInput{
 			RoleArn:         aws.String(dsInfo.AssumeRoleArn),
 			RoleArn:         aws.String(dsInfo.AssumeRoleArn),
 			RoleSessionName: aws.String("GrafanaSession"),
 			RoleSessionName: aws.String("GrafanaSession"),

+ 2 - 0
pkg/tsdb/influxdb/model_parser.go

@@ -16,6 +16,7 @@ func (qp *InfluxdbQueryParser) Parse(model *simplejson.Json, dsInfo *models.Data
 	rawQuery := model.Get("query").MustString("")
 	rawQuery := model.Get("query").MustString("")
 	useRawQuery := model.Get("rawQuery").MustBool(false)
 	useRawQuery := model.Get("rawQuery").MustBool(false)
 	alias := model.Get("alias").MustString("")
 	alias := model.Get("alias").MustString("")
+	tz := model.Get("tz").MustString("")
 
 
 	measurement := model.Get("measurement").MustString("")
 	measurement := model.Get("measurement").MustString("")
 
 
@@ -55,6 +56,7 @@ func (qp *InfluxdbQueryParser) Parse(model *simplejson.Json, dsInfo *models.Data
 		Interval:     parsedInterval,
 		Interval:     parsedInterval,
 		Alias:        alias,
 		Alias:        alias,
 		UseRawQuery:  useRawQuery,
 		UseRawQuery:  useRawQuery,
+		Tz:           tz,
 	}, nil
 	}, nil
 }
 }
 
 

+ 2 - 0
pkg/tsdb/influxdb/model_parser_test.go

@@ -41,6 +41,7 @@ func TestInfluxdbQueryParser(t *testing.T) {
           }
           }
         ],
         ],
         "measurement": "logins.count",
         "measurement": "logins.count",
+        "tz": "Europe/Paris",
         "policy": "default",
         "policy": "default",
         "refId": "B",
         "refId": "B",
         "resultFormat": "time_series",
         "resultFormat": "time_series",
@@ -115,6 +116,7 @@ func TestInfluxdbQueryParser(t *testing.T) {
 			So(len(res.GroupBy), ShouldEqual, 3)
 			So(len(res.GroupBy), ShouldEqual, 3)
 			So(len(res.Selects), ShouldEqual, 3)
 			So(len(res.Selects), ShouldEqual, 3)
 			So(len(res.Tags), ShouldEqual, 2)
 			So(len(res.Tags), ShouldEqual, 2)
+			So(res.Tz, ShouldEqual, "Europe/Paris")
 			So(res.Interval, ShouldEqual, time.Second*20)
 			So(res.Interval, ShouldEqual, time.Second*20)
 			So(res.Alias, ShouldEqual, "serie alias")
 			So(res.Alias, ShouldEqual, "serie alias")
 		})
 		})

+ 1 - 0
pkg/tsdb/influxdb/models.go

@@ -13,6 +13,7 @@ type Query struct {
 	UseRawQuery  bool
 	UseRawQuery  bool
 	Alias        string
 	Alias        string
 	Interval     time.Duration
 	Interval     time.Duration
+	Tz           string
 }
 }
 
 
 type Tag struct {
 type Tag struct {

+ 10 - 0
pkg/tsdb/influxdb/query.go

@@ -26,6 +26,7 @@ func (query *Query) Build(queryContext *tsdb.TsdbQuery) (string, error) {
 		res += query.renderWhereClause()
 		res += query.renderWhereClause()
 		res += query.renderTimeFilter(queryContext)
 		res += query.renderTimeFilter(queryContext)
 		res += query.renderGroupBy(queryContext)
 		res += query.renderGroupBy(queryContext)
+		res += query.renderTz()
 	}
 	}
 
 
 	calculator := tsdb.NewIntervalCalculator(&tsdb.IntervalOptions{})
 	calculator := tsdb.NewIntervalCalculator(&tsdb.IntervalOptions{})
@@ -154,3 +155,12 @@ func (query *Query) renderGroupBy(queryContext *tsdb.TsdbQuery) string {
 
 
 	return groupBy
 	return groupBy
 }
 }
+
+func (query *Query) renderTz() string {
+	tz := query.Tz
+	if tz == "" {
+		return ""
+	} else {
+		return fmt.Sprintf(" tz('%s')", tz)
+	}
+}

+ 14 - 0
pkg/tsdb/influxdb/query_test.go

@@ -47,6 +47,20 @@ func TestInfluxdbQueryBuilder(t *testing.T) {
 			So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "policy"."cpu" WHERE time > now() - 5m GROUP BY time(10s) fill(null)`)
 			So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "policy"."cpu" WHERE time > now() - 5m GROUP BY time(10s) fill(null)`)
 		})
 		})
 
 
+		Convey("can build query with tz", func() {
+			query := &Query{
+				Selects:     []*Select{{*qp1, *qp2}},
+				Measurement: "cpu",
+				GroupBy:     []*QueryPart{groupBy1},
+				Tz:          "Europe/Paris",
+				Interval:    time.Second * 5,
+			}
+
+			rawQuery, err := query.Build(queryContext)
+			So(err, ShouldBeNil)
+			So(rawQuery, ShouldEqual, `SELECT mean("value") FROM "cpu" WHERE time > now() - 5m GROUP BY time(5s) tz('Europe/Paris')`)
+		})
+
 		Convey("can build query with group bys", func() {
 		Convey("can build query with group bys", func() {
 			query := &Query{
 			query := &Query{
 				Selects:     []*Select{{*qp1, *qp2}},
 				Selects:     []*Select{{*qp1, *qp2}},

+ 3 - 3
pkg/tsdb/postgres/macros.go

@@ -86,11 +86,11 @@ func (m *postgresMacroEngine) evaluateMacro(name string, args []string) (string,
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 			return "", fmt.Errorf("missing time column argument for macro %v", name)
 		}
 		}
 
 
-		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
+		return fmt.Sprintf("%s BETWEEN '%s' AND '%s'", args[0], m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339Nano), m.timeRange.GetToAsTimeUTC().Format(time.RFC3339Nano)), nil
 	case "__timeFrom":
 	case "__timeFrom":
-		return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339)), nil
+		return fmt.Sprintf("'%s'", m.timeRange.GetFromAsTimeUTC().Format(time.RFC3339Nano)), nil
 	case "__timeTo":
 	case "__timeTo":
-		return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339)), nil
+		return fmt.Sprintf("'%s'", m.timeRange.GetToAsTimeUTC().Format(time.RFC3339Nano)), nil
 	case "__timeGroup":
 	case "__timeGroup":
 		if len(args) < 2 {
 		if len(args) < 2 {
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)
 			return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name)

+ 20 - 3
pkg/tsdb/postgres/macros_test.go

@@ -41,7 +41,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
 			})
 			})
 
 
 			Convey("interpolate __timeFrom function", func() {
 			Convey("interpolate __timeFrom function", func() {
@@ -138,7 +138,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
 			})
 			})
 
 
 			Convey("interpolate __unixEpochFilter function", func() {
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -158,7 +158,7 @@ func TestMacroEngine(t *testing.T) {
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339), to.Format(time.RFC3339)))
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
 			})
 			})
 
 
 			Convey("interpolate __unixEpochFilter function", func() {
 			Convey("interpolate __unixEpochFilter function", func() {
@@ -168,5 +168,22 @@ func TestMacroEngine(t *testing.T) {
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 				So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
 			})
 			})
 		})
 		})
+
+		Convey("Given a time range between 1960-02-01 07:00:00.5 and 1980-02-03 08:00:00.5", func() {
+			from := time.Date(1960, 2, 1, 7, 0, 0, 500e6, time.UTC)
+			to := time.Date(1980, 2, 3, 8, 0, 0, 500e6, time.UTC)
+			timeRange := tsdb.NewTimeRange(strconv.FormatInt(from.UnixNano()/int64(time.Millisecond), 10), strconv.FormatInt(to.UnixNano()/int64(time.Millisecond), 10))
+
+			So(from.Format(time.RFC3339Nano), ShouldEqual, "1960-02-01T07:00:00.5Z")
+			So(to.Format(time.RFC3339Nano), ShouldEqual, "1980-02-03T08:00:00.5Z")
+			Convey("interpolate __timeFilter function", func() {
+				sql, err := engine.Interpolate(query, timeRange, "WHERE $__timeFilter(time_column)")
+				So(err, ShouldBeNil)
+
+				So(sql, ShouldEqual, fmt.Sprintf("WHERE time_column BETWEEN '%s' AND '%s'", from.Format(time.RFC3339Nano), to.Format(time.RFC3339Nano)))
+			})
+
+		})
+
 	})
 	})
 }
 }

+ 4 - 2
public/app/core/components/CustomScrollbar/CustomScrollbar.tsx

@@ -28,8 +28,10 @@ class CustomScrollbar extends PureComponent<Props> {
       <Scrollbars
       <Scrollbars
         className={customClassName}
         className={customClassName}
         autoHeight={true}
         autoHeight={true}
-        autoHeightMin={'inherit'}
-        autoHeightMax={'inherit'}
+        // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
+        // Before these where set to inhert but that caused problems with cut of legends in firefox
+        autoHeightMin={'0'}
+        autoHeightMax={'100%'}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}

+ 4 - 4
public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap

@@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
   style={
   style={
     Object {
     Object {
       "height": "auto",
       "height": "auto",
-      "maxHeight": "inherit",
-      "minHeight": "inherit",
+      "maxHeight": "100%",
+      "minHeight": "0",
       "overflow": "hidden",
       "overflow": "hidden",
       "position": "relative",
       "position": "relative",
       "width": "100%",
       "width": "100%",
@@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
         "left": undefined,
         "left": undefined,
         "marginBottom": 0,
         "marginBottom": 0,
         "marginRight": 0,
         "marginRight": 0,
-        "maxHeight": "calc(inherit + 0px)",
-        "minHeight": "calc(inherit + 0px)",
+        "maxHeight": "calc(100% + 0px)",
+        "minHeight": "calc(0 + 0px)",
         "overflow": "scroll",
         "overflow": "scroll",
         "position": "relative",
         "position": "relative",
         "right": undefined,
         "right": undefined,

+ 8 - 6
public/app/core/components/EmptyListCTA/EmptyListCTA.tsx

@@ -24,12 +24,14 @@ class EmptyListCTA extends Component<Props, any> {
           <i className={buttonIcon} />
           <i className={buttonIcon} />
           {buttonTitle}
           {buttonTitle}
         </a>
         </a>
-        <div className="empty-list-cta__pro-tip">
-          <i className="fa fa-rocket" /> ProTip: {proTip}
-          <a className="text-link empty-list-cta__pro-tip-link" href={proTipLink} target={proTipTarget}>
-            {proTipLinkTitle}
-          </a>
-        </div>
+        {proTip && (
+          <div className="empty-list-cta__pro-tip">
+            <i className="fa fa-rocket" /> ProTip: {proTip}
+            <a className="text-link empty-list-cta__pro-tip-link" href={proTipLink} target={proTipTarget}>
+              {proTipLinkTitle}
+            </a>
+          </div>
+        )}
       </div>
       </div>
     );
     );
   }
   }

+ 44 - 0
public/app/core/components/ErrorBoundary/ErrorBoundary.tsx

@@ -0,0 +1,44 @@
+import { Component } from 'react';
+
+interface ErrorInfo {
+  componentStack: string;
+}
+
+interface RenderProps {
+  error: Error;
+  errorInfo: ErrorInfo;
+}
+
+interface Props {
+  children: (r: RenderProps) => JSX.Element;
+}
+
+interface State {
+  error: Error;
+  errorInfo: ErrorInfo;
+}
+
+class ErrorBoundary extends Component<Props, State> {
+  readonly state: State = {
+    error: null,
+    errorInfo: null,
+  };
+
+  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+    this.setState({
+      error: error,
+      errorInfo: errorInfo
+    });
+  }
+
+  render() {
+    const { children } = this.props;
+    const { error, errorInfo } = this.state;
+    return children({
+      error,
+      errorInfo,
+    });
+  }
+}
+
+export default ErrorBoundary;

+ 0 - 43
public/app/core/components/Form/Element.tsx

@@ -1,43 +0,0 @@
-import React, { PureComponent, ReactNode, ReactElement } from 'react';
-import { Label } from './Label';
-import { uniqueId } from 'lodash';
-
-interface Props {
-  label?: ReactNode;
-  labelClassName?: string;
-  id?: string;
-  children: ReactElement<any>;
-}
-
-export class Element extends PureComponent<Props> {
-  elementId: string = this.props.id || uniqueId('form-element-');
-
-  get elementLabel() {
-    const { label, labelClassName } = this.props;
-
-    if (label) {
-      return (
-        <Label htmlFor={this.elementId} className={labelClassName}>
-          {label}
-        </Label>
-      );
-    }
-
-    return null;
-  }
-
-  get children() {
-    const { children } = this.props;
-
-    return React.cloneElement(children, { id: this.elementId });
-  }
-
-  render() {
-    return (
-      <div className="our-custom-wrapper-class">
-        {this.elementLabel}
-        {this.children}
-      </div>
-    );
-  }
-}

+ 0 - 19
public/app/core/components/Form/Label.tsx

@@ -1,19 +0,0 @@
-import React, { PureComponent, ReactNode } from 'react';
-
-interface Props {
-  children: ReactNode;
-  htmlFor?: string;
-  className?: string;
-}
-
-export class Label extends PureComponent<Props> {
-  render() {
-    const { children, htmlFor, className } = this.props;
-
-    return (
-      <label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
-        {children}
-      </label>
-    );
-  }
-}

+ 0 - 2
public/app/core/components/Form/index.ts

@@ -1,3 +1 @@
-export { Element } from './Element';
 export { Input } from './Input';
 export { Input } from './Input';
-export { Label } from './Label';

+ 83 - 0
public/app/core/components/PluginHelp/PluginHelp.tsx

@@ -0,0 +1,83 @@
+import React, { PureComponent } from 'react';
+import Remarkable from 'remarkable';
+import { getBackendSrv } from '../../services/backend_srv';
+
+interface Props {
+  plugin: {
+    name: string;
+    id: string;
+  };
+  type: string;
+}
+
+interface State {
+  isError: boolean;
+  isLoading: boolean;
+  help: string;
+}
+
+export class PluginHelp extends PureComponent<Props, State> {
+  state = {
+    isError: false,
+    isLoading: false,
+    help: '',
+  };
+
+  componentDidMount(): void {
+    this.loadHelp();
+  }
+
+  constructPlaceholderInfo() {
+    return 'No plugin help or readme markdown file was found';
+  }
+
+  loadHelp = () => {
+    const { plugin, type } = this.props;
+    this.setState({ isLoading: true });
+
+    getBackendSrv()
+      .get(`/api/plugins/${plugin.id}/markdown/${type}`)
+      .then(response => {
+        const markdown = new Remarkable();
+        const helpHtml = markdown.render(response);
+
+        if (response === '' && type === 'help') {
+          this.setState({
+            isError: false,
+            isLoading: false,
+            help: this.constructPlaceholderInfo(),
+          });
+        } else {
+          this.setState({
+            isError: false,
+            isLoading: false,
+            help: helpHtml,
+          });
+        }
+      })
+      .catch(() => {
+        this.setState({
+          isError: true,
+          isLoading: false,
+        });
+      });
+  };
+
+  render() {
+    const { type } = this.props;
+    const { isError, isLoading, help } = this.state;
+
+    if (isLoading) {
+      return <h2>Loading help...</h2>;
+    }
+
+    if (isError) {
+      return <h3>'Error occurred when loading help'</h3>;
+    }
+
+    if (type === 'panel_help' && help === '') {
+    }
+
+    return <div className="markdown-html" dangerouslySetInnerHTML={{ __html: help }} />;
+  }
+}

+ 5 - 1
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -52,7 +52,11 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
   );
   );
 
 
   if (tooltip) {
   if (tooltip) {
-    return <Tooltip content={tooltip}>{button}</Tooltip>;
+    return (
+      <Tooltip content={tooltip} placement="bottom">
+        {button}
+      </Tooltip>
+    );
   } else {
   } else {
     return button;
     return button;
   }
   }

+ 10 - 2
public/app/core/components/Tooltip/Popper.tsx

@@ -3,6 +3,11 @@ import Portal from 'app/core/components/Portal/Portal';
 import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
 import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
 import Transition from 'react-transition-group/Transition';
 import Transition from 'react-transition-group/Transition';
 
 
+export enum Themes {
+  Default = 'popper__background--default',
+  Error = 'popper__background--error',
+}
+
 const defaultTransitionStyles = {
 const defaultTransitionStyles = {
   transition: 'opacity 200ms linear',
   transition: 'opacity 200ms linear',
   opacity: 0,
   opacity: 0,
@@ -21,13 +26,16 @@ interface Props {
   placement?: any;
   placement?: any;
   content: string | ((props: any) => JSX.Element);
   content: string | ((props: any) => JSX.Element);
   refClassName?: string;
   refClassName?: string;
+  theme?: Themes;
 }
 }
 
 
 class Popper extends PureComponent<Props> {
 class Popper extends PureComponent<Props> {
   render() {
   render() {
-    const { children, renderContent, show, placement, refClassName } = this.props;
+    const { children, renderContent, show, placement, refClassName, theme } = this.props;
     const { content } = this.props;
     const { content } = this.props;
 
 
+    const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
+
     return (
     return (
       <Manager>
       <Manager>
         <Reference>
         <Reference>
@@ -53,7 +61,7 @@ class Popper extends PureComponent<Props> {
                       data-placement={placement}
                       data-placement={placement}
                       className="popper"
                       className="popper"
                     >
                     >
-                      <div className="popper__background">
+                      <div className={popperBackgroundClassName}>
                         {renderContent(content)}
                         {renderContent(content)}
                         <div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
                         <div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
                       </div>
                       </div>

+ 3 - 2
public/app/core/components/Tooltip/withPopper.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
 import React from 'react';
-
+import { Themes } from './Popper';
 export interface UsingPopperProps {
 export interface UsingPopperProps {
   showPopper: (prevState: object) => void;
   showPopper: (prevState: object) => void;
   hidePopper: (prevState: object) => void;
   hidePopper: (prevState: object) => void;
@@ -9,6 +9,7 @@ export interface UsingPopperProps {
   content: string | ((props: any) => JSX.Element);
   content: string | ((props: any) => JSX.Element);
   className?: string;
   className?: string;
   refClassName?: string;
   refClassName?: string;
+  theme?: Themes;
 }
 }
 
 
 interface Props {
 interface Props {
@@ -16,6 +17,7 @@ interface Props {
   className?: string;
   className?: string;
   refClassName?: string;
   refClassName?: string;
   content: string | ((props: any) => JSX.Element);
   content: string | ((props: any) => JSX.Element);
+  theme?: Themes;
 }
 }
 
 
 interface State {
 interface State {
@@ -71,7 +73,6 @@ export default function withPopper(WrappedComponent) {
     render() {
     render() {
       const { show, placement } = this.state;
       const { show, placement } = this.state;
       const className = this.props.className || '';
       const className = this.props.className || '';
-
       return (
       return (
         <WrappedComponent
         <WrappedComponent
           {...this.props}
           {...this.props}

+ 2 - 2
public/app/core/components/code_editor/code_editor.ts

@@ -50,7 +50,7 @@ const DEFAULT_THEME_LIGHT = 'ace/theme/textmate';
 const DEFAULT_MODE = 'text';
 const DEFAULT_MODE = 'text';
 const DEFAULT_MAX_LINES = 10;
 const DEFAULT_MAX_LINES = 10;
 const DEFAULT_TAB_SIZE = 2;
 const DEFAULT_TAB_SIZE = 2;
-const DEFAULT_BEHAVIOURS = true;
+const DEFAULT_BEHAVIORS = true;
 const DEFAULT_SNIPPETS = true;
 const DEFAULT_SNIPPETS = true;
 
 
 const editorTemplate = `<div></div>`;
 const editorTemplate = `<div></div>`;
@@ -61,7 +61,7 @@ function link(scope, elem, attrs) {
   const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
   const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
   const showGutter = attrs.showGutter !== undefined;
   const showGutter = attrs.showGutter !== undefined;
   const tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
   const tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
-  const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS;
+  const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIORS;
   const snippetsEnabled = attrs.snippetsEnabled ? attrs.snippetsEnabled === 'true' : DEFAULT_SNIPPETS;
   const snippetsEnabled = attrs.snippetsEnabled ? attrs.snippetsEnabled === 'true' : DEFAULT_SNIPPETS;
 
 
   // Initialize editor
   // Initialize editor

+ 1 - 1
public/app/core/components/json_explorer/helpers.ts

@@ -1,5 +1,5 @@
 // Based on work https://github.com/mohsen1/json-formatter-js
 // Based on work https://github.com/mohsen1/json-formatter-js
-// Licence MIT, Copyright (c) 2015 Mohsen Azimi
+// License MIT, Copyright (c) 2015 Mohsen Azimi
 
 
 /*
 /*
  * Escapes `"` characters from string
  * Escapes `"` characters from string

+ 1 - 1
public/app/core/components/json_explorer/json_explorer.ts

@@ -1,5 +1,5 @@
 // Based on work https://github.com/mohsen1/json-formatter-js
 // Based on work https://github.com/mohsen1/json-formatter-js
-// Licence MIT, Copyright (c) 2015 Mohsen Azimi
+// License MIT, Copyright (c) 2015 Mohsen Azimi
 
 
 import { isObject, getObjectName, getType, getValuePreview, cssClass, createElement } from './helpers';
 import { isObject, getObjectName, getType, getValuePreview, cssClass, createElement } from './helpers';
 
 

+ 1 - 1
public/app/core/components/sidemenu/BottomNavLinks.test.tsx

@@ -36,7 +36,7 @@ describe('Render', () => {
     expect(wrapper).toMatchSnapshot();
     expect(wrapper).toMatchSnapshot();
   });
   });
 
 
-  it('should render organisation switcher', () => {
+  it('should render organization switcher', () => {
     const wrapper = setup({
     const wrapper = setup({
       link: {
       link: {
         showOrgSwitcher: true,
         showOrgSwitcher: true,

+ 1 - 1
public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap

@@ -73,7 +73,7 @@ exports[`Render should render component 1`] = `
 </div>
 </div>
 `;
 `;
 
 
-exports[`Render should render organisation switcher 1`] = `
+exports[`Render should render organization switcher 1`] = `
 <div
 <div
   className="sidemenu-item dropdown dropup"
   className="sidemenu-item dropdown dropup"
 >
 >

+ 2 - 2
public/app/core/directives/tags.ts

@@ -69,7 +69,7 @@ function bootstrapTagsinput() {
             },
             },
       });
       });
 
 
-      select.on('itemAdded', event => {
+      select.on('itemAdded', (event: any) => {
         if (scope.model.indexOf(event.item) === -1) {
         if (scope.model.indexOf(event.item) === -1) {
           scope.model.push(event.item);
           scope.model.push(event.item);
           if (scope.onTagsUpdated) {
           if (scope.onTagsUpdated) {
@@ -85,7 +85,7 @@ function bootstrapTagsinput() {
         setColor(event.item, tagElement);
         setColor(event.item, tagElement);
       });
       });
 
 
-      select.on('itemRemoved', event => {
+      select.on('itemRemoved', (event: any) => {
         const idx = scope.model.indexOf(event.item);
         const idx = scope.model.indexOf(event.item);
         if (idx !== -1) {
         if (idx !== -1) {
           scope.model.splice(idx, 1);
           scope.model.splice(idx, 1);

+ 1 - 1
public/app/core/live/live_srv.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
 import _ from 'lodash';
 import config from 'app/core/config';
 import config from 'app/core/config';
 
 
-import { Observable } from 'rxjs/Observable';
+import { Observable } from 'rxjs';
 
 
 export class LiveSrv {
 export class LiveSrv {
   conn: any;
   conn: any;

+ 12 - 1
public/app/core/logs_model.ts

@@ -2,14 +2,23 @@ import _ from 'lodash';
 import { TimeSeries } from 'app/core/core';
 import { TimeSeries } from 'app/core/core';
 import colors, { getThemeColor } from 'app/core/utils/colors';
 import colors, { getThemeColor } from 'app/core/utils/colors';
 
 
+/**
+ * Mapping of log level abbreviation to canonical log level.
+ * Supported levels are reduce to limit color variation.
+ */
 export enum LogLevel {
 export enum LogLevel {
+  emerg = 'critical',
+  alert = 'critical',
   crit = 'critical',
   crit = 'critical',
   critical = 'critical',
   critical = 'critical',
   warn = 'warning',
   warn = 'warning',
   warning = 'warning',
   warning = 'warning',
   err = 'error',
   err = 'error',
+  eror = 'error',
   error = 'error',
   error = 'error',
   info = 'info',
   info = 'info',
+  notice = 'info',
+  dbug = 'debug',
   debug = 'debug',
   debug = 'debug',
   trace = 'trace',
   trace = 'trace',
   unkown = 'unkown',
   unkown = 'unkown',
@@ -81,7 +90,9 @@ export interface LogsStream {
 
 
 export interface LogsStreamEntry {
 export interface LogsStreamEntry {
   line: string;
   line: string;
-  timestamp: string;
+  ts: string;
+  // Legacy, was renamed to ts
+  timestamp?: string;
 }
 }
 
 
 export interface LogsStreamLabels {
 export interface LogsStreamLabels {

+ 2 - 2
public/app/core/services/backend_srv.ts

@@ -5,7 +5,7 @@ import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 
 
 export class BackendSrv {
 export class BackendSrv {
   private inFlightRequests = {};
   private inFlightRequests = {};
-  private HTTP_REQUEST_CANCELLED = -1;
+  private HTTP_REQUEST_CANCELED = -1;
   private noBackendCache: boolean;
   private noBackendCache: boolean;
 
 
   /** @ngInject */
   /** @ngInject */
@@ -178,7 +178,7 @@ export class BackendSrv {
         return response;
         return response;
       })
       })
       .catch(err => {
       .catch(err => {
-        if (err.status === this.HTTP_REQUEST_CANCELLED) {
+        if (err.status === this.HTTP_REQUEST_CANCELED) {
           throw { err, cancelled: true };
           throw { err, cancelled: true };
         }
         }
 
 

Неке датотеке нису приказане због велике количине промена