ソースを参照

Merge remote-tracking branch 'upstream/master' into graph-legend-to-react

Alexander Zobnin 7 年 前
コミット
5a4c362985
100 ファイル変更2248 行追加535 行削除
  1. 86 4
      .circleci/config.yml
  2. 15 3
      CHANGELOG.md
  3. 15 15
      README.md
  4. 89 0
      UPGRADING_DEPENDENCIES.md
  5. 1 1
      appveyor.yml
  6. 3 0
      conf/defaults.ini
  7. 1 0
      docs/sources/administration/provisioning.md
  8. 1 1
      docs/sources/alerting/notifications.md
  9. 4 3
      docs/sources/features/datasources/cloudwatch.md
  10. 3 2
      docs/sources/features/datasources/mssql.md
  11. 1 0
      docs/sources/features/datasources/stackdriver.md
  12. 16 1
      docs/sources/installation/docker.md
  13. 1 1
      docs/sources/project/building_from_source.md
  14. 2 2
      latest.json
  15. 1 0
      package.json
  16. 4 4
      pkg/api/api.go
  17. 18 1
      pkg/api/dataproxy.go
  18. 7 7
      pkg/api/datasources.go
  19. 26 23
      pkg/api/dtos/alerting.go
  20. 21 4
      pkg/api/frontendsettings.go
  21. 1 1
      pkg/api/index.go
  22. 3 1
      pkg/login/ldap.go
  23. 26 23
      pkg/models/alert_notifications.go
  24. 29 2
      pkg/models/datasource.go
  25. 1 0
      pkg/services/alerting/interfaces.go
  26. 26 16
      pkg/services/alerting/notifiers/base.go
  27. 5 0
      pkg/services/alerting/notifiers/base_test.go
  28. 14 10
      pkg/services/sqlstore/alert_notification.go
  29. 10 7
      pkg/services/sqlstore/alert_notification_test.go
  30. 1 0
      pkg/services/sqlstore/datasource.go
  31. 3 0
      pkg/services/sqlstore/migrations/alert_mig.go
  32. 9 2
      pkg/services/sqlstore/sqlstore.go
  33. 5 0
      pkg/setting/setting.go
  34. 16 10
      pkg/tsdb/cloudwatch/cloudwatch.go
  35. 29 2
      pkg/tsdb/cloudwatch/metric_find_query.go
  36. 6 1
      pkg/tsdb/mssql/mssql.go
  37. 6 1
      pkg/tsdb/mysql/mysql.go
  38. 18 27
      public/app/app.ts
  39. 4 0
      public/app/core/components/PermissionList/AddPermission.tsx
  40. 0 4
      public/app/core/components/Picker/DescriptionPicker.tsx
  41. 2 1
      public/app/core/components/query_part/query_part_editor.ts
  42. 1 0
      public/app/core/components/scroll/scroll.ts
  43. 3 2
      public/app/core/components/sql_part/sql_part_editor.ts
  44. 2 1
      public/app/core/config.ts
  45. 3 0
      public/app/core/constants.ts
  46. 0 2
      public/app/core/core.ts
  47. 17 1
      public/app/core/core_module.ts
  48. 11 6
      public/app/core/directives/dash_class.ts
  49. 5 6
      public/app/core/directives/metric_segment.ts
  50. 9 2
      public/app/core/reducers/location.ts
  51. 14 20
      public/app/core/services/dynamic_directive_srv.ts
  52. 2 2
      public/app/core/services/keybindingSrv.ts
  53. 1 0
      public/app/features/alerting/NotificationsEditCtrl.ts
  54. 10 3
      public/app/features/alerting/partials/notification_edit.html
  55. 0 1
      public/app/features/dashboard/all.ts
  56. 9 12
      public/app/features/dashboard/dashboard_ctrl.ts
  57. 37 0
      public/app/features/dashboard/dashboard_model.ts
  58. 4 7
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  59. 22 24
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  60. 1 3
      public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
  61. 134 27
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  62. 7 17
      public/app/features/dashboard/dashgrid/DashboardRow.tsx
  63. 151 0
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  64. 84 0
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  65. 0 7
      public/app/features/dashboard/dashgrid/PanelContainer.ts
  66. 121 0
      public/app/features/dashboard/dashgrid/PanelEditor.tsx
  67. 83 0
      public/app/features/dashboard/dashgrid/PanelHeader.tsx
  68. 53 0
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  69. 69 0
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx
  70. 2 0
      public/app/features/dashboard/dashnav/dashnav.ts
  71. 45 4
      public/app/features/dashboard/panel_model.ts
  72. 1 1
      public/app/features/dashboard/settings/settings.ts
  73. 1 2
      public/app/features/dashboard/share_snapshot_ctrl.ts
  74. 17 7
      public/app/features/dashboard/specs/AddPanelPanel.test.tsx
  75. 4 9
      public/app/features/dashboard/specs/DashboardRow.test.tsx
  76. 1 1
      public/app/features/dashboard/specs/exporter.test.ts
  77. 7 7
      public/app/features/dashboard/specs/viewstate_srv.test.ts
  78. 17 1
      public/app/features/dashboard/state/actions.ts
  79. 2 2
      public/app/features/dashboard/submenu/submenu.ts
  80. 21 12
      public/app/features/dashboard/time_srv.ts
  81. 1 1
      public/app/features/dashboard/timepicker/settings.html
  82. 2 1
      public/app/features/dashboard/timepicker/timepicker.ts
  83. 27 85
      public/app/features/dashboard/view_state_srv.ts
  84. 63 0
      public/app/features/datasources/DashboardsTable.test.tsx
  85. 55 0
      public/app/features/datasources/DashboardsTable.tsx
  86. 29 0
      public/app/features/datasources/DataSourceDashboards.test.tsx
  87. 93 0
      public/app/features/datasources/DataSourceDashboards.tsx
  88. 125 0
      public/app/features/datasources/DataSourceSettings.tsx
  89. 0 3
      public/app/features/datasources/NewDataSourcePage.tsx
  90. 88 0
      public/app/features/datasources/__snapshots__/DashboardsTable.test.tsx.snap
  91. 18 0
      public/app/features/datasources/__snapshots__/DataSourceDashboards.test.tsx.snap
  92. 38 2
      public/app/features/datasources/state/actions.ts
  93. 109 0
      public/app/features/datasources/state/navModel.ts
  94. 8 0
      public/app/features/datasources/state/reducers.ts
  95. 9 0
      public/app/features/datasources/state/selectors.ts
  96. 3 1
      public/app/features/explore/Explore.tsx
  97. 33 3
      public/app/features/explore/PromQueryField.test.tsx
  98. 6 6
      public/app/features/explore/PromQueryField.tsx
  99. 7 1
      public/app/features/explore/QueryField.tsx
  100. 34 63
      public/app/features/explore/Table.tsx

