Просмотр исходного кода

Merge pull request #1 from grafana/master

Syncing
Yuan Liu 7 лет назад
Родитель
Сommit
76fc48e2eb
100 измененных файлов с 1230 добавлено и 768 удалено
  1. 1 0
      .bra.toml
  2. 4 3
      .circleci/config.yml
  3. 1 1
      .gitignore
  4. 16 1
      CHANGELOG.md
  5. 14 2
      Gopkg.lock
  6. 4 0
      Gopkg.toml
  7. 1 1
      README.md
  8. 1 1
      appveyor.yml
  9. 1 1
      devenv/docker/blocks/influxdb/docker-compose.yaml
  10. 4 3
      docs/sources/administration/provisioning.md
  11. 1 1
      docs/sources/alerting/notifications.md
  12. 3 0
      docs/sources/auth/generic-oauth.md
  13. 3 0
      docs/sources/auth/github.md
  14. 3 0
      docs/sources/auth/gitlab.md
  15. 3 0
      docs/sources/auth/google.md
  16. 3 2
      docs/sources/features/datasources/mssql.md
  17. 50 20
      docs/sources/features/datasources/stackdriver.md
  18. 17 9
      docs/sources/index.md
  19. 2 3
      docs/sources/plugins/developing/development.md
  20. 2 2
      latest.json
  21. 4 3
      package.json
  22. 61 61
      pkg/api/api.go
  23. 3 2
      pkg/api/dashboard.go
  24. 2 1
      pkg/api/dashboard_test.go
  25. 26 23
      pkg/api/dtos/alerting.go
  26. 3 1
      pkg/api/http_server.go
  27. 6 5
      pkg/api/index.go
  28. 2 2
      pkg/api/login.go
  29. 16 10
      pkg/api/org_users.go
  30. 3 3
      pkg/api/user.go
  31. 1 1
      pkg/cmd/grafana-server/main.go
  32. 4 0
      pkg/extensions/main.go
  33. 7 0
      pkg/middleware/middleware.go
  34. 26 23
      pkg/models/alert_notifications.go
  35. 0 1
      pkg/models/dashboards.go
  36. 4 2
      pkg/models/org_user.go
  37. 5 4
      pkg/services/alerting/conditions/evaluator.go
  38. 5 6
      pkg/services/alerting/extractor.go
  39. 1 1
      pkg/services/alerting/extractor_test.go
  40. 1 0
      pkg/services/alerting/interfaces.go
  41. 26 16
      pkg/services/alerting/notifiers/base.go
  42. 5 0
      pkg/services/alerting/notifiers/base_test.go
  43. 7 1
      pkg/services/alerting/notifiers/telegram.go
  44. 5 5
      pkg/services/alerting/rule.go
  45. 1 1
      pkg/services/cleanup/cleanup.go
  46. 6 2
      pkg/services/dashboards/dashboard_service.go
  47. 2 2
      pkg/services/dashboards/dashboard_service_test.go
  48. 30 0
      pkg/services/hooks/hooks.go
  49. 14 10
      pkg/services/sqlstore/alert_notification.go
  50. 10 7
      pkg/services/sqlstore/alert_notification_test.go
  51. 3 0
      pkg/services/sqlstore/migrations/alert_mig.go
  52. 15 0
      pkg/services/sqlstore/org_test.go
  53. 25 10
      pkg/services/sqlstore/org_users.go
  54. 1 1
      pkg/services/sqlstore/sqlstore.go
  55. 20 16
      pkg/services/sqlstore/user.go
  56. 0 2
      pkg/services/sqlstore/user_auth_test.go
  57. 24 18
      pkg/setting/setting.go
  58. 12 12
      pkg/setting/setting_test.go
  59. 6 1
      pkg/tsdb/mssql/mssql.go
  60. 1 1
      pkg/tsdb/mssql/mssql_test.go
  61. 1 1
      pkg/tsdb/mysql/mysql_test.go
  62. 1 1
      pkg/tsdb/postgres/postgres_test.go
  63. 6 2
      public/app/app.ts
  64. 17 0
      public/app/core/components/PageLoader/PageLoader.tsx
  65. 5 15
      public/app/core/components/PermissionList/AddPermission.tsx
  66. 2 2
      public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
  67. 2 2
      public/app/core/components/PermissionList/PermissionListItem.tsx
  68. 18 49
      public/app/core/components/Picker/DescriptionOption.tsx
  69. 26 18
      public/app/core/components/Picker/DescriptionPicker.tsx
  70. 15 0
      public/app/core/components/Picker/IndicatorsContainer.tsx
  71. 18 0
      public/app/core/components/Picker/NoOptionsMessage.tsx
  72. 20 4
      public/app/core/components/Picker/PickerOption.test.tsx
  73. 17 49
      public/app/core/components/Picker/PickerOption.tsx
  74. 23 0
      public/app/core/components/Picker/ResetStyles.tsx
  75. 30 25
      public/app/core/components/Picker/TeamPicker.tsx
  76. 26 23
      public/app/core/components/Picker/UserPicker.tsx
  77. 12 13
      public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
  78. 85 55
      public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap
  79. 85 55
      public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap
  80. 2 7
      public/app/core/components/TagFilter/TagBadge.tsx
  81. 38 24
      public/app/core/components/TagFilter/TagFilter.tsx
  82. 16 46
      public/app/core/components/TagFilter/TagOption.tsx
  83. 1 1
      public/app/core/components/TagFilter/TagValue.tsx
  84. 1 1
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  85. 6 2
      public/app/core/components/search/search.ts
  86. 1 1
      public/app/core/components/sidemenu/SideMenu.tsx
  87. 1 1
      public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
  88. 71 0
      public/app/core/specs/kbn.test.ts
  89. 12 0
      public/app/core/utils/dag.test.ts
  90. 18 2
      public/app/core/utils/dag.ts
  91. 55 0
      public/app/core/utils/kbn.ts
  92. 1 0
      public/app/features/alerting/NotificationsEditCtrl.ts
  93. 10 3
      public/app/features/alerting/partials/notification_edit.html
  94. 7 4
      public/app/features/annotations/annotations_srv.ts
  95. 2 0
      public/app/features/api-keys/ApiKeysPage.test.tsx
  96. 41 29
      public/app/features/api-keys/ApiKeysPage.tsx
  97. 5 26
      public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
  98. 2 1
      public/app/features/api-keys/state/reducers.ts
  99. 2 2
      public/app/features/api-keys/state/selectors.test.ts
  100. 2 0
      public/app/features/datasources/DataSourcesListPage.test.tsx

+ 1 - 0
.bra.toml

@@ -4,6 +4,7 @@ init_cmds = [
 	["./bin/grafana-server", "cfg:app_mode=development"]
 ]
 watch_all = true
+follow_symlinks = true
 watch_dirs = [
 	"$WORKDIR/pkg",
 	"$WORKDIR/public/views",

+ 4 - 3
.circleci/config.yml

@@ -126,7 +126,7 @@ jobs:
 
   build-all:
     docker:
-     - image: grafana/build-container:1.1.0
+     - image: grafana/build-container:1.2.0
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout
@@ -170,10 +170,11 @@ jobs:
             - scripts/*.sh
             - scripts/publish
             - scripts/build/release_publisher/release_publisher
+            - scripts/build/publish.sh
 
   build:
     docker:
-     - image: grafana/build-container:1.1.0
+     - image: grafana/build-container:1.2.0
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout
@@ -232,7 +233,7 @@ jobs:
 
   build-enterprise:
     docker:
-     - image: grafana/build-container:v0.1
+     - image: grafana/build-container:1.2.0
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout

+ 1 - 1
.gitignore

@@ -54,6 +54,7 @@ profile.cov
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/debug
 /pkg/extensions
+/public/app/extensions
 debug.test
 /examples/*/dist
 /packaging/**/*.rpm
@@ -68,7 +69,6 @@ debug.test
 /vendor/**/*.yml
 /vendor/**/*_test.go
 /vendor/**/.editorconfig
-/vendor/**/appengine*
 *.orig
 
 /devenv/bulk-dashboards/*.json

+ 16 - 1
CHANGELOG.md

@@ -3,15 +3,30 @@
 ### New Features
 
 * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
+* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
+* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
 
 ### Minor
 
 * **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
+* **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
 
 ### Breaking changes
 
 * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
 
+# 5.3.1 (2018-10-16)
+
+* **Render**: Fix PhantomJS render of graph panel when legend displayed as table to the right [#13616](https://github.com/grafana/grafana/issues/13616)
+* **Stackdriver**: Filter option disappears after removing initial filter [#13607](https://github.com/grafana/grafana/issues/13607)
+* **Elasticsearch**: Fix no limit size in terms aggregation for alerting queries [#13172](https://github.com/grafana/grafana/issues/13172), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **InfluxDB**: Fix for annotation issue that caused text to be shown twice [#13553](https://github.com/grafana/grafana/issues/13553)
+* **Variables**: Fix nesting variables leads to exception and missing refresh [#13628](https://github.com/grafana/grafana/issues/13628)
+* **Variables**: Prometheus: Single letter labels are not supported [#13641](https://github.com/grafana/grafana/issues/13641), thx [@olshansky](https://github.com/olshansky)
+* **Graph**: Fix graph time formatting for Last 24h ranges [#13650](https://github.com/grafana/grafana/issues/13650)
+* **Playlist**: Fix cannot add dashboards with long names to playlist [#13464](https://github.com/grafana/grafana/issues/13464), thx [@neufeldtech](https://github.com/neufeldtech)
+* **HTTP API**: Fix /api/org/users so that query and limit querystrings works
+
 # 5.3.0 (2018-10-10)
 
 * **Stackdriver**: Filter wildcards and regex matching are not yet supported [#13495](https://github.com/grafana/grafana/issues/13495)
@@ -63,7 +78,7 @@
 * **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476)
 * **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos)
 * **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
-* ****: **: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
+* **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
 * **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm)
 * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
 

+ 14 - 2
Gopkg.lock

@@ -264,7 +264,7 @@
   branch = "master"
   name = "github.com/hashicorp/yamux"
   packages = ["."]
-  revision = "2658be15c5f05e76244154714161f17e3e77de2e"
+  revision = "7221087c3d281fda5f794e28c2ea4c6e4d5c4558"
 
 [[projects]]
   name = "github.com/inconshreveable/log15"
@@ -507,6 +507,8 @@
   branch = "master"
   name = "golang.org/x/crypto"
   packages = [
+    "ed25519",
+    "ed25519/internal/edwards25519",
     "md4",
     "pbkdf2"
   ]
@@ -670,6 +672,16 @@
   revision = "e6179049628164864e6e84e973cfb56335748dea"
   version = "v2.3.2"
 
+[[projects]]
+  name = "gopkg.in/square/go-jose.v2"
+  packages = [
+    ".",
+    "cipher",
+    "json"
+  ]
+  revision = "ef984e69dd356202fd4e4910d4d9c24468bdf0b8"
+  version = "v2.1.9"
+
 [[projects]]
   name = "gopkg.in/yaml.v2"
   packages = ["."]
@@ -679,6 +691,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "6e9458f912a5f0eb3430b968f1b4dbc4e3b7671b282cf4fe1573419a6d9ba0d4"
+  inputs-digest = "6f7f271afd27f78b7d8ebe27436fee72c9925fb82a978bdc57fde44e01f3ca51"
   solver-name = "gps-cdcl"
   solver-version = 1

+ 4 - 0
Gopkg.toml

@@ -207,3 +207,7 @@ ignored = [
 [[constraint]]
   name = "github.com/VividCortex/mysqlerr"
   branch = "master"
+
+[[constraint]]
+  name = "gopkg.in/square/go-jose.v2"
+  version = "2.1.9"

+ 1 - 1
README.md

@@ -138,5 +138,5 @@ plugin development.
 
 ## License
 
-Grafana is distributed under Apache 2.0 License.
+Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md).
 

+ 1 - 1
appveyor.yml

@@ -5,7 +5,7 @@ os: Windows Server 2012 R2
 clone_folder: c:\gopath\src\github.com\grafana\grafana
 
 environment:
-  nodejs_version: "6"
+  nodejs_version: "8"
   GOPATH: C:\gopath
   GOVERSION: 1.11
 

+ 1 - 1
devenv/docker/blocks/influxdb/docker-compose.yaml

@@ -6,7 +6,7 @@
       - "8083:8083"
       - "8086:8086"
     volumes:
-      - ./blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
+      - ./docker/blocks/influxdb/influxdb.conf:/etc/influxdb/influxdb.conf
 
   fake-influxdb-data:
     image: grafana/fake-data-gen

+ 4 - 3
docs/sources/administration/provisioning.md

@@ -156,9 +156,9 @@ Since not all datasources have the same configuration settings we only have the
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | graphiteVersion | string | Graphite |  Graphite version  |
 | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
-| esVersion | number | Elastic | Elasticsearch version as a number (2/5/56) |
-| timeField | string | Elastic | Which field that should be used as timestamp |
-| interval | string | Elastic | Index date time format |
+| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
+| timeField | string | Elasticsearch | Which field that should be used as timestamp |
+| interval | string | Elasticsearch | Index date time format |
 | authType | string | Cloudwatch | Auth provider. keys/credentials/arn |
 | assumeRoleArn | string | Cloudwatch | ARN of Assume Role |
 | defaultRegion | string | Cloudwatch | AWS region |
@@ -166,6 +166,7 @@ Since not all datasources have the same configuration settings we only have the
 | tsdbVersion | string | OpenTSDB | Version |
 | tsdbResolution | string | OpenTSDB | Resolution |
 | sslmode | string | PostgreSQL | SSLmode. 'disable', 'require', 'verify-ca' or 'verify-full' |
+| encrypt | string | MSSQL | Connection SSL encryption handling. 'disable', 'false' or 'true' |
 | postgresVersion | number | PostgreSQL | Postgres version as a number (903/904/905/906/1000) meaning v9.3, v9.4, ..., v10 |
 | timescaledb | boolean | PostgreSQL | Enable usage of TimescaleDB extension |
 | maxOpenConns | number | MySQL, PostgreSQL & MSSQL | Maximum number of open connections to the database (Grafana v5.4+) |

+ 1 - 1
docs/sources/alerting/notifications.md

@@ -128,7 +128,7 @@ Example json body:
 
 In DingTalk PC Client:
 
-1. Click "more" icon on left bottom of the panel.
+1. Click "more" icon on upper right of the panel.
 
 2. Click "Robot Manage" item in the pop menu, there will be a new panel call "Robot Manage".
 

+ 3 - 0
docs/sources/auth/generic-oauth.md

@@ -17,6 +17,9 @@ 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 
+correct. For example in case you are serving Grafana behind a proxy.
+
 Example config:
 
 ```bash

+ 3 - 0
docs/sources/auth/github.md

@@ -46,6 +46,9 @@ team_ids =
 allowed_organizations =
 ```
 
+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.
+
 Restart the Grafana back-end. You should now see a GitHub login button
 on the login page. You can now login or sign up with your GitHub
 accounts.

+ 3 - 0
docs/sources/auth/gitlab.md

@@ -58,6 +58,9 @@ api_url = https://gitlab.com/api/v4
 allowed_groups =
 ```
 
+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.
+
 Restart the Grafana backend for your changes to take effect.
 
 If you use your own instance of GitLab instead of `gitlab.com`, adjust

+ 3 - 0
docs/sources/auth/google.md

@@ -45,6 +45,9 @@ allowed_domains = mycompany.com mycompany.org
 allow_sign_up = true
 ```
 
+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.
+
 Restart the Grafana back-end. You should now see a Google login button
 on the login page. You can now login or sign up with your Google
 accounts. The `allowed_domains` option is optional, and domains were separated by space.

+ 3 - 2
docs/sources/features/datasources/mssql.md

@@ -32,6 +32,7 @@ Name | Description
 *Database* | Name of your MSSQL database.
 *User* | Database user's login/username
 *Password* | Database user's password
+*Encrypt* | This option determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server, default `false` (Grafana v5.4+).
 *Max open* | The maximum number of open connections to the database, default `unlimited` (Grafana v5.4+).
 *Max idle* | The maximum number of connections in the idle connection pool, default `2` (Grafana v5.4+).
 *Max lifetime* | The maximum amount of time in seconds a connection may be reused, default `14400`/4 hours (Grafana v5.4+).
@@ -72,8 +73,8 @@ Make sure the user does not get any unwanted privileges from the public role.
 
 ### Known Issues
 
-MSSQL 2008 and 2008 R2 engine cannot handle login records when SSL encryption is not disabled. Due to this you may receive an `Login error: EOF` error when trying to create your datasource.
-To fix MSSQL 2008 R2 issue, install MSSQL 2008 R2 Service Pack 2. To fix MSSQL 2008 issue, install Microsoft MSSQL 2008 Service Pack 3 and Cumulative update package 3 for MSSQL 2008 SP3.
+If you're using an older version of Microsoft SQL Server like 2008 and 2008R2 you may need to disable encryption to be able to connect.
+If possible, we recommend you to use the latest service pack available for optimal compatibility.
 
 ## Query Editor
 

+ 50 - 20
docs/sources/features/datasources/stackdriver.md

@@ -22,7 +22,7 @@ Grafana ships with built-in support for Google Stackdriver. Just add it as a dat
 1. Open the side menu by clicking the Grafana icon in the top header.
 2. In the side menu under the `Dashboards` link you should find a link named `Data Sources`.
 3. Click the `+ Add data source` button in the top header.
-4. Select `Stackdriver` from the *Type* dropdown.
+4. Select `Stackdriver` from the _Type_ dropdown.
 5. Upload or paste in the Service Account Key file. See below for steps on how to create a Service Account Key file.
 
 > NOTE: If you're not seeing the `Data Sources` link in your side menu it means that your current user does not have the `Admin` role for the current organization.
@@ -43,36 +43,46 @@ To authenticate with the Stackdriver API, you need to create a Google Cloud Plat
 
 The following APIs need to be enabled first:
 
-- [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com)
-- [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
+* [Monitoring API](https://console.cloud.google.com/apis/library/monitoring.googleapis.com)
+* [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
 
 Click on the links above and click the `Enable` button:
 
-![Enable GCP APIs](/img/docs/v54/stackdriver_enable_api.png)
+{{< docs-imagebox img="/img/docs/v53/stackdriver_enable_api.png" class="docs-image--no-shadow" caption="Enable GCP APIs" >}}
 
 #### Create a GCP Service Account for a Project
 
 1. Navigate to the [APIs & Services Credentials page](https://console.cloud.google.com/apis/credentials).
 2. Click on the `Create credentials` dropdown/button and choose the `Service account key` option.
 
-    ![Create service account button](/img/docs/v54/stackdriver_create_service_account_button.png)
+    {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_button.png" class="docs-image--no-shadow" caption="Create service account button" >}}
+
 3. On the `Create service account key` page, choose key type `JSON`. Then in the `Service Account` dropdown, choose the `New service account` option:
 
-    ![Create service account key](/img/docs/v54/stackdriver_create_service_account_key.png)
+    {{< docs-imagebox img="/img/docs/v53/stackdriver_create_service_account_key.png" class="docs-image--no-shadow" caption="Create service account key" >}}
+
 4. Some new fields will appear. Fill in a name for the service account in the `Service account name` field and then choose the `Monitoring Viewer` role from the `Role` dropdown:
 
-    ![Choose role](/img/docs/v54/stackdriver_service_account_choose_role.png)
+    {{< docs-imagebox img="/img/docs/v53/stackdriver_service_account_choose_role.png" class="docs-image--no-shadow" caption="Choose role" >}}
+  
 5. Click the Create button. A JSON key file will be created and downloaded to your computer. Store this file in a secure place as it allows access to your Stackdriver data.
 6. Upload it to Grafana on the datasource Configuration page. You can either upload the file or paste in the contents of the file.
-    
-    ![Choose role](/img/docs/v54/stackdriver_grafana_upload_key.png)
+
+    {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_upload_key.png" class="docs-image--no-shadow" caption="Upload service key file to Grafana" >}}
+
 7. The file contents will be encrypted and saved in the Grafana database. Don't forget to save after uploading the file!
-    
-    ![Choose role](/img/docs/v54/stackdriver_grafana_key_uploaded.png)
+
+    {{< docs-imagebox img="/img/docs/v53/stackdriver_grafana_key_uploaded.png" class="docs-image--no-shadow" caption="Service key file is uploaded to Grafana" >}}
 
 ## Metric Query Editor
 
-Choose a metric from the `Metric` dropdown.
+{{< docs-imagebox img="/img/docs/v53/stackdriver_query_editor.png" max-width= "400px" class="docs-image--right" >}}
+
+The Stackdriver query editor allows you to select metrics, group/aggregate by labels and by time, and use filters to specify which time series you want in the results.
+
+Begin by choosing a `Service` and then a metric from the `Metric` dropdown. Use the plus and minus icons in the filter and group by sections to add/remove filters or group by clauses.
+
+Stackdriver metrics can be of different kinds (GAUGE, DELTA, CUMULATIVE) and these kinds have support for different aggregation options (reducers and aligners). The Grafana query editor shows the list of available aggregation methods for a selected metric and sets a default reducer and aligner when you select the metric. Units for the Y-axis are also automatically selected by the query editor.
 
 ### Filter
 
@@ -80,7 +90,7 @@ To add a filter, click the plus icon and choose a field to filter by and enter a
 
 #### Simple wildcards
 
-When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions. 
+When the operator is set to `=` or `!=` it is possible to add wildcards to the filter value field. E.g `us-*` will capture all values that starts with "us-" and `*central-a` will capture all values that ends with "central-a". `*-central-*` captures all values that has the substring of -central-. Simple wildcards are less expensive than regular expressions.
 
 #### Regular expressions
 
@@ -97,9 +107,9 @@ The `Aligner` field allows you to align multiple time series after the same grou
 The `Alignment Period` groups a metric by time if an aggregation is chosen. The default is to use the GCP Stackdriver default groupings (which allows you to compare graphs in Grafana with graphs in the Stackdriver UI).
 The option is called `Stackdriver auto` and the defaults are:
 
-- 1m for time ranges < 23 hours
-- 5m for time ranges >= 23 hours and < 6 days
-- 1h for time ranges >= 6 days
+* 1m for time ranges < 23 hours
+* 5m for time ranges >= 23 hours and < 6 days
+* 1h for time ranges >= 6 days
 
 The other automatic option is `Grafana auto`. This will automatically set the group by time depending on the time range chosen and the width of the graph panel. Read more about the details [here](http://docs.grafana.org/reference/templating/#the-interval-variable).
 
@@ -151,15 +161,34 @@ Writing variable queries is not supported yet.
 
 There are two syntaxes:
 
-- `$<varname>`  Example: rate(http_requests_total{job=~"$job"}[5m])
-- `[[varname]]` Example: rate(http_requests_total{job=~"[[job]]"}[5m])
+* `$<varname>` Example: `metric.label.$metric_label`
+* `[[varname]]` Example: `metric.label.[[metric_label]]`
 
-Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the *Multi-value* or *Include all value* options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`.
+Why two ways? The first syntax is easier to read and write but does not allow you to use a variable in the middle of a word. When the _Multi-value_ or _Include all value_ options are enabled, Grafana converts the labels from plain text to a regex compatible string, which means you have to use `=~` instead of `=`.
 
 ## Annotations
 
+{{< docs-imagebox img="/img/docs/v53/stackdriver_annotations_query_editor.png" max-width= "400px" class="docs-image--right" >}}
+
 [Annotations]({{< relref "reference/annotations.md" >}}) allows you to overlay rich event information on top of graphs. You add annotation
-queries via the Dashboard menu / Annotations view.
+queries via the Dashboard menu / Annotations view. Annotation rendering is expensive so it is important to limit the number of rows returned. There is no support for showing Stackdriver annotations and events yet but it works well with [custom metrics](https://cloud.google.com/monitoring/custom-metrics/) in Stackdriver.
+
+With the query editor for annotations, you can select a metric and filters. The `Title` and `Text` fields support templating and can use data returned from the query. For example, the Title field could have the following text:
+
+`{{metric.type}} has value: {{metric.value}}`
+
+Example Result: `monitoring.googleapis.com/uptime_check/http_status has this value: 502`
+
+### Patterns for the Annotation Query Editor
+
+| Alias Pattern Format     | Description                      | Alias Pattern Example            | Example Result                                    |
+| ------------------------ | -------------------------------- | -------------------------------- | ------------------------------------------------- |
+| `{{metric.value}}`       | value of the metric/point        | `{{metric.value}}`               | `555`                                             |
+| `{{metric.type}}`        | returns the full Metric Type     | `{{metric.type}}`                | `compute.googleapis.com/instance/cpu/utilization` |
+| `{{metric.name}}`        | returns the metric name part     | `{{metric.name}}`                | `instance/cpu/utilization`                        |
+| `{{metric.service}}`     | returns the service part         | `{{metric.service}}`             | `compute`                                         |
+| `{{metric.label.xxx}}`   | returns the metric label value   | `{{metric.label.instance_name}}` | `grafana-1-prod`                                  |
+| `{{resource.label.xxx}}` | returns the resource label value | `{{resource.label.zone}}`        | `us-east1-b`                                      |
 
 ## Configure the Datasource with Provisioning
 
@@ -173,6 +202,7 @@ apiVersion: 1
 datasources:
   - name: Stackdriver
     type: stackdriver
+    access: proxy
     jsonData:
       tokenUri: https://oauth2.googleapis.com/token
       clientEmail: stackdriver@myproject.iam.gserviceaccount.com

+ 17 - 9
docs/sources/index.md

@@ -60,9 +60,9 @@ aliases = ["v1.1", "guides/reference/admin"]
         <h4>Provisioning</h4>
         <p>A guide to help you automate your Grafana setup & configuration.</p>
     </a>
-    <a href="{{< relref "guides/whats-new-in-v5-2.md" >}}" class="nav-cards__item nav-cards__item--guide">
-        <h4>What's new in v5.2</h4>
-        <p>Article on all the new cool features and enhancements in v5.2</p>
+    <a href="{{< relref "guides/whats-new-in-v5-3.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>What's new in v5.3</h4>
+        <p>Article on all the new cool features and enhancements in v5.3</p>
     </a>
     <a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
         <h4>Screencasts</h4>
@@ -88,9 +88,13 @@ aliases = ["v1.1", "guides/reference/admin"]
       <img src="/img/docs/logos/icon_prometheus.svg" >
       <h5>Prometheus</h5>
     </a>
-    <a href="{{< relref "features/datasources/opentsdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
-      <img src="/img/docs/logos/icon_opentsdb.png" >
-      <h5>OpenTSDB</h5>
+    <a href="{{< relref "features/datasources/stackdriver.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/stackdriver_logo.png">
+      <h5>Google Stackdriver</h5>
+    </a>
+    <a href="{{< relref "features/datasources/cloudwatch.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_cloudwatch.svg">
+      <h5>Cloudwatch</h5>
     </a>
     <a href="{{< relref "features/datasources/mysql.md" >}}" class="nav-cards__item nav-cards__item--ds">
       <img src="/img/docs/logos/icon_mysql.png" >
@@ -100,8 +104,12 @@ aliases = ["v1.1", "guides/reference/admin"]
       <img src="/img/docs/logos/icon_postgres.svg" >
       <h5>Postgres</h5>
     </a>
-    <a href="{{< relref "features/datasources/cloudwatch.md" >}}" class="nav-cards__item nav-cards__item--ds">
-      <img src="/img/docs/logos/icon_cloudwatch.svg">
-      <h5>Cloudwatch</h5>
+    <a href="{{< relref "features/datasources/mssql.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/sql_server_logo.svg">
+      <h5>Microsoft SQL Server</h5>
+    </a>
+    <a href="{{< relref "features/datasources/opentsdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_opentsdb.png" >
+      <h5>OpenTSDB</h5>
     </a>
 </div>

+ 2 - 3
docs/sources/plugins/developing/development.md

@@ -10,7 +10,7 @@ weight = 1
 
 # Developer Guide
 
-You can extend Grafana by writing your own plugins and then share then with other users in [our plugin repository](https://grafana.com/plugins).
+You can extend Grafana by writing your own plugins and then share them with other users in [our plugin repository](https://grafana.com/plugins).
 
 ## Short version
 
@@ -33,7 +33,7 @@ There are two blog posts about authoring a plugin that might also be of interest
 ## What languages?
 
 Since everything turns into javascript it's up to you to choose which language you want. That said it's probably a good idea to choose es6 or typescript since
-we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo is you choose one of those languages.
+we use es6 classes in Grafana. So it's easier to get inspiration from the Grafana repo if you choose one of those languages.
 
 ## Buildscript
 
@@ -60,7 +60,6 @@ and [apps]({{< relref "apps.md" >}}) plugins in the documentation.
 The Grafana SDK is quite small so far and can be found here:
 
 - [SDK file in Grafana](https://github.com/grafana/grafana/blob/master/public/app/plugins/sdk.ts)
-- [SDK Readme](https://github.com/grafana/grafana/blob/master/public/app/plugins/plugin_api.md)
 
 The SDK contains three different plugin classes: PanelCtrl, MetricsPanelCtrl and QueryCtrl. For plugins of the panel type, the module.js file should export one of these. There are some extra classes for [data sources]({{< relref "datasources.md" >}}).
 

+ 2 - 2
latest.json

@@ -1,4 +1,4 @@
 {
-  "stable": "5.3.0",
-  "testing": "5.3.0"
+  "stable": "5.3.1",
+  "testing": "5.3.1"
 }

+ 4 - 3
package.json

@@ -17,6 +17,7 @@
     "@types/react": "^16.4.14",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-dom": "^16.0.7",
+    "@types/react-select": "^2.0.4",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^6.4.0",
     "axios": "^0.17.1",
@@ -79,14 +80,14 @@
     "style-loader": "^0.21.0",
     "systemjs": "0.20.19",
     "systemjs-plugin-css": "^0.1.36",
-    "ts-jest": "^23.1.4",
+    "ts-jest": "^23.10.4",
     "ts-loader": "^5.1.0",
     "tslib": "^1.9.3",
     "tslint": "^5.8.0",
     "tslint-loader": "^3.5.3",
     "typescript": "^3.0.3",
     "uglifyjs-webpack-plugin": "^1.2.7",
-    "webpack": "^4.8.0",
+    "webpack": "4.19.1",
     "webpack-bundle-analyzer": "^2.9.0",
     "webpack-cleanup-plugin": "^0.5.1",
     "webpack-cli": "^2.1.4",
@@ -157,7 +158,7 @@
     "react-highlight-words": "^0.10.0",
     "react-popper": "^0.7.5",
     "react-redux": "^5.0.7",
-    "react-select": "^1.1.0",
+    "react-select": "2.1.0",
     "react-sizeme": "^2.3.6",
     "react-transition-group": "^2.2.1",
     "redux": "^4.0.0",

+ 61 - 61
pkg/api/api.go

@@ -10,10 +10,10 @@ import (
 )
 
 func (hs *HTTPServer) registerRoutes() {
-	reqSignedIn := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true})
-	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
-	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
-	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	reqSignedIn := middleware.ReqSignedIn
+	reqGrafanaAdmin := middleware.ReqGrafanaAdmin
+	reqEditorRole := middleware.ReqEditorRole
+	reqOrgAdmin := middleware.ReqOrgAdmin
 	redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL()
 	redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL()
 	quota := middleware.Quota
@@ -22,66 +22,66 @@ func (hs *HTTPServer) registerRoutes() {
 	r := hs.RouteRegister
 
 	// not logged in views
-	r.Get("/", reqSignedIn, Index)
+	r.Get("/", reqSignedIn, hs.Index)
 	r.Get("/logout", Logout)
 	r.Post("/login", quota("session"), bind(dtos.LoginCommand{}), Wrap(LoginPost))
 	r.Get("/login/:name", quota("session"), OAuthLogin)
-	r.Get("/login", LoginView)
-	r.Get("/invite/:code", Index)
+	r.Get("/login", hs.LoginView)
+	r.Get("/invite/:code", hs.Index)
 
 	// authed views
-	r.Get("/profile/", reqSignedIn, Index)
-	r.Get("/profile/password", reqSignedIn, Index)
-	r.Get("/profile/switch-org/:id", reqSignedIn, ChangeActiveOrgAndRedirectToHome)
-	r.Get("/org/", reqSignedIn, Index)
-	r.Get("/org/new", reqSignedIn, Index)
-	r.Get("/datasources/", reqSignedIn, Index)
-	r.Get("/datasources/new", reqSignedIn, Index)
-	r.Get("/datasources/edit/*", reqSignedIn, Index)
-	r.Get("/org/users", reqSignedIn, Index)
-	r.Get("/org/users/new", reqSignedIn, Index)
-	r.Get("/org/users/invite", reqSignedIn, Index)
-	r.Get("/org/teams", reqSignedIn, Index)
-	r.Get("/org/teams/*", reqSignedIn, Index)
-	r.Get("/org/apikeys/", reqSignedIn, Index)
-	r.Get("/dashboard/import/", reqSignedIn, Index)
-	r.Get("/configuration", reqGrafanaAdmin, Index)
-	r.Get("/admin", reqGrafanaAdmin, Index)
-	r.Get("/admin/settings", reqGrafanaAdmin, Index)
-	r.Get("/admin/users", reqGrafanaAdmin, Index)
-	r.Get("/admin/users/create", reqGrafanaAdmin, Index)
-	r.Get("/admin/users/edit/:id", reqGrafanaAdmin, Index)
-	r.Get("/admin/orgs", reqGrafanaAdmin, Index)
-	r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, Index)
-	r.Get("/admin/stats", reqGrafanaAdmin, Index)
-
-	r.Get("/styleguide", reqSignedIn, Index)
-
-	r.Get("/plugins", reqSignedIn, Index)
-	r.Get("/plugins/:id/edit", reqSignedIn, Index)
-	r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
-
-	r.Get("/d/:uid/:slug", reqSignedIn, Index)
-	r.Get("/d/:uid", reqSignedIn, Index)
-	r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, Index)
-	r.Get("/dashboard/script/*", reqSignedIn, Index)
-	r.Get("/dashboard-solo/snapshot/*", Index)
-	r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
-	r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, Index)
-	r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
-	r.Get("/import/dashboard", reqSignedIn, Index)
-	r.Get("/dashboards/", reqSignedIn, Index)
-	r.Get("/dashboards/*", reqSignedIn, Index)
-
-	r.Get("/explore", reqEditorRole, Index)
-
-	r.Get("/playlists/", reqSignedIn, Index)
-	r.Get("/playlists/*", reqSignedIn, Index)
-	r.Get("/alerting/", reqSignedIn, Index)
-	r.Get("/alerting/*", reqSignedIn, Index)
+	r.Get("/profile/", reqSignedIn, hs.Index)
+	r.Get("/profile/password", reqSignedIn, hs.Index)
+	r.Get("/profile/switch-org/:id", reqSignedIn, hs.ChangeActiveOrgAndRedirectToHome)
+	r.Get("/org/", reqSignedIn, hs.Index)
+	r.Get("/org/new", reqSignedIn, hs.Index)
+	r.Get("/datasources/", reqSignedIn, hs.Index)
+	r.Get("/datasources/new", reqSignedIn, hs.Index)
+	r.Get("/datasources/edit/*", reqSignedIn, hs.Index)
+	r.Get("/org/users", reqSignedIn, hs.Index)
+	r.Get("/org/users/new", reqSignedIn, hs.Index)
+	r.Get("/org/users/invite", reqSignedIn, hs.Index)
+	r.Get("/org/teams", reqSignedIn, hs.Index)
+	r.Get("/org/teams/*", reqSignedIn, hs.Index)
+	r.Get("/org/apikeys/", reqSignedIn, hs.Index)
+	r.Get("/dashboard/import/", reqSignedIn, hs.Index)
+	r.Get("/configuration", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/settings", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/users", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/users/create", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/users/edit/:id", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index)
+	r.Get("/admin/stats", reqGrafanaAdmin, hs.Index)
+
+	r.Get("/styleguide", reqSignedIn, hs.Index)
+
+	r.Get("/plugins", reqSignedIn, hs.Index)
+	r.Get("/plugins/:id/edit", reqSignedIn, hs.Index)
+	r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index)
+
+	r.Get("/d/:uid/:slug", reqSignedIn, hs.Index)
+	r.Get("/d/:uid", reqSignedIn, hs.Index)
+	r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardURL, hs.Index)
+	r.Get("/dashboard/script/*", reqSignedIn, hs.Index)
+	r.Get("/dashboard-solo/snapshot/*", hs.Index)
+	r.Get("/d-solo/:uid/:slug", reqSignedIn, hs.Index)
+	r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloURL, hs.Index)
+	r.Get("/dashboard-solo/script/*", reqSignedIn, hs.Index)
+	r.Get("/import/dashboard", reqSignedIn, hs.Index)
+	r.Get("/dashboards/", reqSignedIn, hs.Index)
+	r.Get("/dashboards/*", reqSignedIn, hs.Index)
+
+	r.Get("/explore", reqEditorRole, hs.Index)
+
+	r.Get("/playlists/", reqSignedIn, hs.Index)
+	r.Get("/playlists/*", reqSignedIn, hs.Index)
+	r.Get("/alerting/", reqSignedIn, hs.Index)
+	r.Get("/alerting/*", reqSignedIn, hs.Index)
 
 	// sign up
-	r.Get("/signup", Index)
+	r.Get("/signup", hs.Index)
 	r.Get("/api/user/signup/options", Wrap(GetSignUpOptions))
 	r.Post("/api/user/signup", quota("user"), bind(dtos.SignUpForm{}), Wrap(SignUp))
 	r.Post("/api/user/signup/step2", bind(dtos.SignUpStep2Form{}), Wrap(SignUpStep2))
@@ -91,15 +91,15 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Post("/api/user/invite/complete", bind(dtos.CompleteInviteForm{}), Wrap(CompleteInvite))
 
 	// reset password
-	r.Get("/user/password/send-reset-email", Index)
-	r.Get("/user/password/reset", Index)
+	r.Get("/user/password/send-reset-email", hs.Index)
+	r.Get("/user/password/reset", hs.Index)
 
 	r.Post("/api/user/password/send-reset-email", bind(dtos.SendResetPasswordEmailForm{}), Wrap(SendResetPasswordEmail))
 	r.Post("/api/user/password/reset", bind(dtos.ResetUserPasswordForm{}), Wrap(ResetPassword))
 
 	// dashboard snapshots
-	r.Get("/dashboard/snapshot/*", Index)
-	r.Get("/dashboard/snapshots/", reqSignedIn, Index)
+	r.Get("/dashboard/snapshot/*", hs.Index)
+	r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index)
 
 	// api for dashboard snapshots
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)

+ 3 - 2
pkg/api/dashboard.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"path"
 
+	"github.com/grafana/grafana/pkg/services/alerting"
 	"github.com/grafana/grafana/pkg/services/dashboards"
 
 	"github.com/grafana/grafana/pkg/api/dtos"
@@ -251,8 +252,8 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
 		return Error(403, err.Error(), err)
 	}
 
-	if err == m.ErrDashboardContainsInvalidAlertData {
-		return Error(500, "Invalid alert data. Cannot save dashboard", err)
+	if validationErr, ok := err.(alerting.ValidationError); ok {
+		return Error(422, validationErr.Error(), nil)
 	}
 
 	if err != nil {

+ 2 - 1
pkg/api/dashboard_test.go

@@ -9,6 +9,7 @@ import (
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/alerting"
 	"github.com/grafana/grafana/pkg/services/dashboards"
 	"github.com/grafana/grafana/pkg/setting"
 
@@ -725,7 +726,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				{SaveError: m.ErrDashboardVersionMismatch, ExpectedStatusCode: 412},
 				{SaveError: m.ErrDashboardTitleEmpty, ExpectedStatusCode: 400},
 				{SaveError: m.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: 400},
-				{SaveError: m.ErrDashboardContainsInvalidAlertData, ExpectedStatusCode: 500},
+				{SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: 422},
 				{SaveError: m.ErrDashboardFailedToUpdateAlertData, ExpectedStatusCode: 500},
 				{SaveError: m.ErrDashboardFailedGenerateUniqueUid, ExpectedStatusCode: 500},
 				{SaveError: m.ErrDashboardTypeMismatch, ExpectedStatusCode: 400},

+ 26 - 23
pkg/api/dtos/alerting.go

@@ -49,28 +49,30 @@ func formatShort(interval time.Duration) string {
 
 func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
 	return &AlertNotification{
-		Id:           notification.Id,
-		Name:         notification.Name,
-		Type:         notification.Type,
-		IsDefault:    notification.IsDefault,
-		Created:      notification.Created,
-		Updated:      notification.Updated,
-		Frequency:    formatShort(notification.Frequency),
-		SendReminder: notification.SendReminder,
-		Settings:     notification.Settings,
+		Id:                    notification.Id,
+		Name:                  notification.Name,
+		Type:                  notification.Type,
+		IsDefault:             notification.IsDefault,
+		Created:               notification.Created,
+		Updated:               notification.Updated,
+		Frequency:             formatShort(notification.Frequency),
+		SendReminder:          notification.SendReminder,
+		DisableResolveMessage: notification.DisableResolveMessage,
+		Settings:              notification.Settings,
 	}
 }
 
 type AlertNotification struct {
-	Id           int64            `json:"id"`
-	Name         string           `json:"name"`
-	Type         string           `json:"type"`
-	IsDefault    bool             `json:"isDefault"`
-	SendReminder bool             `json:"sendReminder"`
-	Frequency    string           `json:"frequency"`
-	Created      time.Time        `json:"created"`
-	Updated      time.Time        `json:"updated"`
-	Settings     *simplejson.Json `json:"settings"`
+	Id                    int64            `json:"id"`
+	Name                  string           `json:"name"`
+	Type                  string           `json:"type"`
+	IsDefault             bool             `json:"isDefault"`
+	SendReminder          bool             `json:"sendReminder"`
+	DisableResolveMessage bool             `json:"disableResolveMessage"`
+	Frequency             string           `json:"frequency"`
+	Created               time.Time        `json:"created"`
+	Updated               time.Time        `json:"updated"`
+	Settings              *simplejson.Json `json:"settings"`
 }
 
 type AlertTestCommand struct {
@@ -100,11 +102,12 @@ type EvalMatch struct {
 }
 
 type NotificationTestCommand struct {
-	Name         string           `json:"name"`
-	Type         string           `json:"type"`
-	SendReminder bool             `json:"sendReminder"`
-	Frequency    string           `json:"frequency"`
-	Settings     *simplejson.Json `json:"settings"`
+	Name                  string           `json:"name"`
+	Type                  string           `json:"type"`
+	SendReminder          bool             `json:"sendReminder"`
+	DisableResolveMessage bool             `json:"disableResolveMessage"`
+	Frequency             string           `json:"frequency"`
+	Settings              *simplejson.Json `json:"settings"`
 }
 
 type PauseAlertCommand struct {

+ 3 - 1
pkg/api/http_server.go

@@ -28,6 +28,7 @@ import (
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/plugins"
 	"github.com/grafana/grafana/pkg/registry"
+	"github.com/grafana/grafana/pkg/services/hooks"
 	"github.com/grafana/grafana/pkg/services/rendering"
 	"github.com/grafana/grafana/pkg/setting"
 )
@@ -52,6 +53,7 @@ type HTTPServer struct {
 	Bus           bus.Bus               `inject:""`
 	RenderService rendering.Service     `inject:""`
 	Cfg           *setting.Cfg          `inject:""`
+	HooksService  *hooks.HooksService   `inject:""`
 }
 
 func (hs *HTTPServer) Init() error {
@@ -184,7 +186,7 @@ func (hs *HTTPServer) applyRoutes() {
 	// then custom app proxy routes
 	hs.initAppPluginRoutes(hs.macaron)
 	// lastly not found route
-	hs.macaron.NotFound(NotFoundHandler)
+	hs.macaron.NotFound(hs.NotFoundHandler)
 }
 
 func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {

+ 6 - 5
pkg/api/index.go

@@ -17,7 +17,7 @@ const (
 	darkName  = "dark"
 )
 
-func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
+func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 	settings, err := getFrontendSettingsMap(c)
 	if err != nil {
 		return nil, err
@@ -350,11 +350,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		},
 	})
 
+	hs.HooksService.RunIndexDataHooks(&data)
 	return &data, nil
 }
 
-func Index(c *m.ReqContext) {
-	data, err := setIndexViewData(c)
+func (hs *HTTPServer) Index(c *m.ReqContext) {
+	data, err := hs.setIndexViewData(c)
 	if err != nil {
 		c.Handle(500, "Failed to get settings", err)
 		return
@@ -362,13 +363,13 @@ func Index(c *m.ReqContext) {
 	c.HTML(200, "index", data)
 }
 
-func NotFoundHandler(c *m.ReqContext) {
+func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) {
 	if c.IsApiRequest() {
 		c.JsonApiErr(404, "Not found", nil)
 		return
 	}
 
-	data, err := setIndexViewData(c)
+	data, err := hs.setIndexViewData(c)
 	if err != nil {
 		c.Handle(500, "Failed to get settings", err)
 		return

+ 2 - 2
pkg/api/login.go

@@ -17,8 +17,8 @@ const (
 	ViewIndex = "index"
 )
 
-func LoginView(c *m.ReqContext) {
-	viewData, err := setIndexViewData(c)
+func (hs *HTTPServer) LoginView(c *m.ReqContext) {
+	viewData, err := hs.setIndexViewData(c)
 	if err != nil {
 		c.Handle(500, "Failed to get settings", err)
 		return

+ 16 - 10
pkg/api/org_users.go

@@ -45,7 +45,7 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 
 // GET /api/org/users
 func GetOrgUsersForCurrentOrg(c *m.ReqContext) Response {
-	return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit"))
+	return getOrgUsersHelper(c.OrgId, c.Query("query"), c.QueryInt("limit"))
 }
 
 // GET /api/orgs/:orgId/users
@@ -102,26 +102,32 @@ func updateOrgUserHelper(cmd m.UpdateOrgUserCommand) Response {
 
 // DELETE /api/org/users/:userId
 func RemoveOrgUserForCurrentOrg(c *m.ReqContext) Response {
-	userID := c.ParamsInt64(":userId")
-	return removeOrgUserHelper(c.OrgId, userID)
+	return removeOrgUserHelper(&m.RemoveOrgUserCommand{
+		UserId:                   c.ParamsInt64(":userId"),
+		OrgId:                    c.OrgId,
+		ShouldDeleteOrphanedUser: true,
+	})
 }
 
 // DELETE /api/orgs/:orgId/users/:userId
 func RemoveOrgUser(c *m.ReqContext) Response {
-	userID := c.ParamsInt64(":userId")
-	orgID := c.ParamsInt64(":orgId")
-	return removeOrgUserHelper(orgID, userID)
+	return removeOrgUserHelper(&m.RemoveOrgUserCommand{
+		UserId: c.ParamsInt64(":userId"),
+		OrgId:  c.ParamsInt64(":orgId"),
+	})
 }
 
-func removeOrgUserHelper(orgID int64, userID int64) Response {
-	cmd := m.RemoveOrgUserCommand{OrgId: orgID, UserId: userID}
-
-	if err := bus.Dispatch(&cmd); err != nil {
+func removeOrgUserHelper(cmd *m.RemoveOrgUserCommand) Response {
+	if err := bus.Dispatch(cmd); err != nil {
 		if err == m.ErrLastOrgAdmin {
 			return Error(400, "Cannot remove last organization admin", nil)
 		}
 		return Error(500, "Failed to remove user from organization", err)
 	}
 
+	if cmd.UserWasDeleted {
+		return Success("User deleted")
+	}
+
 	return Success("User removed from organization")
 }

+ 3 - 3
pkg/api/user.go

@@ -177,17 +177,17 @@ func UserSetUsingOrg(c *m.ReqContext) Response {
 }
 
 // GET /profile/switch-org/:id
-func ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
+func (hs *HTTPServer) ChangeActiveOrgAndRedirectToHome(c *m.ReqContext) {
 	orgID := c.ParamsInt64(":id")
 
 	if !validateUsingOrg(c.UserId, orgID) {
-		NotFoundHandler(c)
+		hs.NotFoundHandler(c)
 	}
 
 	cmd := m.SetUsingOrgCommand{UserId: c.UserId, OrgId: orgID}
 
 	if err := bus.Dispatch(&cmd); err != nil {
-		NotFoundHandler(c)
+		hs.NotFoundHandler(c)
 	}
 
 	c.Redirect(setting.AppSubUrl + "/")

+ 1 - 1
pkg/cmd/grafana-server/main.go

@@ -100,7 +100,7 @@ func listenToSystemSignals(server *GrafanaServerImpl) {
 	sighupChan := make(chan os.Signal, 1)
 
 	signal.Notify(sighupChan, syscall.SIGHUP)
-	signal.Notify(signalChan, os.Interrupt, os.Kill, syscall.SIGTERM)
+	signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
 
 	for {
 		select {

+ 4 - 0
pkg/extensions/main.go

@@ -1,3 +1,7 @@
 package extensions
 
+import (
+	_ "gopkg.in/square/go-jose.v2"
+)
+
 var IsEnterprise bool = false

+ 7 - 0
pkg/middleware/middleware.go

@@ -14,6 +14,13 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
+var (
+	ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
+	ReqSignedIn     = Auth(&AuthOptions{ReqSignedIn: true})
+	ReqEditorRole   = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
+	ReqOrgAdmin     = RoleAuth(m.ROLE_ADMIN)
+)
+
 func GetContextHandler() macaron.Handler {
 	return func(c *macaron.Context) {
 		ctx := &m.ReqContext{

+ 26 - 23
pkg/models/alert_notifications.go

@@ -23,38 +23,41 @@ var (
 )
 
 type AlertNotification struct {
-	Id           int64            `json:"id"`
-	OrgId        int64            `json:"-"`
-	Name         string           `json:"name"`
-	Type         string           `json:"type"`
-	SendReminder bool             `json:"sendReminder"`
-	Frequency    time.Duration    `json:"frequency"`
-	IsDefault    bool             `json:"isDefault"`
-	Settings     *simplejson.Json `json:"settings"`
-	Created      time.Time        `json:"created"`
-	Updated      time.Time        `json:"updated"`
+	Id                    int64            `json:"id"`
+	OrgId                 int64            `json:"-"`
+	Name                  string           `json:"name"`
+	Type                  string           `json:"type"`
+	SendReminder          bool             `json:"sendReminder"`
+	DisableResolveMessage bool             `json:"disableResolveMessage"`
+	Frequency             time.Duration    `json:"frequency"`
+	IsDefault             bool             `json:"isDefault"`
+	Settings              *simplejson.Json `json:"settings"`
+	Created               time.Time        `json:"created"`
+	Updated               time.Time        `json:"updated"`
 }
 
 type CreateAlertNotificationCommand struct {
-	Name         string           `json:"name"  binding:"Required"`
-	Type         string           `json:"type"  binding:"Required"`
-	SendReminder bool             `json:"sendReminder"`
-	Frequency    string           `json:"frequency"`
-	IsDefault    bool             `json:"isDefault"`
-	Settings     *simplejson.Json `json:"settings"`
+	Name                  string           `json:"name"  binding:"Required"`
+	Type                  string           `json:"type"  binding:"Required"`
+	SendReminder          bool             `json:"sendReminder"`
+	DisableResolveMessage bool             `json:"disableResolveMessage"`
+	Frequency             string           `json:"frequency"`
+	IsDefault             bool             `json:"isDefault"`
+	Settings              *simplejson.Json `json:"settings"`
 
 	OrgId  int64 `json:"-"`
 	Result *AlertNotification
 }
 
 type UpdateAlertNotificationCommand struct {
-	Id           int64            `json:"id"  binding:"Required"`
-	Name         string           `json:"name"  binding:"Required"`
-	Type         string           `json:"type"  binding:"Required"`
-	SendReminder bool             `json:"sendReminder"`
-	Frequency    string           `json:"frequency"`
-	IsDefault    bool             `json:"isDefault"`
-	Settings     *simplejson.Json `json:"settings"  binding:"Required"`
+	Id                    int64            `json:"id"  binding:"Required"`
+	Name                  string           `json:"name"  binding:"Required"`
+	Type                  string           `json:"type"  binding:"Required"`
+	SendReminder          bool             `json:"sendReminder"`
+	DisableResolveMessage bool             `json:"disableResolveMessage"`
+	Frequency             string           `json:"frequency"`
+	IsDefault             bool             `json:"isDefault"`
+	Settings              *simplejson.Json `json:"settings"  binding:"Required"`
 
 	OrgId  int64 `json:"-"`
 	Result *AlertNotification

+ 0 - 1
pkg/models/dashboards.go

@@ -21,7 +21,6 @@ var (
 	ErrDashboardVersionMismatch                = errors.New("The dashboard has been changed by someone else")
 	ErrDashboardTitleEmpty                     = errors.New("Dashboard title cannot be empty")
 	ErrDashboardFolderCannotHaveParent         = errors.New("A Dashboard Folder cannot be added to another folder")
-	ErrDashboardContainsInvalidAlertData       = errors.New("Invalid alert data. Cannot save dashboard")
 	ErrDashboardFailedToUpdateAlertData        = errors.New("Failed to save alert data")
 	ErrDashboardsWithSameSlugExists            = errors.New("Multiple dashboards with the same slug exists")
 	ErrDashboardFailedGenerateUniqueUid        = errors.New("Failed to generate unique dashboard id")

+ 4 - 2
pkg/models/org_user.go

@@ -72,8 +72,10 @@ type OrgUser struct {
 // COMMANDS
 
 type RemoveOrgUserCommand struct {
-	UserId int64
-	OrgId  int64
+	UserId                   int64
+	OrgId                    int64
+	ShouldDeleteOrphanedUser bool
+	UserWasDeleted           bool
 }
 
 type AddOrgUserCommand struct {

+ 5 - 4
pkg/services/alerting/conditions/evaluator.go

@@ -2,6 +2,7 @@ package conditions
 
 import (
 	"encoding/json"
+	"fmt"
 
 	"github.com/grafana/grafana/pkg/components/null"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -31,12 +32,12 @@ type ThresholdEvaluator struct {
 func newThresholdEvaluator(typ string, model *simplejson.Json) (*ThresholdEvaluator, error) {
 	params := model.Get("params").MustArray()
 	if len(params) == 0 {
-		return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"}
+		return nil, fmt.Errorf("Evaluator missing threshold parameter")
 	}
 
 	firstParam, ok := params[0].(json.Number)
 	if !ok {
-		return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"}
+		return nil, fmt.Errorf("Evaluator has invalid parameter")
 	}
 
 	defaultEval := &ThresholdEvaluator{Type: typ}
@@ -107,7 +108,7 @@ func (e *RangedEvaluator) Eval(reducedValue null.Float) bool {
 func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
 	typ := model.Get("type").MustString()
 	if typ == "" {
-		return nil, alerting.ValidationError{Reason: "Evaluator missing type property"}
+		return nil, fmt.Errorf("Evaluator missing type property")
 	}
 
 	if inSlice(typ, defaultTypes) {
@@ -122,7 +123,7 @@ func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
 		return &NoValueEvaluator{}, nil
 	}
 
-	return nil, alerting.ValidationError{Reason: "Evaluator invalid evaluator type: " + typ}
+	return nil, fmt.Errorf("Evaluator invalid evaluator type: %s", typ)
 }
 
 func inSlice(a string, list []string) bool {

+ 5 - 6
pkg/services/alerting/extractor.go

@@ -82,8 +82,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 		if collapsed && collapsedJSON.MustBool() {
 
 			// extract alerts from sub panels for collapsed panels
-			alertSlice, err := e.getAlertFromPanels(panel,
-				validateAlertFunc)
+			alertSlice, err := e.getAlertFromPanels(panel, validateAlertFunc)
 			if err != nil {
 				return nil, err
 			}
@@ -100,7 +99,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 
 		panelID, err := panel.Get("id").Int64()
 		if err != nil {
-			return nil, fmt.Errorf("panel id is required. err %v", err)
+			return nil, ValidationError{Reason: "A numeric panel id property is missing"}
 		}
 
 		// backward compatibility check, can be removed later
@@ -146,7 +145,8 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 
 			datasource, err := e.lookupDatasourceID(dsName)
 			if err != nil {
-				return nil, err
+				e.log.Debug("Error looking up datasource", "error", err)
+				return nil, ValidationError{Reason: fmt.Sprintf("Data source used by alert rule not found, alertName=%v, datasource=%s", alert.Name, dsName)}
 			}
 
 			jsonQuery.SetPath([]string{"datasourceId"}, datasource.Id)
@@ -167,8 +167,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 		}
 
 		if !validateAlertFunc(alert) {
-			e.log.Debug("Invalid Alert Data. Dashboard, Org or Panel ID is not correct", "alertName", alert.Name, "panelId", alert.PanelId)
-			return nil, m.ErrDashboardContainsInvalidAlertData
+			return nil, ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelId)}
 		}
 
 		alerts = append(alerts, alert)

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

@@ -258,7 +258,7 @@ func TestAlertRuleExtraction(t *testing.T) {
 
 			Convey("Should fail on save", func() {
 				_, err := extractor.GetAlerts()
-				So(err, ShouldEqual, m.ErrDashboardContainsInvalidAlertData)
+				So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
 			})
 		})
 	})

+ 1 - 0
pkg/services/alerting/interfaces.go

@@ -27,6 +27,7 @@ type Notifier interface {
 	GetNotifierId() int64
 	GetIsDefault() bool
 	GetSendReminder() bool
+	GetDisableResolveMessage() bool
 	GetFrequency() time.Duration
 }
 

+ 26 - 16
pkg/services/alerting/notifiers/base.go

@@ -6,7 +6,6 @@ import (
 
 	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/models"
-
 	"github.com/grafana/grafana/pkg/services/alerting"
 )
 
@@ -15,13 +14,14 @@ const (
 )
 
 type NotifierBase struct {
-	Name         string
-	Type         string
-	Id           int64
-	IsDeault     bool
-	UploadImage  bool
-	SendReminder bool
-	Frequency    time.Duration
+	Name                  string
+	Type                  string
+	Id                    int64
+	IsDeault              bool
+	UploadImage           bool
+	SendReminder          bool
+	DisableResolveMessage bool
+	Frequency             time.Duration
 
 	log log.Logger
 }
@@ -34,14 +34,15 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase {
 	}
 
 	return NotifierBase{
-		Id:           model.Id,
-		Name:         model.Name,
-		IsDeault:     model.IsDefault,
-		Type:         model.Type,
-		UploadImage:  uploadImage,
-		SendReminder: model.SendReminder,
-		Frequency:    model.Frequency,
-		log:          log.New("alerting.notifier." + model.Name),
+		Id:                    model.Id,
+		Name:                  model.Name,
+		IsDeault:              model.IsDefault,
+		Type:                  model.Type,
+		UploadImage:           uploadImage,
+		SendReminder:          model.SendReminder,
+		DisableResolveMessage: model.DisableResolveMessage,
+		Frequency:             model.Frequency,
+		log:                   log.New("alerting.notifier." + model.Name),
 	}
 }
 
@@ -83,6 +84,11 @@ func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalC
 		}
 	}
 
+	// Do not notify when state is OK if DisableResolveMessage is set to true
+	if context.Rule.State == models.AlertStateOK && n.DisableResolveMessage {
+		return false
+	}
+
 	return true
 }
 
@@ -106,6 +112,10 @@ func (n *NotifierBase) GetSendReminder() bool {
 	return n.SendReminder
 }
 
+func (n *NotifierBase) GetDisableResolveMessage() bool {
+	return n.DisableResolveMessage
+}
+
 func (n *NotifierBase) GetFrequency() time.Duration {
 	return n.Frequency
 }

+ 5 - 0
pkg/services/alerting/notifiers/base_test.go

@@ -179,5 +179,10 @@ func TestBaseNotifier(t *testing.T) {
 			base := NewNotifierBase(model)
 			So(base.UploadImage, ShouldBeTrue)
 		})
+
+		Convey("default value should be false for backwards compatibility", func() {
+			base := NewNotifierBase(model)
+			So(base.DisableResolveMessage, ShouldBeFalse)
+		})
 	})
 }

+ 7 - 1
pkg/services/alerting/notifiers/telegram.go

@@ -127,7 +127,13 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval
 	var err error
 
 	imageFile, err = os.Open(evalContext.ImageOnDiskPath)
-	defer imageFile.Close()
+	defer func() {
+		err := imageFile.Close()
+		if err != nil {
+			log.Error2("Could not close Telegram inline image.", "err", err)
+		}
+	}()
+
 	if err != nil {
 		return nil, err
 	}

+ 5 - 5
pkg/services/alerting/rule.go

@@ -36,13 +36,13 @@ type ValidationError struct {
 }
 
 func (e ValidationError) Error() string {
-	extraInfo := ""
+	extraInfo := e.Reason
 	if e.Alertid != 0 {
 		extraInfo = fmt.Sprintf("%s AlertId: %v", extraInfo, e.Alertid)
 	}
 
 	if e.PanelId != 0 {
-		extraInfo = fmt.Sprintf("%s PanelId: %v ", extraInfo, e.PanelId)
+		extraInfo = fmt.Sprintf("%s PanelId: %v", extraInfo, e.PanelId)
 	}
 
 	if e.DashboardId != 0 {
@@ -50,10 +50,10 @@ func (e ValidationError) Error() string {
 	}
 
 	if e.Err != nil {
-		return fmt.Sprintf("%s %s%s", e.Err.Error(), e.Reason, extraInfo)
+		return fmt.Sprintf("Alert validation error: %s%s", e.Err.Error(), extraInfo)
 	}
 
-	return fmt.Sprintf("Failed to extract alert.Reason: %s %s", e.Reason, extraInfo)
+	return fmt.Sprintf("Alert validation error: %s", extraInfo)
 }
 
 var (
@@ -128,7 +128,7 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 	}
 
 	if len(model.Conditions) == 0 {
-		return nil, fmt.Errorf("Alert is missing conditions")
+		return nil, ValidationError{Reason: "Alert is missing conditions"}
 	}
 
 	return model, nil

+ 1 - 1
pkg/services/cleanup/cleanup.go

@@ -73,7 +73,7 @@ func (srv *CleanUpService) cleanUpTmpFiles() {
 		}
 	}
 
-	srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "keept", len(files))
+	srv.log.Debug("Found old rendered image to delete", "deleted", len(toDelete), "kept", len(files))
 }
 
 func (srv *CleanUpService) shouldCleanupTempFile(filemtime time.Time, now time.Time) bool {

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

@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/log"
 	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/util"
@@ -25,7 +26,9 @@ type DashboardProvisioningService interface {
 
 // NewService factory for creating a new dashboard service
 var NewService = func() DashboardService {
-	return &dashboardServiceImpl{}
+	return &dashboardServiceImpl{
+		log: log.New("dashboard-service"),
+	}
 }
 
 // NewProvisioningService factory for creating a new dashboard provisioning service
@@ -45,6 +48,7 @@ type SaveDashboardDTO struct {
 type dashboardServiceImpl struct {
 	orgId int64
 	user  *models.SignedInUser
+	log   log.Logger
 }
 
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
@@ -89,7 +93,7 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO,
 		}
 
 		if err := bus.Dispatch(&validateAlertsCmd); err != nil {
-			return nil, models.ErrDashboardContainsInvalidAlertData
+			return nil, err
 		}
 	}
 

+ 2 - 2
pkg/services/dashboards/dashboard_service_test.go

@@ -117,12 +117,12 @@ func TestDashboardService(t *testing.T) {
 				})
 
 				bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
-					return errors.New("error")
+					return errors.New("Alert validation error")
 				})
 
 				dto.Dashboard = models.NewDashboard("Dash")
 				_, err := service.SaveDashboard(dto)
-				So(err, ShouldEqual, models.ErrDashboardContainsInvalidAlertData)
+				So(err.Error(), ShouldEqual, "Alert validation error")
 			})
 		})
 

+ 30 - 0
pkg/services/hooks/hooks.go

@@ -0,0 +1,30 @@
+package hooks
+
+import (
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/registry"
+)
+
+type IndexDataHook func(indexData *dtos.IndexViewData)
+
+type HooksService struct {
+	indexDataHooks []IndexDataHook
+}
+
+func init() {
+	registry.RegisterService(&HooksService{})
+}
+
+func (srv *HooksService) Init() error {
+	return nil
+}
+
+func (srv *HooksService) AddIndexDataHook(hook IndexDataHook) {
+	srv.indexDataHooks = append(srv.indexDataHooks, hook)
+}
+
+func (srv *HooksService) RunIndexDataHooks(indexData *dtos.IndexViewData) {
+	for _, hook := range srv.indexDataHooks {
+		hook(indexData)
+	}
+}

+ 14 - 10
pkg/services/sqlstore/alert_notification.go

@@ -66,6 +66,7 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro
 										alert_notification.updated,
 										alert_notification.settings,
 										alert_notification.is_default,
+										alert_notification.disable_resolve_message,
 										alert_notification.send_reminder,
 										alert_notification.frequency
 										FROM alert_notification
@@ -106,6 +107,7 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS
 										alert_notification.updated,
 										alert_notification.settings,
 										alert_notification.is_default,
+										alert_notification.disable_resolve_message,
 										alert_notification.send_reminder,
 										alert_notification.frequency
 										FROM alert_notification
@@ -166,15 +168,16 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error
 		}
 
 		alertNotification := &m.AlertNotification{
-			OrgId:        cmd.OrgId,
-			Name:         cmd.Name,
-			Type:         cmd.Type,
-			Settings:     cmd.Settings,
-			SendReminder: cmd.SendReminder,
-			Frequency:    frequency,
-			Created:      time.Now(),
-			Updated:      time.Now(),
-			IsDefault:    cmd.IsDefault,
+			OrgId:                 cmd.OrgId,
+			Name:                  cmd.Name,
+			Type:                  cmd.Type,
+			Settings:              cmd.Settings,
+			SendReminder:          cmd.SendReminder,
+			DisableResolveMessage: cmd.DisableResolveMessage,
+			Frequency:             frequency,
+			Created:               time.Now(),
+			Updated:               time.Now(),
+			IsDefault:             cmd.IsDefault,
 		}
 
 		if _, err = sess.MustCols("send_reminder").Insert(alertNotification); err != nil {
@@ -210,6 +213,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
 		current.Type = cmd.Type
 		current.IsDefault = cmd.IsDefault
 		current.SendReminder = cmd.SendReminder
+		current.DisableResolveMessage = cmd.DisableResolveMessage
 
 		if current.SendReminder {
 			if cmd.Frequency == "" {
@@ -224,7 +228,7 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error {
 			current.Frequency = frequency
 		}
 
-		sess.UseBool("is_default", "send_reminder")
+		sess.UseBool("is_default", "send_reminder", "disable_resolve_message")
 
 		if affected, err := sess.ID(cmd.Id).Update(current); err != nil {
 			return err

+ 10 - 7
pkg/services/sqlstore/alert_notification_test.go

@@ -219,6 +219,7 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 			So(cmd.Result.OrgId, ShouldNotEqual, 0)
 			So(cmd.Result.Type, ShouldEqual, "email")
 			So(cmd.Result.Frequency, ShouldEqual, 10*time.Second)
+			So(cmd.Result.DisableResolveMessage, ShouldBeFalse)
 
 			Convey("Cannot save Alert Notification with the same name", func() {
 				err = CreateAlertNotificationCommand(cmd)
@@ -227,18 +228,20 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 
 			Convey("Can update alert notification", func() {
 				newCmd := &models.UpdateAlertNotificationCommand{
-					Name:         "NewName",
-					Type:         "webhook",
-					OrgId:        cmd.Result.OrgId,
-					SendReminder: true,
-					Frequency:    "60s",
-					Settings:     simplejson.New(),
-					Id:           cmd.Result.Id,
+					Name:                  "NewName",
+					Type:                  "webhook",
+					OrgId:                 cmd.Result.OrgId,
+					SendReminder:          true,
+					DisableResolveMessage: true,
+					Frequency:             "60s",
+					Settings:              simplejson.New(),
+					Id:                    cmd.Result.Id,
 				}
 				err := UpdateAlertNotification(newCmd)
 				So(err, ShouldBeNil)
 				So(newCmd.Result.Name, ShouldEqual, "NewName")
 				So(newCmd.Result.Frequency, ShouldEqual, 60*time.Second)
+				So(newCmd.Result.DisableResolveMessage, ShouldBeTrue)
 			})
 
 			Convey("Can update alert notification to disable sending of reminders", func() {

+ 3 - 0
pkg/services/sqlstore/migrations/alert_mig.go

@@ -71,6 +71,9 @@ func addAlertMigrations(mg *Migrator) {
 	mg.AddMigration("Add column send_reminder", NewAddColumnMigration(alert_notification, &Column{
 		Name: "send_reminder", Type: DB_Bool, Nullable: true, Default: "0",
 	}))
+	mg.AddMigration("Add column disable_resolve_message", NewAddColumnMigration(alert_notification, &Column{
+		Name: "disable_resolve_message", Type: DB_Bool, Nullable: false, Default: "0",
+	}))
 
 	mg.AddMigration("add index alert_notification org_id & name", NewAddIndexMigration(alert_notification, alert_notification.Indices[0]))
 

+ 15 - 0
pkg/services/sqlstore/org_test.go

@@ -182,6 +182,21 @@ func TestAccountDataAccess(t *testing.T) {
 					})
 				})
 
+				Convey("Removing user from org should delete user completely if in no other org", func() {
+					// make sure ac2 has no org
+					err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId})
+					So(err, ShouldBeNil)
+
+					// remove frome ac2 from ac1 org
+					remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true}
+					err = RemoveOrgUser(&remCmd)
+					So(err, ShouldBeNil)
+					So(remCmd.UserWasDeleted, ShouldBeTrue)
+
+					err = GetSignedInUser(&m.GetSignedInUserQuery{UserId: ac2.Id})
+					So(err, ShouldEqual, m.ErrUserNotFound)
+				})
+
 				Convey("Cannot delete last admin org user", func() {
 					cmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac1.Id}
 					err := RemoveOrgUser(&cmd)

+ 25 - 10
pkg/services/sqlstore/org_users.go

@@ -157,6 +157,12 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
 			}
 		}
 
+		// validate that after delete there is at least one user with admin role in org
+		if err := validateOneAdminLeftInOrg(cmd.OrgId, sess); err != nil {
+			return err
+		}
+
+		// check user other orgs and update user current org
 		var userOrgs []*m.UserOrgDTO
 		sess.Table("org_user")
 		sess.Join("INNER", "org", "org_user.org_id=org.id")
@@ -168,22 +174,31 @@ func RemoveOrgUser(cmd *m.RemoveOrgUserCommand) error {
 			return err
 		}
 
-		hasCurrentOrgSet := false
-		for _, userOrg := range userOrgs {
-			if user.OrgId == userOrg.OrgId {
-				hasCurrentOrgSet = true
-				break
+		if len(userOrgs) > 0 {
+			hasCurrentOrgSet := false
+			for _, userOrg := range userOrgs {
+				if user.OrgId == userOrg.OrgId {
+					hasCurrentOrgSet = true
+					break
+				}
 			}
-		}
 
-		if !hasCurrentOrgSet && len(userOrgs) > 0 {
-			err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
-			if err != nil {
+			if !hasCurrentOrgSet {
+				err = setUsingOrgInTransaction(sess, user.Id, userOrgs[0].OrgId)
+				if err != nil {
+					return err
+				}
+			}
+		} else if cmd.ShouldDeleteOrphanedUser {
+			// no other orgs, delete the full user
+			if err := deleteUserInTransaction(sess, &m.DeleteUserCommand{UserId: user.Id}); err != nil {
 				return err
 			}
+
+			cmd.UserWasDeleted = true
 		}
 
-		return validateOneAdminLeftInOrg(cmd.OrgId, sess)
+		return nil
 	})
 }
 

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

@@ -233,7 +233,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
 	case migrator.SQLITE:
 		// special case for tests
 		if !filepath.IsAbs(ss.dbCfg.Path) {
-			ss.dbCfg.Path = filepath.Join(setting.DataPath, ss.dbCfg.Path)
+			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"

+ 20 - 16
pkg/services/sqlstore/user.go

@@ -445,25 +445,29 @@ func SearchUsers(query *m.SearchUsersQuery) error {
 
 func DeleteUser(cmd *m.DeleteUserCommand) error {
 	return inTransaction(func(sess *DBSession) error {
-		deletes := []string{
-			"DELETE FROM star WHERE user_id = ?",
-			"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
-			"DELETE FROM org_user WHERE user_id = ?",
-			"DELETE FROM dashboard_acl WHERE user_id = ?",
-			"DELETE FROM preferences WHERE user_id = ?",
-			"DELETE FROM team_member WHERE user_id = ?",
-			"DELETE FROM user_auth WHERE user_id = ?",
-		}
+		return deleteUserInTransaction(sess, cmd)
+	})
+}
 
-		for _, sql := range deletes {
-			_, err := sess.Exec(sql, cmd.UserId)
-			if err != nil {
-				return err
-			}
+func deleteUserInTransaction(sess *DBSession, cmd *m.DeleteUserCommand) error {
+	deletes := []string{
+		"DELETE FROM star WHERE user_id = ?",
+		"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
+		"DELETE FROM org_user WHERE user_id = ?",
+		"DELETE FROM dashboard_acl WHERE user_id = ?",
+		"DELETE FROM preferences WHERE user_id = ?",
+		"DELETE FROM team_member WHERE user_id = ?",
+		"DELETE FROM user_auth WHERE user_id = ?",
+	}
+
+	for _, sql := range deletes {
+		_, err := sess.Exec(sql, cmd.UserId)
+		if err != nil {
+			return err
 		}
+	}
 
-		return nil
-	})
+	return nil
 }
 
 func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error {

+ 0 - 2
pkg/services/sqlstore/user_auth_test.go

@@ -16,7 +16,6 @@ func TestUserAuth(t *testing.T) {
 	Convey("Given 5 users", t, func() {
 		var err error
 		var cmd *m.CreateUserCommand
-		users := []m.User{}
 		for i := 0; i < 5; i++ {
 			cmd = &m.CreateUserCommand{
 				Email: fmt.Sprint("user", i, "@test.com"),
@@ -25,7 +24,6 @@ func TestUserAuth(t *testing.T) {
 			}
 			err = CreateUser(context.Background(), cmd)
 			So(err, ShouldBeNil)
-			users = append(users, cmd.Result)
 		}
 
 		Reset(func() {

+ 24 - 18
pkg/setting/setting.go

@@ -54,14 +54,11 @@ var (
 	ApplicationName string
 
 	// Paths
-	LogsPath       string
 	HomePath       string
-	DataPath       string
 	PluginsPath    string
 	CustomInitPath = "conf/custom.ini"
 
 	// Log settings.
-	LogModes   []string
 	LogConfigs []util.DynMap
 
 	// Http server options
@@ -187,11 +184,18 @@ var (
 	ImageUploadProvider string
 )
 
+// TODO move all global vars to this struct
 type Cfg struct {
 	Raw *ini.File
 
+	// HTTP Server Settings
+	AppUrl    string
+	AppSubUrl string
+
 	// Paths
 	ProvisioningPath string
+	DataPath         string
+	LogsPath         string
 
 	// SMTP email settings
 	Smtp SmtpSettings
@@ -411,7 +415,7 @@ func loadSpecifedConfigFile(configFile string, masterFile *ini.File) error {
 	return nil
 }
 
-func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
+func (cfg *Cfg) loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
 	var err error
 
 	// load config defaults
@@ -442,7 +446,7 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
 	// load specified config file
 	err = loadSpecifedConfigFile(args.Config, parsedFile)
 	if err != nil {
-		initLogging(parsedFile)
+		cfg.initLogging(parsedFile)
 		log.Fatal(3, err.Error())
 	}
 
@@ -459,8 +463,8 @@ func loadConfiguration(args *CommandLineArgs) (*ini.File, error) {
 	evalConfigValues(parsedFile)
 
 	// update data path and logging config
-	DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
-	initLogging(parsedFile)
+	cfg.DataPath = makeAbsolute(parsedFile.Section("paths").Key("data").String(), HomePath)
+	cfg.initLogging(parsedFile)
 
 	return parsedFile, err
 }
@@ -517,7 +521,7 @@ func NewCfg() *Cfg {
 func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	setHomePath(args)
 
-	iniFile, err := loadConfiguration(args)
+	iniFile, err := cfg.loadConfiguration(args)
 	if err != nil {
 		return err
 	}
@@ -538,6 +542,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	cfg.ProvisioningPath = makeAbsolute(iniFile.Section("paths").Key("provisioning").String(), HomePath)
 	server := iniFile.Section("server")
 	AppUrl, AppSubUrl = parseAppUrlAndSubUrl(server)
+	cfg.AppUrl = AppUrl
+	cfg.AppSubUrl = AppSubUrl
 
 	Protocol = HTTP
 	if server.Key("protocol").MustString("http") == "https" {
@@ -662,7 +668,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 			log.Fatal(4, "Invalid callback_url(%s): %s", cfg.RendererCallbackUrl, err)
 		}
 	}
-	cfg.ImagesDir = filepath.Join(DataPath, "png")
+	cfg.ImagesDir = filepath.Join(cfg.DataPath, "png")
 	cfg.PhantomDir = filepath.Join(HomePath, "tools/phantomjs")
 	cfg.TempDataLifetime = iniFile.Section("paths").Key("temp_data_lifetime").MustDuration(time.Second * 3600 * 24)
 	cfg.MetricsEndpointEnabled = iniFile.Section("metrics").Key("enabled").MustBool(true)
@@ -720,7 +726,7 @@ func (cfg *Cfg) readSessionConfig() {
 	SessionOptions.IDLength = 16
 
 	if SessionOptions.Provider == "file" {
-		SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, DataPath)
+		SessionOptions.ProviderConfig = makeAbsolute(SessionOptions.ProviderConfig, cfg.DataPath)
 		os.MkdirAll(path.Dir(SessionOptions.ProviderConfig), os.ModePerm)
 	}
 
@@ -731,15 +737,15 @@ func (cfg *Cfg) readSessionConfig() {
 	SessionConnMaxLifetime = cfg.Raw.Section("session").Key("conn_max_lifetime").MustInt64(14400)
 }
 
-func initLogging(file *ini.File) {
+func (cfg *Cfg) initLogging(file *ini.File) {
 	// split on comma
-	LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), ",")
+	logModes := strings.Split(file.Section("log").Key("mode").MustString("console"), ",")
 	// also try space
-	if len(LogModes) == 1 {
-		LogModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
+	if len(logModes) == 1 {
+		logModes = strings.Split(file.Section("log").Key("mode").MustString("console"), " ")
 	}
-	LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
-	log.ReadLoggingConfig(LogModes, LogsPath, file)
+	cfg.LogsPath = makeAbsolute(file.Section("paths").Key("logs").String(), HomePath)
+	log.ReadLoggingConfig(logModes, cfg.LogsPath, file)
 }
 
 func (cfg *Cfg) LogConfigSources() {
@@ -763,8 +769,8 @@ func (cfg *Cfg) LogConfigSources() {
 	}
 
 	logger.Info("Path Home", "path", HomePath)
-	logger.Info("Path Data", "path", DataPath)
-	logger.Info("Path Logs", "path", LogsPath)
+	logger.Info("Path Data", "path", cfg.DataPath)
+	logger.Info("Path Logs", "path", cfg.LogsPath)
 	logger.Info("Path Plugins", "path", PluginsPath)
 	logger.Info("Path Provisioning", "path", cfg.ProvisioningPath)
 	logger.Info("App mode " + Env)

+ 12 - 12
pkg/setting/setting_test.go

@@ -30,8 +30,8 @@ func TestLoadingSettings(t *testing.T) {
 			cfg.Load(&CommandLineArgs{HomePath: "../../"})
 
 			So(AdminUser, ShouldEqual, "superduper")
-			So(DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
-			So(LogsPath, ShouldEqual, filepath.Join(DataPath, "log"))
+			So(cfg.DataPath, ShouldEqual, filepath.Join(HomePath, "data"))
+			So(cfg.LogsPath, ShouldEqual, filepath.Join(cfg.DataPath, "log"))
 		})
 
 		Convey("Should replace password when defined in environment", func() {
@@ -76,8 +76,8 @@ func TestLoadingSettings(t *testing.T) {
 					HomePath: "../../",
 					Args:     []string{`cfg:paths.data=c:\tmp\data`, `cfg:paths.logs=c:\tmp\logs`},
 				})
-				So(DataPath, ShouldEqual, `c:\tmp\data`)
-				So(LogsPath, ShouldEqual, `c:\tmp\logs`)
+				So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
+				So(cfg.LogsPath, ShouldEqual, `c:\tmp\logs`)
 			} else {
 				cfg := NewCfg()
 				cfg.Load(&CommandLineArgs{
@@ -85,8 +85,8 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{"cfg:paths.data=/tmp/data", "cfg:paths.logs=/tmp/logs"},
 				})
 
-				So(DataPath, ShouldEqual, "/tmp/data")
-				So(LogsPath, ShouldEqual, "/tmp/logs")
+				So(cfg.DataPath, ShouldEqual, "/tmp/data")
+				So(cfg.LogsPath, ShouldEqual, "/tmp/logs")
 			}
 		})
 
@@ -112,7 +112,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{`cfg:default.paths.data=c:\tmp\data`},
 				})
 
-				So(DataPath, ShouldEqual, `c:\tmp\override`)
+				So(cfg.DataPath, ShouldEqual, `c:\tmp\override`)
 			} else {
 				cfg := NewCfg()
 				cfg.Load(&CommandLineArgs{
@@ -121,7 +121,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{"cfg:default.paths.data=/tmp/data"},
 				})
 
-				So(DataPath, ShouldEqual, "/tmp/override")
+				So(cfg.DataPath, ShouldEqual, "/tmp/override")
 			}
 		})
 
@@ -134,7 +134,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{`cfg:paths.data=c:\tmp\data`},
 				})
 
-				So(DataPath, ShouldEqual, `c:\tmp\data`)
+				So(cfg.DataPath, ShouldEqual, `c:\tmp\data`)
 			} else {
 				cfg := NewCfg()
 				cfg.Load(&CommandLineArgs{
@@ -143,7 +143,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{"cfg:paths.data=/tmp/data"},
 				})
 
-				So(DataPath, ShouldEqual, "/tmp/data")
+				So(cfg.DataPath, ShouldEqual, "/tmp/data")
 			}
 		})
 
@@ -156,7 +156,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{"cfg:paths.data=${GF_DATA_PATH}"},
 				})
 
-				So(DataPath, ShouldEqual, `c:\tmp\env_override`)
+				So(cfg.DataPath, ShouldEqual, `c:\tmp\env_override`)
 			} else {
 				os.Setenv("GF_DATA_PATH", "/tmp/env_override")
 				cfg := NewCfg()
@@ -165,7 +165,7 @@ func TestLoadingSettings(t *testing.T) {
 					Args:     []string{"cfg:paths.data=${GF_DATA_PATH}"},
 				})
 
-				So(DataPath, ShouldEqual, "/tmp/env_override")
+				So(cfg.DataPath, ShouldEqual, "/tmp/env_override")
 			}
 		})
 

+ 6 - 1
pkg/tsdb/mssql/mssql.go

@@ -52,13 +52,18 @@ func generateConnectionString(datasource *models.DataSource) string {
 	}
 
 	server, port := hostParts[0], hostParts[1]
-	return fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
+	encrypt := datasource.JsonData.Get("encrypt").MustString("false")
+	connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
 		server,
 		port,
 		datasource.Database,
 		datasource.User,
 		password,
 	)
+	if encrypt != "false" {
+		connStr += fmt.Sprintf("encrypt=%s;", encrypt)
+	}
+	return connStr
 }
 
 type mssqlRowTransformer struct {

+ 1 - 1
pkg/tsdb/mssql/mssql_test.go

@@ -692,7 +692,7 @@ func TestMSSQL(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)

+ 1 - 1
pkg/tsdb/mysql/mysql_test.go

@@ -769,7 +769,7 @@ func TestMySQL(t *testing.T) {
 				},
 			}
 
-			resp, err := endpoint.Query(nil, nil, query)
+			resp, err := endpoint.Query(context.Background(), nil, query)
 			So(err, ShouldBeNil)
 			queryResult := resp.Results["A"]
 			So(queryResult.Error, ShouldBeNil)

+ 1 - 1
pkg/tsdb/postgres/postgres_test.go

@@ -701,7 +701,7 @@ func TestPostgres(t *testing.T) {
 					},
 				}
 
-				resp, err := endpoint.Query(nil, nil, query)
+				resp, err := endpoint.Query(context.Background(), nil, query)
 				So(err, ShouldBeNil)
 				queryResult := resp.Results["A"]
 				So(queryResult.Error, ShouldBeNil)

+ 6 - 2
public/app/app.ts

@@ -29,7 +29,11 @@ _.move = (array, fromIndex, toIndex) => {
 import { coreModule, registerAngularDirectives } from './core/core';
 import { setupAngularRoutes } from './routes/routes';
 
-declare var System: any;
+// import symlinked extensions
+const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
+extensionsIndex.keys().forEach(key => {
+  extensionsIndex(key);
+});
 
 export class GrafanaApp {
   registerFunctions: any;
@@ -119,7 +123,7 @@ export class GrafanaApp {
     coreModule.config(setupAngularRoutes);
     registerAngularDirectives();
 
-    const preBootRequires = [System.import('app/features/all')];
+    const preBootRequires = [import('app/features/all')];
 
     Promise.all(preBootRequires)
       .then(() => {

+ 17 - 0
public/app/core/components/PageLoader/PageLoader.tsx

@@ -0,0 +1,17 @@
+import React, { SFC } from 'react';
+
+interface Props {
+  pageName: string;
+}
+
+const PageLoader: SFC<Props> = ({ pageName }) => {
+  const loadingText = `Loading ${pageName}...`;
+  return (
+    <div className="page-loader-wrapper">
+      <i className="page-loader-wrapper__spinner fa fa-spinner fa-spin" />
+      <div className="page-loader-wrapper__text">{loadingText}</div>
+    </div>
+  );
+};
+
+export default PageLoader;

+ 5 - 15
public/app/core/components/PermissionList/AddPermission.tsx

@@ -50,11 +50,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
   };
 
   onUserSelected = (user: User) => {
-    this.setState({ userId: user ? user.id : 0 });
+    this.setState({ userId: user && !Array.isArray(user) ? user.id : 0 });
   };
 
   onTeamSelected = (team: Team) => {
-    this.setState({ teamId: team ? team.id : 0 });
+    this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
   };
 
   onPermissionChanged = (permission: OptionWithDescription) => {
@@ -82,7 +82,6 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
     const newItem = this.state;
     const pickerClassName = 'width-20';
     const isValid = this.isValid();
-
     return (
       <div className="gf-form-inline cta-form">
         <button className="cta-form__close btn btn-transparent" onClick={onCancel}>
@@ -107,21 +106,13 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
 
             {newItem.type === AclTarget.User ? (
               <div className="gf-form">
-                <UserPicker
-                  onSelected={this.onUserSelected}
-                  value={newItem.userId.toString()}
-                  className={pickerClassName}
-                />
+                <UserPicker onSelected={this.onUserSelected} className={pickerClassName} />
               </div>
             ) : null}
 
             {newItem.type === AclTarget.Team ? (
               <div className="gf-form">
-                <TeamPicker
-                  onSelected={this.onTeamSelected}
-                  value={newItem.teamId.toString()}
-                  className={pickerClassName}
-                />
+                <TeamPicker onSelected={this.onTeamSelected} className={pickerClassName} />
               </div>
             ) : null}
 
@@ -129,9 +120,8 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
               <DescriptionPicker
                 optionsWithDesc={dashboardPermissionLevels}
                 onSelected={this.onPermissionChanged}
-                value={newItem.permission}
                 disabled={false}
-                className={'gf-form-input--form-dropdown-right'}
+                className={'gf-form-select-box__control--menu-right'}
               />
             </div>
 

+ 2 - 2
public/app/core/components/PermissionList/DisabledPermissionListItem.tsx

@@ -26,9 +26,9 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
             <DescriptionPicker
               optionsWithDesc={dashboardPermissionLevels}
               onSelected={() => {}}
-              value={item.permission}
               disabled={true}
-              className={'gf-form-input--form-dropdown-right'}
+              className={'gf-form-select-box__control--menu-right'}
+              value={item.permission}
             />
           </div>
         </td>

+ 2 - 2
public/app/core/components/PermissionList/PermissionListItem.tsx

@@ -77,9 +77,9 @@ export default class PermissionsListItem extends PureComponent<Props> {
             <DescriptionPicker
               optionsWithDesc={dashboardPermissionLevels}
               onSelected={this.onPermissionChanged}
-              value={item.permission}
               disabled={item.inherited}
-              className={'gf-form-input--form-dropdown-right'}
+              className={'gf-form-select-box__control--menu-right'}
+              value={item.permission}
             />
           </div>
         </td>

+ 18 - 49
public/app/core/components/Picker/DescriptionOption.tsx

@@ -1,56 +1,25 @@
-import React, { Component } from 'react';
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
 
-export interface Props {
-  onSelect: any;
-  onFocus: any;
-  option: any;
-  isFocused: any;
-  className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps<any> {
+  data: any;
 }
 
-class DescriptionOption extends Component<Props, any> {
-  constructor(props) {
-    super(props);
-    this.handleMouseDown = this.handleMouseDown.bind(this);
-    this.handleMouseEnter = this.handleMouseEnter.bind(this);
-    this.handleMouseMove = this.handleMouseMove.bind(this);
-  }
-
-  handleMouseDown(event) {
-    event.preventDefault();
-    event.stopPropagation();
-    this.props.onSelect(this.props.option, event);
-  }
-
-  handleMouseEnter(event) {
-    this.props.onFocus(this.props.option, event);
-  }
-
-  handleMouseMove(event) {
-    if (this.props.isFocused) {
-      return;
-    }
-    this.props.onFocus(this.props.option, event);
-  }
-
-  render() {
-    const { option, children, className } = this.props;
-    return (
-      <button
-        onMouseDown={this.handleMouseDown}
-        onMouseEnter={this.handleMouseEnter}
-        onMouseMove={this.handleMouseMove}
-        title={option.title}
-        className={`description-picker-option__button btn btn-link ${className} width-19`}
-      >
+export const Option = (props: ExtendedOptionProps) => {
+  const { children, isSelected, data, className } = props;
+  return (
+    <components.Option {...props}>
+      <div className={`description-picker-option__button btn btn-link ${className}`}>
+        {isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
         <div className="gf-form">{children}</div>
         <div className="gf-form">
-          <div className="muted width-17">{option.description}</div>
-          {className.indexOf('is-selected') > -1 && <i className="fa fa-check" aria-hidden="true" />}
+          <div className="muted width-17">{data.description}</div>
         </div>
-      </button>
-    );
-  }
-}
+      </div>
+    </components.Option>
+  );
+};
 
-export default DescriptionOption;
+export default Option;

+ 26 - 18
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -1,44 +1,52 @@
 import React, { Component } from 'react';
 import Select from 'react-select';
 import DescriptionOption from './DescriptionOption';
+import IndicatorsContainer from './IndicatorsContainer';
+import ResetStyles from './ResetStyles';
+import NoOptionsMessage from './NoOptionsMessage';
+
+export interface OptionWithDescription {
+  value: any;
+  label: string;
+  description: string;
+}
 
 export interface Props {
   optionsWithDesc: OptionWithDescription[];
   onSelected: (permission) => void;
-  value: number;
   disabled: boolean;
   className?: string;
+  value?: any;
 }
 
-export interface OptionWithDescription {
-  value: any;
-  label: string;
-  description: string;
-}
+const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
 
 class DescriptionPicker extends Component<Props, any> {
   constructor(props) {
     super(props);
-    this.state = {};
   }
 
   render() {
-    const { optionsWithDesc, onSelected, value, disabled, className } = this.props;
-
+    const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
+    const selectedOption = getSelectedOption(optionsWithDesc, value);
     return (
       <div className="permissions-picker">
         <Select
-          value={value}
-          valueKey="value"
-          multi={false}
-          clearable={false}
-          labelKey="label"
+          placeholder="Choose"
+          classNamePrefix={`gf-form-select-box`}
+          className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           options={optionsWithDesc}
+          components={{
+            Option: DescriptionOption,
+            IndicatorsContainer,
+            NoOptionsMessage,
+          }}
+          styles={ResetStyles}
+          isDisabled={disabled}
           onChange={onSelected}
-          className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          optionComponent={DescriptionOption}
-          placeholder="Choose"
-          disabled={disabled}
+          getOptionValue={i => i.value}
+          getOptionLabel={i => i.label}
+          value={selectedOption}
         />
       </div>
     );

+ 15 - 0
public/app/core/components/Picker/IndicatorsContainer.tsx

@@ -0,0 +1,15 @@
+import React from 'react';
+import { components } from 'react-select';
+
+export const IndicatorsContainer = props => {
+  const isOpen = props.selectProps.menuIsOpen;
+  return (
+    <components.IndicatorsContainer {...props}>
+      <span
+        className={`gf-form-select-box__select-arrow ${isOpen ? `gf-form-select-box__select-arrow--reversed` : ''}`}
+      />
+    </components.IndicatorsContainer>
+  );
+};
+
+export default IndicatorsContainer;

+ 18 - 0
public/app/core/components/Picker/NoOptionsMessage.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
+
+export interface Props {
+  children: Element;
+}
+
+export const PickerOption = (props: OptionProps<any>) => {
+  const { children, className } = props;
+  return (
+    <components.Option {...props}>
+      <div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
+    </components.Option>
+  );
+};
+
+export default PickerOption;

+ 20 - 4
public/app/core/components/Picker/PickerOption.test.tsx

@@ -3,10 +3,26 @@ import renderer from 'react-test-renderer';
 import PickerOption from './PickerOption';
 
 const model = {
-  onSelect: () => {},
-  onFocus: () => {},
-  isFocused: () => {},
-  option: {
+  cx: jest.fn(),
+  clearValue: jest.fn(),
+  onSelect: jest.fn(),
+  getStyles: jest.fn(),
+  getValue: jest.fn(),
+  hasValue: true,
+  isMulti: false,
+  options: [],
+  selectOption: jest.fn(),
+  selectProps: {},
+  setValue: jest.fn(),
+  isDisabled: false,
+  isFocused: false,
+  isSelected: false,
+  innerRef: null,
+  innerProps: null,
+  label: 'Option label',
+  type: null,
+  children: 'Model title',
+  data: {
     title: 'Model title',
     avatarUrl: 'url/to/avatar',
     label: 'User picker label',

+ 17 - 49
public/app/core/components/Picker/PickerOption.tsx

@@ -1,54 +1,22 @@
-import React, { Component } from 'react';
+import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
 
-export interface Props {
-  onSelect: any;
-  onFocus: any;
-  option: any;
-  isFocused: any;
-  className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps<any> {
+  data: any;
 }
 
-class UserPickerOption extends Component<Props, any> {
-  constructor(props) {
-    super(props);
-    this.handleMouseDown = this.handleMouseDown.bind(this);
-    this.handleMouseEnter = this.handleMouseEnter.bind(this);
-    this.handleMouseMove = this.handleMouseMove.bind(this);
-  }
-
-  handleMouseDown(event) {
-    event.preventDefault();
-    event.stopPropagation();
-    this.props.onSelect(this.props.option, event);
-  }
-
-  handleMouseEnter(event) {
-    this.props.onFocus(this.props.option, event);
-  }
-
-  handleMouseMove(event) {
-    if (this.props.isFocused) {
-      return;
-    }
-    this.props.onFocus(this.props.option, event);
-  }
-
-  render() {
-    const { option, children, className } = this.props;
-
-    return (
-      <button
-        onMouseDown={this.handleMouseDown}
-        onMouseEnter={this.handleMouseEnter}
-        onMouseMove={this.handleMouseMove}
-        title={option.title}
-        className={`user-picker-option__button btn btn-link ${className}`}
-      >
-        <img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
+export const PickerOption = (props: ExtendedOptionProps) => {
+  const { children, data, className } = props;
+  return (
+    <components.Option {...props}>
+      <div className={`description-picker-option__button btn btn-link ${className}`}>
+        {data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
         {children}
-      </button>
-    );
-  }
-}
+      </div>
+    </components.Option>
+  );
+};
 
-export default UserPickerOption;
+export default PickerOption;

+ 23 - 0
public/app/core/components/Picker/ResetStyles.tsx

@@ -0,0 +1,23 @@
+export default {
+  clearIndicator: () => ({}),
+  container: () => ({}),
+  control: () => ({}),
+  dropdownIndicator: () => ({}),
+  group: () => ({}),
+  groupHeading: () => ({}),
+  indicatorsContainer: () => ({}),
+  indicatorSeparator: () => ({}),
+  input: () => ({}),
+  loadingIndicator: () => ({}),
+  loadingMessage: () => ({}),
+  menu: () => ({}),
+  menuList: () => ({}),
+  multiValue: () => ({}),
+  multiValueLabel: () => ({}),
+  multiValueRemove: () => ({}),
+  noOptionsMessage: () => ({}),
+  option: () => ({}),
+  placeholder: () => ({}),
+  singleValue: () => ({}),
+  valueContainer: () => ({}),
+};

+ 30 - 25
public/app/core/components/Picker/TeamPicker.tsx

@@ -1,24 +1,26 @@
 import React, { Component } from 'react';
-import Select from 'react-select';
+import AsyncSelect from 'react-select/lib/Async';
 import PickerOption from './PickerOption';
 import { debounce } from 'lodash';
 import { getBackendSrv } from 'app/core/services/backend_srv';
+import ResetStyles from './ResetStyles';
+import IndicatorsContainer from './IndicatorsContainer';
+import NoOptionsMessage from './NoOptionsMessage';
+
+export interface Team {
+  id: number;
+  label: string;
+  name: string;
+  avatarUrl: string;
+}
 
 export interface Props {
   onSelected: (team: Team) => void;
-  value?: string;
   className?: string;
 }
 
 export interface State {
-  isLoading;
-}
-
-export interface Team {
-  id: number;
-  label: string;
-  name: string;
-  avatarUrl: string;
+  isLoading: boolean;
 }
 
 export class TeamPicker extends Component<Props, State> {
@@ -31,7 +33,7 @@ export class TeamPicker extends Component<Props, State> {
 
     this.debouncedSearch = debounce(this.search, 300, {
       leading: true,
-      trailing: false,
+      trailing: true,
     });
   }
 
@@ -39,7 +41,7 @@ export class TeamPicker extends Component<Props, State> {
     const backendSrv = getBackendSrv();
     this.setState({ isLoading: true });
 
-    return backendSrv.get(`/api/teams/search?perpage=50&page=1&query=${query}`).then(result => {
+    return backendSrv.get(`/api/teams/search?perpage=10&page=1&query=${query}`).then(result => {
       const teams = result.teams.map(team => {
         return {
           id: team.id,
@@ -50,31 +52,34 @@ export class TeamPicker extends Component<Props, State> {
       });
 
       this.setState({ isLoading: false });
-      return { options: teams };
+      return teams;
     });
   }
 
   render() {
-    const { onSelected, value, className } = this.props;
+    const { onSelected, className } = this.props;
     const { isLoading } = this.state;
-
     return (
       <div className="user-picker">
-        <Select.Async
-          valueKey="id"
-          multi={false}
-          labelKey="label"
-          cache={false}
+        <AsyncSelect
+          classNamePrefix={`gf-form-select-box`}
+          isMulti={false}
           isLoading={isLoading}
+          defaultOptions={true}
           loadOptions={this.debouncedSearch}
-          loadingPlaceholder="Loading..."
-          noResultsText="No teams found"
           onChange={onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          optionComponent={PickerOption}
+          styles={ResetStyles}
+          components={{
+            Option: PickerOption,
+            IndicatorsContainer,
+            NoOptionsMessage,
+          }}
           placeholder="Select a team"
-          value={value}
-          autosize={true}
+          loadingMessage={() => 'Loading...'}
+          noOptionsMessage={() => 'No teams found'}
+          getOptionValue={i => i.id}
+          getOptionLabel={i => i.label}
         />
       </div>
     );

+ 26 - 23
public/app/core/components/Picker/UserPicker.tsx

@@ -1,13 +1,15 @@
 import React, { Component } from 'react';
-import Select from 'react-select';
+import AsyncSelect from 'react-select/lib/Async';
 import PickerOption from './PickerOption';
 import { debounce } from 'lodash';
 import { getBackendSrv } from 'app/core/services/backend_srv';
 import { User } from 'app/types';
+import ResetStyles from './ResetStyles';
+import IndicatorsContainer from './IndicatorsContainer';
+import NoOptionsMessage from './NoOptionsMessage';
 
 export interface Props {
   onSelected: (user: User) => void;
-  value?: string;
   className?: string;
 }
 
@@ -31,20 +33,17 @@ export class UserPicker extends Component<Props, State> {
 
   search(query?: string) {
     const backendSrv = getBackendSrv();
-
     this.setState({ isLoading: true });
 
     return backendSrv
       .get(`/api/org/users?query=${query}&limit=10`)
       .then(result => {
-        return {
-          options: result.map(user => ({
-            id: user.userId,
-            label: `${user.login} - ${user.email}`,
-            avatarUrl: user.avatarUrl,
-            login: user.login,
-          })),
-        };
+        return result.map(user => ({
+          id: user.userId,
+          label: `${user.login} - ${user.email}`,
+          avatarUrl: user.avatarUrl,
+          login: user.login,
+        }));
       })
       .finally(() => {
         this.setState({ isLoading: false });
@@ -52,26 +51,30 @@ export class UserPicker extends Component<Props, State> {
   }
 
   render() {
-    const { value, className } = this.props;
+    const { className, onSelected } = this.props;
     const { isLoading } = this.state;
 
     return (
       <div className="user-picker">
-        <Select.Async
-          valueKey="id"
-          multi={false}
-          labelKey="label"
-          cache={false}
+        <AsyncSelect
+          classNamePrefix={`gf-form-select-box`}
+          isMulti={false}
           isLoading={isLoading}
+          defaultOptions={true}
           loadOptions={this.debouncedSearch}
-          loadingPlaceholder="Loading..."
-          noResultsText="No users found"
-          onChange={this.props.onSelected}
+          onChange={onSelected}
           className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          optionComponent={PickerOption}
+          styles={ResetStyles}
+          components={{
+            Option: PickerOption,
+            IndicatorsContainer,
+            NoOptionsMessage,
+          }}
           placeholder="Select user"
-          value={value}
-          autosize={true}
+          loadingMessage={() => 'Loading...'}
+          noOptionsMessage={() => 'No users found'}
+          getOptionValue={i => i.id}
+          getOptionLabel={i => i.label}
         />
       </div>
     );

+ 12 - 13
public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap

@@ -1,17 +1,16 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`PickerOption renders correctly 1`] = `
-<button
-  className="user-picker-option__button btn btn-link class-for-user-picker"
-  onMouseDown={[Function]}
-  onMouseEnter={[Function]}
-  onMouseMove={[Function]}
-  title="Model title"
->
-  <img
-    alt="User picker label"
-    className="user-picker-option__avatar"
-    src="url/to/avatar"
-  />
-</button>
+<div>
+  <div
+    className="description-picker-option__button btn btn-link class-for-user-picker"
+  >
+    <img
+      alt="User picker label"
+      className="user-picker-option__avatar"
+      src="url/to/avatar"
+    />
+    Model title
+  </div>
+</div>
 `;

+ 85 - 55
public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap

@@ -5,85 +5,115 @@ exports[`TeamPicker renders correctly 1`] = `
   className="user-picker"
 >
   <div
-    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
+    className="css-0 gf-form-input gf-form-input--form-dropdown"
+    onKeyDown={[Function]}
   >
     <div
-      className="Select-control"
-      onKeyDown={[Function]}
+      className="css-0 gf-form-select-box__control"
       onMouseDown={[Function]}
       onTouchEnd={[Function]}
-      onTouchMove={[Function]}
-      onTouchStart={[Function]}
     >
       <div
-        className="Select-multi-value-wrapper"
-        id="react-select-2--value"
+        className="css-0 gf-form-select-box__value-container"
       >
         <div
-          className="Select-placeholder"
+          className="css-0 gf-form-select-box__placeholder"
         >
-          Loading...
+          Select a team
         </div>
         <div
-          className="Select-input"
-          style={
-            Object {
-              "display": "inline-block",
-            }
-          }
+          className="css-0"
         >
-          <input
-            aria-activedescendant="react-select-2--value"
-            aria-expanded="false"
-            aria-haspopup="false"
-            aria-owns=""
-            onBlur={[Function]}
-            onChange={[Function]}
-            onFocus={[Function]}
-            required={false}
-            role="combobox"
-            style={
-              Object {
-                "boxSizing": "content-box",
-                "width": "5px",
-              }
-            }
-            value=""
-          />
           <div
+            className="gf-form-select-box__input"
             style={
               Object {
-                "height": 0,
-                "left": 0,
-                "overflow": "scroll",
-                "position": "absolute",
-                "top": 0,
-                "visibility": "hidden",
-                "whiteSpace": "pre",
+                "display": "inline-block",
               }
             }
           >
-            
+            <input
+              aria-autocomplete="list"
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect="off"
+              disabled={false}
+              id="react-select-2-input"
+              onBlur={[Function]}
+              onChange={[Function]}
+              onFocus={[Function]}
+              spellCheck="false"
+              style={
+                Object {
+                  "background": 0,
+                  "border": 0,
+                  "boxSizing": "content-box",
+                  "color": "inherit",
+                  "fontSize": "inherit",
+                  "opacity": 1,
+                  "outline": 0,
+                  "padding": 0,
+                  "width": "1px",
+                }
+              }
+              tabIndex="0"
+              theme={
+                Object {
+                  "borderRadius": 4,
+                  "colors": Object {
+                    "danger": "#DE350B",
+                    "dangerLight": "#FFBDAD",
+                    "neutral0": "hsl(0, 0%, 100%)",
+                    "neutral10": "hsl(0, 0%, 90%)",
+                    "neutral20": "hsl(0, 0%, 80%)",
+                    "neutral30": "hsl(0, 0%, 70%)",
+                    "neutral40": "hsl(0, 0%, 60%)",
+                    "neutral5": "hsl(0, 0%, 95%)",
+                    "neutral50": "hsl(0, 0%, 50%)",
+                    "neutral60": "hsl(0, 0%, 40%)",
+                    "neutral70": "hsl(0, 0%, 30%)",
+                    "neutral80": "hsl(0, 0%, 20%)",
+                    "neutral90": "hsl(0, 0%, 10%)",
+                    "primary": "#2684FF",
+                    "primary25": "#DEEBFF",
+                    "primary50": "#B2D4FF",
+                    "primary75": "#4C9AFF",
+                  },
+                  "spacing": Object {
+                    "baseUnit": 4,
+                    "controlHeight": 38,
+                    "menuGutter": 8,
+                  },
+                }
+              }
+              type="text"
+              value=""
+            />
+            <div
+              style={
+                Object {
+                  "height": 0,
+                  "left": 0,
+                  "overflow": "scroll",
+                  "position": "absolute",
+                  "top": 0,
+                  "visibility": "hidden",
+                  "whiteSpace": "pre",
+                }
+              }
+            >
+              
+            </div>
           </div>
         </div>
       </div>
-      <span
-        aria-hidden="true"
-        className="Select-loading-zone"
-      >
-        <span
-          className="Select-loading"
-        />
-      </span>
-      <span
-        className="Select-arrow-zone"
-        onMouseDown={[Function]}
+      <div
+        className="css-0 gf-form-select-box__indicators"
       >
         <span
-          className="Select-arrow"
-          onMouseDown={[Function]}
+          className="gf-form-select-box__select-arrow "
         />
-      </span>
+      </div>
     </div>
   </div>
 </div>

+ 85 - 55
public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap

@@ -5,85 +5,115 @@ exports[`UserPicker renders correctly 1`] = `
   className="user-picker"
 >
   <div
-    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
+    className="css-0 gf-form-input gf-form-input--form-dropdown"
+    onKeyDown={[Function]}
   >
     <div
-      className="Select-control"
-      onKeyDown={[Function]}
+      className="css-0 gf-form-select-box__control"
       onMouseDown={[Function]}
       onTouchEnd={[Function]}
-      onTouchMove={[Function]}
-      onTouchStart={[Function]}
     >
       <div
-        className="Select-multi-value-wrapper"
-        id="react-select-2--value"
+        className="css-0 gf-form-select-box__value-container"
       >
         <div
-          className="Select-placeholder"
+          className="css-0 gf-form-select-box__placeholder"
         >
-          Loading...
+          Select user
         </div>
         <div
-          className="Select-input"
-          style={
-            Object {
-              "display": "inline-block",
-            }
-          }
+          className="css-0"
         >
-          <input
-            aria-activedescendant="react-select-2--value"
-            aria-expanded="false"
-            aria-haspopup="false"
-            aria-owns=""
-            onBlur={[Function]}
-            onChange={[Function]}
-            onFocus={[Function]}
-            required={false}
-            role="combobox"
-            style={
-              Object {
-                "boxSizing": "content-box",
-                "width": "5px",
-              }
-            }
-            value=""
-          />
           <div
+            className="gf-form-select-box__input"
             style={
               Object {
-                "height": 0,
-                "left": 0,
-                "overflow": "scroll",
-                "position": "absolute",
-                "top": 0,
-                "visibility": "hidden",
-                "whiteSpace": "pre",
+                "display": "inline-block",
               }
             }
           >
-            
+            <input
+              aria-autocomplete="list"
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect="off"
+              disabled={false}
+              id="react-select-2-input"
+              onBlur={[Function]}
+              onChange={[Function]}
+              onFocus={[Function]}
+              spellCheck="false"
+              style={
+                Object {
+                  "background": 0,
+                  "border": 0,
+                  "boxSizing": "content-box",
+                  "color": "inherit",
+                  "fontSize": "inherit",
+                  "opacity": 1,
+                  "outline": 0,
+                  "padding": 0,
+                  "width": "1px",
+                }
+              }
+              tabIndex="0"
+              theme={
+                Object {
+                  "borderRadius": 4,
+                  "colors": Object {
+                    "danger": "#DE350B",
+                    "dangerLight": "#FFBDAD",
+                    "neutral0": "hsl(0, 0%, 100%)",
+                    "neutral10": "hsl(0, 0%, 90%)",
+                    "neutral20": "hsl(0, 0%, 80%)",
+                    "neutral30": "hsl(0, 0%, 70%)",
+                    "neutral40": "hsl(0, 0%, 60%)",
+                    "neutral5": "hsl(0, 0%, 95%)",
+                    "neutral50": "hsl(0, 0%, 50%)",
+                    "neutral60": "hsl(0, 0%, 40%)",
+                    "neutral70": "hsl(0, 0%, 30%)",
+                    "neutral80": "hsl(0, 0%, 20%)",
+                    "neutral90": "hsl(0, 0%, 10%)",
+                    "primary": "#2684FF",
+                    "primary25": "#DEEBFF",
+                    "primary50": "#B2D4FF",
+                    "primary75": "#4C9AFF",
+                  },
+                  "spacing": Object {
+                    "baseUnit": 4,
+                    "controlHeight": 38,
+                    "menuGutter": 8,
+                  },
+                }
+              }
+              type="text"
+              value=""
+            />
+            <div
+              style={
+                Object {
+                  "height": 0,
+                  "left": 0,
+                  "overflow": "scroll",
+                  "position": "absolute",
+                  "top": 0,
+                  "visibility": "hidden",
+                  "whiteSpace": "pre",
+                }
+              }
+            >
+              
+            </div>
           </div>
         </div>
       </div>
-      <span
-        aria-hidden="true"
-        className="Select-loading-zone"
-      >
-        <span
-          className="Select-loading"
-        />
-      </span>
-      <span
-        className="Select-arrow-zone"
-        onMouseDown={[Function]}
+      <div
+        className="css-0 gf-form-select-box__indicators"
       >
         <span
-          className="Select-arrow"
-          onMouseDown={[Function]}
+          className="gf-form-select-box__select-arrow "
         />
-      </span>
+      </div>
     </div>
   </div>
 </div>

+ 2 - 7
public/app/core/components/TagFilter/TagBadge.tsx

@@ -5,17 +5,12 @@ export interface Props {
   label: string;
   removeIcon: boolean;
   count: number;
-  onClick: any;
+  onClick?: any;
 }
 
 export class TagBadge extends React.Component<Props, any> {
   constructor(props) {
     super(props);
-    this.onClick = this.onClick.bind(this);
-  }
-
-  onClick(event) {
-    this.props.onClick(event);
   }
 
   render() {
@@ -28,7 +23,7 @@ export class TagBadge extends React.Component<Props, any> {
     const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
 
     return (
-      <span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
+      <span className={`label label-tag`} style={tagStyle}>
         {removeIcon && <i className="fa fa-remove" />}
         {label} {countLabel}
       </span>

+ 38 - 24
public/app/core/components/TagFilter/TagFilter.tsx

@@ -1,8 +1,11 @@
-import _ from 'lodash';
 import React from 'react';
-import { Async } from 'react-select';
-import { TagValue } from './TagValue';
+import AsyncSelect from 'react-select/lib/Async';
 import { TagOption } from './TagOption';
+import { TagBadge } from './TagBadge';
+import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
+import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
+import { components } from 'react-select';
+import ResetStyles from 'app/core/components/Picker/ResetStyles';
 
 export interface Props {
   tags: string[];
@@ -18,15 +21,15 @@ export class TagFilter extends React.Component<Props, any> {
 
     this.searchTags = this.searchTags.bind(this);
     this.onChange = this.onChange.bind(this);
-    this.onTagRemove = this.onTagRemove.bind(this);
   }
 
   searchTags(query) {
     return this.props.tagOptions().then(options => {
-      const tags = _.map(options, tagOption => {
-        return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
-      });
-      return { options: tags };
+      return options.map(option => ({
+        value: option.term,
+        label: option.term,
+        count: option.count,
+      }));
     });
   }
 
@@ -34,33 +37,44 @@ export class TagFilter extends React.Component<Props, any> {
     this.props.onSelect(newTags);
   }
 
-  onTagRemove(tag) {
-    let newTags = _.without(this.props.tags, tag.label);
-    newTags = _.map(newTags, tag => {
-      return { value: tag };
-    });
-    this.props.onSelect(newTags);
-  }
-
   render() {
     const selectOptions = {
+      classNamePrefix: 'gf-form-select-box',
+      isMulti: true,
+      defaultOptions: true,
       loadOptions: this.searchTags,
       onChange: this.onChange,
-      value: this.props.tags,
-      multi: true,
       className: 'gf-form-input gf-form-input--form-dropdown',
       placeholder: 'Tags',
-      loadingPlaceholder: 'Loading...',
-      noResultsText: 'No tags found',
-      optionComponent: TagOption,
-    };
+      loadingMessage: () => 'Loading...',
+      noOptionsMessage: () => 'No tags found',
+      getOptionValue: i => i.value,
+      getOptionLabel: i => i.label,
+      value: this.props.tags,
+      styles: ResetStyles,
+      components: {
+        Option: TagOption,
+        IndicatorsContainer,
+        NoOptionsMessage,
+        MultiValueLabel: () => {
+          return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
+        },
+        MultiValueRemove: props => {
+          const { data } = props;
 
-    selectOptions['valueComponent'] = TagValue;
+          return (
+            <components.MultiValueRemove {...props}>
+              <TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
+            </components.MultiValueRemove>
+          );
+        },
+      },
+    };
 
     return (
       <div className="gf-form gf-form--has-input-icon gf-form--grow">
         <div className="tag-filter">
-          <Async {...selectOptions} />
+          <AsyncSelect {...selectOptions} />
         </div>
         <i className="gf-form-input-icon fa fa-tag" />
       </div>

+ 16 - 46
public/app/core/components/TagFilter/TagOption.tsx

@@ -1,52 +1,22 @@
 import React from 'react';
+import { components } from 'react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
 import { TagBadge } from './TagBadge';
 
-export interface Props {
-  onSelect: any;
-  onFocus: any;
-  option: any;
-  isFocused: any;
-  className: any;
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps<any> {
+  data: any;
 }
 
-export class TagOption extends React.Component<Props, any> {
-  constructor(props) {
-    super(props);
-    this.handleMouseDown = this.handleMouseDown.bind(this);
-    this.handleMouseEnter = this.handleMouseEnter.bind(this);
-    this.handleMouseMove = this.handleMouseMove.bind(this);
-  }
+export const TagOption = (props: ExtendedOptionProps) => {
+  const { data, className, label } = props;
+  return (
+    <components.Option {...props}>
+      <div className={`tag-filter-option btn btn-link ${className || ''}`}>
+        <TagBadge label={label} removeIcon={false} count={data.count} />
+      </div>
+    </components.Option>
+  );
+};
 
-  handleMouseDown(event) {
-    event.preventDefault();
-    event.stopPropagation();
-    this.props.onSelect(this.props.option, event);
-  }
-
-  handleMouseEnter(event) {
-    this.props.onFocus(this.props.option, event);
-  }
-
-  handleMouseMove(event) {
-    if (this.props.isFocused) {
-      return;
-    }
-    this.props.onFocus(this.props.option, event);
-  }
-
-  render() {
-    const { option, className } = this.props;
-
-    return (
-      <button
-        onMouseDown={this.handleMouseDown}
-        onMouseEnter={this.handleMouseEnter}
-        onMouseMove={this.handleMouseMove}
-        title={option.title}
-        className={`tag-filter-option btn btn-link ${className || ''}`}
-      >
-        <TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
-      </button>
-    );
-  }
-}
+export default TagOption;

+ 1 - 1
public/app/core/components/TagFilter/TagValue.tsx

@@ -21,6 +21,6 @@ export class TagValue extends React.Component<Props, any> {
   render() {
     const { value } = this.props;
 
-    return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
+    return <TagBadge label={value.label} removeIcon={false} count={0} onClick={this.onClick} />;
   }
 }

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

@@ -207,7 +207,7 @@ export class ManageDashboardsCtrl {
     const template =
       '<move-to-folder-modal dismiss="dismiss()" ' +
       'dashboards="model.dashboards" after-save="model.afterSave()">' +
-      '</move-to-folder-modal>`';
+      '</move-to-folder-modal>';
     appEvents.emit('show-modal', {
       templateHtml: template,
       modalClass: 'modal--narrow',

+ 6 - 2
public/app/core/components/search/search.ts

@@ -160,8 +160,12 @@ export class SearchCtrl {
   searchDashboards() {
     this.currentSearchId = this.currentSearchId + 1;
     const localSearchId = this.currentSearchId;
+    const query = {
+      ...this.query,
+      tag: this.query.tag.map(i => i.value),
+    };
 
-    return this.searchSrv.search(this.query).then(results => {
+    return this.searchSrv.search(query).then(results => {
       if (localSearchId < this.currentSearchId) {
         return;
       }
@@ -196,7 +200,7 @@ export class SearchCtrl {
   }
 
   onTagSelect(newTags) {
-    this.query.tag = _.map(newTags, tag => tag.value);
+    this.query.tag = newTags;
     this.search();
   }
 

+ 1 - 1
public/app/core/components/sidemenu/SideMenu.tsx

@@ -17,7 +17,7 @@ export class SideMenu extends PureComponent {
   render() {
     return [
       <div className="sidemenu__logo" onClick={this.toggleSideMenu} key="logo">
-        <img src="public/img/grafana_icon.svg" alt="graphana_logo" />
+        <img src="public/img/grafana_icon.svg" alt="Grafana" />
       </div>,
       <div className="sidemenu__logo_small_breakpoint" onClick={this.toggleSideMenuSmallBreakpoint} key="hamburger">
         <i className="fa fa-bars" />

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

@@ -8,7 +8,7 @@ Array [
     onClick={[Function]}
   >
     <img
-      alt="graphana_logo"
+      alt="Grafana"
       src="public/img/grafana_icon.svg"
     />
   </div>,

+ 71 - 0
public/app/core/specs/kbn.test.ts

@@ -399,6 +399,77 @@ describe('duration', () => {
   });
 });
 
+describe('clock', () => {
+  it('null', () => {
+    const str = kbn.toClock(null, 0);
+    expect(str).toBe('');
+  });
+  it('size less than 1 second', () => {
+    const str = kbn.toClock(999, 0);
+    expect(str).toBe('999ms');
+  });
+  describe('size less than 1 minute', () => {
+    it('default', () => {
+      const str = kbn.toClock(59999);
+      expect(str).toBe('59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = kbn.toClock(59999, 0);
+      expect(str).toBe('59s');
+    });
+  });
+  describe('size less than 1 hour', () => {
+    it('default', () => {
+      const str = kbn.toClock(3599999);
+      expect(str).toBe('59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = kbn.toClock(3599999, 0);
+      expect(str).toBe('59m');
+    });
+    it('decimals equals 1', () => {
+      const str = kbn.toClock(3599999, 1);
+      expect(str).toBe('59m:59s');
+    });
+  });
+  describe('size greater than or equal 1 hour', () => {
+    it('default', () => {
+      const str = kbn.toClock(7199999);
+      expect(str).toBe('01h:59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = kbn.toClock(7199999, 0);
+      expect(str).toBe('01h');
+    });
+    it('decimals equals 1', () => {
+      const str = kbn.toClock(7199999, 1);
+      expect(str).toBe('01h:59m');
+    });
+    it('decimals equals 2', () => {
+      const str = kbn.toClock(7199999, 2);
+      expect(str).toBe('01h:59m:59s');
+    });
+  });
+  describe('size greater than or equal 1 day', () => {
+    it('default', () => {
+      const str = kbn.toClock(89999999);
+      expect(str).toBe('24h:59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = kbn.toClock(89999999, 0);
+      expect(str).toBe('24h');
+    });
+    it('decimals equals 1', () => {
+      const str = kbn.toClock(89999999, 1);
+      expect(str).toBe('24h:59m');
+    });
+    it('decimals equals 2', () => {
+      const str = kbn.toClock(89999999, 2);
+      expect(str).toBe('24h:59m:59s');
+    });
+  });
+});
+
 describe('volume', () => {
   it('1000m3', () => {
     const str = kbn.valueFormats['m3'](1000, 1, null);

+ 12 - 0
public/app/core/utils/dag.test.ts

@@ -104,5 +104,17 @@ describe('Directed acyclic graph', () => {
       const actual = nodeH.getOptimizedInputEdges();
       expect(actual).toHaveLength(0);
     });
+
+    it('when linking non-existing input node with existing output node should throw error', () => {
+      expect(() => {
+        dag.link('non-existing', 'A');
+      }).toThrowError("cannot link input node named non-existing since it doesn't exist in graph");
+    });
+
+    it('when linking existing input node with non-existing output node should throw error', () => {
+      expect(() => {
+        dag.link('A', 'non-existing');
+      }).toThrowError("cannot link output node named non-existing since it doesn't exist in graph");
+    });
   });
 });

+ 18 - 2
public/app/core/utils/dag.ts

@@ -15,6 +15,14 @@ export class Edge {
   }
 
   link(inputNode: Node, outputNode: Node) {
+    if (!inputNode) {
+      throw Error('inputNode is required');
+    }
+
+    if (!outputNode) {
+      throw Error('outputNode is required');
+    }
+
     this.unlink();
     this.inputNode = inputNode;
     this.outputNode = outputNode;
@@ -152,7 +160,11 @@ export class Graph {
     for (let n = 0; n < inputArr.length; n++) {
       const i = inputArr[n];
       if (typeof i === 'string') {
-        inputNodes.push(this.getNode(i));
+        const n = this.getNode(i);
+        if (!n) {
+          throw Error(`cannot link input node named ${i} since it doesn't exist in graph`);
+        }
+        inputNodes.push(n);
       } else {
         inputNodes.push(i);
       }
@@ -161,7 +173,11 @@ export class Graph {
     for (let n = 0; n < outputArr.length; n++) {
       const i = outputArr[n];
       if (typeof i === 'string') {
-        outputNodes.push(this.getNode(i));
+        const n = this.getNode(i);
+        if (!n) {
+          throw Error(`cannot link output node named ${i} since it doesn't exist in graph`);
+        }
+        outputNodes.push(n);
       } else {
         outputNodes.push(i);
       }

+ 55 - 0
public/app/core/utils/kbn.ts

@@ -808,6 +808,51 @@ kbn.toDuration = (size, decimals, timeScale) => {
   return strings.join(', ');
 };
 
+kbn.toClock = (size, decimals) => {
+  if (size === null) {
+    return '';
+  }
+
+  // < 1 second
+  if (size < 1000) {
+    return moment.utc(size).format('SSS\\m\\s');
+  }
+
+  // < 1 minute
+  if (size < 60000) {
+    let format = 'ss\\s:SSS\\m\\s';
+    if (decimals === 0) {
+      format = 'ss\\s';
+    }
+    return moment.utc(size).format(format);
+  }
+
+  // < 1 hour
+  if (size < 3600000) {
+    let format = 'mm\\m:ss\\s:SSS\\m\\s';
+    if (decimals === 0) {
+      format = 'mm\\m';
+    } else if (decimals === 1) {
+      format = 'mm\\m:ss\\s';
+    }
+    return moment.utc(size).format(format);
+  }
+
+  let format = 'mm\\m:ss\\s:SSS\\m\\s';
+
+  const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
+
+  if (decimals === 0) {
+    format = '';
+  } else if (decimals === 1) {
+    format = 'mm\\m';
+  } else if (decimals === 2) {
+    format = 'mm\\m:ss\\s';
+  }
+
+  return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
+};
+
 kbn.valueFormats.dtdurationms = (size, decimals) => {
   return kbn.toDuration(size, decimals, 'millisecond');
 };
@@ -824,6 +869,14 @@ kbn.valueFormats.timeticks = (size, decimals, scaledDecimals) => {
   return kbn.valueFormats.s(size / 100, decimals, scaledDecimals);
 };
 
+kbn.valueFormats.clockms = (size, decimals) => {
+  return kbn.toClock(size, decimals);
+};
+
+kbn.valueFormats.clocks = (size, decimals) => {
+  return kbn.toClock(size * 1000, decimals);
+};
+
 kbn.valueFormats.dateTimeAsIso = (epoch, isUtc) => {
   const time = isUtc ? moment.utc(epoch) : moment(epoch);
 
@@ -901,6 +954,8 @@ kbn.getUnitFormats = () => {
         { text: 'duration (s)', value: 'dtdurations' },
         { text: 'duration (hh:mm:ss)', value: 'dthms' },
         { text: 'Timeticks (s/100)', value: 'timeticks' },
+        { text: 'clock (ms)', value: 'clockms' },
+        { text: 'clock (s)', value: 'clocks' },
       ],
     },
     {

+ 1 - 0
public/app/features/alerting/NotificationsEditCtrl.ts

@@ -12,6 +12,7 @@ export class AlertNotificationEditCtrl {
   defaults: any = {
     type: 'email',
     sendReminder: false,
+    disableResolveMessage: false,
     frequency: '15m',
     settings: {
       httpMethod: 'POST',

+ 10 - 3
public/app/features/alerting/partials/notification_edit.html

@@ -21,21 +21,28 @@
       <gf-form-switch
           class="gf-form"
           label="Send on all alerts"
-          label-class="width-12"
+          label-class="width-14"
           checked="ctrl.model.isDefault"
           tooltip="Use this notification for all alerts">
       </gf-form-switch>
       <gf-form-switch
           class="gf-form"
           label="Include image"
-          label-class="width-12"
+          label-class="width-14"
           checked="ctrl.model.settings.uploadImage"
           tooltip="Captures an image and include it in the notification">
       </gf-form-switch>
+      <gf-form-switch
+          class="gf-form"
+          label="Disable Resolve Message"
+          label-class="width-14"
+          checked="ctrl.model.disableResolveMessage"
+          tooltip="Disable the resolve message [OK] that is sent when alerting state returns to false">
+      </gf-form-switch>
       <gf-form-switch
           class="gf-form"
           label="Send reminders"
-          label-class="width-12"
+          label-class="width-14"
           checked="ctrl.model.sendReminder"
           tooltip="Send additional notifications for triggered alerts">
       </gf-form-switch>

+ 7 - 4
public/app/features/annotations/annotations_srv.ts

@@ -8,6 +8,7 @@ import { makeRegions, dedupAnnotations } from './events_processing';
 export class AnnotationsSrv {
   globalAnnotationsPromise: any;
   alertStatesPromise: any;
+  datasourcePromises: any;
 
   /** @ngInject */
   constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
@@ -18,6 +19,7 @@ export class AnnotationsSrv {
   clearCache() {
     this.globalAnnotationsPromise = null;
     this.alertStatesPromise = null;
+    this.datasourcePromises = null;
   }
 
   getAnnotations(options) {
@@ -90,6 +92,7 @@ export class AnnotationsSrv {
 
     const range = this.timeSrv.timeRange();
     const promises = [];
+    const dsPromises = [];
 
     for (const annotation of dashboard.annotations.list) {
       if (!annotation.enable) {
@@ -99,10 +102,10 @@ export class AnnotationsSrv {
       if (annotation.snapshotData) {
         return this.translateQueryResult(annotation, annotation.snapshotData);
       }
-
+      const datasourcePromise = this.datasourceSrv.get(annotation.datasource);
+      dsPromises.push(datasourcePromise);
       promises.push(
-        this.datasourceSrv
-          .get(annotation.datasource)
+        datasourcePromise
           .then(datasource => {
             // issue query against data source
             return datasource.annotationQuery({
@@ -122,7 +125,7 @@ export class AnnotationsSrv {
           })
       );
     }
-
+    this.datasourcePromises = this.$q.all(dsPromises);
     this.globalAnnotationsPromise = this.$q.all(promises);
     return this.globalAnnotationsPromise;
   }

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

@@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
     navModel: {} as NavModel,
     apiKeys: [] as ApiKey[],
     searchQuery: '',
+    hasFetched: false,
     loadApiKeys: jest.fn(),
     deleteApiKey: jest.fn(),
     setSearchQuery: jest.fn(),
@@ -35,6 +36,7 @@ describe('Render', () => {
   it('should render API keys table', () => {
     const { wrapper } = setup({
       apiKeys: getMultipleMockKeys(5),
+      hasFetched: true,
     });
 
     expect(wrapper).toMatchSnapshot();

+ 41 - 29
public/app/features/api-keys/ApiKeysPage.tsx

@@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
 import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import SlideDown from 'app/core/components/Animations/SlideDown';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import ApiKeysAddedModal from './ApiKeysAddedModal';
 import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
@@ -16,6 +17,7 @@ export interface Props {
   navModel: NavModel;
   apiKeys: ApiKey[];
   searchQuery: string;
+  hasFetched: boolean;
   loadApiKeys: typeof loadApiKeys;
   deleteApiKey: typeof deleteApiKey;
   setSearchQuery: typeof setSearchQuery;
@@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
     });
   };
 
+  renderTable() {
+    const { apiKeys } = this.props;
+
+    return [
+      <h3 key="header" className="page-heading">
+        Existing Keys
+      </h3>,
+      <table key="table" className="filter-table">
+        <thead>
+          <tr>
+            <th>Name</th>
+            <th>Role</th>
+            <th style={{ width: '34px' }} />
+          </tr>
+        </thead>
+        {apiKeys.length > 0 && (
+          <tbody>
+            {apiKeys.map(key => {
+              return (
+                <tr key={key.id}>
+                  <td>{key.name}</td>
+                  <td>{key.role}</td>
+                  <td>
+                    <a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
+                      <i className="fa fa-remove" />
+                    </a>
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        )}
+      </table>,
+    ];
+  }
+
   render() {
     const { newApiKey, isAdding } = this.state;
-    const { navModel, apiKeys, searchQuery } = this.props;
+    const { hasFetched, navModel, searchQuery } = this.props;
 
     return (
       <div>
@@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
               </form>
             </div>
           </SlideDown>
-
-          <h3 className="page-heading">Existing Keys</h3>
-          <table className="filter-table">
-            <thead>
-              <tr>
-                <th>Name</th>
-                <th>Role</th>
-                <th style={{ width: '34px' }} />
-              </tr>
-            </thead>
-            {apiKeys.length > 0 ? (
-              <tbody>
-                {apiKeys.map(key => {
-                  return (
-                    <tr key={key.id}>
-                      <td>{key.name}</td>
-                      <td>{key.role}</td>
-                      <td>
-                        <a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
-                          <i className="fa fa-remove" />
-                        </a>
-                      </td>
-                    </tr>
-                  );
-                })}
-              </tbody>
-            ) : null}
-          </table>
+          {hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
         </div>
       </div>
     );
@@ -209,6 +220,7 @@ function mapStateToProps(state) {
     navModel: getNavModel(state.navIndex, 'apikeys'),
     apiKeys: getApiKeys(state.apiKeys),
     searchQuery: state.apiKeys.searchQuery,
+    hasFetched: state.apiKeys.hasFetched,
   };
 }
 

+ 5 - 26
public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap

@@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
     </Component>
     <h3
       className="page-heading"
+      key="header"
     >
       Existing Keys
     </h3>
     <table
       className="filter-table"
+      key="table"
     >
       <thead>
         <tr>
@@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
         </form>
       </div>
     </Component>
-    <h3
-      className="page-heading"
-    >
-      Existing Keys
-    </h3>
-    <table
-      className="filter-table"
-    >
-      <thead>
-        <tr>
-          <th>
-            Name
-          </th>
-          <th>
-            Role
-          </th>
-          <th
-            style={
-              Object {
-                "width": "34px",
-              }
-            }
-          />
-        </tr>
-      </thead>
-    </table>
+    <PageLoader
+      pageName="Api keys"
+    />
   </div>
 </div>
 `;

+ 2 - 1
public/app/features/api-keys/state/reducers.ts

@@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
 export const initialApiKeysState: ApiKeysState = {
   keys: [],
   searchQuery: '',
+  hasFetched: false,
 };
 
 export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
   switch (action.type) {
     case ActionTypes.LoadApiKeys:
-      return { ...state, keys: action.payload };
+      return { ...state, hasFetched: true, keys: action.payload };
     case ActionTypes.SetApiKeysSearchQuery:
       return { ...state, searchQuery: action.payload };
   }

+ 2 - 2
public/app/features/api-keys/state/selectors.test.ts

@@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
     const mockKeys = getMultipleMockKeys(5);
 
     it('should return all keys if no search query', () => {
-      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
+      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
 
       const keys = getApiKeys(mockState);
 
@@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
     });
 
     it('should filter keys if search query exists', () => {
-      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
+      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
 
       const keys = getApiKeys(mockState);
 

+ 2 - 0
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
     searchQuery: '',
     setDataSourcesSearchQuery: jest.fn(),
     setDataSourcesLayoutMode: jest.fn(),
+    hasFetched: false,
   };
 
   Object.assign(props, propOverrides);
@@ -33,6 +34,7 @@ describe('Render', () => {
     const wrapper = setup({
       dataSources: getMockDataSources(5),
       dataSourcesCount: 5,
+      hasFetched: true,
     });
 
     expect(wrapper).toMatchSnapshot();

Некоторые файлы не были показаны из-за большого количества измененных файлов