浏览代码

merge from 14293-metric-display-names

Erik Sundell 7 年之前
父节点
当前提交
0e49225d1d
共有 100 个文件被更改,包括 1427 次插入302 次删除
  1. 5 5
      .circleci/config.yml
  2. 8 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. 6 0
      docs/sources/installation/configuration.md
  14. 3 1
      jest.config.js
  15. 41 16
      package.json
  16. 4 0
      packages/grafana-build/README.md
  17. 13 0
      packages/grafana-build/package.json
  18. 3 0
      packages/grafana-ui/README.md
  19. 33 0
      packages/grafana-ui/package.json
  20. 5 4
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.test.tsx
  21. 10 10
      packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx
  22. 0 0
      packages/grafana-ui/src/components/DeleteButton/_DeleteButton.scss
  23. 1 0
      packages/grafana-ui/src/components/index.scss
  24. 1 0
      packages/grafana-ui/src/components/index.ts
  25. 23 0
      packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx
  26. 1 0
      packages/grafana-ui/src/forms/index.ts
  27. 1 0
      packages/grafana-ui/src/index.scss
  28. 5 0
      packages/grafana-ui/src/index.ts
  29. 3 0
      packages/grafana-ui/src/types/index.ts
  30. 17 0
      packages/grafana-ui/src/types/jquery.d.ts
  31. 31 0
      packages/grafana-ui/src/types/panel.ts
  32. 53 0
      packages/grafana-ui/src/types/series.ts
  33. 17 0
      packages/grafana-ui/src/types/time.ts
  34. 1 0
      packages/grafana-ui/src/utils/index.ts
  35. 174 0
      packages/grafana-ui/src/utils/processTimeSeries.ts
  36. 8 6
      packages/grafana-ui/src/visualizations/Graph/Graph.tsx
  37. 1 0
      packages/grafana-ui/src/visualizations/index.ts
  38. 18 0
      packages/grafana-ui/tsconfig.json
  39. 3 0
      packages/grafana-ui/tslint.json
  40. 112 8
      pkg/api/dashboard_snapshot.go
  41. 87 0
      pkg/api/dashboard_snapshot_test.go
  42. 7 5
      pkg/api/frontendsettings.go
  43. 8 0
      pkg/api/plugins.go
  44. 2 0
      pkg/login/ldap.go
  45. 21 7
      pkg/middleware/auth_proxy.go
  46. 90 0
      pkg/middleware/middleware_test.go
  47. 13 9
      pkg/models/dashboard_snapshot.go
  48. 0 12
      pkg/plugins/datasource_plugin.go
  49. 4 2
      pkg/services/alerting/notifier.go
  50. 1 1
      pkg/services/alerting/test_notification.go
  51. 2 0
      pkg/services/dashboards/dashboard_service.go
  52. 5 1
      pkg/services/notifications/webhook.go
  53. 27 12
      pkg/services/session/mysql.go
  54. 12 10
      pkg/services/sqlstore/dashboard_snapshot.go
  55. 2 2
      pkg/services/sqlstore/datasource.go
  56. 4 0
      pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go
  57. 38 3
      pkg/services/sqlstore/migrator/conditions.go
  58. 7 1
      pkg/services/sqlstore/migrator/dialect.go
  59. 9 13
      pkg/services/sqlstore/migrator/migrations.go
  60. 17 7
      pkg/services/sqlstore/migrator/migrator.go
  61. 12 6
      pkg/services/sqlstore/migrator/mysql_dialect.go
  62. 3 3
      pkg/services/sqlstore/migrator/postgres_dialect.go
  63. 4 3
      pkg/services/sqlstore/migrator/sqlite_dialect.go
  64. 19 10
      pkg/services/sqlstore/sqlstore.go
  65. 6 1
      pkg/services/sqlstore/user.go
  66. 22 1
      pkg/services/sqlstore/user_test.go
  67. 15 14
      pkg/setting/setting_oauth.go
  68. 22 16
      pkg/social/social.go
  69. 2 0
      pkg/tsdb/influxdb/model_parser.go
  70. 2 0
      pkg/tsdb/influxdb/model_parser_test.go
  71. 1 0
      pkg/tsdb/influxdb/models.go
  72. 10 0
      pkg/tsdb/influxdb/query.go
  73. 14 0
      pkg/tsdb/influxdb/query_test.go
  74. 10 0
      public/app/core/angular_wrappers.ts
  75. 0 43
      public/app/core/components/Form/Element.tsx
  76. 0 19
      public/app/core/components/Form/Label.tsx
  77. 0 2
      public/app/core/components/Form/index.ts
  78. 83 0
      public/app/core/components/PluginHelp/PluginHelp.tsx
  79. 90 0
      public/app/core/components/Select/MetricSelect.tsx
  80. 1 1
      public/app/core/components/Select/UnitPicker.tsx
  81. 5 1
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  82. 2 2
      public/app/core/components/code_editor/code_editor.ts
  83. 1 1
      public/app/core/components/json_explorer/helpers.ts
  84. 1 1
      public/app/core/components/json_explorer/json_explorer.ts
  85. 1 1
      public/app/core/components/sidemenu/BottomNavLinks.test.tsx
  86. 1 1
      public/app/core/components/sidemenu/__snapshots__/BottomNavLinks.test.tsx.snap
  87. 2 2
      public/app/core/directives/tags.ts
  88. 1 1
      public/app/core/live/live_srv.ts
  89. 12 1
      public/app/core/logs_model.ts
  90. 2 2
      public/app/core/services/backend_srv.ts
  91. 2 2
      public/app/core/services/search_srv.ts
  92. 1 1
      public/app/core/services/timer.ts
  93. 2 0
      public/app/core/specs/file_export.test.ts
  94. 1 1
      public/app/core/table_model.ts
  95. 17 7
      public/app/core/utils/explore.ts
  96. 1 3
      public/app/core/utils/file_export.ts
  97. 32 9
      public/app/core/utils/kbn.ts
  98. 1 1
      public/app/core/utils/rangeutil.ts
  99. 3 3
      public/app/features/alerting/AlertRuleItem.tsx
  100. 2 2
      public/app/features/api-keys/ApiKeysPage.tsx

+ 5 - 5
.circleci/config.yml

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

+ 8 - 1
CHANGELOG.md

@@ -2,6 +2,7 @@
 
 ### New Features
 * **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
 
@@ -13,12 +14,18 @@
 * **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)
 * **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)
+
+### 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)
 
 * **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)
-* **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)
 

+ 1 - 1
Dockerfile

@@ -1,5 +1,5 @@
 # Golang build container
-FROM golang:1.11
+FROM golang:1.11.4
 
 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`
 
-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
 

+ 1 - 1
appveyor.yml

@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
 environment:
   nodejs_version: "8"
   GOPATH: C:\gopath
-  GOVERSION: 1.11
+  GOVERSION: 1.11.4
 
 install:
   - 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
 path = grafana.db
 
+# For "sqlite3" only. cache mode setting used for connecting to the database
+cache_mode = private
+
 #################################### Session #############################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -335,6 +338,7 @@ tls_skip_verify_insecure = false
 tls_client_cert =
 tls_client_key =
 tls_client_ca =
+send_client_credentials_via_post = false
 
 #################################### Basic Auth ##########################
 [auth.basic]

+ 7 - 0
conf/sample.ini

@@ -99,6 +99,9 @@
 # Set to true to log the sql calls and execution times.
 log_queries =
 
+# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
+;cache_mode = private
+
 #################################### Session ####################################
 [session]
 # Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -284,6 +287,10 @@ log_queries =
 ;tls_client_key =
 ;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 ####################
 [auth.grafana_com]
 ;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
 # 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.
+# Example `whitelist = 192.168.1.1, 192.168.1.0/24, 2001::23, 2001::0/120`
 whitelist =
 # 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 =
 ```
 

+ 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`.
 
-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.
 
 Example config:
@@ -209,6 +209,17 @@ allowed_organizations =
     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>
 
 

+ 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.
 
+### 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 />
 
 ## [security]

+ 3 - 1
jest.config.js

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

+ 41 - 16
package.json

@@ -1,4 +1,5 @@
 {
+  "private": true,
   "author": {
     "name": "Torkel Ödegaard",
     "company": "Grafana Labs"
@@ -11,14 +12,16 @@
   },
   "devDependencies": {
     "@babel/core": "^7.1.2",
-    "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
     "@babel/plugin-syntax-dynamic-import": "^7.0.0",
     "@babel/preset-env": "^7.1.0",
     "@babel/preset-react": "^7.0.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/enzyme": "^3.1.13",
     "@types/jest": "^23.3.2",
+    "@types/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
     "@types/react": "^16.7.6",
     "@types/react-custom-scrollbars": "^4.0.5",
@@ -49,15 +52,12 @@
     "grunt-cli": "~1.2.0",
     "grunt-contrib-clean": "~1.0.0",
     "grunt-contrib-compress": "^1.3.0",
-    "grunt-contrib-concat": "^1.0.1",
     "grunt-contrib-copy": "~1.0.0",
-    "grunt-contrib-cssmin": "~1.0.2",
     "grunt-exec": "^1.0.1",
     "grunt-newer": "^1.3.0",
     "grunt-notify": "^0.4.5",
     "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-webpack": "^3.0.2",
     "html-loader": "^0.5.1",
@@ -73,6 +73,7 @@
     "ng-annotate-webpack-plugin": "^0.3.0",
     "ngtemplate-loader": "^2.0.1",
     "npm": "^5.4.2",
+    "node-sass": "^4.11.0",
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "phantomjs-prebuilt": "^2.1.15",
     "postcss-browser-reporter": "^0.5.0",
@@ -92,6 +93,7 @@
     "tslib": "^1.9.3",
     "tslint": "^5.8.0",
     "tslint-loader": "^3.5.3",
+    "tslint-react": "^3.6.0",
     "typescript": "^3.0.3",
     "uglifyjs-webpack-plugin": "^1.2.7",
     "webpack": "4.19.1",
@@ -108,15 +110,30 @@
     "watch": "webpack --progress --colors --watch --mode development --config scripts/webpack/webpack.dev.js",
     "build": "grunt build",
     "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",
     "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": {
-    "*.{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": {
     "trailingComma": "es5",
@@ -126,6 +143,7 @@
   "license": "Apache-2.0",
   "dependencies": {
     "@babel/polyfill": "^7.0.0",
+    "@torkelo/react-select": "2.1.1",
     "angular": "1.6.6",
     "angular-bindonce": "0.3.1",
     "angular-native-dragdrop": "1.2.2",
@@ -133,7 +151,7 @@
     "angular-sanitize": "1.6.6",
     "baron": "^3.0.3",
     "brace": "^0.10.0",
-    "classnames": "^2.2.5",
+    "classnames": "^2.2.6",
     "clipboard": "^1.7.1",
     "d3": "^4.11.0",
     "d3-scale-chromatic": "^1.3.0",
@@ -152,10 +170,9 @@
     "react-custom-scrollbars": "^4.2.1",
     "react-dom": "^16.6.3",
     "react-grid-layout": "0.16.6",
-    "react-popper": "^1.3.0",
     "react-highlight-words": "0.11.0",
+    "react-popper": "^1.3.0",
     "react-redux": "^5.0.7",
-    "@torkelo/react-select": "2.1.1",
     "react-sizeme": "^2.3.6",
     "react-table": "^6.8.6",
     "react-transition-group": "^2.2.1",
@@ -165,18 +182,26 @@
     "redux-thunk": "^2.3.0",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
-    "rxjs": "^5.4.3",
+    "rxjs": "^6.3.3",
     "slate": "^0.33.4",
     "slate-plain-serializer": "^0.5.10",
     "slate-prism": "^0.5.0",
     "slate-react": "^0.12.4",
     "tether": "^1.4.0",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
-    "tinycolor2": "^1.4.1",
-    "tslint-react": "^3.6.0"
+    "tinycolor2": "^1.4.1"
   },
   "resolutions": {
     "caniuse-db": "1.0.30000772",
     "**/@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 DeleteButton from './DeleteButton';
+import { DeleteButton } from './DeleteButton';
 import { shallow } from 'enzyme';
 
 describe('DeleteButton', () => {
-  let wrapper;
-  let deleted;
+  let wrapper: any;
+  let deleted: any;
 
   beforeAll(() => {
     deleted = false;
@@ -12,7 +12,8 @@ describe('DeleteButton', () => {
     function deleteItem() {
       deleted = true;
     }
-    wrapper = shallow(<DeleteButton onConfirmDelete={() => deleteItem()} />);
+
+    wrapper = shallow(<DeleteButton onConfirm={() => deleteItem()} />);
   });
 
   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;
 }
 
-export default class DeleteButton extends PureComponent<DeleteButtonProps, DeleteButtonStates> {
-  state: DeleteButtonStates = {
+export class DeleteButton extends PureComponent<Props, State> {
+  state: State = {
     showConfirm: false,
   };
 
-  onClickDelete = event => {
+  onClickDelete = (event: SyntheticEvent) => {
     if (event) {
       event.preventDefault();
     }
@@ -23,7 +23,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
     });
   };
 
-  onClickCancel = event => {
+  onClickCancel = (event: SyntheticEvent) => {
     if (event) {
       event.preventDefault();
     }
@@ -33,7 +33,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
   };
 
   render() {
-    const onClickConfirm = this.props.onConfirmDelete;
+    const { onConfirm } = this.props;
     let showConfirm;
     let showDeleteButton;
 
@@ -55,7 +55,7 @@ export default class DeleteButton extends PureComponent<DeleteButtonProps, Delet
             <a className="btn btn-small" onClick={this.onClickCancel}>
               Cancel
             </a>
-            <a className="btn btn-danger btn-small" onClick={onClickConfirm}>
+            <a className="btn btn-danger btn-small" onClick={onConfirm}>
               Confirm Delete
             </a>
           </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;
+}

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

@@ -1,11 +1,9 @@
 // Libraries
 import $ from 'jquery';
 import React, { PureComponent } from 'react';
-import 'vendor/flot/jquery.flot';
-import 'vendor/flot/jquery.flot.time';
 
 // Types
-import { TimeRange, TimeSeriesVMs } from 'app/types';
+import { TimeRange, TimeSeriesVMs } from '../../types';
 
 interface GraphProps {
   timeSeries: TimeSeriesVMs;
@@ -24,7 +22,7 @@ export class Graph extends PureComponent<GraphProps> {
     showBars: false,
   };
 
-  element: HTMLElement;
+  element: HTMLElement | null;
 
   componentDidUpdate() {
     this.draw();
@@ -35,6 +33,10 @@ export class Graph extends PureComponent<GraphProps> {
   }
 
   draw() {
+    if (this.element === null) {
+      return;
+    }
+
     const { width, timeSeries, timeRange, showLines, showBars, showPoints } = this.props;
 
     if (!width) {
@@ -76,7 +78,7 @@ export class Graph extends PureComponent<GraphProps> {
         max: max,
         label: 'Datetime',
         ticks: ticks,
-        timeformat: time_format(ticks, min, max),
+        timeformat: timeFormat(ticks, min, max),
       },
       grid: {
         minBorderMargin: 0,
@@ -109,7 +111,7 @@ export class Graph extends PureComponent<GraphProps> {
 }
 
 // Copied from graph.ts
-function time_format(ticks, min, max) {
+function timeFormat(ticks: number, min: number, max: number): string {
   if (min && max && ticks) {
     const range = max - min;
     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
 
 import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
 	"time"
 
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/guardian"
@@ -12,6 +17,11 @@ import (
 	"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) {
 	c.JSON(200, util.DynMap{
 		"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) {
 	if cmd.Name == "" {
 		cmd.Name = "Unnamed snapshot"
 	}
 
+	var url string
+	cmd.ExternalUrl = ""
+	cmd.OrgId = c.OrgId
+	cmd.UserId = c.UserId
+
 	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
 		}
 
-		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()
 	} else {
 		cmd.Key = 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()
 	}
 
@@ -51,7 +114,7 @@ func CreateDashboardSnapshot(c *m.ReqContext, cmd m.CreateDashboardSnapshotComma
 	c.JSON(200, util.DynMap{
 		"key":       cmd.Key,
 		"deleteKey": cmd.DeleteKey,
-		"url":       setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key),
+		"url":       url,
 		"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
 	})
 }
@@ -91,6 +154,33 @@ func GetDashboardSnapshot(c *m.ReqContext) {
 	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
 func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 	key := c.Params(":deleteKey")
@@ -102,6 +192,13 @@ func DeleteDashboardSnapshotByDeleteKey(c *m.ReqContext) Response {
 		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}
 
 	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)
 	}
 
+	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}
 
 	if err := bus.Dispatch(cmd); err != nil {

+ 87 - 0
pkg/api/dashboard_snapshot_test.go

@@ -1,6 +1,9 @@
 package api
 
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 	"time"
 
@@ -13,13 +16,17 @@ import (
 
 func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 	Convey("Given a single snapshot", t, func() {
+		var externalRequest *http.Request
 		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
 
 		mockSnapshotResult := &m.DashboardSnapshot{
 			Id:        1,
+			Key:       "12345",
+			DeleteKey: "54321",
 			Dashboard: jsonModel,
 			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
 			UserId:    999999,
+			External:  true,
 		}
 
 		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
@@ -45,13 +52,25 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 			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("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) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						externalRequest = req
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
 					sc.handlerFunc = DeleteDashboardSnapshot
 					sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
 					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("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) {
+					ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) {
+						rw.WriteHeader(200)
+						externalRequest = req
+					})
+
+					mockSnapshotResult.ExternalDeleteUrl = ts.URL
 					sc.handlerFunc = DeleteDashboardSnapshotByDeleteKey
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec()
 
@@ -67,6 +92,10 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 
 					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() {
 				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.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec()
 
@@ -87,6 +122,8 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) {
 					So(err, ShouldBeNil)
 
 					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() {
 			aclMockResp = []*m.DashboardAclInfoDTO{}
 			mockSnapshotResult.UserId = TestUserID
+			mockSnapshotResult.External = false
 
 			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) {
@@ -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)
+				})
+			})
+		})
 	})
 }

+ 7 - 5
pkg/api/frontendsettings.go

@@ -186,16 +186,18 @@ func getPanelSort(id string) int {
 		sort = 1
 	case "singlestat":
 		sort = 2
-	case "table":
+	case "gauge":
 		sort = 3
-	case "text":
+	case "table":
 		sort = 4
-	case "heatmap":
+	case "text":
 		sort = 5
-	case "alertlist":
+	case "heatmap":
 		sort = 6
-	case "dashlist":
+	case "alertlist":
 		sort = 7
+	case "dashlist":
+		sort = 8
 	}
 	return sort
 }

+ 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)
 	}
 
+	// 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.Header("Content-Type", "text/plain; charset=utf-8")
 	return resp

+ 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),
 		}
 
+		a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq))
+
 		searchResult, err = a.conn.Search(&searchReq)
 		if err != nil {
 			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, ",")
-	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 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) {
 			setting.AuthProxyEnabled = true
 			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) {
 			setting.AuthProxyEnabled = true
 			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) {
 			setting.AuthProxyEnabled = true
 			setting.AuthProxyHeaderName = "X-WEBAUTH-USER"

+ 13 - 9
pkg/models/dashboard_snapshot.go

@@ -8,14 +8,15 @@ import (
 
 // DashboardSnapshot model
 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
 	Created time.Time
@@ -48,7 +49,10 @@ type CreateDashboardSnapshotCommand struct {
 	Expires   int64            `json:"expires"`
 
 	// 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"`
 	DeleteKey string `json:"deleteKey"`
 

+ 0 - 12
pkg/plugins/datasource_plugin.go

@@ -3,10 +3,8 @@ package plugins
 import (
 	"context"
 	"encoding/json"
-	"os"
 	"os/exec"
 	"path"
-	"path/filepath"
 	"time"
 
 	"github.com/grafana/grafana-plugin-model/go/datasource"
@@ -29,7 +27,6 @@ type DataSourcePlugin struct {
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	Mixed        bool              `json:"mixed,omitempty"`
-	HasQueryHelp bool              `json:"hasQueryHelp,omitempty"`
 	Routes       []*AppPluginRoute `json:"routes"`
 
 	Backend    bool   `json:"backend,omitempty"`
@@ -48,15 +45,6 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error {
 		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
 	return nil
 }

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

@@ -166,7 +166,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
 
 	var result notifierStateSlice
 	for _, notification := range query.Result {
-		not, err := n.createNotifierFor(notification)
+		not, err := InitNotifier(notification)
 		if err != nil {
 			n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
 			continue
@@ -195,7 +195,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
 	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]
 	if !found {
 		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)
 
+// RegisterNotifier register an notifier
 func RegisterNotifier(plugin *NotifierPlugin) {
 	notifierFactories[plugin.Type] = plugin
 }

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

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

+ 2 - 0
pkg/services/dashboards/dashboard_service.go

@@ -175,7 +175,9 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 	dto.User = &models.SignedInUser{
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
+		OrgId:   dto.OrgId,
 	}
+
 	cmd, err := dr.buildSaveDashboardCommand(dto, true, false)
 	if err != nil {
 		return nil, err

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

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

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

@@ -29,18 +29,22 @@ import (
 
 // MysqlStore represents a mysql session store implementation.
 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.
-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{
-		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()
 
 	s.data[key] = val
+	s.dirty = true
 	return nil
 }
 
@@ -67,6 +72,7 @@ func (s *MysqlStore) Delete(key interface{}) error {
 	defer s.lock.Unlock()
 
 	delete(s.data, key)
+	s.dirty = true
 	return nil
 }
 
@@ -77,13 +83,20 @@ func (s *MysqlStore) ID() string {
 
 // Release releases resource and save data to provider.
 func (s *MysqlStore) Release() error {
+	newExpiry := time.Now().Unix()
+	if !s.dirty && (s.expiry+60) >= newExpiry {
+		return nil
+	}
+
 	data, err := session.EncodeGob(s.data)
 	if err != nil {
 		return err
 	}
 
 	_, 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
 }
 
@@ -93,6 +106,7 @@ func (s *MysqlStore) Flush() error {
 	defer s.lock.Unlock()
 
 	s.data = make(map[interface{}]interface{})
+	s.dirty = true
 	return nil
 }
 
@@ -117,11 +131,12 @@ func (p *MysqlProvider) Init(expire int64, connStr string) (err error) {
 
 // Read returns raw session store by session ID.
 func (p *MysqlProvider) Read(sid string) (session.RawStore, error) {
+	expiry := time.Now().Unix()
 	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 {
 		_, err = p.c.Exec("INSERT INTO session(`key`,data,expiry) VALUES(?,?,?)",
-			sid, "", time.Now().Unix())
+			sid, "", expiry)
 	}
 	if err != nil {
 		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.

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

@@ -47,16 +47,18 @@ func CreateDashboardSnapshot(cmd *m.CreateDashboardSnapshotCommand) error {
 		}
 
 		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)

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

@@ -53,14 +53,14 @@ func GetDataSourceByName(query *m.GetDataSourceByNameQuery) 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)
 	return sess.Find(&query.Result)
 }
 
 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)
 	return sess.Find(&query.Result)

+ 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: "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 {
 	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
+	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
 	DropIndexSql(tableName string, index *Index) string
 
-	TableCheckSql(tableName string) (string, []interface{})
 	RenameTable(oldName string, newName string) string
 	UpdateTableSql(tableName string, columns []*Column) string
 
+	IndexCheckSql(tableName, indexName string) (string, []interface{})
+	ColumnCheckSql(tableName, columnName string) (string, []interface{})
+
 	ColString(*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))
 }
 
+func (db *BaseDialect) ColumnCheckSql(tableName, columnName string) (string, []interface{}) {
+	return "", nil
+}
+
 func (db *BaseDialect) DropIndexSql(tableName string, index *Index) string {
 	quote := db.dialect.Quote
 	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 {
-	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 {
@@ -109,7 +111,9 @@ type AddIndexMigration struct {
 }
 
 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 {
@@ -128,7 +132,9 @@ type DropIndexMigration struct {
 }
 
 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 {
@@ -179,11 +185,6 @@ func NewRenameTableMigration(oldName string, newName string) *RenameTableMigrati
 	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 {
 	m.oldName = oldName
 	m.newName = newName
@@ -212,11 +213,6 @@ func NewCopyTableDataMigration(targetTable string, sourceTable string, colMap ma
 	return m
 }
 
-func (m *CopyTableDataMigration) IfTableExists(tableName string) *CopyTableDataMigration {
-	m.Condition = &IfTableExistsCondition{TableName: tableName}
-	return m
-}
-
 func (m *CopyTableDataMigration) Sql(d Dialect) string {
 	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(),
 		}
 
-		mg.Logger.Debug("Executing", "sql", sql)
-
 		err := mg.inTransaction(func(sess *xorm.Session) error {
 			err := mg.exec(m, sess)
 			if err != nil {
@@ -123,18 +121,30 @@ func (mg *Migrator) exec(m Migration, sess *xorm.Session) error {
 	condition := m.GetCondition()
 	if condition != nil {
 		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
 	if codeMigration, ok := m.(CodeMigration); ok {
+		mg.Logger.Debug("Executing code migration", "id", m.Id())
 		err = codeMigration.Exec(sess, mg)
 	} 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 {

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

@@ -90,12 +90,6 @@ func (db *Mysql) SqlType(c *Column) string {
 	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 {
 	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, ", ") + ";"
 }
 
+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 {
 	tables, _ := db.engine.DBMetas()
 	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
 }
 
-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
 }
 

+ 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 {

+ 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)
 		}
 		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:
 		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.ServerCertName = sec.Key("server_cert_name").String()
 	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 {
@@ -391,13 +393,20 @@ func IsTestDbPostgres() bool {
 }
 
 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
 }
 
+func newSignedInUserCacheKey(orgID, userID int64) string {
+	return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID)
+}
+
 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 {
 		query.Result = cached.(*m.SignedInUser)
 		return nil
@@ -357,6 +361,7 @@ func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) erro
 		return err
 	}
 
+	cacheKey = newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
 	ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
 	return nil
 }

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

@@ -13,7 +13,7 @@ import (
 func TestUserDataAccess(t *testing.T) {
 
 	Convey("Testing DB", t, func() {
-		InitTestDB(t)
+		ss := InitTestDB(t)
 
 		Convey("Creating a user", func() {
 			cmd := &m.CreateUserCommand{
@@ -153,6 +153,27 @@ func TestUserDataAccess(t *testing.T) {
 						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
 
 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 {

+ 22 - 16
pkg/social/social.go

@@ -63,28 +63,34 @@ func NewOAuthService() {
 	for _, name := range allOauthes {
 		sec := setting.Raw.Section("auth." + name)
 		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 {
 			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" {
 			name = grafanaCom
 		}

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

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

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

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

@@ -13,6 +13,7 @@ type Query struct {
 	UseRawQuery  bool
 	Alias        string
 	Interval     time.Duration
+	Tz           string
 }
 
 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.renderTimeFilter(queryContext)
 		res += query.renderGroupBy(queryContext)
+		res += query.renderTz()
 	}
 
 	calculator := tsdb.NewIntervalCalculator(&tsdb.IntervalOptions{})
@@ -154,3 +155,12 @@ func (query *Query) renderGroupBy(queryContext *tsdb.TsdbQuery) string {
 
 	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)`)
 		})
 
+		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() {
 			query := &Query{
 				Selects:     []*Select{{*qp1, *qp2}},

+ 10 - 0
public/app/core/angular_wrappers.ts

@@ -5,6 +5,7 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import { SearchResult } from './components/search/SearchResult';
 import { TagFilter } from './components/TagFilter/TagFilter';
 import { SideMenu } from './components/sidemenu/SideMenu';
+import { MetricSelect } from './components/Select/MetricSelect';
 import AppNotificationList from './components/AppNotifications/AppNotificationList';
 
 export function registerAngularDirectives() {
@@ -19,4 +20,13 @@ export function registerAngularDirectives() {
     ['onChange', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
+  react2AngularDirective('metricSelect', MetricSelect, [
+    'options',
+    'onChange',
+    'value',
+    'isSearchable',
+    'className',
+    'placeholder',
+    ['variables', { watchDepth: 'reference' }],
+  ]);
 }

+ 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 { 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 }} />;
+  }
+}

+ 90 - 0
public/app/core/components/Select/MetricSelect.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import _ from 'lodash';
+
+import Select from './Select';
+import { Variable } from 'app/types/templates';
+import { SelectOptionItem } from './Select';
+
+export interface Props {
+  onChange: (value: string) => void;
+  options: SelectOptionItem[];
+  isSearchable: boolean;
+  value: string;
+  placeholder?: string;
+  className?: string;
+  variables?: Variable[];
+}
+
+interface State {
+  options: any[];
+}
+
+export class MetricSelect extends React.Component<Props, State> {
+  static defaultProps = {
+    variables: [],
+    options: [],
+    isSearchable: true,
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = { options: [] };
+  }
+
+  componentDidMount() {
+    this.setState({ options: this.buildOptions(this.props) });
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.options.length > 0 || nextProps.variables.length) {
+      this.setState({ options: this.buildOptions(nextProps) });
+    }
+  }
+
+  shouldComponentUpdate(nextProps: Props) {
+    const nextOptions = this.buildOptions(nextProps);
+    return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options);
+  }
+
+  buildOptions({ variables = [], options }) {
+    return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options;
+  }
+
+  getVariablesGroup() {
+    return {
+      label: 'Template Variables',
+      options: this.props.variables.map(v => ({
+        label: `$${v.name}`,
+        value: `$${v.name}`,
+      })),
+    };
+  }
+
+  getSelectedOption() {
+    const { options } = this.state;
+    const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
+    return allOptions.find(option => option.value === this.props.value);
+  }
+
+  render() {
+    const { placeholder, className, isSearchable, onChange } = this.props;
+    const { options } = this.state;
+    const selectedOption = this.getSelectedOption();
+
+    return (
+      <Select
+        className={className}
+        isMulti={false}
+        isClearable={false}
+        backspaceRemovesValue={false}
+        onChange={item => onChange(item.value)}
+        options={options}
+        isSearchable={isSearchable}
+        maxMenuHeight={500}
+        placeholder={placeholder}
+        noOptionsMessage={() => 'No options found'}
+        value={selectedOption}
+      />
+    );
+  }
+}

+ 1 - 1
public/app/core/components/Select/UnitPicker.tsx

@@ -3,7 +3,7 @@ import Select from './Select';
 import kbn from 'app/core/utils/kbn';
 
 interface Props {
-  onChange: (item: any) => {} | void;
+  onChange: (item: any) => void;
   defaultValue?: string;
   width?: number;
 }

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

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

+ 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_MAX_LINES = 10;
 const DEFAULT_TAB_SIZE = 2;
-const DEFAULT_BEHAVIOURS = true;
+const DEFAULT_BEHAVIORS = true;
 const DEFAULT_SNIPPETS = true;
 
 const editorTemplate = `<div></div>`;
@@ -61,7 +61,7 @@ function link(scope, elem, attrs) {
   const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
   const showGutter = attrs.showGutter !== undefined;
   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;
 
   // 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
-// Licence MIT, Copyright (c) 2015 Mohsen Azimi
+// License MIT, Copyright (c) 2015 Mohsen Azimi
 
 /*
  * 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
-// Licence MIT, Copyright (c) 2015 Mohsen Azimi
+// License MIT, Copyright (c) 2015 Mohsen Azimi
 
 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();
   });
 
-  it('should render organisation switcher', () => {
+  it('should render organization switcher', () => {
     const wrapper = setup({
       link: {
         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>
 `;
 
-exports[`Render should render organisation switcher 1`] = `
+exports[`Render should render organization switcher 1`] = `
 <div
   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) {
           scope.model.push(event.item);
           if (scope.onTagsUpdated) {
@@ -85,7 +85,7 @@ function bootstrapTagsinput() {
         setColor(event.item, tagElement);
       });
 
-      select.on('itemRemoved', event => {
+      select.on('itemRemoved', (event: any) => {
         const idx = scope.model.indexOf(event.item);
         if (idx !== -1) {
           scope.model.splice(idx, 1);

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

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

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

@@ -2,14 +2,23 @@ import _ from 'lodash';
 import { TimeSeries } from 'app/core/core';
 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 {
+  emerg = 'critical',
+  alert = 'critical',
   crit = 'critical',
   critical = 'critical',
   warn = 'warning',
   warning = 'warning',
   err = 'error',
+  eror = 'error',
   error = 'error',
   info = 'info',
+  notice = 'info',
+  dbug = 'debug',
   debug = 'debug',
   trace = 'trace',
   unkown = 'unkown',
@@ -81,7 +90,9 @@ export interface LogsStream {
 
 export interface LogsStreamEntry {
   line: string;
-  timestamp: string;
+  ts: string;
+  // Legacy, was renamed to ts
+  timestamp?: string;
 }
 
 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 {
   private inFlightRequests = {};
-  private HTTP_REQUEST_CANCELLED = -1;
+  private HTTP_REQUEST_CANCELED = -1;
   private noBackendCache: boolean;
 
   /** @ngInject */
@@ -178,7 +178,7 @@ export class BackendSrv {
         return response;
       })
       .catch(err => {
-        if (err.status === this.HTTP_REQUEST_CANCELLED) {
+        if (err.status === this.HTTP_REQUEST_CANCELED) {
           throw { err, cancelled: true };
         }
 

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

@@ -31,7 +31,7 @@ export class SearchSrv {
   }
 
   private queryForRecentDashboards() {
-    const dashIds = _.take(impressionSrv.getDashboardOpened(), 5);
+    const dashIds = _.take(impressionSrv.getDashboardOpened(), 30);
     if (dashIds.length === 0) {
       return Promise.resolve([]);
     }
@@ -70,7 +70,7 @@ export class SearchSrv {
       return Promise.resolve();
     }
 
-    return this.backendSrv.search({ starred: true, limit: 5 }).then(result => {
+    return this.backendSrv.search({ starred: true, limit: 30 }).then(result => {
       if (result.length > 0) {
         sections['starred'] = {
           title: 'Starred',

+ 1 - 1
public/app/core/services/timer.ts

@@ -2,7 +2,7 @@ import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 
 // This service really just tracks a list of $timeout promises to give us a
-// method for cancelling them all when we need to
+// method for canceling them all when we need to
 export class Timer {
   timers = [];
 

+ 2 - 0
public/app/core/specs/file_export.test.ts

@@ -73,6 +73,7 @@ describe('file_export', () => {
         ],
         rows: [
           [123, 'some_string', 1.234, true],
+          [1000, 'some_string', 1.234567891, true],
           [0o765, 'some string with " in the middle', 1e-2, false],
           [0o765, 'some string with "" in the middle', 1e-2, false],
           [0o765, 'some string with """ in the middle', 1e-2, false],
@@ -89,6 +90,7 @@ describe('file_export', () => {
       const expectedText =
         '"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
         '123;"some_string";1.234;true\r\n' +
+        '1000;"some_string";1.234567891;true\r\n' +
         '501;"some string with "" in the middle";0.01;false\r\n' +
         '501;"some string with """" in the middle";0.01;false\r\n' +
         '501;"some string with """""" in the middle";0.01;false\r\n' +

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

@@ -40,7 +40,7 @@ export default class TableModel {
     this.rows.sort((a, b) => {
       a = a[options.col];
       b = b[options.col];
-      // Sort null or undefined seperately from comparable values
+      // Sort null or undefined separately from comparable values
       return +(a == null) - +(b == null) || +(a > b) || -(a < b);
     });
 

+ 17 - 7
public/app/core/utils/explore.ts

@@ -9,7 +9,8 @@ import { parse as parseDate } from 'app/core/utils/datemath';
 import TimeSeries from 'app/core/time_series2';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
 import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore';
-import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series';
+import { DataQuery, DataSourceApi } from 'app/types/series';
+import { RawTimeRange, IntervalValues } from '@grafana/ui';
 
 export const DEFAULT_RANGE = {
   from: 'now-6h',
@@ -57,12 +58,19 @@ export async function getExploreUrl(
     }
   }
 
-  if (exploreDatasource && exploreDatasource.meta.explore) {
+  if (panelDatasource) {
     const range = timeSrv.timeRangeForUrl();
-    const state = {
-      ...exploreDatasource.getExploreState(exploreTargets),
-      range,
-    };
+    let state: Partial<ExploreUrlState> = { range };
+    if (exploreDatasource.getExploreState) {
+      state = { ...state, ...exploreDatasource.getExploreState(exploreTargets) };
+    } else {
+      state = {
+        ...state,
+        datasource: panelDatasource.name,
+        queries: exploreTargets.map(t => ({ ...t, datasource: panelDatasource.name })),
+      };
+    }
+
     const exploreState = JSON.stringify(state);
     url = renderUrl('/explore', { state: exploreState });
   }
@@ -151,7 +159,9 @@ export function calculateResultsFromQueryTransactions(
   );
   const tableResult = mergeTablesIntoModel(
     new TableModel(),
-    ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
+    ...queryTransactions
+      .filter(qt => qt.resultType === 'Table' && qt.done && qt.result && qt.result.columns && qt.result.rows)
+      .map(qt => qt.result)
   );
   const logsResult =
     datasource && datasource.mergeStreams

+ 1 - 3
public/app/core/utils/file_export.ts

@@ -41,10 +41,8 @@ function formatSpecialHeader(useExcelHeader) {
 function formatRow(row, addEndRowDelimiter = true) {
   let text = '';
   for (let i = 0; i < row.length; i += 1) {
-    if (isBoolean(row[i]) || isNullOrUndefined(row[i])) {
+    if (isBoolean(row[i]) || isNumber(row[i]) || isNullOrUndefined(row[i])) {
       text += row[i];
-    } else if (isNumber(row[i])) {
-      text += row[i].toLocaleString();
     } else {
       text += `${QUOTE}${csvEscaped(htmlUnescaped(htmlDecoded(row[i])))}${QUOTE}`;
     }

+ 32 - 9
public/app/core/utils/kbn.ts

@@ -484,6 +484,14 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
 kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
 kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 
+// Floating Point Operations per Second
+kbn.valueFormats.flops = kbn.formatBuilders.decimalSIPrefix('FLOP/s');
+kbn.valueFormats.mflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 2);
+kbn.valueFormats.gflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 3);
+kbn.valueFormats.tflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 4);
+kbn.valueFormats.pflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 5);
+kbn.valueFormats.eflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 6);
+
 // Hash Rate
 kbn.valueFormats.Hs = kbn.formatBuilders.decimalSIPrefix('H/s');
 kbn.valueFormats.KHs = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
@@ -621,6 +629,8 @@ kbn.valueFormats.conmgm3 = kbn.formatBuilders.fixedUnit('mg/m³');
 kbn.valueFormats.conmgNm3 = kbn.formatBuilders.fixedUnit('mg/Nm³');
 kbn.valueFormats.congm3 = kbn.formatBuilders.fixedUnit('g/m³');
 kbn.valueFormats.congNm3 = kbn.formatBuilders.fixedUnit('g/Nm³');
+kbn.valueFormats.conmgdL = kbn.formatBuilders.fixedUnit('mg/dL');
+kbn.valueFormats.conmmolL = kbn.formatBuilders.fixedUnit('mmol/L');
 
 // Time
 kbn.valueFormats.hertz = kbn.formatBuilders.decimalSIPrefix('Hz');
@@ -1019,6 +1029,17 @@ kbn.getUnitFormats = () => {
         { text: 'exahashes/sec', value: 'EHs' },
       ],
     },
+    {
+      text: 'computation throughput',
+      submenu: [
+        { text: 'FLOP/s', value: 'flops' },
+        { text: 'MFLOP/s', value: 'mflops' },
+        { text: 'GFLOP/s', value: 'gflops' },
+        { text: 'TFLOP/s', value: 'tflops' },
+        { text: 'PFLOP/s', value: 'pflops' },
+        { text: 'EFLOP/s', value: 'eflops' },
+      ],
+    },
     {
       text: 'throughput',
       submenu: [
@@ -1085,7 +1106,7 @@ kbn.getUnitFormats = () => {
         { text: 'Watt (W)', value: 'watt' },
         { text: 'Kilowatt (kW)', value: 'kwatt' },
         { text: 'Milliwatt (mW)', value: 'mwatt' },
-        { text: 'Watt per square metre (W/m²)', value: 'Wm2' },
+        { text: 'Watt per square meter (W/m²)', value: 'Wm2' },
         { text: 'Volt-ampere (VA)', value: 'voltamp' },
         { text: 'Kilovolt-ampere (kVA)', value: 'kvoltamp' },
         { text: 'Volt-ampere reactive (var)', value: 'voltampreact' },
@@ -1182,14 +1203,16 @@ kbn.getUnitFormats = () => {
       submenu: [
         { text: 'parts-per-million (ppm)', value: 'ppm' },
         { text: 'parts-per-billion (ppb)', value: 'conppb' },
-        { text: 'nanogram per cubic metre (ng/m³)', value: 'conngm3' },
-        { text: 'nanogram per normal cubic metre (ng/Nm³)', value: 'conngNm3' },
-        { text: 'microgram per cubic metre (μg/m³)', value: 'conμgm3' },
-        { text: 'microgram per normal cubic metre (μg/Nm³)', value: 'conμgNm3' },
-        { text: 'milligram per cubic metre (mg/m³)', value: 'conmgm3' },
-        { text: 'milligram per normal cubic metre (mg/Nm³)', value: 'conmgNm3' },
-        { text: 'gram per cubic metre (g/m³)', value: 'congm3' },
-        { text: 'gram per normal cubic metre (g/Nm³)', value: 'congNm3' },
+        { text: 'nanogram per cubic meter (ng/m³)', value: 'conngm3' },
+        { text: 'nanogram per normal cubic meter (ng/Nm³)', value: 'conngNm3' },
+        { text: 'microgram per cubic meter (μg/m³)', value: 'conμgm3' },
+        { text: 'microgram per normal cubic meter (μg/Nm³)', value: 'conμgNm3' },
+        { text: 'milligram per cubic meter (mg/m³)', value: 'conmgm3' },
+        { text: 'milligram per normal cubic meter (mg/Nm³)', value: 'conmgNm3' },
+        { text: 'gram per cubic meter (g/m³)', value: 'congm3' },
+        { text: 'gram per normal cubic meter (g/Nm³)', value: 'congNm3' },
+        { text: 'milligrams per decilitre (mg/dL)', value: 'conmgdL' },
+        { text: 'millimoles per litre (mmol/L)', value: 'conmmolL' },
       ],
     },
   ];

+ 1 - 1
public/app/core/utils/rangeutil.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
 import moment from 'moment';
 
-import { RawTimeRange } from 'app/types/series';
+import { RawTimeRange } from '@grafana/ui';
 
 import * as dateMath from './datemath';
 

+ 3 - 3
public/app/features/alerting/AlertRuleItem.tsx

@@ -1,6 +1,6 @@
 import React, { PureComponent } from 'react';
 import Highlighter from 'react-highlight-words';
-import classNames from 'classnames/bind';
+import classNames from 'classnames';
 import { AlertRule } from '../../types';
 
 export interface Props {
@@ -23,7 +23,7 @@ class AlertRuleItem extends PureComponent<Props> {
   render() {
     const { rule, onTogglePause } = this.props;
 
-    const stateClass = classNames({
+    const iconClassName = classNames({
       fa: true,
       'fa-play': rule.state === 'paused',
       'fa-pause': rule.state !== 'paused',
@@ -55,7 +55,7 @@ class AlertRuleItem extends PureComponent<Props> {
             title="Pausing an alert rule prevents it from executing"
             onClick={onTogglePause}
           >
-            <i className={stateClass} />
+            <i className={iconClassName} />
           </button>
           <a className="btn btn-small btn-inverse alert-list__btn width-2" href={ruleUrl} title="Edit alert rule">
             <i className="icon-gf icon-gf-settings" />

+ 2 - 2
public/app/features/api-keys/ApiKeysPage.tsx

@@ -13,7 +13,7 @@ import ApiKeysAddedModal from './ApiKeysAddedModal';
 import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
 import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
-import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
+import { DeleteButton } from '@grafana/ui';
 
 export interface Props {
   navModel: NavModel;
@@ -224,7 +224,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
                     <td>{key.name}</td>
                     <td>{key.role}</td>
                     <td>
-                      <DeleteButton onConfirmDelete={() => this.onDeleteApiKey(key)} />
+                      <DeleteButton onConfirm={() => this.onDeleteApiKey(key)} />
                     </td>
                   </tr>
                 );

部分文件因为文件数量过多而无法显示