+ 86 - 4
.circleci/config.yml

@@ -238,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'
@@ -254,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
@@ -267,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
@@ -313,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
@@ -356,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:
@@ -385,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

+ 15 - 3
CHANGELOG.md

@@ -2,25 +2,37 @@
 
 ### New Features
 
+* **Alerting**: Option to disable OK alert notifications [#12330](https://github.com/grafana/grafana/issues/12330) & [#6696](https://github.com/grafana/grafana/issues/6696), thx [@davewat](https://github.com/davewat)
 * **Postgres/MySQL/MSSQL**: Adds support for configuration of max open/idle connections and connection max lifetime. Also, panels with multiple SQL queries will now be executed concurrently [#11711](https://github.com/grafana/grafana/issues/11711), thx [@connection-reset](https://github.com/connection-reset)
+* **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
+* **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
 
 ### Minor
 
-* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
+* **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
 * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
+* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
 
 ### Breaking changes
 
 * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
 
-# 5.3.1 (unreleased)
+# 5.3.2 (unreleased)
+
+* **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)
+* **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

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

+ 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

+ 1 - 0
docs/sources/administration/provisioning.md

@@ -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

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

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

+ 2 - 2
latest.json

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

+ 1 - 0
package.json

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

+ 4 - 4
pkg/api/api.go

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

+ 18 - 1
pkg/api/dataproxy.go

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

+ 7 - 7
pkg/api/datasources.go

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

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

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

+ 21 - 4
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 {
@@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
 			return nil, err
 		}
 
-		orgDataSources = query.Result
+		dsFilterQuery := m.DatasourcesPermissionFilterQuery{
+			User:        c.SignedInUser,
+			Datasources: query.Result,
+		}
+
+		if err := bus.Dispatch(&dsFilterQuery); err != nil {
+			if err != bus.ErrHandlerNotFound {
+				return nil, err
+			}
+
+			orgDataSources = query.Result
+		} else {
+			orgDataSources = dsFilterQuery.Result
+		}
 	}
 
 	datasources := make(map[string]interface{})
@@ -120,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,
@@ -183,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

+ 1 - 1
pkg/api/index.go

@@ -18,7 +18,7 @@ const (
 )
 
 func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
-	settings, err := getFrontendSettingsMap(c)
+	settings, err := hs.getFrontendSettingsMap(c)
 	if err != nil {
 		return nil, err
 	}

+ 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

+ 29 - 2
pkg/models/datasource.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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', () => {

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

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

+ 17 - 7
public/app/features/dashboard/specs/AddPanelPanel.test.tsx

@@ -14,7 +14,7 @@ jest.mock('app/core/store', () => ({
 }));
 
 describe('AddPanelPanel', () => {
-  let wrapper, dashboardMock, getPanelContainer, panel;
+  let wrapper, dashboardMock, panel;
 
   beforeEach(() => {
     config.panels = [
@@ -23,6 +23,9 @@ describe('AddPanelPanel', () => {
         hideFromList: false,
         name: 'Singlestat',
         sort: 2,
+        module: '',
+        baseUrl: '',
+        meta: {},
         info: {
           logos: {
             small: '',
@@ -34,6 +37,9 @@ describe('AddPanelPanel', () => {
         hideFromList: true,
         name: 'Hidden',
         sort: 100,
+        meta: {},
+        module: '',
+        baseUrl: '',
         info: {
           logos: {
             small: '',
@@ -45,6 +51,9 @@ describe('AddPanelPanel', () => {
         hideFromList: false,
         name: 'Graph',
         sort: 1,
+        meta: {},
+        module: '',
+        baseUrl: '',
         info: {
           logos: {
             small: '',
@@ -56,6 +65,9 @@ describe('AddPanelPanel', () => {
         hideFromList: false,
         name: 'Zabbix',
         sort: 100,
+        meta: {},
+        module: '',
+        baseUrl: '',
         info: {
           logos: {
             small: '',
@@ -67,6 +79,9 @@ describe('AddPanelPanel', () => {
         hideFromList: false,
         name: 'Piechart',
         sort: 100,
+        meta: {},
+        module: '',
+        baseUrl: '',
         info: {
           logos: {
             small: '',
@@ -77,13 +92,8 @@ describe('AddPanelPanel', () => {
 
     dashboardMock = { toggleRow: jest.fn() };
 
-    getPanelContainer = jest.fn().mockReturnValue({
-      getDashboard: jest.fn().mockReturnValue(dashboardMock),
-      getPanelLoader: jest.fn(),
-    });
-
     panel = new PanelModel({ collapsed: false });
-    wrapper = shallow(<AddPanelPanel panel={panel} getPanelContainer={getPanelContainer} />);
+    wrapper = shallow(<AddPanelPanel panel={panel} dashboard={dashboardMock} />);
   });
 
   it('should fetch all panels sorted with core plugins first', () => {

+ 4 - 9
public/app/features/dashboard/specs/DashboardRow.test.tsx

@@ -4,7 +4,7 @@ import { DashboardRow } from '../dashgrid/DashboardRow';
 import { PanelModel } from '../panel_model';
 
 describe('DashboardRow', () => {
-  let wrapper, panel, getPanelContainer, dashboardMock;
+  let wrapper, panel, dashboardMock;
 
   beforeEach(() => {
     dashboardMock = {
@@ -14,13 +14,8 @@ describe('DashboardRow', () => {
       },
     };
 
-    getPanelContainer = jest.fn().mockReturnValue({
-      getDashboard: jest.fn().mockReturnValue(dashboardMock),
-      getPanelLoader: jest.fn(),
-    });
-
     panel = new PanelModel({ collapsed: false });
-    wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
+    wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
   });
 
   it('Should not have collapsed class when collaped is false', () => {
@@ -41,14 +36,14 @@ describe('DashboardRow', () => {
 
   it('should not show row drag handle when cannot edit', () => {
     dashboardMock.meta.canEdit = false;
-    wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
+    wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
     expect(wrapper.find('.dashboard-row__drag')).toHaveLength(0);
   });
 
   it('should have zero actions when cannot edit', () => {
     dashboardMock.meta.canEdit = false;
     panel = new PanelModel({ collapsed: false });
-    wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
+    wrapper = shallow(<DashboardRow panel={panel} dashboard={dashboardMock} />);
     expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
   });
 });

+ 1 - 1
public/app/features/dashboard/specs/exporter.test.ts

@@ -240,5 +240,5 @@ stubs['-- Grafana --'] = {
 };
 
 function getStub(arg) {
-  return Promise.resolve(stubs[arg]);
+  return Promise.resolve(stubs[arg || 'gfdb']);
 }

+ 7 - 7
public/app/features/dashboard/specs/viewstate_srv.test.ts

@@ -2,6 +2,7 @@
 import 'app/features/dashboard/view_state_srv';
 import config from 'app/core/config';
 import { DashboardViewState } from '../view_state_srv';
+import { DashboardModel } from '../dashboard_model';
 
 describe('when updating view state', () => {
   const location = {
@@ -10,14 +11,13 @@ describe('when updating view state', () => {
   };
 
   const $scope = {
+    appEvent: jest.fn(),
     onAppEvent: jest.fn(() => {}),
-    dashboard: {
-      meta: {},
-      panels: [],
-    },
+    dashboard: new DashboardModel({
+      panels: [{ id: 1 }],
+    }),
   };
 
-  const $rootScope = {};
   let viewState;
 
   beforeEach(() => {
@@ -33,7 +33,7 @@ describe('when updating view state', () => {
       location.search = jest.fn(() => {
         return { fullscreen: true, edit: true, panelId: 1 };
       });
-      viewState = new DashboardViewState($scope, location, {}, $rootScope);
+      viewState = new DashboardViewState($scope, location, {});
     });
 
     it('should update querystring and view state', () => {
@@ -55,7 +55,7 @@ describe('when updating view state', () => {
 
   describe('to fullscreen false', () => {
     beforeEach(() => {
-      viewState = new DashboardViewState($scope, location, {}, $rootScope);
+      viewState = new DashboardViewState($scope, location, {});
     });
     it('should remove params from query string', () => {
       viewState.update({ fullscreen: true, panelId: 1, edit: true });

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

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

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

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

+ 21 - 12
public/app/features/dashboard/time_srv.ts

@@ -1,8 +1,14 @@
+// Libraries
 import moment from 'moment';
 import _ from 'lodash';
-import coreModule from 'app/core/core_module';
+
+// Utils
 import kbn from 'app/core/utils/kbn';
+import coreModule from 'app/core/core_module';
 import * as dateMath from 'app/core/utils/datemath';
+// Types
+
+import { TimeRange } from 'app/types';
 
 export class TimeSrv {
   time: any;
@@ -24,7 +30,6 @@ export class TimeSrv {
     document.addEventListener('visibilitychange', () => {
       if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
         this.autoRefreshBlocked = false;
-
         this.refreshDashboard();
       }
     });
@@ -142,7 +147,7 @@ export class TimeSrv {
   }
 
   refreshDashboard() {
-    this.$rootScope.$broadcast('refresh');
+    this.dashboard.timeRangeUpdated();
   }
 
   private startNextRefreshTimer(afterMs) {
@@ -201,7 +206,7 @@ export class TimeSrv {
     return range;
   }
 
-  timeRange() {
+  timeRange(): TimeRange {
     // make copies if they are moment  (do not want to return out internal moment, because they are mutable!)
     const raw = {
       from: moment.isMoment(this.time.from) ? moment(this.time.from) : this.time.from,
@@ -223,17 +228,21 @@ export class TimeSrv {
     const timespan = range.to.valueOf() - range.from.valueOf();
     const center = range.to.valueOf() - timespan / 2;
 
-    let to = center + timespan * factor / 2;
-    let from = center - timespan * factor / 2;
-
-    if (to > Date.now() && range.to <= Date.now()) {
-      const offset = to - Date.now();
-      from = from - offset;
-      to = Date.now();
-    }
+    const to = center + timespan * factor / 2;
+    const from = center - timespan * factor / 2;
 
     this.setTime({ from: moment.utc(from), to: moment.utc(to) });
   }
 }
 
+let singleton;
+
+export function setTimeSrv(srv: TimeSrv) {
+  singleton = srv;
+}
+
+export function getTimeSrv(): TimeSrv {
+  return singleton;
+}
+
 coreModule.service('timeSrv', TimeSrv);

+ 1 - 1
public/app/features/dashboard/timepicker/settings.html

@@ -5,7 +5,7 @@
 		<div class="gf-form">
 			<label class="gf-form-label width-10">Timezone</label>
 			<div class="gf-form-select-wrapper">
-				<select ng-model="ctrl.dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]" ng-change="timezoneChanged()"></select>
+				<select ng-model="ctrl.dashboard.timezone" class='gf-form-input' ng-options="f.value as f.text for f in [{value: '', text: 'Default'}, {value: 'browser', text: 'Local browser time'},{value: 'utc', text: 'UTC'}]"></select>
 			</div>
 		</div>
 

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

@@ -31,9 +31,10 @@ export class TimePickerCtrl {
 
     $rootScope.onAppEvent('shift-time-forward', () => this.move(1), $scope);
     $rootScope.onAppEvent('shift-time-backward', () => this.move(-1), $scope);
-    $rootScope.onAppEvent('refresh', this.onRefresh.bind(this), $scope);
     $rootScope.onAppEvent('closeTimepicker', this.openDropdown.bind(this), $scope);
 
+    this.dashboard.on('refresh', this.onRefresh.bind(this), $scope);
+
     // init options
     this.panel = this.dashboard.timepicker;
     _.defaults(this.panel, TimePickerCtrl.defaults);

+ 27 - 85
public/app/features/dashboard/view_state_srv.ts

@@ -1,6 +1,7 @@
 import angular from 'angular';
 import _ from 'lodash';
 import config from 'app/core/config';
+import appEvents from 'app/core/app_events';
 import { DashboardModel } from './dashboard_model';
 
 // represents the transient view state
@@ -10,12 +11,11 @@ export class DashboardViewState {
   panelScopes: any;
   $scope: any;
   dashboard: DashboardModel;
-  editStateChanged: any;
   fullscreenPanel: any;
   oldTimeRange: any;
 
   /** @ngInject */
-  constructor($scope, private $location, private $timeout, private $rootScope) {
+  constructor($scope, private $location, private $timeout) {
     const self = this;
     self.state = {};
     self.panelScopes = [];
@@ -33,10 +33,6 @@ export class DashboardViewState {
       self.update(payload);
     });
 
-    $scope.onAppEvent('panel-initialized', (evt, payload) => {
-      self.registerPanel(payload.scope);
-    });
-
     // this marks changes to location during this digest cycle as not to add history item
     // don't want url changes like adding orgId to add browser history
     $location.replace();
@@ -75,9 +71,6 @@ export class DashboardViewState {
       }
     }
 
-    // remember if editStateChanged
-    this.editStateChanged = (state.edit || false) !== (this.state.edit || false);
-
     _.extend(this.state, state);
     this.dashboard.meta.fullscreen = this.state.fullscreen;
 
@@ -124,110 +117,59 @@ export class DashboardViewState {
   }
 
   syncState() {
-    if (this.panelScopes.length === 0) {
-      return;
-    }
-
     if (this.dashboard.meta.fullscreen) {
-      const panelScope = this.getPanelScope(this.state.panelId);
-      if (!panelScope) {
-        return;
-      }
+      const panel = this.dashboard.getPanelById(this.state.panelId);
 
-      if (this.fullscreenPanel) {
-        // if already fullscreen
-        if (this.fullscreenPanel === panelScope && this.editStateChanged === false) {
-          return;
-        } else {
-          this.leaveFullscreen(false);
-        }
-      }
-
-      if (!panelScope.ctrl.editModeInitiated) {
-        panelScope.ctrl.initEditMode();
+      if (!panel) {
+        return;
       }
 
-      if (!panelScope.ctrl.fullscreen) {
-        this.enterFullscreen(panelScope);
+      if (!panel.fullscreen) {
+        this.enterFullscreen(panel);
+      } else {
+        // already in fullscreen view just update the view mode
+        this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
       }
     } else if (this.fullscreenPanel) {
-      this.leaveFullscreen(true);
+      this.leaveFullscreen();
     }
   }
 
-  getPanelScope(id) {
-    return _.find(this.panelScopes, panelScope => {
-      return panelScope.ctrl.panel.id === id;
-    });
-  }
-
-  leaveFullscreen(render) {
-    const self = this;
-    const ctrl = self.fullscreenPanel.ctrl;
-
-    ctrl.editMode = false;
-    ctrl.fullscreen = false;
+  leaveFullscreen() {
+    const panel = this.fullscreenPanel;
 
-    this.dashboard.setViewMode(ctrl.panel, false, false);
-    this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id });
-    this.$scope.appEvent('dash-scroll', { restore: true });
+    this.dashboard.setViewMode(panel, false, false);
 
-    if (!render) {
-      return false;
-    }
+    delete this.fullscreenPanel;
 
     this.$timeout(() => {
-      if (self.oldTimeRange !== ctrl.range) {
-        self.$rootScope.$broadcast('refresh');
+      appEvents.emit('dash-scroll', { restore: true });
+
+      if (this.oldTimeRange !== this.dashboard.time) {
+        this.dashboard.startRefresh();
       } else {
-        self.$rootScope.$broadcast('render');
+        this.dashboard.render();
       }
-      delete self.fullscreenPanel;
     });
-    return true;
   }
 
-  enterFullscreen(panelScope) {
-    const ctrl = panelScope.ctrl;
-
-    ctrl.editMode = this.state.edit && this.dashboard.meta.canEdit;
-    ctrl.fullscreen = true;
+  enterFullscreen(panel) {
+    const isEditing = this.state.edit && this.dashboard.meta.canEdit;
 
-    this.oldTimeRange = ctrl.range;
-    this.fullscreenPanel = panelScope;
+    this.oldTimeRange = this.dashboard.time;
+    this.fullscreenPanel = panel;
 
     // Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
     this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
-    this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode);
-    this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
-  }
-
-  registerPanel(panelScope) {
-    const self = this;
-    self.panelScopes.push(panelScope);
-
-    if (!self.dashboard.meta.soloMode) {
-      if (self.state.panelId === panelScope.ctrl.panel.id) {
-        if (self.state.edit) {
-          panelScope.ctrl.editPanel();
-        } else {
-          panelScope.ctrl.viewPanel();
-        }
-      }
-    }
-
-    const unbind = panelScope.$on('$destroy', () => {
-      self.panelScopes = _.without(self.panelScopes, panelScope);
-      unbind();
-    });
+    this.dashboard.setViewMode(panel, true, isEditing);
   }
 }
 
 /** @ngInject */
-export function dashboardViewStateSrv($location, $timeout, $rootScope) {
+export function dashboardViewStateSrv($location, $timeout) {
   return {
     create: $scope => {
-      return new DashboardViewState($scope, $location, $timeout, $rootScope);
+      return new DashboardViewState($scope, $location, $timeout);
     },
   };
 }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -111,7 +111,7 @@ export function willApplySuggestion(
 
     case 'context-label-values': {
       // Always add quotes and remove existing ones instead
-      if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
+      if (!typeaheadText.match(/^(!?=~?"|")/)) {
         suggestion = `"${suggestion}`;
       }
       if (getNextCharacter() !== '"') {
@@ -421,7 +421,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     const containsMetric = selector.indexOf('__name__=') > -1;
     const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
-    if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
+    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
       // Label values
       if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
         const labelValues = this.state.labelValues[selector][labelKey];
@@ -571,10 +571,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
               <button className="btn navbar-button navbar-button--tight">Log labels</button>
             </Cascader>
           ) : (
-              <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
-                <button className="btn navbar-button navbar-button--tight">Metrics</button>
-              </Cascader>
-            )}
+            <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
+              <button className="btn navbar-button navbar-button--tight">Metrics</button>
+            </Cascader>
+          )}
         </div>
         <div className="prom-query-field-wrapper">
           <div className="slate-query-field-wrapper">

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

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

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

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

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません