Procházet zdrojové kódy

Merge branch 'master' into data-source-settings-to-react

Peter Holmberg před 7 roky
rodič
revize
c92813f313
100 změnil soubory, kde provedl 1681 přidání a 468 odebrání
  1. 1 0
      .bra.toml
  2. 87 4
      .circleci/config.yml
  3. 0 1
      .gitignore
  4. 23 3
      CHANGELOG.md
  5. 16 16
      README.md
  6. 89 0
      UPGRADING_DEPENDENCIES.md
  7. 1 1
      appveyor.yml
  8. 3 0
      conf/defaults.ini
  9. 4 3
      docs/sources/administration/provisioning.md
  10. 1 1
      docs/sources/alerting/notifications.md
  11. 4 3
      docs/sources/features/datasources/cloudwatch.md
  12. 3 2
      docs/sources/features/datasources/mssql.md
  13. 1 0
      docs/sources/features/datasources/stackdriver.md
  14. 16 1
      docs/sources/installation/docker.md
  15. 2 3
      docs/sources/plugins/developing/development.md
  16. 1 1
      docs/sources/project/building_from_source.md
  17. 2 2
      latest.json
  18. 2 1
      package.json
  19. 58 58
      pkg/api/api.go
  20. 3 2
      pkg/api/dashboard.go
  21. 2 1
      pkg/api/dashboard_test.go
  22. 0 1
      pkg/api/dataproxy.go
  23. 1 17
      pkg/api/datasources.go
  24. 26 23
      pkg/api/dtos/alerting.go
  25. 7 3
      pkg/api/frontendsettings.go
  26. 3 1
      pkg/api/http_server.go
  27. 14 20
      pkg/api/index.go
  28. 2 2
      pkg/api/login.go
  29. 3 3
      pkg/api/user.go
  30. 1 1
      pkg/cmd/grafana-server/main.go
  31. 3 1
      pkg/login/ldap.go
  32. 26 23
      pkg/models/alert_notifications.go
  33. 0 1
      pkg/models/dashboards.go
  34. 2 8
      pkg/models/datasource.go
  35. 5 4
      pkg/services/alerting/conditions/evaluator.go
  36. 5 6
      pkg/services/alerting/extractor.go
  37. 1 1
      pkg/services/alerting/extractor_test.go
  38. 1 0
      pkg/services/alerting/interfaces.go
  39. 26 16
      pkg/services/alerting/notifiers/base.go
  40. 5 0
      pkg/services/alerting/notifiers/base_test.go
  41. 7 1
      pkg/services/alerting/notifiers/telegram.go
  42. 5 5
      pkg/services/alerting/rule.go
  43. 1 1
      pkg/services/cleanup/cleanup.go
  44. 6 2
      pkg/services/dashboards/dashboard_service.go
  45. 2 2
      pkg/services/dashboards/dashboard_service_test.go
  46. 30 0
      pkg/services/hooks/hooks.go
  47. 14 10
      pkg/services/sqlstore/alert_notification.go
  48. 10 7
      pkg/services/sqlstore/alert_notification_test.go
  49. 3 0
      pkg/services/sqlstore/migrations/alert_mig.go
  50. 0 2
      pkg/services/sqlstore/user_auth_test.go
  51. 5 0
      pkg/setting/setting.go
  52. 16 10
      pkg/tsdb/cloudwatch/cloudwatch.go
  53. 29 2
      pkg/tsdb/cloudwatch/metric_find_query.go
  54. 6 1
      pkg/tsdb/mssql/mssql.go
  55. 1 1
      pkg/tsdb/mssql/mssql_test.go
  56. 6 1
      pkg/tsdb/mysql/mysql.go
  57. 1 1
      pkg/tsdb/mysql/mysql_test.go
  58. 1 1
      pkg/tsdb/postgres/postgres_test.go
  59. 18 27
      public/app/app.ts
  60. 1 1
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  61. 2 1
      public/app/core/components/query_part/query_part_editor.ts
  62. 1 0
      public/app/core/components/scroll/scroll.ts
  63. 1 1
      public/app/core/components/sidemenu/SideMenu.tsx
  64. 1 1
      public/app/core/components/sidemenu/__snapshots__/SideMenu.test.tsx.snap
  65. 3 2
      public/app/core/components/sql_part/sql_part_editor.ts
  66. 2 1
      public/app/core/config.ts
  67. 3 0
      public/app/core/constants.ts
  68. 0 2
      public/app/core/core.ts
  69. 17 1
      public/app/core/core_module.ts
  70. 11 6
      public/app/core/directives/dash_class.ts
  71. 5 6
      public/app/core/directives/metric_segment.ts
  72. 9 2
      public/app/core/reducers/location.ts
  73. 14 20
      public/app/core/services/dynamic_directive_srv.ts
  74. 2 2
      public/app/core/services/keybindingSrv.ts
  75. 71 0
      public/app/core/specs/kbn.test.ts
  76. 12 0
      public/app/core/utils/dag.test.ts
  77. 18 2
      public/app/core/utils/dag.ts
  78. 55 0
      public/app/core/utils/kbn.ts
  79. 1 0
      public/app/features/alerting/NotificationsEditCtrl.ts
  80. 10 3
      public/app/features/alerting/partials/notification_edit.html
  81. 7 4
      public/app/features/annotations/annotations_srv.ts
  82. 0 1
      public/app/features/dashboard/all.ts
  83. 9 12
      public/app/features/dashboard/dashboard_ctrl.ts
  84. 37 0
      public/app/features/dashboard/dashboard_model.ts
  85. 4 7
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  86. 22 24
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  87. 1 3
      public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
  88. 134 27
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  89. 7 17
      public/app/features/dashboard/dashgrid/DashboardRow.tsx
  90. 151 0
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  91. 84 0
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  92. 0 7
      public/app/features/dashboard/dashgrid/PanelContainer.ts
  93. 121 0
      public/app/features/dashboard/dashgrid/PanelEditor.tsx
  94. 83 0
      public/app/features/dashboard/dashgrid/PanelHeader.tsx
  95. 53 0
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  96. 69 0
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx
  97. 2 0
      public/app/features/dashboard/dashnav/dashnav.ts
  98. 45 4
      public/app/features/dashboard/panel_model.ts
  99. 1 1
      public/app/features/dashboard/settings/settings.ts
  100. 1 2
      public/app/features/dashboard/share_snapshot_ctrl.ts

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

+ 87 - 4
.circleci/config.yml

@@ -170,6 +170,7 @@ jobs:
             - scripts/*.sh
             - scripts/publish
             - scripts/build/release_publisher/release_publisher
+            - scripts/build/publish.sh
 
   build:
     docker:
@@ -237,8 +238,17 @@ jobs:
     steps:
       - checkout
       - run:
-          name: build, test and package grafana enterprise
-          command: './scripts/build/build_enterprise.sh'
+          name: prepare build tools
+          command: '/tmp/bootstrap.sh'
+      - run:
+          name: checkout enterprise
+          command: './scripts/build/prepare_enterprise.sh'
+      - run:
+          name: test enterprise
+          command: 'go test ./pkg/extensions/...'
+      - run:
+          name: build and package enterprise
+          command: './scripts/build/build.sh -enterprise'
       - run:
           name: sign packages
           command: './scripts/build/sign_packages.sh'
@@ -253,6 +263,53 @@ jobs:
           paths:
             - enterprise-dist/grafana-enterprise*
 
+  build-all-enterprise:
+    docker:
+    - image: grafana/build-container:1.2.0
+    working_directory: /go/src/github.com/grafana/grafana
+    steps:
+    - checkout
+    - run:
+        name: prepare build tools
+        command: '/tmp/bootstrap.sh'
+    - run:
+        name: checkout enterprise
+        command: './scripts/build/prepare_enterprise.sh'
+    - restore_cache:
+        key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
+    - run:
+        name: download phantomjs binaries
+        command: './scripts/build/download-phantomjs.sh'
+    - save_cache:
+        key: phantomjs-binaries-{{ checksum "scripts/build/download-phantomjs.sh" }}
+        paths:
+        - /tmp/phantomjs
+    - run:
+        name: test enterprise
+        command: 'go test ./pkg/extensions/...'
+    - run:
+        name: build and package grafana
+        command: './scripts/build/build-all.sh -enterprise'
+    - run:
+        name: sign packages
+        command: './scripts/build/sign_packages.sh'
+    - run:
+        name: verify signed packages
+        command: |
+          mkdir -p ~/.rpmdb/pubkeys
+          curl -s https://grafanarel.s3.amazonaws.com/RPM-GPG-KEY-grafana > ~/.rpmdb/pubkeys/grafana.key
+          ./scripts/build/verify_signed_packages.sh dist/*.rpm
+    - run:
+        name: sha-sum packages
+        command: 'go run build.go sha-dist'
+    - run:
+        name: move enterprise packages into their own folder
+        command: 'mv dist enterprise-dist'
+    - persist_to_workspace:
+        root: .
+        paths:
+        - enterprise-dist/grafana-enterprise*
+
   deploy-enterprise-master:
     docker:
       - image: circleci/python:2.7-stretch
@@ -266,6 +323,19 @@ jobs:
           name: deploy to s3
           command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/master'
 
+deploy-enterprise-release:
+  docker:
+  - image: circleci/python:2.7-stretch
+  steps:
+  - attach_workspace:
+      at: .
+  - run:
+      name: install awscli
+      command: 'sudo pip install awscli'
+  - run:
+      name: deploy to s3
+      command: 'aws s3 sync ./enterprise-dist s3://$ENTERPRISE_BUCKET_NAME/release'
+
   deploy-master:
     docker:
       - image: circleci/python:2.7-stretch
@@ -312,7 +382,7 @@ workflows:
     jobs:
       - build-all:
           filters: *filter-only-master
-      - build-enterprise:
+      - build-all-enterprise:
           filters: *filter-only-master
       - codespell:
           filters: *filter-only-master
@@ -355,13 +425,15 @@ workflows:
             - gometalinter
             - mysql-integration-test
             - postgres-integration-test
-            - build-enterprise
+            - build-all-enterprise
           filters: *filter-only-master
 
   release:
     jobs:
       - build-all:
           filters: *filter-only-release
+      - build-all-enterprise:
+          filters: *filter-only-release
       - codespell:
           filters: *filter-only-release
       - gometalinter:
@@ -384,6 +456,17 @@ workflows:
             - mysql-integration-test
             - postgres-integration-test
           filters: *filter-only-release
+      - deploy-enterprise-release:
+          requires:
+            - build-all
+            - build-all-enterprise
+            - test-backend
+            - test-frontend
+            - codespell
+            - gometalinter
+            - mysql-integration-test
+            - postgres-integration-test
+          filters: *filter-only-release
       - grafana-docker-release:
           requires:
             - build-all

+ 0 - 1
.gitignore

@@ -69,7 +69,6 @@ debug.test
 /vendor/**/*.yml
 /vendor/**/*_test.go
 /vendor/**/.editorconfig
-/vendor/**/appengine*
 *.orig
 
 /devenv/bulk-dashboards/*.json

+ 23 - 3
CHANGELOG.md

@@ -2,20 +2,40 @@
 
 ### 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
 
+* **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)
-* **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)
 
 ### 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)
+
+* **InfluxDB/Graphite/Postgres**: Prevent cross site scripting (XSS) in query editor [#13667](https://github.com/grafana/grafana/issues/13667), thx [@svenklemm](https://github.com/svenklemm)
+* **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)
+* **Stackdriver/Cloudwatch**: Allow user to change unit in graph panel if cloudwatch/stackdriver datasource response doesn't include unit [#13718](https://github.com/grafana/grafana/issues/13718), thx [@mtanda](https://github.com/mtanda)
+* **LDAP**: Fix super admins can also be admins of orgs [#13710](https://github.com/grafana/grafana/issues/13710), thx [@adrien-f](https://github.com/adrien-f)
+
+# 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)
 
@@ -68,7 +88,7 @@
 * **Profile**: List teams that the user is member of in current/active organization [#12476](https://github.com/grafana/grafana/issues/12476)
 * **Configuration**: Allow auto-assigning users to specific organization (other than Main. Org) [#1823](https://github.com/grafana/grafana/issues/1823) [#12801](https://github.com/grafana/grafana/issues/12801), thx [@gzzo](https://github.com/gzzo) and [@ofosos](https://github.com/ofosos)
 * **Dataproxy**: Pass configured/auth headers to a Datasource [#10971](https://github.com/grafana/grafana/issues/10971), thx [@mrsiano](https://github.com/mrsiano)
-* ****: **: CloudWatch GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
+* **CloudWatch**: GetMetricData support [#11487](https://github.com/grafana/grafana/issues/11487), thx [@mtanda](https://github.com/mtanda)
 * **Postgres**: TimescaleDB support, e.g. use `time_bucket` for grouping by time when option enabled [#12680](https://github.com/grafana/grafana/pull/12680), thx [svenklemm](https://github.com/svenklemm)
 * **Cleanup**: Make temp file time to live configurable [#11607](https://github.com/grafana/grafana/issues/11607), thx [@xapon](https://github.com/xapon)
 

+ 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
 

+ 3 - 0
conf/defaults.ini

@@ -554,3 +554,6 @@ container_name =
 # Options to configure external image rendering server like https://github.com/grafana/grafana-image-renderer
 server_url =
 callback_url =
+
+[panels]
+enable_alpha = false

+ 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
 

+ 1 - 0
docs/sources/features/datasources/stackdriver.md

@@ -206,6 +206,7 @@ datasources:
     jsonData:
       tokenUri: https://oauth2.googleapis.com/token
       clientEmail: stackdriver@myproject.iam.gserviceaccount.com
+      defaultProject: my-project-name
     secureJsonData:
       privateKey: |
         -----BEGIN PRIVATE KEY-----

+ 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

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

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

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

+ 2 - 1
package.json

@@ -80,7 +80,7 @@
     "style-loader": "^0.21.0",
     "systemjs": "0.20.19",
     "systemjs-plugin-css": "^0.1.36",
-    "ts-jest": "^23.1.4",
+    "ts-jest": "^23.10.4",
     "ts-loader": "^5.1.0",
     "tslib": "^1.9.3",
     "tslint": "^5.8.0",
@@ -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",

+ 58 - 58
pkg/api/api.go

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

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

+ 0 - 1
pkg/api/dataproxy.go

@@ -55,7 +55,6 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 
 	dsId := c.ParamsInt64(":id")
 	ds, err := hs.getDatasourceFromCache(dsId, c)
-	hs.log.Debug("We are in the ds proxy", "dsId", dsId)
 
 	if err != nil {
 		c.JsonApiErr(500, "Unable to load datasource meta data", err)

+ 1 - 17
pkg/api/datasources.go

@@ -17,24 +17,8 @@ func GetDataSources(c *m.ReqContext) Response {
 		return Error(500, "Failed to query datasources", err)
 	}
 
-	dsFilterQuery := m.DatasourcesPermissionFilterQuery{
-		User:        c.SignedInUser,
-		Datasources: query.Result,
-	}
-
-	datasources := []*m.DataSource{}
-	if err := bus.Dispatch(&dsFilterQuery); err != nil {
-		if err != bus.ErrHandlerNotFound {
-			return Error(500, "Could not get datasources", err)
-		}
-
-		datasources = query.Result
-	} else {
-		datasources = dsFilterQuery.Result
-	}
-
 	result := make(dtos.DataSourceList, 0)
-	for _, ds := range datasources {
+	for _, ds := range query.Result {
 		dsItem := dtos.DataSourceListItemDTO{
 			OrgId:     ds.OrgId,
 			Id:        ds.Id,

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

+ 7 - 3
pkg/api/frontendsettings.go

@@ -11,7 +11,7 @@ import (
 	"github.com/grafana/grafana/pkg/util"
 )
 
-func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
+func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
 	orgDataSources := make([]*m.DataSource, 0)
 
 	if c.OrgId != 0 {
@@ -133,6 +133,10 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
 
 	panels := map[string]interface{}{}
 	for _, panel := range enabledPlugins.Panels {
+		if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels {
+			continue
+		}
+
 		panels[panel.Id] = map[string]interface{}{
 			"module":       panel.Module,
 			"baseUrl":      panel.BaseUrl,
@@ -196,8 +200,8 @@ func getPanelSort(id string) int {
 	return sort
 }
 
-func GetFrontendSettings(c *m.ReqContext) {
-	settings, err := getFrontendSettingsMap(c)
+func (hs *HTTPServer) GetFrontendSettings(c *m.ReqContext) {
+	settings, err := hs.getFrontendSettingsMap(c)
 	if err != nil {
 		c.JsonApiErr(400, "Failed to get frontend settings", err)
 		return

+ 3 - 1
pkg/api/http_server.go

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

+ 14 - 20
pkg/api/index.go

@@ -17,8 +17,8 @@ const (
 	darkName  = "dark"
 )
 
-func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
-	settings, err := getFrontendSettingsMap(c)
+func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
+	settings, err := hs.getFrontendSettingsMap(c)
 	if err != nil {
 		return nil, err
 	}
@@ -316,19 +316,6 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		}
 
 		if c.IsGrafanaAdmin {
-			children := []*dtos.NavLink{
-				{Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"},
-				{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
-				{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
-				{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
-			}
-
-			if setting.IsEnterprise {
-				children = append(children, &dtos.NavLink{Text: "Licensing", Id: "licensing", Url: setting.AppSubUrl + "/admin/licensing", Icon: "fa fa-fw fa-unlock-alt"})
-			}
-
-			children = append(children, &dtos.NavLink{Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"})
-
 			cfgNode.Children = append(cfgNode.Children, &dtos.NavLink{
 				Text:         "Server Admin",
 				HideFromTabs: true,
@@ -336,7 +323,13 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 				Id:           "admin",
 				Icon:         "gicon gicon-shield",
 				Url:          setting.AppSubUrl + "/admin/users",
-				Children:     children,
+				Children: []*dtos.NavLink{
+					{Text: "Users", Id: "global-users", Url: setting.AppSubUrl + "/admin/users", Icon: "gicon gicon-user"},
+					{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
+					{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
+					{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
+					{Text: "Style Guide", Id: "styleguide", Url: setting.AppSubUrl + "/styleguide", Icon: "fa fa-fw fa-eyedropper"},
+				},
 			})
 		}
 
@@ -357,11 +350,12 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
 		},
 	})
 
+	hs.HooksService.RunIndexDataHooks(&data)
 	return &data, nil
 }
 
-func Index(c *m.ReqContext) {
-	data, err := setIndexViewData(c)
+func (hs *HTTPServer) Index(c *m.ReqContext) {
+	data, err := hs.setIndexViewData(c)
 	if err != nil {
 		c.Handle(500, "Failed to get settings", err)
 		return
@@ -369,13 +363,13 @@ func Index(c *m.ReqContext) {
 	c.HTML(200, "index", data)
 }
 
-func NotFoundHandler(c *m.ReqContext) {
+func (hs *HTTPServer) NotFoundHandler(c *m.ReqContext) {
 	if c.IsApiRequest() {
 		c.JsonApiErr(404, "Not found", nil)
 		return
 	}
 
-	data, err := setIndexViewData(c)
+	data, err := hs.setIndexViewData(c)
 	if err != nil {
 		c.Handle(500, "Failed to get settings", err)
 		return

+ 2 - 2
pkg/api/login.go

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

+ 3 - 3
pkg/api/user.go

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

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

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

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

+ 2 - 8
pkg/models/datasource.go

@@ -195,8 +195,8 @@ type GetDataSourceByNameQuery struct {
 type DsPermissionType int
 
 const (
-	DsPermissionQuery DsPermissionType = 1 << iota
-	DsPermissionNoAccess
+	DsPermissionNoAccess DsPermissionType = iota
+	DsPermissionQuery
 )
 
 func (p DsPermissionType) String() string {
@@ -207,12 +207,6 @@ func (p DsPermissionType) String() string {
 	return names[int(p)]
 }
 
-type HasRequiredDataSourcePermissionQuery struct {
-	Id                 int64
-	User               *SignedInUser
-	RequiredPermission DsPermissionType
-}
-
 type GetDataSourcePermissionsForUserQuery struct {
 	User   *SignedInUser
 	Result map[int64]DsPermissionType

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 5 - 0
pkg/setting/setting.go

@@ -213,6 +213,8 @@ type Cfg struct {
 	TempDataLifetime time.Duration
 
 	MetricsEndpointEnabled bool
+
+	EnableAlphaPanels bool
 }
 
 type CommandLineArgs struct {
@@ -694,6 +696,9 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
 	explore := iniFile.Section("explore")
 	ExploreEnabled = explore.Key("enabled").MustBool(false)
 
+	panels := iniFile.Section("panels")
+	cfg.EnableAlphaPanels = panels.Key("enable_alpha").MustBool(false)
+
 	cfg.readSessionConfig()
 	cfg.readSmtpSettings()
 	cfg.readQuotaSettings()

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

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

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

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

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

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

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

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

+ 18 - 27
public/app/app.ts

@@ -26,8 +26,12 @@ _.move = (array, fromIndex, toIndex) => {
   return array;
 };
 
-import { coreModule, registerAngularDirectives } from './core/core';
-import { setupAngularRoutes } from './routes/routes';
+import { coreModule, angularModules } from 'app/core/core_module';
+import { registerAngularDirectives } from 'app/core/core';
+import { setupAngularRoutes } from 'app/routes/routes';
+
+import 'app/routes/GrafanaCtrl';
+import 'app/features/all';
 
 // import symlinked extensions
 const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
@@ -109,39 +113,26 @@ export class GrafanaApp {
       'react',
     ];
 
-    const moduleTypes = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes'];
-
-    _.each(moduleTypes, type => {
-      const moduleName = 'grafana.' + type;
-      this.useModule(angular.module(moduleName, []));
-    });
-
     // makes it possible to add dynamic stuff
-    this.useModule(coreModule);
+    _.each(angularModules, m => {
+      this.useModule(m);
+    });
 
     // register react angular wrappers
     coreModule.config(setupAngularRoutes);
     registerAngularDirectives();
 
-    const preBootRequires = [import('app/features/all')];
+    // disable tool tip animation
+    $.fn.tooltip.defaults.animation = false;
 
-    Promise.all(preBootRequires)
-      .then(() => {
-        // disable tool tip animation
-        $.fn.tooltip.defaults.animation = false;
-
-        // bootstrap the app
-        angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
-          _.each(this.preBootModules, module => {
-            _.extend(module, this.registerFunctions);
-          });
-
-          this.preBootModules = null;
-        });
-      })
-      .catch(err => {
-        console.log('Application boot failed:', err);
+    // bootstrap the app
+    angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
+      _.each(this.preBootModules, module => {
+        _.extend(module, this.registerFunctions);
       });
+
+      this.preBootModules = null;
+    });
   }
 }
 

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

+ 2 - 1
public/app/core/components/query_part/query_part_editor.ts

@@ -103,7 +103,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
           $scope.$apply(() => {
             $scope.handleEvent({ $event: { name: 'get-param-options' } }).then(result => {
               const dynamicOptions = _.map(result, op => {
-                return op.value;
+                return _.escape(op.value);
               });
               callback(dynamicOptions);
             });
@@ -117,6 +117,7 @@ export function queryPartEditorDirective($compile, templateSrv) {
           minLength: 0,
           items: 1000,
           updater: value => {
+            value = _.unescape(value);
             setTimeout(() => {
               inputBlur.call($input[0], paramIndex);
             }, 0);

+ 1 - 0
public/app/core/components/scroll/scroll.ts

@@ -18,6 +18,7 @@ export function geminiScrollbar() {
       let scrollRoot = elem.parent();
       const scroller = elem;
 
+      console.log('scroll');
       if (attrs.grafanaScrollbar && attrs.grafanaScrollbar === 'scrollonroot') {
         scrollRoot = scroller;
       }

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

+ 3 - 2
public/app/core/components/sql_part/sql_part_editor.ts

@@ -109,12 +109,12 @@ export function sqlPartEditorDirective($compile, templateSrv) {
           $scope.$apply(() => {
             $scope.handleEvent({ $event: { name: 'get-param-options', param: param } }).then(result => {
               const dynamicOptions = _.map(result, op => {
-                return op.value;
+                return _.escape(op.value);
               });
 
               // add current value to dropdown if it's not in dynamicOptions
               if (_.indexOf(dynamicOptions, part.params[paramIndex]) === -1) {
-                dynamicOptions.unshift(part.params[paramIndex]);
+                dynamicOptions.unshift(_.escape(part.params[paramIndex]));
               }
 
               callback(dynamicOptions);
@@ -129,6 +129,7 @@ export function sqlPartEditorDirective($compile, templateSrv) {
           minLength: 0,
           items: 1000,
           updater: value => {
+            value = _.unescape(value);
             if (value === part.params[paramIndex]) {
               clearTimeout(cancelBlur);
               $input.focus();

+ 2 - 1
public/app/core/config.ts

@@ -1,4 +1,5 @@
 import _ from 'lodash';
+import { PanelPlugin } from 'app/types/plugins';
 
 export interface BuildInfo {
   version: string;
@@ -9,7 +10,7 @@ export interface BuildInfo {
 
 export class Settings {
   datasources: any;
-  panels: any;
+  panels: PanelPlugin[];
   appSubUrl: string;
   windowTitlePrefix: string;
   buildInfo: BuildInfo;

+ 3 - 0
public/app/core/constants.ts

@@ -8,3 +8,6 @@ export const DEFAULT_ROW_HEIGHT = 250;
 export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3;
 
 export const LS_PANEL_COPY_KEY = 'panel-copy';
+
+export const DASHBOARD_TOOLBAR_HEIGHT = 55;
+export const DASHBOARD_TOP_PADDING = 20;

+ 0 - 2
public/app/core/core.ts

@@ -19,7 +19,6 @@ import './components/colorpicker/spectrum_picker';
 import './services/search_srv';
 import './services/ng_react';
 
-import { grafanaAppDirective } from './components/grafana_app';
 import { searchDirective } from './components/search/search';
 import { infoPopover } from './components/info_popover';
 import { navbarDirective } from './components/navbar/navbar';
@@ -60,7 +59,6 @@ export {
   registerAngularDirectives,
   arrayJoin,
   coreModule,
-  grafanaAppDirective,
   navbarDirective,
   searchDirective,
   liveSrv,

+ 17 - 1
public/app/core/core_module.ts

@@ -1,2 +1,18 @@
 import angular from 'angular';
-export default angular.module('grafana.core', ['ngRoute']);
+
+const coreModule = angular.module('grafana.core', ['ngRoute']);
+
+// legacy modules
+const angularModules = [
+  coreModule,
+  angular.module('grafana.controllers', []),
+  angular.module('grafana.directives', []),
+  angular.module('grafana.factories', []),
+  angular.module('grafana.services', []),
+  angular.module('grafana.filters', []),
+  angular.module('grafana.routes', []),
+];
+
+export { angularModules, coreModule };
+
+export default coreModule;

+ 11 - 6
public/app/core/directives/dash_class.ts

@@ -2,16 +2,21 @@ import _ from 'lodash';
 import coreModule from '../core_module';
 
 /** @ngInject */
-export function dashClass() {
+function dashClass($timeout) {
   return {
     link: ($scope, elem) => {
-      $scope.onAppEvent('panel-fullscreen-enter', () => {
-        elem.toggleClass('panel-in-fullscreen', true);
+      $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
+        console.log('view-mode-changed', panel.fullscreen);
+        if (panel.fullscreen) {
+          elem.addClass('panel-in-fullscreen');
+        } else {
+          $timeout(() => {
+            elem.removeClass('panel-in-fullscreen');
+          });
+        }
       });
 
-      $scope.onAppEvent('panel-fullscreen-exit', () => {
-        elem.toggleClass('panel-in-fullscreen', false);
-      });
+      elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
 
       $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
         if (newValue) {

+ 5 - 6
public/app/core/directives/metric_segment.ts

@@ -3,7 +3,7 @@ import $ from 'jquery';
 import coreModule from '../core_module';
 
 /** @ngInject */
-export function metricSegment($compile, $sce) {
+export function metricSegment($compile, $sce, templateSrv) {
   const inputTemplate =
     '<input type="text" data-provide="typeahead" ' +
     ' class="gf-form-input input-medium"' +
@@ -41,13 +41,11 @@ export function metricSegment($compile, $sce) {
           return;
         }
 
-        value = _.unescape(value);
-
         $scope.$apply(() => {
           const selected = _.find($scope.altSegments, { value: value });
           if (selected) {
             segment.value = selected.value;
-            segment.html = selected.html || selected.value;
+            segment.html = selected.html || $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(selected.value));
             segment.fake = false;
             segment.expandable = selected.expandable;
 
@@ -56,7 +54,7 @@ export function metricSegment($compile, $sce) {
             }
           } else if (segment.custom !== 'false') {
             segment.value = value;
-            segment.html = $sce.trustAsHtml(value);
+            segment.html = $sce.trustAsHtml(templateSrv.highlightVariablesAsHtml(value));
             segment.expandable = true;
             segment.fake = false;
           }
@@ -95,7 +93,7 @@ export function metricSegment($compile, $sce) {
             // add custom values
             if (segment.custom !== 'false') {
               if (!segment.fake && _.indexOf(options, segment.value) === -1) {
-                options.unshift(segment.value);
+                options.unshift(_.escape(segment.value));
               }
             }
 
@@ -105,6 +103,7 @@ export function metricSegment($compile, $sce) {
       };
 
       $scope.updater = value => {
+        value = _.unescape(value);
         if (value === segment.value) {
           clearTimeout(cancelBlur);
           $input.focus();

+ 9 - 2
public/app/core/reducers/location.ts

@@ -1,6 +1,7 @@
 import { Action } from 'app/core/actions/location';
 import { LocationState } from 'app/types';
 import { renderUrl } from 'app/core/utils/url';
+import _ from 'lodash';
 
 export const initialState: LocationState = {
   url: '',
@@ -12,11 +13,17 @@ export const initialState: LocationState = {
 export const locationReducer = (state = initialState, action: Action): LocationState => {
   switch (action.type) {
     case 'UPDATE_LOCATION': {
-      const { path, query, routeParams } = action.payload;
+      const { path, routeParams } = action.payload;
+      let query = action.payload.query || state.query;
+
+      if (action.payload.partial) {
+        query = _.defaults(query, state.query);
+      }
+
       return {
         url: renderUrl(path || state.path, query),
         path: path || state.path,
-        query: query || state.query,
+        query: query,
         routeParams: routeParams || state.routeParams,
       };
     }

+ 14 - 20
public/app/core/services/dynamic_directive_srv.ts

@@ -3,7 +3,7 @@ import coreModule from '../core_module';
 
 class DynamicDirectiveSrv {
   /** @ngInject */
-  constructor(private $compile, private $rootScope) {}
+  constructor(private $compile) {}
 
   addDirective(element, name, scope) {
     const child = angular.element(document.createElement(name));
@@ -14,25 +14,19 @@ class DynamicDirectiveSrv {
   }
 
   link(scope, elem, attrs, options) {
-    options
-      .directive(scope)
-      .then(directiveInfo => {
-        if (!directiveInfo || !directiveInfo.fn) {
-          elem.empty();
-          return;
-        }
-
-        if (!directiveInfo.fn.registered) {
-          coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn);
-          directiveInfo.fn.registered = true;
-        }
-
-        this.addDirective(elem, directiveInfo.name, scope);
-      })
-      .catch(err => {
-        console.log('Plugin load:', err);
-        this.$rootScope.appEvent('alert-error', ['Plugin error', err.toString()]);
-      });
+    const directiveInfo = options.directive(scope);
+    if (!directiveInfo || !directiveInfo.fn) {
+      elem.empty();
+      return;
+    }
+
+    if (!directiveInfo.fn.registered) {
+      console.log('register panel tab');
+      coreModule.directive(attrs.$normalize(directiveInfo.name), directiveInfo.fn);
+      directiveInfo.fn.registered = true;
+    }
+
+    this.addDirective(elem, directiveInfo.name, scope);
   }
 
   create(options) {

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

@@ -148,7 +148,7 @@ export class KeybindingSrv {
     this.bind('mod+o', () => {
       dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
       appEvents.emit('graph-hover-clear');
-      this.$rootScope.$broadcast('refresh');
+      dashboard.startRefresh();
     });
 
     this.bind('mod+s', e => {
@@ -257,7 +257,7 @@ export class KeybindingSrv {
     });
 
     this.bind('d r', () => {
-      this.$rootScope.$broadcast('refresh');
+      dashboard.startRefresh();
     });
 
     this.bind('d s', () => {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -22,7 +22,6 @@ import './export_data/export_data_modal';
 import './ad_hoc_filters';
 import './repeat_option/repeat_option';
 import './dashgrid/DashboardGridDirective';
-import './dashgrid/PanelLoader';
 import './dashgrid/RowOptions';
 import './folder_picker/folder_picker';
 import './move_to_folder_modal/move_to_folder';

+ 9 - 12
public/app/features/dashboard/dashboard_ctrl.ts

@@ -1,11 +1,10 @@
 import config from 'app/core/config';
 
 import coreModule from 'app/core/core_module';
-import { PanelContainer } from './dashgrid/PanelContainer';
 import { DashboardModel } from './dashboard_model';
 import { PanelModel } from './panel_model';
 
-export class DashboardCtrl implements PanelContainer {
+export class DashboardCtrl {
   dashboard: DashboardModel;
   dashboardViewState: any;
   loadedFallbackDashboard: boolean;
@@ -22,8 +21,7 @@ export class DashboardCtrl implements PanelContainer {
     private dashboardSrv,
     private unsavedChangesSrv,
     private dashboardViewStateSrv,
-    public playlistSrv,
-    private panelLoader
+    public playlistSrv
   ) {
     // temp hack due to way dashboards are loaded
     // can't use controllerAs on route yet
@@ -119,14 +117,6 @@ export class DashboardCtrl implements PanelContainer {
     return this.dashboard;
   }
 
-  getPanelLoader() {
-    return this.panelLoader;
-  }
-
-  timezoneChanged() {
-    this.$rootScope.$broadcast('refresh');
-  }
-
   getPanelContainer() {
     return this;
   }
@@ -168,10 +158,17 @@ export class DashboardCtrl implements PanelContainer {
     this.dashboard.removePanel(panel);
   }
 
+  onDestroy() {
+    if (this.dashboard) {
+      this.dashboard.destroy();
+    }
+  }
+
   init(dashboard) {
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
     this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
     this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
+    this.$scope.$on('$destroy', this.onDestroy.bind(this));
     this.setupDashboard(dashboard);
   }
 }

+ 37 - 0
public/app/features/dashboard/dashboard_model.ts

@@ -200,6 +200,43 @@ export class DashboardModel {
     this.events.emit('view-mode-changed', panel);
   }
 
+  timeRangeUpdated() {
+    this.events.emit('time-range-updated');
+  }
+
+  startRefresh() {
+    this.events.emit('refresh');
+
+    for (const panel of this.panels) {
+      if (!this.otherPanelInFullscreen(panel)) {
+        panel.refresh();
+      }
+    }
+  }
+
+  render() {
+    this.events.emit('render');
+
+    for (const panel of this.panels) {
+      panel.render();
+    }
+  }
+
+  panelInitialized(panel: PanelModel) {
+    if (!this.otherPanelInFullscreen(panel)) {
+      panel.refresh();
+    }
+  }
+
+  otherPanelInFullscreen(panel: PanelModel) {
+    return this.meta.fullscreen && !panel.fullscreen;
+  }
+
+  changePanelType(panel: PanelModel, pluginId: string) {
+    panel.changeType(pluginId);
+    this.events.emit('panel-type-changed', panel);
+  }
+
   private ensureListExist(data) {
     if (!data) {
       data = {};

+ 4 - 7
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx

@@ -3,7 +3,7 @@ import _ from 'lodash';
 import classNames from 'classnames';
 import config from 'app/core/config';
 import { PanelModel } from '../panel_model';
-import { PanelContainer } from './PanelContainer';
+import { DashboardModel } from '../dashboard_model';
 import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
 import store from 'app/core/store';
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
@@ -11,7 +11,7 @@ import Highlighter from 'react-highlight-words';
 
 export interface AddPanelPanelProps {
   panel: PanelModel;
-  getPanelContainer: () => PanelContainer;
+  dashboard: DashboardModel;
 }
 
 export interface AddPanelPanelState {
@@ -93,8 +93,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
   }
 
   onAddPanel = panelPluginInfo => {
-    const panelContainer = this.props.getPanelContainer();
-    const dashboard = panelContainer.getDashboard();
+    const dashboard = this.props.dashboard;
     const { gridPos } = this.props.panel;
 
     const newPanel: any = {
@@ -123,9 +122,7 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
 
   handleCloseAddPanel(evt) {
     evt.preventDefault();
-    const panelContainer = this.props.getPanelContainer();
-    const dashboard = panelContainer.getDashboard();
-    dashboard.removePanel(dashboard.panels[0]);
+    this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
   }
 
   renderText(text: string) {

+ 22 - 24
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -3,7 +3,6 @@ import ReactGridLayout from 'react-grid-layout';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { DashboardPanel } from './DashboardPanel';
 import { DashboardModel } from '../dashboard_model';
-import { PanelContainer } from './PanelContainer';
 import { PanelModel } from '../panel_model';
 import classNames from 'classnames';
 import sizeMe from 'react-sizeme';
@@ -60,18 +59,15 @@ function GridWrapper({
 const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
 
 export interface DashboardGridProps {
-  getPanelContainer: () => PanelContainer;
+  dashboard: DashboardModel;
 }
 
 export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   gridToPanelMap: any;
-  panelContainer: PanelContainer;
-  dashboard: DashboardModel;
   panelMap: { [id: string]: PanelModel };
 
   constructor(props) {
     super(props);
-    this.panelContainer = this.props.getPanelContainer();
     this.onLayoutChange = this.onLayoutChange.bind(this);
     this.onResize = this.onResize.bind(this);
     this.onResizeStop = this.onResizeStop.bind(this);
@@ -81,20 +77,21 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     this.state = { animated: false };
 
     // subscribe to dashboard events
-    this.dashboard = this.panelContainer.getDashboard();
-    this.dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
-    this.dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
-    this.dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
-    this.dashboard.on('view-mode-changed', this.triggerForceUpdate.bind(this));
-    this.dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
-    this.dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
+    const dashboard = this.props.dashboard;
+    dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
+    dashboard.on('panel-removed', this.triggerForceUpdate.bind(this));
+    dashboard.on('repeats-processed', this.triggerForceUpdate.bind(this));
+    dashboard.on('view-mode-changed', this.onViewModeChanged.bind(this));
+    dashboard.on('row-collapsed', this.triggerForceUpdate.bind(this));
+    dashboard.on('row-expanded', this.triggerForceUpdate.bind(this));
+    dashboard.on('panel-type-changed', this.triggerForceUpdate.bind(this));
   }
 
   buildLayout() {
     const layout = [];
     this.panelMap = {};
 
-    for (const panel of this.dashboard.panels) {
+    for (const panel of this.props.dashboard.panels) {
       const stringId = panel.id.toString();
       this.panelMap[stringId] = panel;
 
@@ -129,7 +126,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
       this.panelMap[newPos.i].updateGridPos(newPos);
     }
 
-    this.dashboard.sortPanelsByGridPos();
+    this.props.dashboard.sortPanelsByGridPos();
   }
 
   triggerForceUpdate() {
@@ -137,11 +134,15 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   }
 
   onWidthChange() {
-    for (const panel of this.dashboard.panels) {
+    for (const panel of this.props.dashboard.panels) {
       panel.resizeDone();
     }
   }
 
+  onViewModeChanged(payload) {
+    this.setState({ animated: !payload.fullscreen });
+  }
+
   updateGridPos(item, layout) {
     this.panelMap[item.i].updateGridPos(item);
 
@@ -165,21 +166,18 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
 
   componentDidMount() {
     setTimeout(() => {
-      this.setState(() => {
-        return { animated: true };
-      });
+      this.setState({ animated: true });
     });
   }
 
   renderPanels() {
     const panelElements = [];
 
-    for (const panel of this.dashboard.panels) {
+    for (const panel of this.props.dashboard.panels) {
       const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
       panelElements.push(
-        /** panel-id is set for html bookmarks */
-        <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id.toString()}`}>
-          <DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
+        <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
+          <DashboardPanel panel={panel} dashboard={this.props.dashboard} panelType={panel.type} />
         </div>
       );
     }
@@ -192,8 +190,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
       <SizedReactLayoutGrid
         className={classNames({ layout: true, animated: this.state.animated })}
         layout={this.buildLayout()}
-        isResizable={this.dashboard.meta.canEdit}
-        isDraggable={this.dashboard.meta.canEdit}
+        isResizable={this.props.dashboard.meta.canEdit}
+        isDraggable={this.props.dashboard.meta.canEdit}
         onLayoutChange={this.onLayoutChange}
         onWidthChange={this.onWidthChange}
         onDragStop={this.onDragStop}

+ 1 - 3
public/app/features/dashboard/dashgrid/DashboardGridDirective.ts

@@ -1,6 +1,4 @@
 import { react2AngularDirective } from 'app/core/utils/react2angular';
 import { DashboardGrid } from './DashboardGrid';
 
-react2AngularDirective('dashboardGrid', DashboardGrid, [
-  ['getPanelContainer', { watchDepth: 'reference', wrapApply: false }],
-]);
+react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);

+ 134 - 27
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -1,54 +1,161 @@
 import React from 'react';
-import {PanelModel} from '../panel_model';
-import {PanelContainer} from './PanelContainer';
-import {AttachedPanel} from './PanelLoader';
-import {DashboardRow} from './DashboardRow';
-import {AddPanelPanel} from './AddPanelPanel';
+import config from 'app/core/config';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { DashboardRow } from './DashboardRow';
+import { AddPanelPanel } from './AddPanelPanel';
+import { importPluginModule } from 'app/features/plugins/plugin_loader';
+import { PluginExports, PanelPlugin } from 'app/types/plugins';
+import { PanelChrome } from './PanelChrome';
+import { PanelEditor } from './PanelEditor';
 
-export interface DashboardPanelProps {
+export interface Props {
+  panelType: string;
   panel: PanelModel;
-  getPanelContainer: () => PanelContainer;
+  dashboard: DashboardModel;
 }
 
-export class DashboardPanel extends React.Component<DashboardPanelProps, any> {
+export interface State {
+  pluginExports: PluginExports;
+}
+
+export class DashboardPanel extends React.Component<Props, State> {
   element: any;
-  attachedPanel: AttachedPanel;
+  angularPanel: AngularComponent;
+  pluginInfo: any;
+  specialPanels = {};
 
   constructor(props) {
     super(props);
-    this.state = {};
+
+    this.state = {
+      pluginExports: null,
+    };
+
+    this.specialPanels['row'] = this.renderRow.bind(this);
+    this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
+  }
+
+  isSpecial() {
+    return this.specialPanels[this.props.panel.type];
+  }
+
+  renderRow() {
+    return <DashboardRow panel={this.props.panel} dashboard={this.props.dashboard} />;
+  }
+
+  renderAddPanel() {
+    return <AddPanelPanel panel={this.props.panel} dashboard={this.props.dashboard} />;
+  }
+
+  onPluginTypeChanged = (plugin: PanelPlugin) => {
+    this.props.panel.changeType(plugin.id);
+    this.loadPlugin();
+  };
+
+  onAngularPluginTypeChanged = () => {
+    this.loadPlugin();
+  };
+
+  loadPlugin() {
+    if (this.isSpecial()) {
+      return;
+    }
+
+    // handle plugin loading & changing of plugin type
+    if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
+      this.pluginInfo = config.panels[this.props.panel.type];
+
+      if (this.pluginInfo.exports) {
+        this.cleanUpAngularPanel();
+        this.setState({ pluginExports: this.pluginInfo.exports });
+      } else {
+        importPluginModule(this.pluginInfo.module).then(pluginExports => {
+          this.cleanUpAngularPanel();
+          // cache plugin exports (saves a promise async cycle next time)
+          this.pluginInfo.exports = pluginExports;
+          // update panel state
+          this.setState({ pluginExports: pluginExports });
+        });
+      }
+    }
   }
 
   componentDidMount() {
-    if (!this.element) {
+    this.loadPlugin();
+  }
+
+  componentDidUpdate() {
+    this.loadPlugin();
+
+    // handle angular plugin loading
+    if (!this.element || this.angularPanel) {
       return;
     }
 
-    const panelContainer = this.props.getPanelContainer();
-    const dashboard = panelContainer.getDashboard();
-    const loader = panelContainer.getPanelLoader();
-    this.attachedPanel = loader.load(this.element, this.props.panel, dashboard);
+    const loader = getAngularLoader();
+    const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
+    const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
+    this.angularPanel = loader.load(this.element, scopeProps, template);
   }
 
-  componentWillUnmount() {
-    if (this.attachedPanel) {
-      this.attachedPanel.destroy();
+  cleanUpAngularPanel() {
+    if (this.angularPanel) {
+      this.angularPanel.destroy();
+      this.angularPanel = null;
     }
   }
 
+  componentWillUnmount() {
+    this.cleanUpAngularPanel();
+  }
+
+  renderReactPanel() {
+    const { pluginExports } = this.state;
+    const containerClass = this.props.panel.isEditing ? 'panel-editor-container' : 'panel-height-helper';
+    const panelWrapperClass = this.props.panel.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
+
+    // this might look strange with these classes that change when edit, but
+    // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
+    return (
+      <div className={containerClass}>
+        <div className={panelWrapperClass}>
+          <PanelChrome
+            component={pluginExports.PanelComponent}
+            panel={this.props.panel}
+            dashboard={this.props.dashboard}
+          />
+        </div>
+        {this.props.panel.isEditing && (
+          <div className="panel-editor-container__editor">
+            <PanelEditor
+              panel={this.props.panel}
+              panelType={this.props.panel.type}
+              dashboard={this.props.dashboard}
+              onTypeChanged={this.onPluginTypeChanged}
+              pluginExports={pluginExports}
+            />
+          </div>
+        )}
+      </div>
+    );
+  }
+
   render() {
-    // special handling for rows
-    if (this.props.panel.type === 'row') {
-      return <DashboardRow panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />;
+    if (this.isSpecial()) {
+      return this.specialPanels[this.props.panel.type]();
     }
 
-    if (this.props.panel.type === 'add-panel') {
-      return <AddPanelPanel panel={this.props.panel} getPanelContainer={this.props.getPanelContainer} />;
+    if (!this.state.pluginExports) {
+      return null;
     }
 
-    return (
-      <div ref={element => this.element = element} className="panel-height-helper" />
-    );
+    if (this.state.pluginExports.PanelComponent) {
+      return this.renderReactPanel();
+    }
+
+    // legacy angular rendering
+    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
   }
 }
-

+ 7 - 17
public/app/features/dashboard/dashgrid/DashboardRow.tsx

@@ -1,19 +1,16 @@
 import React from 'react';
 import classNames from 'classnames';
 import { PanelModel } from '../panel_model';
-import { PanelContainer } from './PanelContainer';
+import { DashboardModel } from '../dashboard_model';
 import templateSrv from 'app/features/templating/template_srv';
 import appEvents from 'app/core/app_events';
 
 export interface DashboardRowProps {
   panel: PanelModel;
-  getPanelContainer: () => PanelContainer;
+  dashboard: DashboardModel;
 }
 
 export class DashboardRow extends React.Component<DashboardRowProps, any> {
-  dashboard: any;
-  panelContainer: any;
-
   constructor(props) {
     super(props);
 
@@ -21,9 +18,6 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
       collapsed: this.props.panel.collapsed,
     };
 
-    this.panelContainer = this.props.getPanelContainer();
-    this.dashboard = this.panelContainer.getDashboard();
-
     this.toggle = this.toggle.bind(this);
     this.openSettings = this.openSettings.bind(this);
     this.delete = this.delete.bind(this);
@@ -31,7 +25,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
   }
 
   toggle() {
-    this.dashboard.toggleRow(this.props.panel);
+    this.props.dashboard.toggleRow(this.props.panel);
 
     this.setState(prevState => {
       return { collapsed: !prevState.collapsed };
@@ -39,7 +33,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
   }
 
   update() {
-    this.dashboard.processRepeats();
+    this.props.dashboard.processRepeats();
     this.forceUpdate();
   }
 
@@ -61,14 +55,10 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
       altActionText: 'Delete row only',
       icon: 'fa-trash',
       onConfirm: () => {
-        const panelContainer = this.props.getPanelContainer();
-        const dashboard = panelContainer.getDashboard();
-        dashboard.removeRow(this.props.panel, true);
+        this.props.dashboard.removeRow(this.props.panel, true);
       },
       onAltAction: () => {
-        const panelContainer = this.props.getPanelContainer();
-        const dashboard = panelContainer.getDashboard();
-        dashboard.removeRow(this.props.panel, false);
+        this.props.dashboard.removeRow(this.props.panel, false);
       },
     });
   }
@@ -87,7 +77,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
     const title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
     const count = this.props.panel.panels ? this.props.panel.panels.length : 0;
     const panels = count === 1 ? 'panel' : 'panels';
-    const canEdit = this.dashboard.meta.canEdit === true;
+    const canEdit = this.props.dashboard.meta.canEdit === true;
 
     return (
       <div className={classes}>

+ 151 - 0
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -0,0 +1,151 @@
+// Library
+import React, { Component } from 'react';
+
+// Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+// Types
+import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
+
+interface RenderProps {
+  loading: LoadingState;
+  timeSeries: TimeSeries[];
+}
+
+export interface Props {
+  datasource: string | null;
+  queries: any[];
+  panelId?: number;
+  dashboardId?: number;
+  isVisible?: boolean;
+  timeRange?: TimeRange;
+  refreshCounter: number;
+  children: (r: RenderProps) => JSX.Element;
+}
+
+export interface State {
+  isFirstLoad: boolean;
+  loading: LoadingState;
+  response: DataQueryResponse;
+}
+
+export class DataPanel extends Component<Props, State> {
+  static defaultProps = {
+    isVisible: true,
+    panelId: 1,
+    dashboardId: 1,
+  };
+
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      loading: LoadingState.NotStarted,
+      response: {
+        data: [],
+      },
+      isFirstLoad: true,
+    };
+  }
+
+  componentDidMount() {
+    console.log('DataPanel mount');
+  }
+
+  async componentDidUpdate(prevProps: Props) {
+    if (!this.hasPropsChanged(prevProps)) {
+      return;
+    }
+
+    this.issueQueries();
+  }
+
+  hasPropsChanged(prevProps: Props) {
+    return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
+  }
+
+  issueQueries = async () => {
+    const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
+
+    if (!isVisible) {
+      return;
+    }
+
+    if (!queries.length) {
+      this.setState({ loading: LoadingState.Done });
+      return;
+    }
+
+    this.setState({ loading: LoadingState.Loading });
+
+    try {
+      const dataSourceSrv = getDatasourceSrv();
+      const ds = await dataSourceSrv.get(datasource);
+
+      const queryOptions: DataQueryOptions = {
+        timezone: 'browser',
+        panelId: panelId,
+        dashboardId: dashboardId,
+        range: timeRange,
+        rangeRaw: timeRange.raw,
+        interval: '1s',
+        intervalMs: 60000,
+        targets: queries,
+        maxDataPoints: 500,
+        scopedVars: {},
+        cacheTimeout: null,
+      };
+
+      console.log('Issuing DataPanel query', queryOptions);
+      const resp = await ds.query(queryOptions);
+      console.log('Issuing DataPanel query Resp', resp);
+
+      this.setState({
+        loading: LoadingState.Done,
+        response: resp,
+        isFirstLoad: false,
+      });
+    } catch (err) {
+      console.log('Loading error', err);
+      this.setState({ loading: LoadingState.Error, isFirstLoad: false });
+    }
+  };
+
+  render() {
+    const { response, loading, isFirstLoad } = this.state;
+    console.log('data panel render');
+    const timeSeries = response.data;
+
+    if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
+      return (
+        <div className="loading">
+          <p>Loading</p>
+        </div>
+      );
+    }
+
+    return (
+      <>
+        {this.loadingSpinner}
+        {this.props.children({
+          timeSeries,
+          loading,
+        })}
+      </>
+    );
+  }
+
+  private get loadingSpinner(): JSX.Element {
+    const { loading } = this.state;
+
+    if (loading === LoadingState.Loading) {
+      return (
+        <div className="panel__loading">
+          <i className="fa fa-spinner fa-spin" />
+        </div>
+      );
+    }
+
+    return null;
+  }
+}

+ 84 - 0
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -0,0 +1,84 @@
+// Libraries
+import React, { ComponentClass, PureComponent } from 'react';
+
+// Services
+import { getTimeSrv } from '../time_srv';
+
+// Components
+import { PanelHeader } from './PanelHeader';
+import { DataPanel } from './DataPanel';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { TimeRange, PanelProps } from 'app/types';
+
+export interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+  component: ComponentClass<PanelProps>;
+}
+
+export interface State {
+  refreshCounter: number;
+  timeRange?: TimeRange;
+}
+
+export class PanelChrome extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      refreshCounter: 0,
+    };
+  }
+
+  componentDidMount() {
+    this.props.panel.events.on('refresh', this.onRefresh);
+    this.props.dashboard.panelInitialized(this.props.panel);
+  }
+
+  componentWillUnmount() {
+    this.props.panel.events.off('refresh', this.onRefresh);
+  }
+
+  onRefresh = () => {
+    const timeSrv = getTimeSrv();
+    const timeRange = timeSrv.timeRange();
+
+    this.setState({
+      refreshCounter: this.state.refreshCounter + 1,
+      timeRange: timeRange,
+    });
+  };
+
+  get isVisible() {
+    return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
+  }
+
+  render() {
+    const { panel, dashboard } = this.props;
+    const { datasource, targets } = panel;
+    const { refreshCounter, timeRange } = this.state;
+    const PanelComponent = this.props.component;
+
+    return (
+      <div className="panel-container">
+        <PanelHeader panel={panel} dashboard={dashboard} />
+        <div className="panel-content">
+          <DataPanel
+            datasource={datasource}
+            queries={targets}
+            timeRange={timeRange}
+            isVisible={this.isVisible}
+            refreshCounter={refreshCounter}
+          >
+            {({ loading, timeSeries }) => {
+              return <PanelComponent loading={loading} timeSeries={timeSeries} timeRange={timeRange} />;
+            }}
+          </DataPanel>
+        </div>
+      </div>
+    );
+  }
+}

+ 0 - 7
public/app/features/dashboard/dashgrid/PanelContainer.ts

@@ -1,7 +0,0 @@
-import { DashboardModel } from '../dashboard_model';
-import { PanelLoader } from './PanelLoader';
-
-export interface PanelContainer {
-  getPanelLoader(): PanelLoader;
-  getDashboard(): DashboardModel;
-}

+ 121 - 0
public/app/features/dashboard/dashgrid/PanelEditor.tsx

@@ -0,0 +1,121 @@
+import React from 'react';
+import classNames from 'classnames';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { store } from 'app/store/configureStore';
+import { QueriesTab } from './QueriesTab';
+import { PanelPlugin, PluginExports } from 'app/types/plugins';
+import { VizTypePicker } from './VizTypePicker';
+import { updateLocation } from 'app/core/actions';
+
+interface PanelEditorProps {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+  panelType: string;
+  pluginExports: PluginExports;
+  onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+interface PanelEditorTab {
+  id: string;
+  text: string;
+  icon: string;
+}
+
+export class PanelEditor extends React.Component<PanelEditorProps, any> {
+  tabs: PanelEditorTab[];
+
+  constructor(props) {
+    super(props);
+
+    this.tabs = [
+      { id: 'queries', text: 'Queries', icon: 'fa fa-database' },
+      { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
+    ];
+  }
+
+  renderQueriesTab() {
+    return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
+  }
+
+  renderPanelOptions() {
+    const { pluginExports } = this.props;
+
+    if (pluginExports.PanelOptions) {
+      const PanelOptions = pluginExports.PanelOptions;
+      return <PanelOptions />;
+    } else {
+      return <p>Visualization has no options</p>;
+    }
+  }
+
+  renderVizTab() {
+    return (
+      <div className="viz-editor">
+        <div className="viz-editor-col1">
+          <VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
+        </div>
+        <div className="viz-editor-col2">
+          <h5 className="page-heading">Options</h5>
+          {this.renderPanelOptions()}
+        </div>
+      </div>
+    );
+  }
+
+  onChangeTab = (tab: PanelEditorTab) => {
+    store.dispatch(
+      updateLocation({
+        query: { tab: tab.id },
+        partial: true,
+      })
+    );
+  };
+
+  render() {
+    const { location } = store.getState();
+    const activeTab = location.query.tab || 'queries';
+
+    return (
+      <div className="tabbed-view tabbed-view--new">
+        <div className="tabbed-view-header">
+          <ul className="gf-tabs">
+            {this.tabs.map(tab => {
+              return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
+            })}
+          </ul>
+
+          <button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
+            <i className="fa fa-remove" />
+          </button>
+        </div>
+
+        <div className="tabbed-view-body">
+          {activeTab === 'queries' && this.renderQueriesTab()}
+          {activeTab === 'visualization' && this.renderVizTab()}
+        </div>
+      </div>
+    );
+  }
+}
+
+interface TabItemParams {
+  tab: PanelEditorTab;
+  activeTab: string;
+  onClick: (tab: PanelEditorTab) => void;
+}
+
+function TabItem({ tab, activeTab, onClick }: TabItemParams) {
+  const tabClasses = classNames({
+    'gf-tabs-link': true,
+    active: activeTab === tab.id,
+  });
+
+  return (
+    <li className="gf-tabs-item" key={tab.id}>
+      <a className={tabClasses} onClick={() => onClick(tab)}>
+        <i className={tab.icon} /> {tab.text}
+      </a>
+    </li>
+  );
+}

+ 83 - 0
public/app/features/dashboard/dashgrid/PanelHeader.tsx

@@ -0,0 +1,83 @@
+import React from 'react';
+import classNames from 'classnames';
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { store } from 'app/store/configureStore';
+import { updateLocation } from 'app/core/actions';
+
+interface PanelHeaderProps {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+}
+
+export class PanelHeader extends React.Component<PanelHeaderProps, any> {
+  onEditPanel = () => {
+    store.dispatch(
+      updateLocation({
+        query: {
+          panelId: this.props.panel.id,
+          edit: true,
+          fullscreen: true,
+        },
+      })
+    );
+  };
+
+  onViewPanel = () => {
+    store.dispatch(
+      updateLocation({
+        query: {
+          panelId: this.props.panel.id,
+          edit: false,
+          fullscreen: true,
+        },
+      })
+    );
+  };
+
+  render() {
+    const isFullscreen = false;
+    const isLoading = false;
+    const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
+
+    return (
+      <div className={panelHeaderClass}>
+        <span className="panel-info-corner">
+          <i className="fa" />
+          <span className="panel-info-corner-inner" />
+        </span>
+
+        {isLoading && (
+          <span className="panel-loading">
+            <i className="fa fa-spinner fa-spin" />
+          </span>
+        )}
+
+        <div className="panel-title-container">
+          <span className="panel-title">
+            <span className="icon-gf panel-alert-icon" />
+            <span className="panel-title-text">{this.props.panel.title}</span>
+            <span className="panel-menu-container dropdown">
+              <span className="fa fa-caret-down panel-menu-toggle" data-toggle="dropdown" />
+              <ul className="dropdown-menu dropdown-menu--menu panel-menu" role="menu">
+                <li>
+                  <a onClick={this.onEditPanel}>
+                    <i className="fa fa-fw fa-edit" /> Edit
+                  </a>
+                </li>
+                <li>
+                  <a onClick={this.onViewPanel}>
+                    <i className="fa fa-fw fa-eye" /> View
+                  </a>
+                </li>
+              </ul>
+            </span>
+            <span className="panel-time-info">
+              <i className="fa fa-clock-o" /> 4m
+            </span>
+          </span>
+        </div>
+      </div>
+    );
+  }
+}

+ 53 - 0
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -0,0 +1,53 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Services & utils
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+
+interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+}
+
+export class QueriesTab extends PureComponent<Props> {
+  element: any;
+  component: AngularComponent;
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    if (!this.element) {
+      return;
+    }
+
+    const { panel, dashboard } = this.props;
+
+    const loader = getAngularLoader();
+    const template = '<metrics-tab />';
+    const scopeProps = {
+      ctrl: {
+        panel: panel,
+        dashboard: dashboard,
+        refresh: () => panel.refresh(),
+      },
+    };
+
+    this.component = loader.load(this.element, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    if (this.component) {
+      this.component.destroy();
+    }
+  }
+
+  render() {
+    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
+  }
+}

+ 69 - 0
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -0,0 +1,69 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import config from 'app/core/config';
+import { PanelPlugin } from 'app/types/plugins';
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import _ from 'lodash';
+
+interface Props {
+  currentType: string;
+  onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+interface State {
+  pluginList: PanelPlugin[];
+}
+
+export class VizTypePicker extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      pluginList: this.getPanelPlugins(''),
+    };
+  }
+
+  getPanelPlugins(filter) {
+    const panels = _.chain(config.panels)
+      .filter({ hideFromList: false })
+      .map(item => item)
+      .value();
+
+    // add sort by sort property
+    return _.sortBy(panels, 'sort');
+  }
+
+  renderVizPlugin = (plugin, index) => {
+    const cssClass = classNames({
+      'viz-picker__item': true,
+      'viz-picker__item--selected': plugin.id === this.props.currentType,
+    });
+
+    return (
+      <div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
+        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
+        <div className="viz-picker__item-name">{plugin.name}</div>
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <div className="viz-picker">
+        <div className="viz-picker__search">
+          <div className="gf-form gf-form--grow">
+            <label className="gf-form--has-input-icon gf-form--grow">
+              <input type="text" className="gf-form-input" placeholder="Search type" />
+              <i className="gf-form-input-icon fa fa-search" />
+            </label>
+          </div>
+        </div>
+        <div className="viz-picker__items">
+          <CustomScrollbar>
+            <div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
+          </CustomScrollbar>
+        </div>
+      </div>
+    );
+  }
+}

+ 2 - 0
public/app/features/dashboard/dashnav/dashnav.ts

@@ -42,6 +42,8 @@ export class DashNavCtrl {
     } else if (search.fullscreen) {
       delete search.fullscreen;
       delete search.edit;
+      delete search.tab;
+      delete search.panelId;
     }
     this.$location.search(search);
   }

+ 45 - 4
public/app/features/dashboard/panel_model.ts

@@ -13,6 +13,13 @@ const notPersistedProperties: { [str: string]: boolean } = {
   events: true,
   fullscreen: true,
   isEditing: true,
+  hasRefreshed: true,
+};
+
+const defaults: any = {
+  gridPos: { x: 0, y: 0, h: 3, w: 6 },
+  datasource: null,
+  targets: [{}],
 };
 
 export class PanelModel {
@@ -31,10 +38,14 @@ export class PanelModel {
   collapsed?: boolean;
   panels?: any;
   soloMode?: boolean;
+  targets: any[];
+  datasource: string;
+  thresholds?: any;
 
   // non persisted
   fullscreen: boolean;
   isEditing: boolean;
+  hasRefreshed: boolean;
   events: Emitter;
 
   constructor(model) {
@@ -45,9 +56,8 @@ export class PanelModel {
       this[property] = model[property];
     }
 
-    if (!this.gridPos) {
-      this.gridPos = { x: 0, y: 0, h: 3, w: 6 };
-    }
+    // defaults
+    _.defaultsDeep(this, _.cloneDeep(defaults));
   }
 
   getSaveModel() {
@@ -57,6 +67,10 @@ export class PanelModel {
         continue;
       }
 
+      if (_.isEqual(this[property], defaults[property])) {
+        continue;
+      }
+
       model[property] = _.cloneDeep(this[property]);
     }
 
@@ -82,7 +96,6 @@ export class PanelModel {
     this.gridPos.h = newPos.h;
 
     if (sizeChanged) {
-      console.log('PanelModel sizeChanged event and render events fired');
       this.events.emit('panel-size-changed');
     }
   }
@@ -91,6 +104,34 @@ export class PanelModel {
     this.events.emit('panel-size-changed');
   }
 
+  refresh() {
+    this.hasRefreshed = true;
+    this.events.emit('refresh');
+  }
+
+  render() {
+    if (!this.hasRefreshed) {
+      this.refresh();
+    } else {
+      this.events.emit('render');
+    }
+  }
+
+  panelInitialized() {
+    this.events.emit('panel-initialized');
+  }
+
+  initEditMode() {
+    this.events.emit('panel-init-edit-mode');
+  }
+
+  changeType(pluginId: string) {
+    this.type = pluginId;
+
+    delete this.thresholds;
+    delete this.alert;
+  }
+
   destroy() {
     this.events.removeAllListeners();
   }

+ 1 - 1
public/app/features/dashboard/settings/settings.ts

@@ -32,7 +32,7 @@ export class SettingsCtrl {
 
     this.$scope.$on('$destroy', () => {
       this.dashboard.updateSubmenuVisibility();
-      this.$rootScope.$broadcast('refresh');
+      this.dashboard.startRefresh();
       setTimeout(() => {
         this.$rootScope.appEvent('dash-scroll', { restore: true });
       });

+ 1 - 2
public/app/features/dashboard/share_snapshot_ctrl.ts

@@ -46,8 +46,7 @@ export class ShareSnapshotCtrl {
 
       $scope.loading = true;
       $scope.snapshot.external = external;
-
-      $rootScope.$broadcast('refresh');
+      $scope.dashboard.startRefresh();
 
       $timeout(() => {
         $scope.saveSnapshot(external);

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů