Sfoglia il codice sorgente

Merge branch 'master' into react-panels-step1

Torkel Ödegaard 7 anni fa
parent
commit
dddbe62d6c
99 ha cambiato i file con 1494 aggiunte e 493 eliminazioni
  1. 1 0
      .bra.toml
  2. 1 0
      .circleci/config.yml
  3. 18 3
      CHANGELOG.md
  4. 16 16
      README.md
  5. 89 0
      UPGRADING_DEPENDENCIES.md
  6. 1 1
      appveyor.yml
  7. 4 3
      docs/sources/administration/provisioning.md
  8. 1 1
      docs/sources/alerting/notifications.md
  9. 4 3
      docs/sources/features/datasources/cloudwatch.md
  10. 3 2
      docs/sources/features/datasources/mssql.md
  11. 16 1
      docs/sources/installation/docker.md
  12. 1 1
      docs/sources/project/building_from_source.md
  13. 2 2
      latest.json
  14. 1 0
      package.json
  15. 3 3
      pkg/api/api.go
  16. 3 2
      pkg/api/dashboard.go
  17. 2 1
      pkg/api/dashboard_test.go
  18. 18 1
      pkg/api/dataproxy.go
  19. 7 7
      pkg/api/datasources.go
  20. 26 23
      pkg/api/dtos/alerting.go
  21. 14 1
      pkg/api/frontendsettings.go
  22. 1 1
      pkg/cmd/grafana-server/main.go
  23. 3 1
      pkg/login/ldap.go
  24. 26 23
      pkg/models/alert_notifications.go
  25. 0 1
      pkg/models/dashboards.go
  26. 29 2
      pkg/models/datasource.go
  27. 5 4
      pkg/services/alerting/conditions/evaluator.go
  28. 5 6
      pkg/services/alerting/extractor.go
  29. 1 1
      pkg/services/alerting/extractor_test.go
  30. 1 0
      pkg/services/alerting/interfaces.go
  31. 26 16
      pkg/services/alerting/notifiers/base.go
  32. 5 0
      pkg/services/alerting/notifiers/base_test.go
  33. 5 5
      pkg/services/alerting/rule.go
  34. 1 1
      pkg/services/cleanup/cleanup.go
  35. 6 2
      pkg/services/dashboards/dashboard_service.go
  36. 2 2
      pkg/services/dashboards/dashboard_service_test.go
  37. 14 10
      pkg/services/sqlstore/alert_notification.go
  38. 18 15
      pkg/services/sqlstore/alert_notification_test.go
  39. 1 0
      pkg/services/sqlstore/datasource.go
  40. 3 0
      pkg/services/sqlstore/migrations/alert_mig.go
  41. 9 2
      pkg/services/sqlstore/sqlstore.go
  42. 16 10
      pkg/tsdb/cloudwatch/cloudwatch.go
  43. 29 2
      pkg/tsdb/cloudwatch/metric_find_query.go
  44. 6 1
      pkg/tsdb/mssql/mssql.go
  45. 6 1
      pkg/tsdb/mysql/mysql.go
  46. 4 0
      public/app/core/components/PermissionList/AddPermission.tsx
  47. 0 4
      public/app/core/components/Picker/DescriptionPicker.tsx
  48. 1 1
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  49. 1 1
      public/app/core/components/sidemenu/SideMenu.tsx
  50. 1 1
      public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
  51. 12 0
      public/app/core/utils/dag.test.ts
  52. 18 2
      public/app/core/utils/dag.ts
  53. 1 0
      public/app/features/alerting/NotificationsEditCtrl.ts
  54. 10 3
      public/app/features/alerting/partials/notification_edit.html
  55. 7 4
      public/app/features/annotations/annotations_srv.ts
  56. 17 1
      public/app/features/dashboard/state/actions.ts
  57. 2 2
      public/app/features/dashboard/submenu/submenu.ts
  58. 63 0
      public/app/features/datasources/DashboardsTable.test.tsx
  59. 55 0
      public/app/features/datasources/DashboardsTable.tsx
  60. 29 0
      public/app/features/datasources/DataSourceDashboards.test.tsx
  61. 93 0
      public/app/features/datasources/DataSourceDashboards.tsx
  62. 125 0
      public/app/features/datasources/DataSourceSettings.tsx
  63. 0 3
      public/app/features/datasources/NewDataSourcePage.tsx
  64. 88 0
      public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap
  65. 18 0
      public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
  66. 38 2
      public/app/features/datasources/state/actions.ts
  67. 109 0
      public/app/features/datasources/state/navModel.ts
  68. 8 0
      public/app/features/datasources/state/reducers.ts
  69. 9 0
      public/app/features/datasources/state/selectors.ts
  70. 3 1
      public/app/features/explore/Explore.tsx
  71. 33 3
      public/app/features/explore/PromQueryField.test.tsx
  72. 2 2
      public/app/features/explore/PromQueryField.tsx
  73. 7 1
      public/app/features/explore/QueryField.tsx
  74. 34 63
      public/app/features/explore/Table.tsx
  75. 5 8
      public/app/features/explore/utils/prometheus.ts
  76. 0 1
      public/app/features/plugins/all.ts
  77. 0 7
      public/app/features/plugins/partials/ds_dashboards.html
  78. 22 1
      public/app/features/plugins/state/actions.ts
  79. 11 0
      public/app/features/plugins/state/navModel.ts
  80. 5 0
      public/app/features/plugins/state/reducers.ts
  81. 7 2
      public/app/features/templating/variable_srv.ts
  82. 48 1
      public/app/plugins/datasource/cloudwatch/config_ctrl.ts
  83. 1 1
      public/app/plugins/datasource/cloudwatch/partials/config.html
  84. 10 0
      public/app/plugins/datasource/mssql/config_ctrl.ts
  85. 1 4
      public/app/plugins/datasource/mssql/module.ts
  86. 16 0
      public/app/plugins/datasource/mssql/partials/config.html
  87. 2 2
      public/app/plugins/datasource/postgres/datasource.ts
  88. 10 1
      public/app/plugins/panel/graph/module.ts
  89. 5 3
      public/app/routes/routes.ts
  90. 8 2
      public/app/store/configureStore.ts
  91. 9 0
      public/app/types/acl.ts
  92. 5 3
      public/app/types/datasources.ts
  93. 2 1
      public/app/types/index.ts
  94. 17 0
      public/app/types/plugins.ts
  95. 4 1
      public/sass/_grafana.scss
  96. 1 0
      public/sass/components/_slate_editor.scss
  97. 58 1
      public/sass/pages/_explore.scss
  98. 10 7
      tools/phantomjs/render.js
  99. 0 176
      yarn.lock

+ 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",

+ 1 - 0
.circleci/config.yml

@@ -170,6 +170,7 @@ jobs:
             - scripts/*.sh
             - scripts/publish
             - scripts/build/release_publisher/release_publisher
+            - scripts/build/publish.sh
 
   build:
     docker:

+ 18 - 3
CHANGELOG.md

@@ -2,22 +2,37 @@
 
 ### New Features
 
+* **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)
 * **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)
+* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
 
 ### Minor
 
-* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
-* **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)
+* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
 * **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)
+* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
 
 ### 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 (unreleased)
+# 5.3.2 (unreleased)
+
+* **Postgres**: Fix template variables error [#13692](https://github.com/grafana/grafana/issues/13692), thx [@svenklemm](https://github.com/svenklemm)
+* **Cloudwatch**: Fix service panic because of race conditions [#13674](https://github.com/grafana/grafana/issues/13674), thx [@mtanda](https://github.com/mtanda)
+
+# 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)
 

+ 16 - 16
README.md

@@ -24,7 +24,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
 
 ### Dependencies
 
-- Go 1.11
+- Go (Latest Stable)
 - NodeJS LTS
 
 ### Building the backend
@@ -69,15 +69,27 @@ bra run
 
 Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
 
-### Building a docker image (on linux/amd64)
+### Building a Docker image
 
-This builds a docker image from your local sources:
+There are two different ways to build a Grafana docker image. If you're machine is setup for Grafana development and you run linux/amd64 you can build just the image. Otherwise, there is the option to build Grafana completely within Docker.
+
+Run the image you have built using: `docker run --rm -p 3000:3000 grafana/grafana:dev`
+
+#### Building on linux/amd64 (fast)
 
 1. Build the frontend `go run build.go build-frontend`
 2. Build the docker image `make build-docker-dev`
 
 The resulting image will be tagged as `grafana/grafana:dev`
 
+#### Building anywhere (slower)
+
+Choose this option to build on platforms other than linux/amd64 and/or not have to setup the Grafana development environment.
+
+1. `make build-docker-full` or `docker build -t grafana/grafana:dev .`
+
+The resulting image will be tagged as `grafana/grafana:dev`
+
 ### Dev config
 
 Create a custom.ini in the conf directory to override default configuration options.
@@ -113,18 +125,6 @@ GRAFANA_TEST_DB=mysql go test ./pkg/...
 GRAFANA_TEST_DB=postgres go test ./pkg/...
 ```
 
-## Building custom docker image
-
-You can build a custom image using Docker, which doesn't require installing any dependencies besides docker itself.
-```bash
-git clone https://github.com/grafana/grafana
-cd grafana
-docker build -t grafana:dev .
-docker run -d --name=grafana -p 3000:3000 grafana:dev
-```
-
-Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
-
 ## Contribute
 
 If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
@@ -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).
 

+ 89 - 0
UPGRADING_DEPENDENCIES.md

@@ -0,0 +1,89 @@
+# Guide to Upgrading Dependencies
+
+Upgrading Go or Node.js requires making changes in many different files. See below for a list and explanation for each.
+
+## Go
+
+- CircleCi
+- `grafana/build-container`
+- Appveyor
+- Dockerfile
+
+## Node.js
+
+- CircleCI
+- `grafana/build-container`
+- Appveyor
+- Dockerfile
+
+## Go Dependencies
+
+Updated using `dep`.
+
+- `Gopkg.toml`
+- `Gopkg.lock`
+
+## Node.js Dependencies
+
+Updated using `yarn`.
+
+- `package.json`
+
+## Where to make changes
+
+### CircleCI
+
+Our builds run on CircleCI through our build script.
+
+#### Files
+
+- `.circleci/config.yml`.
+
+#### Dependencies
+
+- nodejs
+- golang
+- grafana/build-container (our custom docker build container)
+
+### grafana/build-container
+
+The main build step (in CircleCI) is built using a custom build container that comes pre-baked with some of the neccesary dependencies.
+
+Link: [grafana-build-container](https://github.com/grafana/grafana-build-container)
+
+#### Dependencies
+
+- fpm
+- nodejs
+- golang
+- crosscompiling (several compilers)
+
+### Appveyor
+
+Master and release builds trigger test runs on Appveyors build environment so that tests will run on Windows.
+
+#### Files:
+
+- `appveyor.yml`
+
+#### Dependencies
+
+- nodejs
+- golang
+
+### Dockerfile
+
+There is a Docker build for Grafana in the root of the project that allows anyone to build Grafana just using Docker.
+
+#### Files
+
+- `Dockerfile`
+
+#### Dependencies
+
+- nodejs
+- golang
+
+### Local developer environments
+
+Please send out a notice in the grafana-dev slack channel when updating Go or Node.js to make it easier for everyone to update their local developer environments.

+ 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
 

+ 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".
 

+ 4 - 3
docs/sources/features/datasources/cloudwatch.md

@@ -46,7 +46,7 @@ Checkout AWS docs on [IAM Roles](http://docs.aws.amazon.com/AWSEC2/latest/UserGu
 ## IAM Policies
 
 Grafana needs permissions granted via IAM to be able to read CloudWatch metrics
-and EC2 tags/instances. You can attach these permissions to IAM roles and
+and EC2 tags/instances/regions. You can attach these permissions to IAM roles and
 utilize Grafana's built-in support for assuming roles.
 
 Here is a minimal policy example:
@@ -65,11 +65,12 @@ Here is a minimal policy example:
             "Resource": "*"
         },
         {
-            "Sid": "AllowReadingTagsFromEC2",
+            "Sid": "AllowReadingTagsInstancesRegionsFromEC2",
             "Effect": "Allow",
             "Action": [
                 "ec2:DescribeTags",
-                "ec2:DescribeInstances"
+                "ec2:DescribeInstances",
+                "ec2:DescribeRegions"
             ],
             "Resource": "*"
         }

+ 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
 

+ 16 - 1
docs/sources/installation/docker.md

@@ -87,7 +87,7 @@ docker run \
 
 ## Building a custom Grafana image with pre-installed plugins
 
-In the [grafana-docker](https://github.com/grafana/grafana-docker/)  there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image.  It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
+In the [grafana-docker](https://github.com/grafana/grafana/tree/master/packaging/docker)  there is a folder called `custom/` which includes a `Dockerfile` that can be used to build a custom Grafana image.  It accepts `GRAFANA_VERSION` and `GF_INSTALL_PLUGINS` as build arguments.
 
 Example of how to build and run:
 ```bash
@@ -103,6 +103,21 @@ docker run \
   grafana:latest-with-plugins
 ```
 
+## Installing Plugins from other sources
+
+> Only available in Grafana v5.3.1+
+
+It's possible to install plugins from custom url:s by specifying the url like this: `GF_INSTALL_PLUGINS=<url to plugin zip>;<plugin name>`
+
+```bash
+docker run \
+  -d \
+  -p 3000:3000 \
+  --name=grafana \
+  -e "GF_INSTALL_PLUGINS=http://plugin-domain.com/my-custom-plugin.zip;custom-plugin" \
+  grafana/grafana
+```
+
 ## Configuring AWS Credentials for CloudWatch Support
 
 ```bash

+ 1 - 1
docs/sources/project/building_from_source.md

@@ -13,7 +13,7 @@ dev environment. Grafana ships with its own required backend server; also comple
 
 ## Dependencies
 
-- [Go 1.11](https://golang.org/dl/)
+- [Go (Latest Stable)](https://golang.org/dl/)
 - [Git](https://git-scm.com/downloads)
 - [NodeJS LTS](https://nodejs.org/download/)
 - node-gyp is the Node.js native addon build tool and it requires extra dependencies: python 2.7, make and GCC. These are already installed for most Linux distros and MacOS. See the Building On Windows section or the [node-gyp installation instructions](https://github.com/nodejs/node-gyp#installation) for more details.

+ 2 - 2
latest.json

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

+ 1 - 0
package.json

@@ -160,6 +160,7 @@
     "react-redux": "^5.0.7",
     "react-select": "2.1.0",
     "react-sizeme": "^2.3.6",
+    "react-table": "^6.8.6",
     "react-transition-group": "^2.2.1",
     "redux": "^4.0.0",
     "redux-logger": "^3.0.6",

+ 3 - 3
pkg/api/api.go

@@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() {
 			datasourceRoute.Get("/", Wrap(GetDataSources))
 			datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
 			datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
-			datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID))
+			datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById))
 			datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
-			datasourceRoute.Get("/:id", Wrap(GetDataSourceByID))
+			datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
 			datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
 		}, reqOrgAdmin)
 
-		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn)
+		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn)
 
 		apiRoute.Get("/plugins", Wrap(GetPluginList))
 		apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))

+ 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},

+ 18 - 1
pkg/api/dataproxy.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"fmt"
+	"github.com/pkg/errors"
 	"time"
 
 	"github.com/grafana/grafana/pkg/api/pluginproxy"
@@ -14,6 +15,20 @@ import (
 const HeaderNameNoBackendCache = "X-Grafana-NoCache"
 
 func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) {
+	userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{
+		User: c.SignedInUser,
+	}
+	if err := bus.Dispatch(&userPermissionsQuery); err != nil {
+		if err != bus.ErrHandlerNotFound {
+			return nil, err
+		}
+	} else {
+		permissionType, exists := userPermissionsQuery.Result[id]
+		if exists && permissionType != m.DsPermissionQuery {
+			return nil, errors.New("User not allowed to access datasource")
+		}
+	}
+
 	nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
 	cacheKey := fmt.Sprintf("ds-%d", id)
 
@@ -38,7 +53,9 @@ func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.Data
 func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 	c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
 
-	ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c)
+	dsId := c.ParamsInt64(":id")
+	ds, err := hs.getDatasourceFromCache(dsId, c)
+
 	if err != nil {
 		c.JsonApiErr(500, "Unable to load datasource meta data", err)
 		return

+ 7 - 7
pkg/api/datasources.go

@@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response {
 	result := make(dtos.DataSourceList, 0)
 	for _, ds := range query.Result {
 		dsItem := dtos.DataSourceListItemDTO{
-			Id:        ds.Id,
 			OrgId:     ds.OrgId,
+			Id:        ds.Id,
 			Name:      ds.Name,
 			Url:       ds.Url,
 			Type:      ds.Type,
@@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response {
 	return JSON(200, &result)
 }
 
-func GetDataSourceByID(c *m.ReqContext) Response {
+func GetDataSourceById(c *m.ReqContext) Response {
 	query := m.GetDataSourceByIdQuery{
 		Id:    c.ParamsInt64(":id"),
 		OrgId: c.OrgId,
@@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response {
 	return JSON(200, &dtos)
 }
 
-func DeleteDataSourceByID(c *m.ReqContext) Response {
+func DeleteDataSourceById(c *m.ReqContext) Response {
 	id := c.ParamsInt64(":id")
 
 	if id <= 0 {
 		return Error(400, "Missing valid datasource id", nil)
 	}
 
-	ds, err := getRawDataSourceByID(id, c.OrgId)
+	ds, err := getRawDataSourceById(id, c.OrgId)
 	if err != nil {
 		return Error(400, "Failed to delete datasource", nil)
 	}
@@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
 		return nil
 	}
 
-	ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId)
+	ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
 	if err != nil {
 		return err
 	}
@@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
 	return nil
 }
 
-func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) {
+func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) {
 	query := m.GetDataSourceByIdQuery{
 		Id:    id,
 		OrgId: orgID,
@@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response {
 }
 
 // Get /api/datasources/id/:name
-func GetDataSourceIDByName(c *m.ReqContext) Response {
+func GetDataSourceIdByName(c *m.ReqContext) Response {
 	query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId}
 
 	if err := bus.Dispatch(&query); err != nil {

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

+ 14 - 1
pkg/api/frontendsettings.go

@@ -22,7 +22,20 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 			return nil, err
 		}
 
-		orgDataSources = query.Result
+		dsFilterQuery := m.DatasourcesPermissionFilterQuery{
+			User:        c.SignedInUser,
+			Datasources: query.Result,
+		}
+
+		if err := bus.Dispatch(&dsFilterQuery); err != nil {
+			if err != bus.ErrHandlerNotFound {
+				return nil, err
+			}
+
+			orgDataSources = query.Result
+		} else {
+			orgDataSources = dsFilterQuery.Result
+		}
 	}
 
 	datasources := make(map[string]interface{})

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

+ 3 - 1
pkg/login/ldap.go

@@ -185,7 +185,9 @@ func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo
 
 		if ldapUser.isMemberOf(group.GroupDN) {
 			extUser.OrgRoles[group.OrgId] = group.OrgRole
-			extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
+			if extUser.IsGrafanaAdmin == nil || *extUser.IsGrafanaAdmin == false {
+				extUser.IsGrafanaAdmin = group.IsGrafanaAdmin
+			}
 		}
 	}
 

+ 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")

+ 29 - 2
pkg/models/datasource.go

@@ -30,6 +30,7 @@ var (
 	ErrDataSourceNameExists         = errors.New("Data source with same name already exists")
 	ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource")
 	ErrDatasourceIsReadOnly         = errors.New("Data source is readonly. Can only be updated from configuration.")
+	ErrDataSourceAccessDenied       = errors.New("Data source access denied")
 )
 
 type DsAccess string
@@ -167,6 +168,7 @@ type DeleteDataSourceByNameCommand struct {
 
 type GetDataSourcesQuery struct {
 	OrgId  int64
+	User   *SignedInUser
 	Result []*DataSource
 }
 
@@ -187,6 +189,31 @@ type GetDataSourceByNameQuery struct {
 }
 
 // ---------------------
-// EVENTS
-type DataSourceCreatedEvent struct {
+//  Permissions
+// ---------------------
+
+type DsPermissionType int
+
+const (
+	DsPermissionNoAccess DsPermissionType = iota
+	DsPermissionQuery
+)
+
+func (p DsPermissionType) String() string {
+	names := map[int]string{
+		int(DsPermissionQuery):    "Query",
+		int(DsPermissionNoAccess): "No Access",
+	}
+	return names[int(p)]
+}
+
+type GetDataSourcePermissionsForUserQuery struct {
+	User   *SignedInUser
+	Result map[int64]DsPermissionType
+}
+
+type DatasourcesPermissionFilterQuery struct {
+	User        *SignedInUser
+	Datasources []*DataSource
+	Result      []*DataSource
 }

+ 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)
+		})
 	})
 }

+ 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")
 			})
 		})
 

+ 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

+ 18 - 15
pkg/services/sqlstore/alert_notification_test.go

@@ -44,8 +44,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 					s := *query.Result
 
 					cmd := models.SetAlertNotificationStateToPendingCommand{
-						Id:                           s.Id,
-						Version:                      s.Version,
+						Id:      s.Id,
+						Version: s.Version,
 						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
 					}
 
@@ -100,8 +100,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 					s := *query.Result
 					s.Version = 1000
 					cmd := models.SetAlertNotificationStateToPendingCommand{
-						Id:                           s.NotifierId,
-						Version:                      s.Version,
+						Id:      s.NotifierId,
+						Version: s.Version,
 						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
 					}
 					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
@@ -111,8 +111,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 				Convey("Updating existing state to pending with incorrect version since alert rule state update version is higher", func() {
 					s := *query.Result
 					cmd := models.SetAlertNotificationStateToPendingCommand{
-						Id:                           s.Id,
-						Version:                      s.Version,
+						Id:      s.Id,
+						Version: s.Version,
 						AlertRuleStateUpdatedVersion: 1000,
 					}
 					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
@@ -125,8 +125,8 @@ func TestAlertNotificationSQLAccess(t *testing.T) {
 					s := *query.Result
 					s.Version = 1000
 					cmd := models.SetAlertNotificationStateToPendingCommand{
-						Id:                           s.Id,
-						Version:                      s.Version,
+						Id:      s.Id,
+						Version: s.Version,
 						AlertRuleStateUpdatedVersion: s.AlertRuleStateUpdatedVersion,
 					}
 					err := SetAlertNotificationStateToPendingCommand(context.Background(), &cmd)
@@ -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() {

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

@@ -27,6 +27,7 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
 
 	datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
 	has, err := x.Get(&datasource)
+
 	if err != nil {
 		return err
 	}

+ 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]))
 

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

@@ -53,6 +53,7 @@ type SqlStore struct {
 	dbCfg           DatabaseConfig
 	engine          *xorm.Engine
 	log             log.Logger
+	Dialect         migrator.Dialect
 	skipEnsureAdmin bool
 }
 
@@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error {
 	}
 
 	ss.engine = engine
+	ss.Dialect = migrator.NewDialect(ss.engine)
 
 	// temporarily still set global var
 	x = engine
-	dialect = migrator.NewDialect(x)
+	dialect = ss.Dialect
+
 	migrator := migrator.NewMigrator(x)
 	migrations.AddMigrations(migrator)
 
@@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore {
 		t.Fatalf("Failed to init test database: %v", err)
 	}
 
-	dialect = migrator.NewDialect(engine)
+	sqlstore.Dialect = migrator.NewDialect(engine)
+
+	// temp global var until we get rid of global vars
+	dialect = sqlstore.Dialect
+
 	if err := dialect.CleanDB(); err != nil {
 		t.Fatalf("Failed to clean test db %v", err)
 	}

+ 16 - 10
pkg/tsdb/cloudwatch/cloudwatch.go

@@ -86,9 +86,10 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
 }
 
 func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
-	result := &tsdb.Response{
+	results := &tsdb.Response{
 		Results: make(map[string]*tsdb.QueryResult),
 	}
+	resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
 
 	eg, ectx := errgroup.WithContext(ctx)
 
@@ -102,10 +103,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 		RefId := queryContext.Queries[i].RefId
 		query, err := parseQuery(queryContext.Queries[i].Model)
 		if err != nil {
-			result.Results[RefId] = &tsdb.QueryResult{
+			results.Results[RefId] = &tsdb.QueryResult{
 				Error: err,
 			}
-			return result, nil
+			return results, nil
 		}
 		query.RefId = RefId
 
@@ -118,10 +119,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 		}
 
 		if query.Id == "" && query.Expression != "" {
-			result.Results[query.RefId] = &tsdb.QueryResult{
+			results.Results[query.RefId] = &tsdb.QueryResult{
 				Error: fmt.Errorf("Invalid query: id should be set if using expression"),
 			}
-			return result, nil
+			return results, nil
 		}
 
 		eg.Go(func() error {
@@ -130,12 +131,13 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 				return err
 			}
 			if err != nil {
-				result.Results[query.RefId] = &tsdb.QueryResult{
+				resultChan <- &tsdb.QueryResult{
+					RefId: query.RefId,
 					Error: err,
 				}
 				return nil
 			}
-			result.Results[queryRes.RefId] = queryRes
+			resultChan <- queryRes
 			return nil
 		})
 	}
@@ -149,10 +151,10 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 					return err
 				}
 				for _, queryRes := range queryResponses {
-					result.Results[queryRes.RefId] = queryRes
 					if err != nil {
-						result.Results[queryRes.RefId].Error = err
+						queryRes.Error = err
 					}
+					resultChan <- queryRes
 				}
 				return nil
 			})
@@ -162,8 +164,12 @@ func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryCo
 	if err := eg.Wait(); err != nil {
 		return nil, err
 	}
+	close(resultChan)
+	for result := range resultChan {
+		results.Results[result.RefId] = result
+	}
 
-	return result, nil
+	return results, nil
 }
 
 func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) {

+ 29 - 2
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -234,10 +234,37 @@ func parseMultiSelectValue(input string) []string {
 // Please update the region list in public/app/plugins/datasource/cloudwatch/partials/config.html
 func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
 	regions := []string{
-		"ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1",
-		"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", "us-isob-east-1", "us-iso-east-1",
+		"ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ca-central-1",
+		"eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", "me-south-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2",
+		"cn-north-1", "cn-northwest-1", "us-gov-east-1", "us-gov-west-1", "us-isob-east-1", "us-iso-east-1",
 	}
 
+	err := e.ensureClientSession("us-east-1")
+	if err != nil {
+		return nil, err
+	}
+	r, err := e.ec2Svc.DescribeRegions(&ec2.DescribeRegionsInput{})
+	if err != nil {
+		// ignore error for backward compatibility
+		plog.Error("Failed to get regions", "error", err)
+	} else {
+		for _, region := range r.Regions {
+			exists := false
+
+			for _, existingRegion := range regions {
+				if existingRegion == *region.RegionName {
+					exists = true
+					break
+				}
+			}
+
+			if !exists {
+				regions = append(regions, *region.RegionName)
+			}
+		}
+	}
+	sort.Strings(regions)
+
 	result := make([]suggestData, 0)
 	for _, region := range regions {
 		result = append(result, suggestData{Text: region, Value: region})

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

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

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"reflect"
 	"strconv"
+	"strings"
 
 	"github.com/go-sql-driver/mysql"
 	"github.com/go-xorm/core"
@@ -20,10 +21,14 @@ func init() {
 func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
 	logger := log.New("tsdb.mysql")
 
+	protocol := "tcp"
+	if strings.HasPrefix(datasource.Url, "/") {
+		protocol = "unix"
+	}
 	cnnstr := fmt.Sprintf("%s:%s@%s(%s)/%s?collation=utf8mb4_unicode_ci&parseTime=true&loc=UTC&allowNativePasswords=true",
 		datasource.User,
 		datasource.Password,
-		"tcp",
+		protocol,
 		datasource.Url,
 		datasource.Database,
 	)

+ 4 - 0
public/app/core/components/PermissionList/AddPermission.tsx

@@ -18,6 +18,10 @@ export interface Props {
 }
 
 class AddPermissions extends Component<Props, NewDashboardAclItem> {
+  static defaultProps = {
+    showPermissionLevels: true,
+  };
+
   constructor(props) {
     super(props);
     this.state = this.getCleanState();

+ 0 - 4
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -22,10 +22,6 @@ export interface Props {
 const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
 
 class DescriptionPicker extends Component<Props, any> {
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
     const selectedOption = getSelectedOption(optionsWithDesc, value);

+ 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',

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

+ 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);
       }

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

+ 17 - 1
public/app/features/dashboard/state/actions.ts

@@ -1,7 +1,8 @@
 import { StoreState } from 'app/types';
 import { ThunkAction } from 'redux-thunk';
 import { getBackendSrv } from 'app/core/services/backend_srv';
-
+import appEvents from 'app/core/app_events';
+import { loadPluginDashboards } from '../../plugins/state/actions';
 import {
   DashboardAcl,
   DashboardAclDTO,
@@ -113,3 +114,18 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
     await dispatch(getDashboardPermissions(dashboardId));
   };
 }
+
+export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().post('/api/dashboards/import', data);
+    appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
+    dispatch(loadPluginDashboards());
+  };
+}
+
+export function removeDashboard(uri: string): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().delete(`/api/dashboards/${uri}`);
+    dispatch(loadPluginDashboards());
+  };
+}

+ 2 - 2
public/app/features/dashboard/submenu/submenu.ts

@@ -7,13 +7,13 @@ export class SubmenuCtrl {
   dashboard: any;
 
   /** @ngInject */
-  constructor(private $rootScope, private variableSrv, private $location) {
+  constructor(private variableSrv, private $location) {
     this.annotations = this.dashboard.templating.list;
     this.variables = this.variableSrv.variables;
   }
 
   annotationStateChanged() {
-    this.$rootScope.$broadcast('refresh');
+    this.dashboard.startRefresh();
   }
 
   variableUpdated(variable) {

+ 63 - 0
public/app/features/datasources/DashboardsTable.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import DashboardsTable, { Props } from './DashboardsTable';
+import { PluginDashboard } from '../../types';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    dashboards: [] as PluginDashboard[],
+    onImport: jest.fn(),
+    onRemove: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<DashboardsTable {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render table', () => {
+    const wrapper = setup({
+      dashboards: [
+        {
+          dashboardId: 0,
+          description: '',
+          folderId: 0,
+          imported: false,
+          importedRevision: 0,
+          importedUri: '',
+          importedUrl: '',
+          path: 'dashboards/carbon_metrics.json',
+          pluginId: 'graphite',
+          removed: false,
+          revision: 1,
+          slug: '',
+          title: 'Graphite Carbon Metrics',
+        },
+        {
+          dashboardId: 0,
+          description: '',
+          folderId: 0,
+          imported: true,
+          importedRevision: 0,
+          importedUri: '',
+          importedUrl: '',
+          path: 'dashboards/carbon_metrics.json',
+          pluginId: 'graphite',
+          removed: false,
+          revision: 1,
+          slug: '',
+          title: 'Graphite Carbon Metrics',
+        },
+      ],
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 55 - 0
public/app/features/datasources/DashboardsTable.tsx

@@ -0,0 +1,55 @@
+import React, { SFC } from 'react';
+import { PluginDashboard } from '../../types';
+
+export interface Props {
+  dashboards: PluginDashboard[];
+  onImport: (dashboard, overwrite) => void;
+  onRemove: (dashboard) => void;
+}
+
+const DashboardsTable: SFC<Props> = ({ dashboards, onImport, onRemove }) => {
+  function buttonText(dashboard: PluginDashboard) {
+    return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import';
+  }
+
+  return (
+    <table className="filter-table">
+      <tbody>
+        {dashboards.map((dashboard, index) => {
+          return (
+            <tr key={`${dashboard.dashboardId}-${index}`}>
+              <td className="width-1">
+                <i className="icon-gf icon-gf-dashboard" />
+              </td>
+              <td>
+                {dashboard.imported ? (
+                  <a href={dashboard.importedUrl}>{dashboard.title}</a>
+                ) : (
+                  <span>{dashboard.title}</span>
+                )}
+              </td>
+              <td style={{ textAlign: 'right' }}>
+                {!dashboard.imported ? (
+                  <button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, false)}>
+                    Import
+                  </button>
+                ) : (
+                  <button className="btn btn-secondary btn-small" onClick={() => onImport(dashboard, true)}>
+                    {buttonText(dashboard)}
+                  </button>
+                )}
+                {dashboard.imported && (
+                  <button className="btn btn-danger btn-small" onClick={() => onRemove(dashboard)}>
+                    <i className="fa fa-trash" />
+                  </button>
+                )}
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+};
+
+export default DashboardsTable;

+ 29 - 0
public/app/features/datasources/DataSourceDashboards.test.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { DataSourceDashboards, Props } from './DataSourceDashboards';
+import { DataSource, NavModel, PluginDashboard } from 'app/types';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    dashboards: [] as PluginDashboard[],
+    dataSource: {} as DataSource,
+    pageId: 1,
+    importDashboard: jest.fn(),
+    loadDataSource: jest.fn(),
+    loadPluginDashboards: jest.fn(),
+    removeDashboard: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<DataSourceDashboards {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 93 - 0
public/app/features/datasources/DataSourceDashboards.tsx

@@ -0,0 +1,93 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import DashboardTable from './DashboardsTable';
+import { DataSource, NavModel, PluginDashboard } from 'app/types';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { getRouteParamsId } from 'app/core/selectors/location';
+import { loadDataSource } from './state/actions';
+import { loadPluginDashboards } from '../plugins/state/actions';
+import { importDashboard, removeDashboard } from '../dashboard/state/actions';
+import { getDataSource } from './state/selectors';
+
+export interface Props {
+  navModel: NavModel;
+  dashboards: PluginDashboard[];
+  dataSource: DataSource;
+  pageId: number;
+  importDashboard: typeof importDashboard;
+  loadDataSource: typeof loadDataSource;
+  loadPluginDashboards: typeof loadPluginDashboards;
+  removeDashboard: typeof removeDashboard;
+}
+
+export class DataSourceDashboards extends PureComponent<Props> {
+  async componentDidMount() {
+    const { loadDataSource, pageId } = this.props;
+
+    await loadDataSource(pageId);
+    this.props.loadPluginDashboards();
+  }
+
+  onImport = (dashboard: PluginDashboard, overwrite: boolean) => {
+    const { dataSource, importDashboard } = this.props;
+    const data = {
+      pluginId: dashboard.pluginId,
+      path: dashboard.path,
+      overwrite: overwrite,
+      inputs: [],
+    };
+
+    if (dataSource) {
+      data.inputs.push({
+        name: '*',
+        type: 'datasource',
+        pluginId: dataSource.type,
+        value: dataSource.name,
+      });
+    }
+
+    importDashboard(data, dashboard.title);
+  };
+
+  onRemove = (dashboard: PluginDashboard) => {
+    this.props.removeDashboard(dashboard.importedUri);
+  };
+
+  render() {
+    const { dashboards, navModel } = this.props;
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <DashboardTable
+            dashboards={dashboards}
+            onImport={(dashboard, overwrite) => this.onImport(dashboard, overwrite)}
+            onRemove={dashboard => this.onRemove(dashboard)}
+          />
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  const pageId = getRouteParamsId(state.location);
+
+  return {
+    navModel: getNavModel(state.navIndex, `datasource-dashboards-${pageId}`),
+    pageId: pageId,
+    dashboards: state.plugins.dashboards,
+    dataSource: getDataSource(state.dataSources, pageId),
+  };
+}
+
+const mapDispatchToProps = {
+  importDashboard,
+  loadDataSource,
+  loadPluginDashboards,
+  removeDashboard,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceDashboards));

+ 125 - 0
public/app/features/datasources/DataSourceSettings.tsx

@@ -0,0 +1,125 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { DataSource, Plugin } from 'app/types';
+
+export interface Props {
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
+}
+interface State {
+  name: string;
+}
+
+enum DataSourceStates {
+  Alpha = 'alpha',
+  Beta = 'beta',
+}
+
+export class DataSourceSettings extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: props.dataSource.name,
+    };
+  }
+
+  onNameChange = event => {
+    this.setState({
+      name: event.target.value,
+    });
+  };
+
+  onSubmit = event => {
+    event.preventDefault();
+    console.log(event);
+  };
+
+  onDelete = event => {
+    console.log(event);
+  };
+
+  isReadyOnly() {
+    return this.props.dataSource.readOnly === true;
+  }
+
+  shouldRenderInfoBox() {
+    const { state } = this.props.dataSourceMeta;
+
+    return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
+  }
+
+  getInfoText() {
+    const { dataSourceMeta } = this.props;
+
+    switch (dataSourceMeta.state) {
+      case DataSourceStates.Alpha:
+        return (
+          'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
+          ' will include breaking changes.'
+        );
+
+      case DataSourceStates.Beta:
+        return (
+          'This plugin is marked as being in a beta development state. This means it is in currently in active' +
+          ' development and could be missing important features.'
+        );
+    }
+
+    return null;
+  }
+
+  render() {
+    const { name } = this.state;
+
+    return (
+      <div>
+        <h3 className="page-sub-heading">Settings</h3>
+        <form onSubmit={this.onSubmit}>
+          <div className="gf-form-group">
+            <div className="gf-form-inline">
+              <div className="gf-form max-width-30">
+                <span className="gf-form-label width-10">Name</span>
+                <input
+                  className="gf-form-input max-width-23"
+                  type="text"
+                  value={name}
+                  placeholder="name"
+                  onChange={this.onNameChange}
+                  required
+                />
+              </div>
+            </div>
+          </div>
+          {this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
+          {this.isReadyOnly() && (
+            <div className="grafana-info-box span8">
+              This datasource was added by config and cannot be modified using the UI. Please contact your server admin
+              to update this datasource.
+            </div>
+          )}
+          <div className="gf-form-button-row">
+            <button type="submit" className="btn btn-success" disabled={this.isReadyOnly()} onClick={this.onSubmit}>
+              Save &amp; Test
+            </button>
+            <button type="submit" className="btn btn-danger" disabled={this.isReadyOnly()} onClick={this.onDelete}>
+              Delete
+            </button>
+            <a className="btn btn-inverse" href="datasources">
+              Back
+            </a>
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    dataSource: state.dataSources.dataSource,
+    dataSourceMeta: state.dataSources.dataSourceMeta,
+  };
+}
+
+export default connect(mapStateToProps)(DataSourceSettings);

+ 0 - 3
public/app/features/datasources/NewDataSourcePage.tsx

@@ -4,7 +4,6 @@ import { hot } from 'react-hot-loader';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import { NavModel, Plugin } from 'app/types';
 import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
-import { updateLocation } from '../../core/actions';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { getDataSourceTypes } from './state/selectors';
 
@@ -13,7 +12,6 @@ export interface Props {
   dataSourceTypes: Plugin[];
   addDataSource: typeof addDataSource;
   loadDataSourceTypes: typeof loadDataSourceTypes;
-  updateLocation: typeof updateLocation;
   dataSourceTypeSearchQuery: string;
   setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
 }
@@ -81,7 +79,6 @@ function mapStateToProps(state) {
 const mapDispatchToProps = {
   addDataSource,
   loadDataSourceTypes,
-  updateLocation,
   setDataSourceTypeSearchQuery,
 };
 

+ 88 - 0
public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap

@@ -0,0 +1,88 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<table
+  className="filter-table"
+>
+  <tbody />
+</table>
+`;
+
+exports[`Render should render table 1`] = `
+<table
+  className="filter-table"
+>
+  <tbody>
+    <tr
+      key="0-0"
+    >
+      <td
+        className="width-1"
+      >
+        <i
+          className="icon-gf icon-gf-dashboard"
+        />
+      </td>
+      <td>
+        <span>
+          Graphite Carbon Metrics
+        </span>
+      </td>
+      <td
+        style={
+          Object {
+            "textAlign": "right",
+          }
+        }
+      >
+        <button
+          className="btn btn-secondary btn-small"
+          onClick={[Function]}
+        >
+          Import
+        </button>
+      </td>
+    </tr>
+    <tr
+      key="0-1"
+    >
+      <td
+        className="width-1"
+      >
+        <i
+          className="icon-gf icon-gf-dashboard"
+        />
+      </td>
+      <td>
+        <a
+          href=""
+        >
+          Graphite Carbon Metrics
+        </a>
+      </td>
+      <td
+        style={
+          Object {
+            "textAlign": "right",
+          }
+        }
+      >
+        <button
+          className="btn btn-secondary btn-small"
+          onClick={[Function]}
+        >
+          Update
+        </button>
+        <button
+          className="btn btn-danger btn-small"
+          onClick={[Function]}
+        >
+          <i
+            className="fa fa-trash"
+          />
+        </button>
+      </td>
+    </tr>
+  </tbody>
+</table>
+`;

+ 18 - 0
public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap

@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <DashboardsTable
+      dashboards={Array []}
+      onImport={[Function]}
+      onRemove={[Function]}
+    />
+  </div>
+</div>
+`;

+ 38 - 2
public/app/features/datasources/state/actions.ts

@@ -2,12 +2,15 @@ import { ThunkAction } from 'redux-thunk';
 import { DataSource, Plugin, StoreState } from 'app/types';
 import { getBackendSrv } from '../../../core/services/backend_srv';
 import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
-import { updateLocation } from '../../../core/actions';
+import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
 import { UpdateLocationAction } from '../../../core/actions/location';
+import { buildNavModel } from './navModel';
 
 export enum ActionTypes {
   LoadDataSources = 'LOAD_DATA_SOURCES',
   LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
+  LoadDataSource = 'LOAD_DATA_SOURCE',
+  LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
   SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
   SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
   SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
@@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction {
   payload: string;
 }
 
+export interface LoadDataSourceAction {
+  type: ActionTypes.LoadDataSource;
+  payload: DataSource;
+}
+
+export interface LoadDataSourceMetaAction {
+  type: ActionTypes.LoadDataSourceMeta;
+  payload: Plugin;
+}
+
 const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
   type: ActionTypes.LoadDataSources,
   payload: dataSources,
 });
 
+const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
+  type: ActionTypes.LoadDataSource,
+  payload: dataSource,
+});
+
+const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
+  type: ActionTypes.LoadDataSourceMeta,
+  payload: dataSourceMeta,
+});
+
 const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
   type: ActionTypes.LoadDataSourceTypes,
   payload: dataSourceTypes,
@@ -69,7 +92,10 @@ export type Action =
   | SetDataSourcesLayoutModeAction
   | UpdateLocationAction
   | LoadDataSourceTypesAction
-  | SetDataSourceTypeSearchQueryAction;
+  | SetDataSourceTypeSearchQueryAction
+  | LoadDataSourceAction
+  | UpdateNavIndexAction
+  | LoadDataSourceMetaAction;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult<void> {
   };
 }
 
+export function loadDataSource(id: number): ThunkResult<void> {
+  return async dispatch => {
+    const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
+    const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`);
+    dispatch(dataSourceLoaded(dataSource));
+    dispatch(dataSourceMetaLoaded(pluginInfo));
+    dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
+  };
+}
+
 export function addDataSource(plugin: Plugin): ThunkResult<void> {
   return async (dispatch, getStore) => {
     await dispatch(loadDataSources());

+ 109 - 0
public/app/features/datasources/state/navModel.ts

@@ -0,0 +1,109 @@
+import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
+import config from 'app/core/config';
+
+export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
+  const navModel = {
+    img: pluginMeta.info.logos.large,
+    id: 'datasource-' + dataSource.id,
+    subTitle: `Type: ${pluginMeta.name}`,
+    url: '',
+    text: dataSource.name,
+    breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }],
+    children: [
+      {
+        active: false,
+        icon: 'fa fa-fw fa-sliders',
+        id: `datasource-settings-${dataSource.id}`,
+        text: 'Settings',
+        url: `datasources/edit/${dataSource.id}`,
+      },
+    ],
+  };
+
+  if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) {
+    navModel.children.push({
+      active: false,
+      icon: 'fa fa-fw fa-th-large',
+      id: `datasource-dashboards-${dataSource.id}`,
+      text: 'Dashboards',
+      url: `datasources/edit/${dataSource.id}/dashboards`,
+    });
+  }
+
+  if (config.buildInfo.isEnterprise) {
+    navModel.children.push({
+      active: false,
+      icon: 'fa fa-fw fa-lock',
+      id: `datasource-permissions-${dataSource.id}`,
+      text: 'Permissions',
+      url: `datasources/edit/${dataSource.id}/permissions`,
+    });
+  }
+
+  return navModel;
+}
+
+export function getDataSourceLoadingNav(pageName: string): NavModel {
+  const main = buildNavModel(
+    {
+      access: '',
+      basicAuth: false,
+      database: '',
+      id: 1,
+      isDefault: false,
+      jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
+      name: 'Loading',
+      orgId: 1,
+      password: '',
+      readOnly: false,
+      type: 'Loading',
+      typeLogoUrl: 'public/img/icn-datasource.svg',
+      url: '',
+      user: '',
+    },
+    {
+      id: '1',
+      name: '',
+      info: {
+        author: {
+          name: '',
+          url: '',
+        },
+        description: '',
+        links: [''],
+        logos: {
+          large: '',
+          small: '',
+        },
+        screenshots: '',
+        updated: '',
+        version: '',
+      },
+      includes: [{ type: '', name: '', path: '' }],
+    }
+  );
+
+  let node: NavModelItem;
+
+  // find active page
+  for (const child of main.children) {
+    if (child.id.indexOf(pageName) > 0) {
+      child.active = true;
+      node = child;
+      break;
+    }
+  }
+
+  return {
+    main: main,
+    node: node,
+  };
+}
+
+function hasDashboards(includes) {
+  return (
+    includes.filter(include => {
+      return include.type === 'dashboard';
+    }).length > 0
+  );
+}

+ 8 - 0
public/app/features/datasources/state/reducers.ts

@@ -4,11 +4,13 @@ import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelec
 
 const initialState: DataSourcesState = {
   dataSources: [] as DataSource[],
+  dataSource: {} as DataSource,
   layoutMode: LayoutModes.Grid,
   searchQuery: '',
   dataSourcesCount: 0,
   dataSourceTypes: [] as Plugin[],
   dataSourceTypeSearchQuery: '',
+  dataSourceMeta: {} as Plugin,
   hasFetched: false,
 };
 
@@ -17,6 +19,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
     case ActionTypes.LoadDataSources:
       return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
 
+    case ActionTypes.LoadDataSource:
+      return { ...state, dataSource: action.payload };
+
     case ActionTypes.SetDataSourcesSearchQuery:
       return { ...state, searchQuery: action.payload };
 
@@ -28,6 +33,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
 
     case ActionTypes.SetDataSourceTypeSearchQuery:
       return { ...state, dataSourceTypeSearchQuery: action.payload };
+
+    case ActionTypes.LoadDataSourceMeta:
+      return { ...state, dataSourceMeta: action.payload };
   }
 
   return state;

+ 9 - 0
public/app/features/datasources/state/selectors.ts

@@ -1,3 +1,5 @@
+import { DataSource } from '../../../types';
+
 export const getDataSources = state => {
   const regex = new RegExp(state.searchQuery, 'i');
 
@@ -14,6 +16,13 @@ export const getDataSourceTypes = state => {
   });
 };
 
+export const getDataSource = (state, dataSourceId): DataSource | null => {
+  if (state.dataSource.id === parseInt(dataSourceId, 10)) {
+    return state.dataSource;
+  }
+  return null;
+};
+
 export const getDataSourcesSearchQuery = state => state.searchQuery;
 export const getDataSourcesLayoutMode = state => state.layoutMode;
 export const getDataSourcesCount = state => state.dataSourcesCount;

+ 3 - 1
public/app/features/explore/Explore.tsx

@@ -644,7 +644,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                   />
                 )}
               {supportsTable && showingTable ? (
-                <Table className="m-t-3" data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
+                <div className="panel-container">
+                  <Table data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
+                </div>
               ) : null}
               {supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
             </main>

+ 33 - 3
public/app/features/explore/PromQueryField.test.tsx

@@ -96,11 +96,14 @@ describe('PromQueryField typeahead handling', () => {
 
     it('returns label suggestions on label context but leaves out labels that already exist', () => {
       const instance = shallow(
-        <PromQueryField {...defaultProps} labelKeys={{ '{job="foo"}': ['bar', 'job'] }} />
+        <PromQueryField
+          {...defaultProps}
+          labelKeys={{ '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] }}
+        />
       ).instance() as PromQueryField;
-      const value = Plain.deserialize('{job="foo",}');
+      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
       const range = value.selection.merge({
-        anchorOffset: 11,
+        anchorOffset: 36,
       });
       const valueWithSelection = value.change().select(range).value;
       const result = instance.getTypeahead({
@@ -113,6 +116,33 @@ describe('PromQueryField typeahead handling', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
 
+    it('returns label value suggestions inside a label value context after a negated matching operator', () => {
+      const instance = shallow(
+        <PromQueryField
+          {...defaultProps}
+          labelKeys={{ '{}': ['label'] }}
+          labelValues={{ '{}': { label: ['a', 'b', 'c'] } }}
+        />
+      ).instance() as PromQueryField;
+      const value = Plain.deserialize('{label!=}');
+      const range = value.selection.merge({ anchorOffset: 8 });
+      const valueWithSelection = value.change().select(range).value;
+      const result = instance.getTypeahead({
+        text: '!=',
+        prefix: '',
+        wrapperClasses: ['context-labels'],
+        labelKey: 'label',
+        value: valueWithSelection,
+      });
+      expect(result.context).toBe('context-label-values');
+      expect(result.suggestions).toEqual([
+        {
+          items: [{ label: 'a' }, { label: 'b' }, { label: 'c' }],
+          label: 'Label values for "label"',
+        },
+      ]);
+    });
+
     it('returns a refresher on label context and unavailable metric', () => {
       const instance = shallow(
         <PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />

+ 2 - 2
public/app/features/explore/PromQueryField.tsx

@@ -111,7 +111,7 @@ export function willApplySuggestion(
 
     case 'context-label-values': {
       // Always add quotes and remove existing ones instead
-      if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
+      if (!typeaheadText.match(/^(!?=~?"|")/)) {
         suggestion = `"${suggestion}`;
       }
       if (getNextCharacter() !== '"') {
@@ -421,7 +421,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     const containsMetric = selector.indexOf('__name__=') > -1;
     const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
-    if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
+    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
       // Label values
       if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
         const labelValues = this.state.labelValues[selector][labelKey];

+ 7 - 1
public/app/features/explore/QueryField.tsx

@@ -228,7 +228,13 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
       const offset = range.startOffset;
       const text = selection.anchorNode.textContent;
       let prefix = text.substr(0, offset);
-      if (cleanText) {
+
+      // Label values could have valid characters erased if `cleanText()` is
+      // blindly applied, which would undesirably interfere with suggestions
+      const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
+      if (labelValueMatch) {
+        prefix = labelValueMatch[1];
+      } else if (cleanText) {
         prefix = cleanText(prefix);
       }
 

+ 34 - 63
public/app/features/explore/Table.tsx

@@ -1,84 +1,55 @@
+import _ from 'lodash';
 import React, { PureComponent } from 'react';
+import ReactTable from 'react-table';
+
 import TableModel from 'app/core/table_model';
 
 const EMPTY_TABLE = new TableModel();
 
 interface TableProps {
-  className?: string;
   data: TableModel;
   loading: boolean;
   onClickCell?: (columnKey: string, rowValue: string) => void;
 }
 
-interface SFCCellProps {
-  columnIndex: number;
-  onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
-  rowIndex: number;
-  table: TableModel;
-  value: string;
+function prepareRows(rows, columnNames) {
+  return rows.map(cells => _.zipObject(columnNames, cells));
 }
 
-function Cell(props: SFCCellProps) {
-  const { columnIndex, rowIndex, table, value, onClickCell } = props;
-  const column = table.columns[columnIndex];
-  if (column && column.filterable && onClickCell) {
-    const onClick = event => {
-      event.preventDefault();
-      onClickCell(column.text, value, columnIndex, rowIndex, table);
+export default class Table extends PureComponent<TableProps> {
+  getCellProps = (state, rowInfo, column) => {
+    return {
+      onClick: () => {
+        const columnKey = column.Header;
+        const rowValue = rowInfo.row[columnKey];
+        this.props.onClickCell(columnKey, rowValue);
+      },
     };
-    return (
-      <td>
-        <a className="link" onClick={onClick}>
-          {value}
-        </a>
-      </td>
-    );
-  }
-  return <td>{value}</td>;
-}
+  };
 
-export default class Table extends PureComponent<TableProps, {}> {
   render() {
-    const { className = '', data, loading, onClickCell } = this.props;
+    const { data, loading } = this.props;
     const tableModel = data || EMPTY_TABLE;
-    if (!loading && data && data.rows.length === 0) {
-      return (
-        <table className={`${className} filter-table`}>
-          <thead>
-            <tr>
-              <th>Table</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr>
-              <td className="muted">The queries returned no data for a table.</td>
-            </tr>
-          </tbody>
-        </table>
-      );
-    }
+    const columnNames = tableModel.columns.map(({ text }) => text);
+    const columns = tableModel.columns.map(({ filterable, text }) => ({
+      Header: text,
+      accessor: text,
+      show: text !== 'Time',
+      Cell: row => <span className={filterable ? 'link' : ''}>{row.value}</span>,
+    }));
+    const noDataText = data ? 'The queries returned no data for a table.' : '';
+
     return (
-      <table className={`${className} filter-table`}>
-        <thead>
-          <tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr>
-        </thead>
-        <tbody>
-          {tableModel.rows.map((row, i) => (
-            <tr key={i}>
-              {row.map((value, j) => (
-                <Cell
-                  key={j}
-                  columnIndex={j}
-                  rowIndex={i}
-                  value={String(value)}
-                  table={data}
-                  onClickCell={onClickCell}
-                />
-              ))}
-            </tr>
-          ))}
-        </tbody>
-      </table>
+      <ReactTable
+        columns={columns}
+        data={tableModel.rows}
+        getTdProps={this.getCellProps}
+        loading={loading}
+        minRows={0}
+        noDataText={noDataText}
+        resolveData={data => prepareRows(data, columnNames)}
+        showPagination={data}
+      />
     );
   }
 }

+ 5 - 8
public/app/features/explore/utils/prometheus.ts

@@ -28,7 +28,7 @@ export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 
 // const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
 const selectorRegexp = /\{[^}]*?\}/;
-const labelRegexp = /\b\w+="[^"\n]*?"/g;
+const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
 export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
   if (!query.match(selectorRegexp)) {
     // Special matcher for metrics
@@ -66,11 +66,8 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
   // Extract clean labels to form clean selector, incomplete labels are dropped
   const selector = query.slice(prefixOpen, suffixClose);
   const labels = {};
-  selector.replace(labelRegexp, match => {
-    const delimiterIndex = match.indexOf('=');
-    const key = match.slice(0, delimiterIndex);
-    const value = match.slice(delimiterIndex + 1, match.length);
-    labels[key] = value;
+  selector.replace(labelRegexp, (_, key, operator, value) => {
+    labels[key] = { value, operator };
     return '';
   });
 
@@ -78,12 +75,12 @@ export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any
   const metricPrefix = query.slice(0, prefixOpen);
   const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/);
   if (metricMatch) {
-    labels['__name__'] = `"${metricMatch[0]}"`;
+    labels['__name__'] = { value: `"${metricMatch[0]}"`, operator: '=' };
   }
 
   // Build sorted selector
   const labelKeys = Object.keys(labels).sort();
-  const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(',');
+  const cleanSelector = labelKeys.map(key => `${key}${labels[key].operator}${labels[key].value}`).join(',');
 
   const selectorString = ['{', cleanSelector, '}'].join('');
 

+ 0 - 1
public/app/features/plugins/all.ts

@@ -2,6 +2,5 @@ import './plugin_edit_ctrl';
 import './plugin_page_ctrl';
 import './import_list/import_list';
 import './ds_edit_ctrl';
-import './ds_dashboards_ctrl';
 import './datasource_srv';
 import './plugin_component';

+ 0 - 7
public/app/features/plugins/partials/ds_dashboards.html

@@ -1,7 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body" ng-if="ctrl.datasourceMeta">
-
-	<dashboard-import-list plugin="ctrl.datasourceMeta" datasource="ctrl.current"></dashboard-import-list>
-
-</div>

+ 22 - 1
public/app/features/plugins/state/actions.ts

@@ -2,9 +2,11 @@ import { Plugin, StoreState } from 'app/types';
 import { ThunkAction } from 'redux-thunk';
 import { getBackendSrv } from '../../../core/services/backend_srv';
 import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
+import { PluginDashboard } from '../../../types/plugins';
 
 export enum ActionTypes {
   LoadPlugins = 'LOAD_PLUGINS',
+  LoadPluginDashboards = 'LOAD_PLUGIN_DASHBOARDS',
   SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY',
   SetLayoutMode = 'SET_LAYOUT_MODE',
 }
@@ -14,6 +16,11 @@ export interface LoadPluginsAction {
   payload: Plugin[];
 }
 
+export interface LoadPluginDashboardsAction {
+  type: ActionTypes.LoadPluginDashboards;
+  payload: PluginDashboard[];
+}
+
 export interface SetPluginsSearchQueryAction {
   type: ActionTypes.SetPluginsSearchQuery;
   payload: string;
@@ -39,7 +46,12 @@ const pluginsLoaded = (plugins: Plugin[]): LoadPluginsAction => ({
   payload: plugins,
 });
 
-export type Action = LoadPluginsAction | SetPluginsSearchQueryAction | SetLayoutModeAction;
+const pluginDashboardsLoaded = (dashboards: PluginDashboard[]): LoadPluginDashboardsAction => ({
+  type: ActionTypes.LoadPluginDashboards,
+  payload: dashboards,
+});
+
+export type Action = LoadPluginsAction | LoadPluginDashboardsAction | SetPluginsSearchQueryAction | SetLayoutModeAction;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -49,3 +61,12 @@ export function loadPlugins(): ThunkResult<void> {
     dispatch(pluginsLoaded(result));
   };
 }
+
+export function loadPluginDashboards(): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const dataSourceType = getStore().dataSources.dataSource.type;
+
+    const response = await getBackendSrv().get(`api/plugins/${dataSourceType}/dashboards`);
+    dispatch(pluginDashboardsLoaded(response));
+  };
+}

+ 11 - 0
public/app/features/plugins/state/navModel.ts

@@ -1,5 +1,6 @@
 import _ from 'lodash';
 import { DataSource, PluginMeta, NavModel } from 'app/types';
+import config from 'app/core/config';
 
 export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: string): NavModel {
   let title = 'New';
@@ -38,6 +39,16 @@ export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: s
     });
   }
 
+  if (config.buildInfo.isEnterprise) {
+    main.children.push({
+      active: currentPage === 'datasource-permissions',
+      icon: 'fa fa-fw fa-lock',
+      id: 'datasource-permissions',
+      text: 'Permissions',
+      url: `datasources/edit/${ds.id}/permissions`,
+    });
+  }
+
   return {
     main: main,
     node: _.find(main.children, { active: true }),

+ 5 - 0
public/app/features/plugins/state/reducers.ts

@@ -1,12 +1,14 @@
 import { Action, ActionTypes } from './actions';
 import { Plugin, PluginsState } from 'app/types';
 import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+import { PluginDashboard } from '../../../types/plugins';
 
 export const initialState: PluginsState = {
   plugins: [] as Plugin[],
   searchQuery: '',
   layoutMode: LayoutModes.Grid,
   hasFetched: false,
+  dashboards: [] as PluginDashboard[],
 };
 
 export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
@@ -19,6 +21,9 @@ export const pluginsReducer = (state = initialState, action: Action): PluginsSta
 
     case ActionTypes.SetLayoutMode:
       return { ...state, layoutMode: action.payload };
+
+    case ActionTypes.LoadPluginDashboards:
+      return { ...state, dashboards: action.payload };
   }
   return state;
 };

+ 7 - 2
public/app/features/templating/variable_srv.ts

@@ -1,5 +1,8 @@
+// Libaries
 import angular from 'angular';
 import _ from 'lodash';
+
+// Utils & Services
 import coreModule from 'app/core/core_module';
 import { variableTypes } from './variable';
 import { Graph } from 'app/core/utils/dag';
@@ -291,9 +294,11 @@ export class VariableSrv {
   createGraph() {
     const g = new Graph();
 
-    this.variables.forEach(v1 => {
-      g.createNode(v1.name);
+    this.variables.forEach(v => {
+      g.createNode(v.name);
+    });
 
+    this.variables.forEach(v1 => {
       this.variables.forEach(v2 => {
         if (v1 === v2) {
           return;

+ 48 - 1
public/app/plugins/datasource/cloudwatch/config_ctrl.ts

@@ -1,17 +1,21 @@
+import _ from 'lodash';
 export class CloudWatchConfigCtrl {
   static templateUrl = 'partials/config.html';
   current: any;
+  datasourceSrv: any;
 
   accessKeyExist = false;
   secretKeyExist = false;
 
   /** @ngInject */
-  constructor($scope) {
+  constructor($scope, datasourceSrv) {
     this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp';
     this.current.jsonData.authType = this.current.jsonData.authType || 'credentials';
 
     this.accessKeyExist = this.current.secureJsonFields.accessKey;
     this.secretKeyExist = this.current.secureJsonFields.secretKey;
+    this.datasourceSrv = datasourceSrv;
+    this.getRegions();
   }
 
   resetAccessKey() {
@@ -36,4 +40,47 @@ export class CloudWatchConfigCtrl {
     { name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' },
     { name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' },
   ];
+
+  regions = [
+    'ap-northeast-1',
+    'ap-northeast-2',
+    'ap-northeast-3',
+    'ap-south-1',
+    'ap-southeast-1',
+    'ap-southeast-2',
+    'ca-central-1',
+    'cn-north-1',
+    'cn-northwest-1',
+    'eu-central-1',
+    'eu-north-1',
+    'eu-west-1',
+    'eu-west-2',
+    'eu-west-3',
+    'me-south-1',
+    'sa-east-1',
+    'us-east-1',
+    'us-east-2',
+    'us-gov-east-1',
+    'us-gov-west-1',
+    'us-iso-east-1',
+    'us-isob-east-1',
+    'us-west-1',
+    'us-west-2',
+  ];
+
+  getRegions() {
+    this.datasourceSrv
+      .loadDatasource(this.current.name)
+      .then(ds => {
+        return ds.getRegions();
+      })
+      .then(
+        regions => {
+          this.regions = _.map(regions, 'value');
+        },
+        err => {
+          console.error('failed to get latest regions');
+        }
+      );
+  }
 }

+ 1 - 1
public/app/plugins/datasource/cloudwatch/partials/config.html

@@ -39,7 +39,7 @@
   <div class="gf-form">
     <label class="gf-form-label width-13">Default Region</label>
     <div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
-      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ca-central-1', 'cn-north-1', 'cn-northwest-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2', 'us-isob-east-1', 'us-iso-east-1']"></select>
+      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ctrl.regions"></select>
       <info-popover mode="right-absolute">
         Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
       </info-popover>

+ 10 - 0
public/app/plugins/datasource/mssql/config_ctrl.ts

@@ -0,0 +1,10 @@
+export class MssqlConfigCtrl {
+  static templateUrl = 'partials/config.html';
+
+  current: any;
+
+  /** @ngInject */
+  constructor($scope) {
+    this.current.jsonData.encrypt = this.current.jsonData.encrypt || 'false';
+  }
+}

+ 1 - 4
public/app/plugins/datasource/mssql/module.ts

@@ -1,9 +1,6 @@
 import { MssqlDatasource } from './datasource';
 import { MssqlQueryCtrl } from './query_ctrl';
-
-class MssqlConfigCtrl {
-  static templateUrl = 'partials/config.html';
-}
+import { MssqlConfigCtrl } from './config_ctrl';
 
 const defaultQuery = `SELECT
     <time_column> as time,

+ 16 - 0
public/app/plugins/datasource/mssql/partials/config.html

@@ -27,6 +27,22 @@
 			<a class="btn btn-secondary gf-form-btn" href="#" ng-click="ctrl.current.secureJsonFields.password = false">reset</a>
 		</div>
 	</div>
+
+	<div class="gf-form">
+		<label class="gf-form-label width-7">Encrypt</label>
+		<div class="gf-form-select-wrapper max-width-15 gf-form-select-wrapper--has-help-icon">
+			<select class="gf-form-input" ng-model="ctrl.current.jsonData.encrypt" ng-options="mode for mode in ['disable', 'false', 'true']" ng-init="ctrl.current.jsonData.encrypt"></select>
+			<info-popover mode="right-absolute">
+				Determines whether or to which extent a secure SSL TCP/IP connection will be negotiated with the server.
+				<ul>
+					<li><i>disable</i> - Data sent between client and server is not encrypted.</li>
+					<li><i>false</i> - Data sent between client and server is not encrypted beyond the login packet. (default)</li>
+					<li><i>true</i> - Data sent between client and server is encrypted.</li>
+				</ul>
+				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.
+			</info-popover>
+		</div>
+	</div>
 </div>
 
 <b>Connection limits</b>

+ 2 - 2
public/app/plugins/datasource/postgres/datasource.ts

@@ -20,7 +20,7 @@ export class PostgresDatasource {
     this.interval = (instanceSettings.jsonData || {}).timeInterval;
   }
 
-  interpolateVariable(value, variable) {
+  interpolateVariable = (value, variable) => {
     if (typeof value === 'string') {
       if (variable.multi || variable.includeAll) {
         return this.queryModel.quoteLiteral(value);
@@ -37,7 +37,7 @@ export class PostgresDatasource {
       return this.queryModel.quoteLiteral(v);
     });
     return quotedValues.join(',');
-  }
+  };
 
   query(options) {
     const queries = _.filter(options.targets, target => {

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

@@ -156,7 +156,16 @@ class GraphCtrl extends MetricsPanelCtrl {
       panel: this.panel,
       range: this.range,
     });
-    return super.issueQueries(datasource);
+
+    /* Wait for annotationSrv requests to get datasources to
+     * resolve before issuing queries. This allows the annotations
+     * service to fire annotations queries before graph queries
+     * (but not wait for completion). This resolves
+     * issue 11806.
+     */
+    return this.annotationsSrv.datasourcePromises.then(r => {
+      return super.issueQueries(datasource);
+    });
   }
 
   zoomOut(evt) {

+ 5 - 3
public/app/routes/routes.ts

@@ -13,6 +13,7 @@ import FolderPermissions from 'app/features/folders/FolderPermissions';
 import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage';
 import NewDataSourcePage from '../features/datasources/NewDataSourcePage';
 import UsersListPage from 'app/features/users/UsersListPage';
+import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards';
 
 /** @ngInject */
 export function setupAngularRoutes($routeProvider, $locationProvider) {
@@ -78,9 +79,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controllerAs: 'ctrl',
     })
     .when('/datasources/edit/:id/dashboards', {
-      templateUrl: 'public/app/features/plugins/partials/ds_dashboards.html',
-      controller: 'DataSourceDashboardsCtrl',
-      controllerAs: 'ctrl',
+      template: '<react-container />',
+      resolve: {
+        component: () => DataSourceDashboards,
+      },
     })
     .when('/datasources/new', {
       template: '<react-container />',

+ 8 - 2
public/app/store/configureStore.ts

@@ -11,7 +11,7 @@ import pluginReducers from 'app/features/plugins/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
 
-const rootReducer = combineReducers({
+const rootReducers = {
   ...sharedReducers,
   ...alertingReducers,
   ...teamsReducers,
@@ -21,13 +21,19 @@ const rootReducer = combineReducers({
   ...pluginReducers,
   ...dataSourcesReducers,
   ...usersReducers,
-});
+};
 
 export let store;
 
+export function addRootReducer(reducers) {
+  Object.assign(rootReducers, ...reducers);
+}
+
 export function configureStore() {
   const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 
+  const rootReducer = combineReducers(rootReducers);
+
   if (process.env.NODE_ENV !== 'production') {
     // DEV builds we had the logger middleware
     store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger())));

+ 9 - 0
public/app/types/acl.ts

@@ -61,6 +61,11 @@ export enum PermissionLevel {
   Admin = 4,
 }
 
+export enum DataSourcePermissionLevel {
+  Query = 1,
+  Admin = 2,
+}
+
 export enum AclTarget {
   Team = 'Team',
   User = 'User',
@@ -73,6 +78,10 @@ export interface AclTargetInfo {
   text: string;
 }
 
+export const dataSourceAclLevels = [
+  { value: DataSourcePermissionLevel.Query, label: 'Query', description: 'Can query data source.' },
+];
+
 export const dashboardAclTargets: AclTargetInfo[] = [
   { value: AclTarget.Team, text: 'Team' },
   { value: AclTarget.User, text: 'User' },

+ 5 - 3
public/app/types/datasources.ts

@@ -12,10 +12,10 @@ export interface DataSource {
   password: string;
   user: string;
   database: string;
-  basicAuth: false;
-  isDefault: false;
+  basicAuth: boolean;
+  isDefault: boolean;
   jsonData: { authType: string; defaultRegion: string };
-  readOnly: false;
+  readOnly: boolean;
 }
 
 export interface DataSourcesState {
@@ -25,5 +25,7 @@ export interface DataSourcesState {
   layoutMode: LayoutMode;
   dataSourcesCount: number;
   dataSourceTypes: Plugin[];
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
   hasFetched: boolean;
 }

+ 2 - 1
public/app/types/index.ts

@@ -8,7 +8,6 @@ import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
 import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
 import { Invitee, OrgUser, User, UsersState } from './user';
 import { DataSource, DataSourcesState } from './datasources';
-import { PluginMeta, Plugin, PluginsState } from './plugins';
 import {
   TimeRange,
   LoadingState,
@@ -22,6 +21,7 @@ import {
   DataQueryOptions,
 } from './series';
 import { PanelProps } from './panel';
+import { PluginDashboard, PluginMeta, Plugin, PluginsState } from './plugins';
 
 export {
   Team,
@@ -69,6 +69,7 @@ export {
   DataQuery,
   DataQueryResponse,
   DataQueryOptions,
+  PluginDashboard,
 };
 
 export interface StoreState {

+ 17 - 0
public/app/types/plugins.ts

@@ -62,9 +62,26 @@ export interface Plugin {
   type: string;
 }
 
+export interface PluginDashboard {
+  dashboardId: number;
+  description: string;
+  folderId: number;
+  imported: boolean;
+  importedRevision: number;
+  importedUri: string;
+  importedUrl: string;
+  path: string;
+  pluginId: string;
+  removed: boolean;
+  revision: number;
+  slug: string;
+  title: string;
+}
+
 export interface PluginsState {
   plugins: Plugin[];
   searchQuery: string;
   layoutMode: string;
   hasFetched: boolean;
+  dashboards: PluginDashboard[];
 }

+ 4 - 1
public/sass/_grafana.scss

@@ -1,4 +1,7 @@
-// vendor
+// DEPENDENCIES
+@import '../../node_modules/react-table/react-table.css';
+
+// VENDOR
 @import '../vendor/css/timepicker.css';
 @import '../vendor/css/spectrum.css';
 @import '../vendor/css/rc-cascader.scss';

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

@@ -2,6 +2,7 @@
   font-size: $font-size-root;
   font-family: $font-family-monospace;
   height: auto;
+  word-break: break-word;
 }
 
 .slate-query-field-wrapper {

+ 58 - 1
public/sass/pages/_explore.scss

@@ -126,7 +126,7 @@
 }
 
 .query-row-tools {
-  width: 6rem;
+  white-space: nowrap;
 }
 
 .query-row-field {
@@ -186,3 +186,60 @@
     margin: 0.25em 0.5em 0.5em;
   }
 }
+
+// ReactTable basic overrides (does not include pivot/groups/filters)
+// When integrating ReactTable as new panel plugin, move to _panel_table.scss
+
+.ReactTable {
+  border: none;
+  // Allow some space for the no-data text
+  min-height: 120px;
+}
+
+.ReactTable .rt-thead.-header {
+  box-shadow: none;
+  background: $list-item-bg;
+  border-top: 2px solid $body-bg;
+  border-bottom: 2px solid $body-bg;
+  height: 2em;
+}
+.ReactTable .rt-thead.-header .rt-th {
+  text-align: left;
+  color: $blue;
+  font-weight: 500;
+}
+.ReactTable .rt-thead .rt-td,
+.ReactTable .rt-thead .rt-th {
+  padding: 0.45em 0 0.45em 1.1em;
+  border-right: none;
+  box-shadow: none;
+}
+.ReactTable .rt-tbody .rt-td {
+  padding: 0.45em 0 0.45em 1.1em;
+  border-bottom: 2px solid $body-bg;
+  border-right: 2px solid $body-bg;
+}
+.ReactTable .rt-tbody .rt-td:last-child {
+  border-right: none;
+}
+.ReactTable .-pagination .-btn {
+  color: $blue;
+  background: $list-item-bg;
+}
+.ReactTable .-pagination input,
+.ReactTable .-pagination select {
+  color: $input-color;
+  background-color: $input-bg;
+}
+.ReactTable .-loading {
+  background: $input-bg;
+}
+.ReactTable .-loading.-active {
+  opacity: 0.8;
+}
+.ReactTable .-loading > div {
+  color: $input-color;
+}
+.ReactTable .rt-tr .rt-td:last-child {
+  text-align: right;
+}

+ 10 - 7
tools/phantomjs/render.js

@@ -50,19 +50,22 @@
 
       function checkIsReady() {
         var panelsRendered = page.evaluate(function() {
-          var panelCount = document.querySelectorAll('.panel').length;
+          var panelCount = document.querySelectorAll('plugin-component').length;
           return window.panelsRendered >= panelCount;
         });
 
         if (panelsRendered || totalWaitMs > timeoutMs) {
           var bb = page.evaluate(function () {
-            return document.getElementsByClassName("main-view")[0].getBoundingClientRect();
+            var container = document.getElementsByClassName("dashboard-container")
+            if (container.length == 0) {
+               container = document.getElementsByClassName("panel-container")
+            }
+            return container[0].getBoundingClientRect();
           });
-
-          page.clipRect = {
-            top:    bb.top,
-            left:   bb.left,
-            width:  bb.width,
+          
+          // reset viewport to render full page
+          page.viewportSize = {
+            width: bb.width,
             height: bb.height
           };
 

File diff suppressed because it is too large
+ 0 - 176
yarn.lock


Some files were not shown because too many files changed in this diff