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

Merge branch 'master' into panel-edit-ux

Torkel Ödegaard 7 лет назад
Родитель
Сommit
964a21b091
47 измененных файлов с 1328 добавлено и 417 удалено
  1. 1 0
      conf/defaults.ini
  2. 1 0
      conf/sample.ini
  3. 0 116
      docs/sources/administration/permissions.md
  4. 1 1
      docs/sources/administration/provisioning.md
  5. 43 0
      docs/sources/auth/enhanced_ldap.md
  6. 67 0
      docs/sources/enterprise/index.md
  7. 1 1
      docs/sources/features/datasources/elasticsearch.md
  8. 249 0
      docs/sources/http_api/datasource_permissions.md
  9. 111 0
      docs/sources/http_api/external_group_sync.md
  10. 73 0
      docs/sources/permissions/dashboard_folder_permissions.md
  11. 71 0
      docs/sources/permissions/datasource_permissions.md
  12. 12 0
      docs/sources/permissions/index.md
  13. 38 0
      docs/sources/permissions/organization_roles.md
  14. 42 0
      docs/sources/permissions/overview.md
  15. 1 1
      docs/sources/reference/scripting.md
  16. 1 1
      docs/sources/whatsnew/index.md
  17. 18 7
      pkg/tsdb/elasticsearch/client/search_request.go
  18. 54 0
      pkg/tsdb/elasticsearch/time_series_query_test.go
  19. 4 4
      public/app/core/controllers/json_editor_ctrl.ts
  20. 3 1
      public/app/core/reducers/location.ts
  21. 1 1
      public/app/core/services/bridge_srv.ts
  22. 8 34
      public/app/features/dashboard/dashboard_ctrl.ts
  23. 0 5
      public/app/features/dashboard/dashboard_model.ts
  24. 6 2
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  25. 6 6
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  26. 10 8
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  27. 0 83
      public/app/features/dashboard/dashgrid/PanelHeader.tsx
  28. 51 0
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  29. 40 0
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
  30. 23 0
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
  31. 9 13
      public/app/features/dashboard/export/export_modal.html
  32. 34 13
      public/app/features/dashboard/export/export_modal.ts
  33. 22 11
      public/app/features/dashboard/export/exporter.ts
  34. 2 0
      public/app/features/dashboard/shareModalCtrl.ts
  35. 12 2
      public/app/features/dashboard/specs/exporter.test.ts
  36. 120 0
      public/app/features/dashboard/utils/getPanelMenu.ts
  37. 86 0
      public/app/features/dashboard/utils/panel.ts
  38. 12 41
      public/app/features/panel/panel_ctrl.ts
  39. 1 3
      public/app/features/panel/viz_tab.ts
  40. 6 1
      public/app/plugins/datasource/elasticsearch/config_ctrl.ts
  41. 12 1
      public/app/plugins/datasource/elasticsearch/query_builder.ts
  42. 62 0
      public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts
  43. 9 0
      public/app/types/panel.ts
  44. 5 0
      public/sass/components/_dropdown.scss
  45. 0 1
      public/sass/pages/_dashboard.scss
  46. 0 43
      scripts/build/Dockerfile
  47. 0 17
      scripts/build/build_container.sh

+ 1 - 0
conf/defaults.ini

@@ -344,6 +344,7 @@ header_property = username
 auto_sign_up = true
 ldap_sync_ttl = 60
 whitelist =
+headers =
 
 #################################### Auth LDAP ###########################
 [auth.ldap]

+ 1 - 0
conf/sample.ini

@@ -294,6 +294,7 @@ log_queries =
 ;auto_sign_up = true
 ;ldap_sync_ttl = 60
 ;whitelist = 192.168.1.1, 192.168.2.1
+;headers = Email:X-User-Email, Name:X-User-Name
 
 #################################### Basic Auth ##########################
 [auth.basic]

+ 0 - 116
docs/sources/administration/permissions.md

@@ -1,116 +0,0 @@
-+++
-title = "Permissions"
-description = "Grafana user permissions"
-keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"]
-type = "docs"
-aliases = ["/reference/admin"]
-[menu.docs]
-name = "Permissions"
-parent = "admin"
-weight = 3
-+++
-
-# Permissions
-
-Grafana users have permissions that are determined by their:
-
-- **Organization Role** (Admin, Editor, Viewer)
-- Via **Team** memberships where the **Team** has been assigned specific permissions.
-- Via permissions assigned directly to user (on folders or dashboards)
-- The Grafana Admin (i.e. Super Admin) user flag.
-
-## Organization Roles
-
-Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do
-in that organization.
-
-### Admin Role
-
-Can do everything scoped to the organization. For example:
-
-- Add & Edit data sources.
-- Add & Edit organization users & teams.
-- Configure App plugins & set org settings.
-
-### Editor Role
-
-- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
-- **Cannot** create or edit data sources nor invite new users.
-
-### Viewer Role
-
-- View any dashboard. This can be disabled on specific folders and dashboards.
-- **Cannot** create or edit dashboards nor data sources.
-
-This role can be tweaked via Grafana server setting [viewers_can_edit]({{< relref "installation/configuration.md#viewers-can-edit" >}}). If you set this to true users
-with **Viewer** can also make transient dashboard edits, meaning they can modify panels & queries but not save the changes (nor create new dashboards).
-Useful for public Grafana installations where you want anonymous users to be able to edit panels & queries but not save or create new dashboards.
-
-## Grafana Admin
-
-This admin flag makes a user a `Super Admin`. This means they can access the `Server Admin` views where all users and organizations can be administrated.
-
-### Dashboard & Folder Permissions
-
-{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
-
-For dashboards and dashboard folders there is a **Permissions** page that make it possible to
-remove the default role based permissions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
-
-You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
-
-Permission levels:
-
-- **Admin**: Can edit & create dashboards and edit permissions.
-- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
-- **View**: Can only view existing dashboards/folders.
-
-#### Restricting Access
-
-The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL).
-
-- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything.
-- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule.
-
-#### How Grafana Resolves Multiple Permissions - Examples
-
-##### Example 1 (`user1` has the Editor Role)
-
-Permissions for a dashboard:
-
-- `Everyone with Editor Role Can Edit`
-- `user1 Can View`
-
-Result: `user1` has Edit permission as the highest permission always wins.
-
-##### Example 2 (`user1` has the Viewer Role and is a member of `team1`)
-
-Permissions for a dashboard:
-
-- `Everyone with Viewer Role Can View`
-- `user1 Can Edit`
-- `team1 Can Admin`
-
-Result: `user1` has Admin permission as the highest permission always wins.
-
-##### Example 3
-
-Permissions for a dashboard:
-
-- `user1 Can Admin (inherited from parent folder)`
-- `user1 Can Edit`
-
-Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
-
-- **View**: Can only view existing dashboards/folders.
-- You cannot override permissions for users with **Org Admin Role**
-- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
-
-### Data source permissions
-
-Permissions on dashboards and folders **do not** include permissions on data sources. A user with `Viewer` role
-can still issue any possible query to a data source, not just those queries that exist on dashboards he/she has access to.
-We hope to add permissions on data sources in a future release. Until then **do not** view dashboard permissions as a secure
-way to restrict user data access. Dashboard permissions only limits what dashboards & folders a user can view & edit not which
-data sources a user can access nor what queries a user can issue.
-

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

@@ -156,7 +156,7 @@ Since not all datasources have the same configuration settings we only have the
 | tlsSkipVerify | boolean | *All* | Controls whether a client verifies the server's certificate chain and host name. |
 | graphiteVersion | string | Graphite |  Graphite version  |
 | timeInterval | string | Prometheus, Elasticsearch, InfluxDB, MySQL, PostgreSQL & MSSQL | Lowest interval/step value that should be used for this data source |
-| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56) |
+| esVersion | number | Elasticsearch | Elasticsearch version as a number (2/5/56/60) |
 | timeField | string | Elasticsearch | Which field that should be used as timestamp |
 | interval | string | Elasticsearch | Index date time format. nil(No Pattern), 'Hourly', 'Daily', 'Weekly', 'Monthly' or 'Yearly' |
 | authType | string | Cloudwatch | Auth provider. keys/credentials/arn |

+ 43 - 0
docs/sources/auth/enhanced_ldap.md

@@ -0,0 +1,43 @@
++++
+title = "Enhanced LDAP Integration"
+description = "Grafana Enhanced LDAP Integration Guide "
+keywords = ["grafana", "configuration", "documentation", "ldap", "active directory", "enterprise"]
+type = "docs"
+[menu.docs]
+name = "Enhanced LDAP"
+identifier = "enhanced-ldap"
+parent = "authentication"
+weight = 3
++++
+
+# Enhanced LDAP Integration
+
+> Enhanced LDAP Integration is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "enterprise/index.md" >}}).
+
+The enhanced LDAP integration adds additional functionality on top of the [existing LDAP integration]({{< relref "auth/ldap.md" >}}).
+
+## LDAP Group Synchronization for Teams
+
+{{< docs-imagebox img="/img/docs/enterprise/team_members_ldap.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" >}}
+
+With the enhanced LDAP integration it's possible to setup synchronization between LDAP groups and teams. This enables LDAP users which are members
+of certain LDAP groups to automatically be added/removed as members to certain teams in Grafana. Currently the synchronization will only happen every
+time a user logs in, but an active background synchronization is currently being developed.
+
+Grafana keeps track of all synchronized users in teams and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot.
+This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also enables you to manually add
+a user as member of a team and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships.
+
+<div class="clearfix"></div>
+
+### Enable LDAP group synchronization for a team
+
+{{< docs-imagebox img="/img/docs/enterprise/team_add_external_group.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" >}}
+
+1. Navigate to Configuration / Teams.
+2. Select a team.
+3. Select the External group sync tab and click on the `Add group` button.
+4. Insert LDAP distinguished name (DN) of LDAP group you want to synchronize with the team.
+5. Click on `Add group` button to save.
+
+<div class="clearfix"></div>

+ 67 - 0
docs/sources/enterprise/index.md

@@ -0,0 +1,67 @@
++++
+title = "Grafana Enterprise"
+description = "Grafana Enterprise overview"
+keywords = ["grafana", "documentation", "datasource", "permissions", "ldap", "licensing", "enterprise"]
+type = "docs"
+[menu.docs]
+name = "Grafana Enterprise"
+identifier = "enterprise"
+weight = 30
++++
+
+# Grafana Enterprise
+
+Grafana Enterprise is a commercial edition of Grafana that includes additional features not found in the open source
+version.
+
+Building on everything you already know and love about Grafana, Grafana Enterprise adds premium data sources,
+advanced authentication options, more permission controls, 24x7x365 support, and training from the core Grafana team.
+
+Grafana Enterprise includes all of the features found in the open source edition and more.
+
+___
+
+### Enhanced LDAP Integration
+
+With Grafana Enterprise you can set up synchronization between LDAP Groups and Teams. [Learn More]({{< relref "auth/enhanced_ldap.md" >}}).
+
+### Datasource Permissions
+
+Datasource permissions allow you to restrict query access to only specific Teams and Users. [Learn More]({{< relref "permissions/datasource_permissions.md" >}}).
+
+### Premium Plugins
+
+With a Grafana Enterprise licence you will get access to premium plugins, including:
+
+* [Splunk](https://grafana.com/plugins/grafana-splunk-datasource)
+* [AppDynamics](https://grafana.com/plugins/dlopes7-appdynamics-datasource)
+* [DataDog](https://grafana.com/plugins/grafana-datadog-datasource)
+* [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
+* [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource)
+
+## Try Grafana Enterprise
+
+You can learn more about Grafana Enterprise [here](https://grafana.com/enterprise). To purchase or obtain a trial license contact
+the Grafana Labs [Sales Team](https://grafana.com/contact?about=support&topic=Grafana%20Enterprise).
+
+## License file management
+
+To download your Grafana Enterprise license log in to your [Grafana.com](https://grafana.com) account and go to your **Org
+Profile**. In the side menu there is a section for Grafana Enterprise licenses. At the bottom of the license
+details page there is **Download Token** link that will download the *license.jwt* file containing your license.
+
+Place the *license.jwt* file in Grafana's data folder. This is usually located at `/var/lib/grafana/data` on linux systems.
+
+You can also configure a custom location for the license file via the ini setting:
+
+```bash
+[enterprise]
+license_path = /company/secrets/license.jwt
+```
+
+This setting can also be set via ENV variable which is useful if you're running Grafana via docker and have a custom
+volume where you have placed the license file. In this case set the ENV variable `GF_ENTERPRISE_LICENSE_PATH` to point
+to the location of your license file.
+
+
+

+ 1 - 1
docs/sources/features/datasources/elasticsearch.md

@@ -59,7 +59,7 @@ a time pattern for the index name or a wildcard.
 ### Elasticsearch version
 
 Be sure to specify your Elasticsearch version in the version selection dropdown. This is very important as there are differences how queries are composed.
-Currently the versions available is 2.x, 5.x and 5.6+ where 5.6+ means a version of 5.6 or higher, 6.3.2 for example.
+Currently the versions available is 2.x, 5.x, 5.6+ or 6.0+. 5.6+ means a version of 5.6 or less than 6.0. 6.0+ means a version of 6.0 or higher, 6.3.2 for example.
 
 ### Min time interval
 A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example `1m` if your data is written every minute.

+ 249 - 0
docs/sources/http_api/datasource_permissions.md

@@ -0,0 +1,249 @@
++++
+title = "Datasource Permissions HTTP API "
+description = "Grafana Datasource Permissions HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "datasource", "permission", "permissions", "acl", "enterprise"]
+aliases = ["/http_api/datasourcepermissions/"]
+type = "docs"
+[menu.docs]
+name = "Datasource Permissions"
+parent = "http_api"
++++
+
+# Datasource Permissions API
+
+> Datasource Permissions is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "enterprise/index.md" >}}).
+
+This API can be used to enable, disable, list, add and remove permissions for a datasource.
+
+Permissions can be set for a user or a team. Permissions cannot be set for Admins - they always have access to everything.
+
+The permission levels for the permission field:
+
+- 1 = Query
+
+## Enable permissions for a datasource
+
+`POST /api/datasources/:id/enable-permissions`
+
+Enables permissions for the datasource with the given `id`. No one except Org Admins will be able to query the datasource until permissions have been added which permit certain users or teams to query the datasource.
+
+**Example request**:
+
+```http
+POST /api/datasources/1/enable-permissions
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{}
+```
+
+**Example response**:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 35
+
+{"message":"Datasource permissions enabled"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **400** - Permissions cannot be enabled, see response body for details
+- **401** - Unauthorized
+- **403** - Access denied
+- **404** - Datasource not found
+
+## Disable permissions for a datasource
+
+`POST /api/datasources/:id/disable-permissions`
+
+Disables permissions for the datasource with the given `id`. All existing permissions will be removed and anyone will be able to query the datasource.
+
+**Example request**:
+
+```http
+POST /api/datasources/1/disable-permissions
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{}
+```
+
+**Example response**:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 35
+
+{"message":"Datasource permissions disabled"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **400** - Permissions cannot be disabled, see response body for details
+- **401** - Unauthorized
+- **403** - Access denied
+- **404** - Datasource not found
+
+## Get permissions for a datasource
+
+`GET /api/datasources/:id/permissions`
+
+Gets all existing permissions for the datasource with the given `id`.
+
+**Example request**:
+
+```http
+GET /api/datasources/1/permissions HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 551
+
+{
+  "datasourceId": 1,
+  "enabled": true,
+  "permissions":
+  [
+    {
+      "id": 1,
+      "datasourceId": 1,
+      "userId": 1,
+      "userLogin": "user",
+      "userEmail": "user@test.com",
+      "userAvatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56",
+      "permission": 1,
+      "permissionName": "Query",
+      "created": "2017-06-20T02:00:00+02:00",
+      "updated": "2017-06-20T02:00:00+02:00",
+    },
+    {
+      "id": 2,
+      "datasourceId": 1,
+      "teamId": 1,
+      "team": "A Team",
+      "teamAvatarUrl": "/avatar/46d229b033af06a191ff2267bca9ae56",
+      "permission": 1,
+      "permissionName": "Query",
+      "created": "2017-06-20T02:00:00+02:00",
+      "updated": "2017-06-20T02:00:00+02:00",
+    }
+  ]
+}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **401** - Unauthorized
+- **403** - Access denied
+- **404** - Datasource not found
+
+## Add permission for a datasource
+
+`POST /api/datasources/:id/permissions`
+
+Adds a user permission for the datasource with the given `id`.
+
+**Example request**:
+
+```http
+POST /api/datasources/1/permissions
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "userId": 1,
+  "permission": 1
+}
+```
+
+**Example response**:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 35
+
+{"message":"Datasource permission added"}
+```
+
+Adds a team permission for the datasource with the given `id`.
+
+**Example request**:
+
+```http
+POST /api/datasources/1/permissions
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "teamId": 1,
+  "permission": 1
+}
+```
+
+**Example response**:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 35
+
+{"message":"Datasource permission added"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **400** - Permission cannot be added, see response body for details
+- **401** - Unauthorized
+- **403** - Access denied
+- **404** - Datasource not found
+
+## Remove permission for a datasource
+
+`DELETE /api/datasources/:id/permissions/:permissionId`
+
+Removes the permission with the given `permissionId` for the datasource with the given `id`.
+
+**Example request**:
+
+```http
+DELETE /api/datasources/1/permissions/2
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example response**:
+
+```http
+HTTP/1.1 200 OK
+Content-Type: application/json; charset=UTF-8
+Content-Length: 35
+
+{"message":"Datasource permission removed"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **401** - Unauthorized
+- **403** - Access denied
+- **404** - Datasource not found or permission not found

+ 111 - 0
docs/sources/http_api/external_group_sync.md

@@ -0,0 +1,111 @@
++++
+title = "External Group Sync HTTP API "
+description = "Grafana External Group Sync HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "team", "teams", "group", "member", "enterprise"]
+aliases = ["/http_api/external_group_sync/"]
+type = "docs"
+[menu.docs]
+name = "External Group Sync"
+parent = "http_api"
++++
+
+# External Group Synchronization API
+
+> External Group Synchronization is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "enterprise/index.md" >}}).
+
+## Get External Groups
+
+`GET /api/teams/:teamId/groups`
+
+**Example Request**:
+
+```http
+GET /api/teams/1/groups HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+  {
+    "orgId": 1,
+    "teamId": 1,
+    "groupId": "cn=editors,ou=groups,dc=grafana,dc=org"
+  }
+]
+```
+
+Status Codes:
+
+- **200** - Ok
+- **401** - Unauthorized
+- **403** - Permission denied
+
+## Add External Group
+
+`POST /api/teams/:teamId/groups`
+
+**Example Request**:
+
+```http
+POST /api/teams/1/members HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+
+{
+  "groupId": "cn=editors,ou=groups,dc=grafana,dc=org"
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{"message":"Group added to Team"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **400** - Group is already added to this team
+- **401** - Unauthorized
+- **403** - Permission denied
+- **404** - Team not found
+
+## Remove External Group
+
+`DELETE /api/teams/:teamId/groups/:groupId`
+
+**Example Request**:
+
+```http
+DELETE /api/teams/1/groups/cn=editors,ou=groups,dc=grafana,dc=org HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{"message":"Team Group removed"}
+```
+
+Status Codes:
+
+- **200** - Ok
+- **401** - Unauthorized
+- **403** - Permission denied
+- **404** - Team not found/Group not found

+ 73 - 0
docs/sources/permissions/dashboard_folder_permissions.md

@@ -0,0 +1,73 @@
++++
+title = "Dashboard & Folder Permissions"
+description = "Grafana Dashboard & Folder Permissions Guide "
+keywords = ["grafana", "configuration", "documentation", "dashboard", "folder", "permissions", "teams"]
+type = "docs"
+[menu.docs]
+name = "Dashboard & Folder"
+identifier = "dashboard-folder-permissions"
+parent = "permissions"
+weight = 3
++++
+
+# Dashboard & Folder Permissions
+
+{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
+
+For dashboards and dashboard folders there is a **Permissions** page that make it possible to
+remove the default role based permissions for Editors and Viewers. On this page you can add and assign permissions to specific **Users** and **Teams**.
+
+You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
+
+Permission levels:
+
+- **Admin**: Can edit & create dashboards and edit permissions.
+- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
+- **View**: Can only view existing dashboards/folders.
+
+## Restricting Access
+
+The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL).
+
+- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything.
+- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule.
+
+### How Grafana Resolves Multiple Permissions - Examples
+
+#### Example 1 (`user1` has the Editor Role)
+
+Permissions for a dashboard:
+
+- `Everyone with Editor Role Can Edit`
+- `user1 Can View`
+
+Result: `user1` has Edit permission as the highest permission always wins.
+
+#### Example 2 (`user1` has the Viewer Role and is a member of `team1`)
+
+Permissions for a dashboard:
+
+- `Everyone with Viewer Role Can View`
+- `user1 Can Edit`
+- `team1 Can Admin`
+
+Result: `user1` has Admin permission as the highest permission always wins.
+
+#### Example 3
+
+Permissions for a dashboard:
+
+- `user1 Can Admin (inherited from parent folder)`
+- `user1 Can Edit`
+
+Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
+
+## Summary
+
+- **View**: Can only view existing dashboards/folders.
+- You cannot override permissions for users with **Org Admin Role**
+- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level.
+
+For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
+- You cannot override permissions for users with **Org Admin Role**
+- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.

+ 71 - 0
docs/sources/permissions/datasource_permissions.md

@@ -0,0 +1,71 @@
++++
+title = "Datasource Permissions"
+description = "Grafana Datasource Permissions Guide "
+keywords = ["grafana", "configuration", "documentation", "datasource", "permissions", "users", "teams", "enterprise"]
+type = "docs"
+[menu.docs]
+name = "Datasource"
+identifier = "datasource-permissions"
+parent = "permissions"
+weight = 4
++++
+
+# Datasource Permissions
+
+> Datasource Permissions is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "enterprise/index.md" >}}).
+
+Datasource permissions allows you to restrict access for users to query a datasource. For each datasource there is
+a permission page that makes it possible to enable permissions and restrict query permissions to specific
+**Users** and **Teams**.
+
+## Restricting Access - Enable Permissions
+
+{{< docs-imagebox img="/img/docs/enterprise/datasource_permissions_enable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/img/docs/enterprise/datasource_permissions_enable.gif" >}}
+
+By default, permissions are disabled for datasources and a datasource in an organization can be queried by any user in
+that organization. For example a user with `Viewer` role can still issue any possible query to a datasource, not just
+those queries that exist on dashboards he/she has access to.
+
+When permissions are enabled for a datasource in an organization you will restrict admin and query access for that
+datasource to [admin users](/permissions/organization_roles/#admin-role) in that organization.
+
+**To enable permissions for a datasource:**
+
+1. Navigate to Configuration / Data Sources.
+2. Select the datasource you want to enable permissions for.
+3. Select the Permissions tab and click on the `Enable` button.
+
+<div class="clearfix"></div>
+
+## Allow users and teams to query a datasource
+
+{{< docs-imagebox img="/img/docs/enterprise/datasource_permissions_add_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/img/docs/enterprise/datasource_permissions_add.gif" >}}
+
+After you have [enabled permissions](#restricting-access-enable-permissions) for a datasource you can assign query
+permissions to users and teams which will allow access to query the datasource.
+
+**Assign query permission to users and teams:**
+
+1. Navigate to Configuration / Data Sources.
+2. Select the datasource you want to assign query permissions for.
+3. Select the Permissions tab.
+4. click on the `Add Permission` button.
+5. Select Team/User and find the team/user you want to allow query access and click on the `Save` button.
+
+<div class="clearfix"></div>
+
+## Restore Default Access - Disable Permissions
+
+{{< docs-imagebox img="/img/docs/enterprise/datasource_permissions_disable_still.png" class="docs-image--no-shadow docs-image--right" max-width= "600px" animated-gif="/img/docs/enterprise/datasource_permissions_disable.gif" >}}
+
+If you have enabled permissions for a datasource and want to return datasource permissions to the default, i.e.
+datasource can be queried by any user in that organization, you can disable permissions with a click of a button.
+Note that all existing permissions created for datasource will be deleted.
+
+**To disable permissions for a datasource:**
+
+1. Navigate to Configuration / Data Sources.
+2. Select the datasource you want to disable permissions for.
+3. Select the Permissions tab and click on the `Disable Permissions` button.
+
+<div class="clearfix"></div>

+ 12 - 0
docs/sources/permissions/index.md

@@ -0,0 +1,12 @@
++++
+title = "Permissions"
+description = "Permissions"
+type = "docs"
+[menu.docs]
+name = "Permissions"
+identifier = "permissions"
+parent = "admin"
+weight = 3
++++
+
+

+ 38 - 0
docs/sources/permissions/organization_roles.md

@@ -0,0 +1,38 @@
++++
+title = "Organization Roles"
+description = "Grafana Organization Roles Guide "
+keywords = ["grafana", "configuration", "documentation", "organization", "roles", "permissions"]
+type = "docs"
+[menu.docs]
+name = "Organization Roles"
+identifier = "organization-roles"
+parent = "permissions"
+weight = 2
++++
+
+# Organization Roles
+
+Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do
+in that organization.
+
+## Admin Role
+
+Can do everything scoped to the organization. For example:
+
+- Add & Edit data sources.
+- Add & Edit organization users & teams.
+- Configure App plugins & set org settings.
+
+## Editor Role
+
+- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
+- **Cannot** create or edit data sources nor invite new users.
+
+## Viewer Role
+
+- View any dashboard. This can be disabled on specific folders and dashboards.
+- **Cannot** create or edit dashboards nor data sources.
+
+This role can be tweaked via Grafana server setting [viewers_can_edit]({{< relref "installation/configuration.md#viewers-can-edit" >}}). If you set this to true users
+with **Viewer** can also make transient dashboard edits, meaning they can modify panels & queries but not save the changes (nor create new dashboards).
+Useful for public Grafana installations where you want anonymous users to be able to edit panels & queries but not save or create new dashboards.

+ 42 - 0
docs/sources/permissions/overview.md

@@ -0,0 +1,42 @@
++++
+title = "Overview"
+description = "Overview for permissions"
+keywords = ["grafana", "configuration", "documentation", "admin", "users", "datasources", "permissions"]
+type = "docs"
+aliases = ["/reference/admin", "/administration/permissions/"]
+[menu.docs]
+name = "Overview"
+identifier = "overview-permissions"
+parent = "permissions"
+weight = 1
++++
+
+# Permissions Overview
+
+Grafana users have permissions that are determined by their:
+
+- **Organization Role** (Admin, Editor, Viewer)
+- Via **Team** memberships where the **Team** has been assigned specific permissions.
+- Via permissions assigned directly to user (on folders, dashboards, datasources)
+- The Grafana Admin (i.e. Super Admin) user flag.
+
+## Grafana Admin
+
+This admin flag makes a user a `Super Admin`. This means they can access the `Server Admin` views where all users and organizations can be administrated.
+
+## Organization Roles
+
+Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do
+in that organization. Learn more about [Organization Roles]({{< relref "permissions/organization_roles.md" >}}).
+
+
+## Dashboard & Folder Permissions
+
+Dashboard and folder permissions allows you to remove the default role based permissions for Editors and Viewers and assign permissions to specific **Users** and **Teams**. Learn more about [Dashboard & Folder Permissions]({{< relref "permissions/dashboard_folder_permissions.md" >}}).
+
+## Datasource Permissions
+
+Per default, a datasource in an organization can be queried by any user in that organization. For example a user with `Viewer` role can still
+issue any possible query to a data source, not just those queries that exist on dashboards he/she has access to.
+
+Datasource permissions allows you to change the default permissions for datasources and restrict query permissions to specific **Users** and **Teams**. Read more about [Datasource Permissions]({{< relref "permissions/datasource_permissions.md" >}}).

+ 1 - 1
docs/sources/reference/scripting.md

@@ -12,7 +12,7 @@ weight = 9
 
 If you have lots of metric names that change (new servers etc) in a defined pattern it is irritating to constantly have to create new dashboards.
 
-With scripted dashboards you can dynamically create your dashboards using javascript. In the folder grafana install folder
+With scripted dashboards you can dynamically create your dashboards using javascript. In the grafana install folder
 under `public/dashboards/` there is a file named `scripted.js`. This file contains an example of a scripted dashboard. You can access it by using the url:
 `http://grafana_url/dashboard/script/scripted.js?rows=3&name=myName`
 

+ 1 - 1
docs/sources/whatsnew/index.md

@@ -3,7 +3,7 @@ title = "What's New in Grafana"
 [menu.docs]
 name = "What's New In Grafana"
 identifier = "whatsnew"
-weight = 3
+weight = 5
 +++
 
 

+ 18 - 7
pkg/tsdb/elasticsearch/client/search_request.go

@@ -112,7 +112,7 @@ func (b *SearchRequestBuilder) Query() *QueryBuilder {
 
 // Agg initiate and returns a new aggregation builder
 func (b *SearchRequestBuilder) Agg() AggBuilder {
-	aggBuilder := newAggBuilder()
+	aggBuilder := newAggBuilder(b.version)
 	b.aggBuilders = append(b.aggBuilders, aggBuilder)
 	return aggBuilder
 }
@@ -275,11 +275,13 @@ type AggBuilder interface {
 type aggBuilderImpl struct {
 	AggBuilder
 	aggDefs []*aggDef
+	version int
 }
 
-func newAggBuilder() *aggBuilderImpl {
+func newAggBuilder(version int) *aggBuilderImpl {
 	return &aggBuilderImpl{
 		aggDefs: make([]*aggDef, 0),
+		version: version,
 	}
 }
 
@@ -317,7 +319,7 @@ func (b *aggBuilderImpl) Histogram(key, field string, fn func(a *HistogramAgg, b
 	})
 
 	if fn != nil {
-		builder := newAggBuilder()
+		builder := newAggBuilder(b.version)
 		aggDef.builders = append(aggDef.builders, builder)
 		fn(innerAgg, builder)
 	}
@@ -337,7 +339,7 @@ func (b *aggBuilderImpl) DateHistogram(key, field string, fn func(a *DateHistogr
 	})
 
 	if fn != nil {
-		builder := newAggBuilder()
+		builder := newAggBuilder(b.version)
 		aggDef.builders = append(aggDef.builders, builder)
 		fn(innerAgg, builder)
 	}
@@ -347,6 +349,8 @@ func (b *aggBuilderImpl) DateHistogram(key, field string, fn func(a *DateHistogr
 	return b
 }
 
+const termsOrderTerm = "_term"
+
 func (b *aggBuilderImpl) Terms(key, field string, fn func(a *TermsAggregation, b AggBuilder)) AggBuilder {
 	innerAgg := &TermsAggregation{
 		Field: field,
@@ -358,11 +362,18 @@ func (b *aggBuilderImpl) Terms(key, field string, fn func(a *TermsAggregation, b
 	})
 
 	if fn != nil {
-		builder := newAggBuilder()
+		builder := newAggBuilder(b.version)
 		aggDef.builders = append(aggDef.builders, builder)
 		fn(innerAgg, builder)
 	}
 
+	if b.version >= 60 && len(innerAgg.Order) > 0 {
+		if orderBy, exists := innerAgg.Order[termsOrderTerm]; exists {
+			innerAgg.Order["_key"] = orderBy
+			delete(innerAgg.Order, termsOrderTerm)
+		}
+	}
+
 	b.aggDefs = append(b.aggDefs, aggDef)
 
 	return b
@@ -377,7 +388,7 @@ func (b *aggBuilderImpl) Filters(key string, fn func(a *FiltersAggregation, b Ag
 		Aggregation: innerAgg,
 	})
 	if fn != nil {
-		builder := newAggBuilder()
+		builder := newAggBuilder(b.version)
 		aggDef.builders = append(aggDef.builders, builder)
 		fn(innerAgg, builder)
 	}
@@ -398,7 +409,7 @@ func (b *aggBuilderImpl) GeoHashGrid(key, field string, fn func(a *GeoHashGridAg
 	})
 
 	if fn != nil {
-		builder := newAggBuilder()
+		builder := newAggBuilder(b.version)
 		aggDef.builders = append(aggDef.builders, builder)
 		fn(innerAgg, builder)
 	}

+ 54 - 0
pkg/tsdb/elasticsearch/time_series_query_test.go

@@ -127,6 +127,60 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
 			So(avgAgg.Aggregation.Type, ShouldEqual, "avg")
 		})
 
+		Convey("With term agg and order by term", func() {
+			c := newFakeClient(5)
+			_, err := executeTsdbQuery(c, `{
+				"timeField": "@timestamp",
+				"bucketAggs": [
+					{
+						"type": "terms",
+						"field": "@host",
+						"id": "2",
+						"settings": { "size": "5", "order": "asc", "orderBy": "_term"	}
+					},
+					{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
+				],
+				"metrics": [
+					{"type": "count", "id": "1" },
+					{"type": "avg", "field": "@value", "id": "5" }
+				]
+			}`, from, to, 15*time.Second)
+			So(err, ShouldBeNil)
+			sr := c.multisearchRequests[0].Requests[0]
+
+			firstLevel := sr.Aggs[0]
+			So(firstLevel.Key, ShouldEqual, "2")
+			termsAgg := firstLevel.Aggregation.Aggregation.(*es.TermsAggregation)
+			So(termsAgg.Order["_term"], ShouldEqual, "asc")
+		})
+
+		Convey("With term agg and order by term with es6.x", func() {
+			c := newFakeClient(60)
+			_, err := executeTsdbQuery(c, `{
+				"timeField": "@timestamp",
+				"bucketAggs": [
+					{
+						"type": "terms",
+						"field": "@host",
+						"id": "2",
+						"settings": { "size": "5", "order": "asc", "orderBy": "_term"	}
+					},
+					{ "type": "date_histogram", "field": "@timestamp", "id": "3" }
+				],
+				"metrics": [
+					{"type": "count", "id": "1" },
+					{"type": "avg", "field": "@value", "id": "5" }
+				]
+			}`, from, to, 15*time.Second)
+			So(err, ShouldBeNil)
+			sr := c.multisearchRequests[0].Requests[0]
+
+			firstLevel := sr.Aggs[0]
+			So(firstLevel.Key, ShouldEqual, "2")
+			termsAgg := firstLevel.Aggregation.Aggregation.(*es.TermsAggregation)
+			So(termsAgg.Order["_key"], ShouldEqual, "asc")
+		})
+
 		Convey("With metric percentiles", func() {
 			c := newFakeClient(5)
 			_, err := executeTsdbQuery(c, `{

+ 4 - 4
public/app/core/controllers/json_editor_ctrl.ts

@@ -4,13 +4,13 @@ import coreModule from '../core_module';
 export class JsonEditorCtrl {
   /** @ngInject */
   constructor($scope) {
-    $scope.json = angular.toJson($scope.object, true);
-    $scope.canUpdate = $scope.updateHandler !== void 0 && $scope.contextSrv.isEditor;
-    $scope.canCopy = $scope.enableCopy;
+    $scope.json = angular.toJson($scope.model.object, true);
+    $scope.canUpdate = $scope.model.updateHandler !== void 0 && $scope.contextSrv.isEditor;
+    $scope.canCopy = $scope.model.enableCopy;
 
     $scope.update = () => {
       const newObject = angular.fromJson($scope.json);
-      $scope.updateHandler(newObject, $scope.object);
+      $scope.model.updateHandler(newObject, $scope.model.object);
     };
 
     $scope.getContentForClipboard = () => $scope.json;

+ 3 - 1
public/app/core/reducers/location.ts

@@ -24,7 +24,9 @@ export const locationReducer = (state = initialState, action: Action): LocationS
       return {
         url: renderUrl(path || state.path, query),
         path: path || state.path,
-        query: query,
+        query: {
+          ...query,
+        },
         routeParams: routeParams || state.routeParams,
       };
     }

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

@@ -4,7 +4,7 @@ import { store } from 'app/store/configureStore';
 import locationUtil from 'app/core/utils/location_util';
 import { updateLocation } from 'app/core/actions';
 
-// Services that handles angular -> mobx store sync & other react <-> angular sync
+// Services that handles angular -> redux store sync & other react <-> angular sync
 export class BridgeSrv {
   private fullPageReloadRoutes;
 

+ 8 - 34
public/app/features/dashboard/dashboard_ctrl.ts

@@ -2,13 +2,13 @@
 import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
 import coreModule from 'app/core/core_module';
+import { removePanel } from 'app/features/dashboard/utils/panel';
 
 // Services
 import { AnnotationsSrv } from '../annotations/annotations_srv';
 
 // Types
 import { DashboardModel } from './dashboard_model';
-import { PanelModel } from './panel_model';
 
 export class DashboardCtrl {
   dashboard: DashboardModel;
@@ -19,7 +19,6 @@ export class DashboardCtrl {
   /** @ngInject */
   constructor(
     private $scope,
-    private $rootScope,
     private keybindingSrv,
     private timeSrv,
     private variableSrv,
@@ -112,12 +111,14 @@ export class DashboardCtrl {
   }
 
   showJsonEditor(evt, options) {
-    const editScope = this.$rootScope.$new();
-    editScope.object = options.object;
-    editScope.updateHandler = options.updateHandler;
+    const model = {
+      object: options.object,
+      updateHandler: options.updateHandler,
+    };
+
     this.$scope.appEvent('show-dash-editor', {
       src: 'public/app/partials/edit_json.html',
-      scope: editScope,
+      model: model,
     });
   }
 
@@ -136,34 +137,7 @@ export class DashboardCtrl {
     }
 
     const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
-    this.removePanel(panelInfo.panel, true);
-  }
-
-  removePanel(panel: PanelModel, ask: boolean) {
-    // confirm deletion
-    if (ask !== false) {
-      let text2, confirmText;
-
-      if (panel.alert) {
-        text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
-        confirmText = 'YES';
-      }
-
-      this.$scope.appEvent('confirm-modal', {
-        title: 'Remove Panel',
-        text: 'Are you sure you want to remove this panel?',
-        text2: text2,
-        icon: 'fa-trash',
-        confirmText: confirmText,
-        yesText: 'Remove',
-        onConfirm: () => {
-          this.removePanel(panel, false);
-        },
-      });
-      return;
-    }
-
-    this.dashboard.removePanel(panel);
+    removePanel(this.dashboard, panelInfo.panel, true);
   }
 
   onDestroy() {

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

@@ -232,11 +232,6 @@ export class DashboardModel {
     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 = {};

+ 6 - 2
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -83,7 +83,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     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() {
@@ -176,7 +175,12 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
       const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
       panelElements.push(
         <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
-          <DashboardPanel panel={panel} dashboard={this.props.dashboard} panelType={panel.type} />
+          <DashboardPanel
+            panel={panel}
+            dashboard={this.props.dashboard}
+            isEditing={panel.isEditing}
+            isFullscreen={panel.fullscreen}
+          />
         </div>
       );
     }

+ 6 - 6
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { PureComponent } from 'react';
 import config from 'app/core/config';
 
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
@@ -14,16 +14,17 @@ import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 
 export interface Props {
-  panelType: string;
   panel: PanelModel;
   dashboard: DashboardModel;
+  isEditing: boolean;
+  isFullscreen: boolean;
 }
 
 export interface State {
   plugin: PanelPlugin;
 }
 
-export class DashboardPanel extends React.Component<Props, State> {
+export class DashboardPanel extends PureComponent<Props, State> {
   element: any;
   angularPanel: AngularComponent;
   specialPanels = {};
@@ -119,9 +120,8 @@ export class DashboardPanel extends React.Component<Props, State> {
     const { dashboard, panel } = this.props;
     const { plugin } = this.state;
 
-    const containerClass = panel.isEditing ? 'panel-editor-container' : 'panel-height-helper';
-    const panelWrapperClass = panel.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
-
+    const containerClass = this.props.isEditing ? 'panel-editor-container' : 'panel-height-helper';
+    const panelWrapperClass = this.props.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 (

+ 10 - 8
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -5,7 +5,7 @@ import React, { ComponentClass, PureComponent } from 'react';
 import { getTimeSrv } from '../time_srv';
 
 // Components
-import { PanelHeader } from './PanelHeader';
+import { PanelHeader } from './PanelHeader/PanelHeader';
 import { DataPanel } from './DataPanel';
 
 // Types
@@ -49,17 +49,19 @@ export class PanelChrome extends PureComponent<Props, State> {
     const timeSrv = getTimeSrv();
     const timeRange = timeSrv.timeRange();
 
-    this.setState({
+    this.setState(prevState => ({
+      ...prevState,
       refreshCounter: this.state.refreshCounter + 1,
       timeRange: timeRange,
-    });
+    }));
   };
 
   onRender = () => {
     console.log('onRender');
-    this.setState({
+    this.setState(prevState => ({
+      ...prevState,
       renderCounter: this.state.renderCounter + 1,
-    });
+    }));
   };
 
   get isVisible() {
@@ -68,12 +70,12 @@ export class PanelChrome extends PureComponent<Props, State> {
 
   render() {
     const { panel, dashboard } = this.props;
+    const { refreshCounter, timeRange, renderCounter } = this.state;
+
     const { datasource, targets } = panel;
-    const { timeRange, renderCounter, refreshCounter } = this.state;
     const PanelComponent = this.props.component;
 
-    console.log('Panel chrome render');
-
+    console.log('panelChrome render');
     return (
       <div className="panel-container">
         <PanelHeader panel={panel} dashboard={dashboard} />

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

@@ -1,83 +0,0 @@
-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>
-    );
-  }
-}

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

@@ -0,0 +1,51 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+
+import { PanelHeaderMenu } from './PanelHeaderMenu';
+
+import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+
+export interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+}
+
+export class PanelHeader extends PureComponent<Props> {
+  render() {
+    const isFullscreen = false;
+    const isLoading = false;
+    const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
+    const { panel, dashboard } = this.props;
+
+    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">
+          <div className="panel-title">
+            <span className="icon-gf panel-alert-icon" />
+            <span className="panel-title-text" data-toggle="dropdown">
+              {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+            </span>
+
+            <PanelHeaderMenu panel={panel} dashboard={dashboard} />
+
+            <span className="panel-time-info">
+              <i className="fa fa-clock-o" /> 4m
+            </span>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}

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

@@ -0,0 +1,40 @@
+import React, { PureComponent } from 'react';
+import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+import { PanelHeaderMenuItem } from './PanelHeaderMenuItem';
+import { getPanelMenu } from 'app/features/dashboard/utils/getPanelMenu';
+import { PanelMenuItem } from 'app/types/panel';
+
+export interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+}
+
+export class PanelHeaderMenu extends PureComponent<Props> {
+  renderItems = (menu: PanelMenuItem[], isSubMenu = false) => {
+    return (
+      <ul className="dropdown-menu dropdown-menu--menu panel-menu" role={isSubMenu ? '' : 'menu'}>
+        {menu.map((menuItem, idx: number) => {
+          return (
+            <PanelHeaderMenuItem
+              key={`${menuItem.text}${idx}`}
+              type={menuItem.type}
+              text={menuItem.text}
+              iconClassName={menuItem.iconClassName}
+              onClick={menuItem.onClick}
+              shortcut={menuItem.shortcut}
+            >
+              {menuItem.subMenu && this.renderItems(menuItem.subMenu, true)}
+            </PanelHeaderMenuItem>
+          );
+        })}
+      </ul>
+    );
+  };
+
+  render() {
+    const { dashboard, panel } = this.props;
+    const menu = getPanelMenu(dashboard, panel);
+    return <div className="panel-menu-container dropdown">{this.renderItems(menu)}</div>;
+  }
+}

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

@@ -0,0 +1,23 @@
+import React, { SFC } from 'react';
+import { PanelMenuItem } from 'app/types/panel';
+
+interface Props {
+  children: any;
+}
+
+export const PanelHeaderMenuItem: SFC<Props & PanelMenuItem> = props => {
+  const isSubMenu = props.type === 'submenu';
+  const isDivider = props.type === 'divider';
+  return isDivider ? (
+    <li className="divider" />
+  ) : (
+    <li className={isSubMenu ? 'dropdown-submenu' : null}>
+      <a onClick={props.onClick}>
+        {props.iconClassName && <i className={props.iconClassName} />}
+        <span className="dropdown-item-text">{props.text}</span>
+        {props.shortcut && <span className="dropdown-menu-item-shortcut">{props.shortcut}</span>}
+      </a>
+      {props.children}
+    </li>
+  );
+};

+ 9 - 13
public/app/features/dashboard/export/export_modal.html

@@ -1,25 +1,21 @@
-
-<!-- <p> -->
-<!-- 	Exporting will export a cleaned sharable dashboard that can be imported -->
-<!-- 	into another Grafana instance. -->
-<!-- </p> -->
-
 <div class="share-modal-header">
 	<div class="share-modal-big-icon">
 		<i class="fa fa-cloud-upload"></i>
 	</div>
 	<div>
-		<p class="share-modal-info-text">
-			Export the dashboard to a JSON file. The exporter will templatize the
-			dashboard's data sources to make it easy for others to import and reuse.
-			You can share dashboards on <a class="external-link" href="https://grafana.com">Grafana.com</a>
-		</p>
+		<gf-form-switch
+			class="gf-form"
+			label="Export for sharing externally"
+			label-class="width-16"
+			checked="ctrl.shareExternally"
+			tooltip="Useful for sharing dashboard publicly on grafana.com. Will templatize data source names. Can then only be used with the specific dashboard import API.">
+		</gf-form-switch>
 
 		<div class="gf-form-button-row">
-			<button type="button" class="btn gf-form-btn width-10 btn-success" ng-click="ctrl.save()">
+			<button type="button" class="btn gf-form-btn width-10 btn-success" ng-click="ctrl.saveDashboardAsFile()">
 				<i class="fa fa-save"></i> Save to file
 			</button>
-			<button type="button" class="btn gf-form-btn width-10 btn-secondary" ng-click="ctrl.saveJson()">
+			<button type="button" class="btn gf-form-btn width-10 btn-secondary" ng-click="ctrl.viewJson()">
 				<i class="fa fa-file-text-o"></i> View JSON
 			</button>
 			<a class="btn btn-link" ng-click="ctrl.dismiss()">Cancel</a>

+ 34 - 13
public/app/features/dashboard/export/export_modal.ts

@@ -8,34 +8,55 @@ export class DashExportCtrl {
   dash: any;
   exporter: DashboardExporter;
   dismiss: () => void;
+  shareExternally: boolean;
 
   /** @ngInject */
   constructor(private dashboardSrv, datasourceSrv, private $scope, private $rootScope) {
     this.exporter = new DashboardExporter(datasourceSrv);
 
-    this.exporter.makeExportable(this.dashboardSrv.getCurrent()).then(dash => {
-      this.$scope.$apply(() => {
-        this.dash = dash;
+    this.dash = this.dashboardSrv.getCurrent();
+  }
+
+  saveDashboardAsFile() {
+    if (this.shareExternally) {
+      this.exporter.makeExportable(this.dash).then((dashboardJson: any) => {
+        this.$scope.$apply(() => {
+          this.openSaveAsDialog(dashboardJson);
+        });
       });
-    });
+    } else {
+      this.openSaveAsDialog(this.dash.getSaveModelClone());
+    }
+  }
+
+  viewJson() {
+    if (this.shareExternally) {
+      this.exporter.makeExportable(this.dash).then((dashboardJson: any) => {
+        this.$scope.$apply(() => {
+          this.openJsonModal(dashboardJson);
+        });
+      });
+    } else {
+      this.openJsonModal(this.dash.getSaveModelClone());
+    }
   }
 
-  save() {
-    const blob = new Blob([angular.toJson(this.dash, true)], {
+  private openSaveAsDialog(dash: any) {
+    const blob = new Blob([angular.toJson(dash, true)], {
       type: 'application/json;charset=utf-8',
     });
-    saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
+    saveAs(blob, dash.title + '-' + new Date().getTime() + '.json');
   }
 
-  saveJson() {
-    const clone = this.dash;
-    const editScope = this.$rootScope.$new();
-    editScope.object = clone;
-    editScope.enableCopy = true;
+  private openJsonModal(clone: object) {
+    const model = {
+      object: clone,
+      enableCopy: true,
+    };
 
     this.$rootScope.appEvent('show-modal', {
       src: 'public/app/partials/edit_json.html',
-      scope: editScope,
+      model: model,
     });
 
     this.dismiss();

+ 22 - 11
public/app/features/dashboard/export/exporter.ts

@@ -29,19 +29,36 @@ export class DashboardExporter {
     }
 
     const templateizeDatasourceUsage = obj => {
+      let datasource = obj.datasource;
+      let datasourceVariable = null;
+
       // ignore data source properties that contain a variable
-      if (obj.datasource && obj.datasource.indexOf('$') === 0) {
-        if (variableLookup[obj.datasource.substring(1)]) {
-          return;
+      if (datasource && datasource.indexOf('$') === 0) {
+        datasourceVariable = variableLookup[datasource.substring(1)];
+        if (datasourceVariable && datasourceVariable.current) {
+          datasource = datasourceVariable.current.value;
         }
       }
 
       promises.push(
-        this.datasourceSrv.get(obj.datasource).then(ds => {
+        this.datasourceSrv.get(datasource).then(ds => {
           if (ds.meta.builtIn) {
             return;
           }
 
+          // add data source type to require list
+          requires['datasource' + ds.meta.id] = {
+            type: 'datasource',
+            id: ds.meta.id,
+            name: ds.meta.name,
+            version: ds.meta.info.version || '1.0.0',
+          };
+
+          // if used via variable we can skip templatizing usage
+          if (datasourceVariable) {
+            return;
+          }
+
           const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
           datasources[refName] = {
             name: refName,
@@ -51,14 +68,8 @@ export class DashboardExporter {
             pluginId: ds.meta.id,
             pluginName: ds.meta.name,
           };
-          obj.datasource = '${' + refName + '}';
 
-          requires['datasource' + ds.meta.id] = {
-            type: 'datasource',
-            id: ds.meta.id,
-            name: ds.meta.name,
-            version: ds.meta.info.version || '1.0.0',
-          };
+          obj.datasource = '${' + refName + '}';
         })
       );
     };

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

@@ -12,6 +12,8 @@ export function ShareModalCtrl($scope, $rootScope, $location, $timeout, timeSrv,
   $scope.editor = { index: $scope.tabIndex || 0 };
 
   $scope.init = () => {
+    $scope.panel = $scope.model && $scope.model.panel ? $scope.model.panel : $scope.panel; // React pass panel and dashboard in the "model" property
+    $scope.dashboard = $scope.model && $scope.model.dashboard ? $scope.model.dashboard : $scope.dashboard; // ^
     $scope.modeSharePanel = $scope.panel ? true : false;
 
     $scope.tabs = [{ title: 'Link', src: 'shareLink.html' }];

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

@@ -32,8 +32,8 @@ describe('given dashboard with repeated panels', () => {
           {
             name: 'ds',
             type: 'datasource',
-            query: 'testdb',
-            current: { value: 'prod', text: 'prod' },
+            query: 'other2',
+            current: { value: 'other2', text: 'other2' },
             options: [],
           },
         ],
@@ -205,6 +205,11 @@ describe('given dashboard with repeated panels', () => {
     expect(variable.options[0].text).toBe('${VAR_PREFIX}');
     expect(variable.options[0].value).toBe('${VAR_PREFIX}');
   });
+
+  it('should add datasources only use via datasource variable to requires', () => {
+    const require = _.find(exported.__requires, { name: 'OtherDB_2' });
+    expect(require.id).toBe('other2');
+  });
 });
 
 // Stub responses
@@ -219,6 +224,11 @@ stubs['other'] = {
   meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' },
 };
 
+stubs['other2'] = {
+  name: 'other2',
+  meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' },
+};
+
 stubs['-- Mixed --'] = {
   name: 'mixed',
   meta: {

+ 120 - 0
public/app/features/dashboard/utils/getPanelMenu.ts

@@ -0,0 +1,120 @@
+import { updateLocation } from 'app/core/actions';
+import { store } from 'app/store/configureStore';
+
+import { removePanel, duplicatePanel, copyPanel, editPanelJson, sharePanel } from 'app/features/dashboard/utils/panel';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { PanelMenuItem } from 'app/types/panel';
+
+export const getPanelMenu = (dashboard: DashboardModel, panel: PanelModel) => {
+  const onViewPanel = () => {
+    store.dispatch(
+      updateLocation({
+        query: {
+          panelId: panel.id,
+          edit: false,
+          fullscreen: true,
+        },
+        partial: true,
+      })
+    );
+  };
+
+  const onEditPanel = () => {
+    store.dispatch(
+      updateLocation({
+        query: {
+          panelId: panel.id,
+          edit: true,
+          fullscreen: true,
+        },
+        partial: true,
+      })
+    );
+  };
+
+  const onSharePanel = () => {
+    sharePanel(dashboard, panel);
+  };
+
+  const onDuplicatePanel = () => {
+    duplicatePanel(dashboard, panel);
+  };
+
+  const onCopyPanel = () => {
+    copyPanel(panel);
+  };
+
+  const onEditPanelJson = () => {
+    editPanelJson(dashboard, panel);
+  };
+
+  const onRemovePanel = () => {
+    removePanel(dashboard, panel, true);
+  };
+
+  const menu: PanelMenuItem[] = [];
+
+  menu.push({
+    text: 'View',
+    iconClassName: 'fa fa-fw fa-eye',
+    onClick: onViewPanel,
+    shortcut: 'v',
+  });
+
+  if (dashboard.meta.canEdit) {
+    menu.push({
+      text: 'Edit',
+      iconClassName: 'fa fa-fw fa-edit',
+      onClick: onEditPanel,
+      shortcut: 'e',
+    });
+  }
+
+  menu.push({
+    text: 'Share',
+    iconClassName: 'fa fa-fw fa-share',
+    onClick: onSharePanel,
+    shortcut: 'p s',
+  });
+
+  const subMenu: PanelMenuItem[] = [];
+
+  if (!panel.fullscreen && dashboard.meta.canEdit) {
+    subMenu.push({
+      text: 'Duplicate',
+      onClick: onDuplicatePanel,
+      shortcut: 'p d',
+    });
+
+    subMenu.push({
+      text: 'Copy',
+      onClick: onCopyPanel,
+    });
+  }
+
+  subMenu.push({
+    text: 'Panel JSON',
+    onClick: onEditPanelJson,
+  });
+
+  menu.push({
+    type: 'submenu',
+    text: 'More...',
+    iconClassName: 'fa fa-fw fa-cube',
+    subMenu: subMenu,
+  });
+
+  if (dashboard.meta.canEdit) {
+    menu.push({ type: 'divider' });
+
+    menu.push({
+      text: 'Remove',
+      iconClassName: 'fa fa-fw fa-trash',
+      onClick: onRemovePanel,
+      shortcut: 'p r',
+    });
+  }
+
+  return menu;
+};

+ 86 - 0
public/app/features/dashboard/utils/panel.ts

@@ -0,0 +1,86 @@
+import appEvents from 'app/core/app_events';
+import { DashboardModel } from 'app/features/dashboard/dashboard_model';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+import store from 'app/core/store';
+import { LS_PANEL_COPY_KEY } from 'app/core/constants';
+
+export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => {
+  // confirm deletion
+  if (ask !== false) {
+    const text2 = panel.alert ? 'Panel includes an alert rule, removing panel will also remove alert rule' : null;
+    const confirmText = panel.alert ? 'YES' : null;
+
+    appEvents.emit('confirm-modal', {
+      title: 'Remove Panel',
+      text: 'Are you sure you want to remove this panel?',
+      text2: text2,
+      icon: 'fa-trash',
+      confirmText: confirmText,
+      yesText: 'Remove',
+      onConfirm: () => removePanel(dashboard, panel, false),
+    });
+    return;
+  }
+  dashboard.removePanel(panel);
+};
+
+export const duplicatePanel = (dashboard: DashboardModel, panel: PanelModel) => {
+  dashboard.duplicatePanel(panel);
+};
+
+export const copyPanel = (panel: PanelModel) => {
+  store.set(LS_PANEL_COPY_KEY, JSON.stringify(panel.getSaveModel()));
+  appEvents.emit('alert-success', ['Panel copied. Open Add Panel to paste']);
+};
+
+const replacePanel = (dashboard: DashboardModel, newPanel: PanelModel, oldPanel: PanelModel) => {
+  const index = dashboard.panels.findIndex(panel => {
+    return panel.id === oldPanel.id;
+  });
+
+  const deletedPanel = dashboard.panels.splice(index, 1);
+  dashboard.events.emit('panel-removed', deletedPanel);
+
+  newPanel = new PanelModel(newPanel);
+  newPanel.id = oldPanel.id;
+
+  dashboard.panels.splice(index, 0, newPanel);
+  dashboard.sortPanelsByGridPos();
+  dashboard.events.emit('panel-added', newPanel);
+};
+
+export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
+  const model = {
+    object: panel.getSaveModel(),
+    updateHandler: (newPanel: PanelModel, oldPanel: PanelModel) => {
+      replacePanel(dashboard, newPanel, oldPanel);
+    },
+    enableCopy: true,
+  };
+
+  appEvents.emit('show-modal', {
+    src: 'public/app/partials/edit_json.html',
+    model: model,
+  });
+};
+
+export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
+  appEvents.emit('show-modal', {
+    src: 'public/app/features/dashboard/partials/shareModal.html',
+    model: {
+      dashboard: dashboard,
+      panel: panel,
+    },
+  });
+};
+
+export const refreshPanel = (panel: PanelModel) => {
+  panel.refresh();
+};
+
+export const toggleLegend = (panel: PanelModel) => {
+  console.log('Toggle legend is not implemented yet');
+  // We need to set panel.legend defaults first
+  // panel.legend.show = !panel.legend.show;
+  refreshPanel(panel);
+};

+ 12 - 41
public/app/features/panel/panel_ctrl.ts

@@ -1,11 +1,15 @@
 import config from 'app/core/config';
 import _ from 'lodash';
 import $ from 'jquery';
-import { appEvents, profiler } from 'app/core/core';
-import { PanelModel } from 'app/features/dashboard/panel_model';
+import { profiler } from 'app/core/core';
+import {
+  duplicatePanel,
+  copyPanel as copyPanelUtil,
+  editPanelJson as editPanelJsonUtil,
+  sharePanel as sharePanelUtil,
+} from 'app/features/dashboard/utils/panel';
 import Remarkable from 'remarkable';
-import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, LS_PANEL_COPY_KEY } from 'app/core/constants';
-import store from 'app/core/store';
+import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
 
 const TITLE_HEIGHT = 27;
 const PANEL_BORDER = 2;
@@ -241,7 +245,7 @@ export class PanelCtrl {
   }
 
   duplicate() {
-    this.dashboard.duplicatePanel(this.panel);
+    duplicatePanel(this.dashboard, this.panel);
   }
 
   removePanel() {
@@ -251,48 +255,15 @@ export class PanelCtrl {
   }
 
   editPanelJson() {
-    const editScope = this.$scope.$root.$new();
-    editScope.object = this.panel.getSaveModel();
-    editScope.updateHandler = this.replacePanel.bind(this);
-    editScope.enableCopy = true;
-
-    this.publishAppEvent('show-modal', {
-      src: 'public/app/partials/edit_json.html',
-      scope: editScope,
-    });
+    editPanelJsonUtil(this.dashboard, this.panel);
   }
 
   copyPanel() {
-    store.set(LS_PANEL_COPY_KEY, JSON.stringify(this.panel.getSaveModel()));
-    appEvents.emit('alert-success', ['Panel copied. Open Add Panel to paste']);
-  }
-
-  replacePanel(newPanel, oldPanel) {
-    const dashboard = this.dashboard;
-    const index = _.findIndex(dashboard.panels, panel => {
-      return panel.id === oldPanel.id;
-    });
-
-    const deletedPanel = dashboard.panels.splice(index, 1);
-    this.dashboard.events.emit('panel-removed', deletedPanel);
-
-    newPanel = new PanelModel(newPanel);
-    newPanel.id = oldPanel.id;
-
-    dashboard.panels.splice(index, 0, newPanel);
-    dashboard.sortPanelsByGridPos();
-    dashboard.events.emit('panel-added', newPanel);
+    copyPanelUtil(this.panel);
   }
 
   sharePanel() {
-    const shareScope = this.$scope.$new();
-    shareScope.panel = this.panel;
-    shareScope.dashboard = this.dashboard;
-
-    this.publishAppEvent('show-modal', {
-      src: 'public/app/features/dashboard/partials/shareModal.html',
-      scope: shareScope,
-    });
+    sharePanelUtil(this.dashboard, this.panel);
   }
 
   getInfoMode() {

+ 1 - 3
public/app/features/panel/viz_tab.ts

@@ -16,9 +16,7 @@ export class VizTabCtrl {
     $scope.ctrl = this;
   }
 
-  onTypeChanged = (plugin: PanelPlugin) => {
-    this.dashboard.changePanelType(this.panelCtrl.panel, plugin.id);
-  };
+  onTypeChanged = (plugin: PanelPlugin) => {};
 }
 
 const template = `

+ 6 - 1
public/app/plugins/datasource/elasticsearch/config_ctrl.ts

@@ -20,7 +20,12 @@ export class ElasticConfigCtrl {
     { name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' },
   ];
 
-  esVersions = [{ name: '2.x', value: 2 }, { name: '5.x', value: 5 }, { name: '5.6+', value: 56 }];
+  esVersions = [
+    { name: '2.x', value: 2 },
+    { name: '5.x', value: 5 },
+    { name: '5.6+', value: 56 },
+    { name: '6.0+', value: 60 },
+  ];
 
   indexPatternTypeChanged() {
     const def = _.find(this.indexPatternTypes, {

+ 12 - 1
public/app/plugins/datasource/elasticsearch/query_builder.ts

@@ -31,7 +31,11 @@ export class ElasticQueryBuilder {
     queryNode.terms.size = parseInt(aggDef.settings.size, 10) === 0 ? 500 : parseInt(aggDef.settings.size, 10);
     if (aggDef.settings.orderBy !== void 0) {
       queryNode.terms.order = {};
-      queryNode.terms.order[aggDef.settings.orderBy] = aggDef.settings.order;
+      if (aggDef.settings.orderBy === '_term' && this.esVersion >= 60) {
+        queryNode.terms.order['_key'] = aggDef.settings.order;
+      } else {
+        queryNode.terms.order[aggDef.settings.orderBy] = aggDef.settings.order;
+      }
 
       // if metric ref, look it up and add it to this agg level
       metricRef = parseInt(aggDef.settings.orderBy, 10);
@@ -318,6 +322,13 @@ export class ElasticQueryBuilder {
         },
       },
     };
+
+    if (this.esVersion >= 60) {
+      query.aggs['1'].terms.order = {
+        _key: 'asc',
+      };
+    }
+
     return query;
   }
 }

+ 62 - 0
public/app/plugins/datasource/elasticsearch/specs/query_builder.test.ts

@@ -62,6 +62,54 @@ describe('ElasticQueryBuilder', () => {
     expect(aggs['1'].avg.field).toBe('@value');
   });
 
+  it('with term agg and order by term', () => {
+    const query = builder.build(
+      {
+        metrics: [{ type: 'count', id: '1' }, { type: 'avg', field: '@value', id: '5' }],
+        bucketAggs: [
+          {
+            type: 'terms',
+            field: '@host',
+            settings: { size: 5, order: 'asc', orderBy: '_term' },
+            id: '2',
+          },
+          { type: 'date_histogram', field: '@timestamp', id: '3' },
+        ],
+      },
+      100,
+      1000
+    );
+
+    const firstLevel = query.aggs['2'];
+    expect(firstLevel.terms.order._term).toBe('asc');
+  });
+
+  it('with term agg and order by term on es6.x', () => {
+    const builder6x = new ElasticQueryBuilder({
+      timeField: '@timestamp',
+      esVersion: 60,
+    });
+    const query = builder6x.build(
+      {
+        metrics: [{ type: 'count', id: '1' }, { type: 'avg', field: '@value', id: '5' }],
+        bucketAggs: [
+          {
+            type: 'terms',
+            field: '@host',
+            settings: { size: 5, order: 'asc', orderBy: '_term' },
+            id: '2',
+          },
+          { type: 'date_histogram', field: '@timestamp', id: '3' },
+        ],
+      },
+      100,
+      1000
+    );
+
+    const firstLevel = query.aggs['2'];
+    expect(firstLevel.terms.order._key).toBe('asc');
+  });
+
   it('with term agg and order by metric agg', () => {
     const query = builder.build(
       {
@@ -302,4 +350,18 @@ describe('ElasticQueryBuilder', () => {
     expect(query.query.bool.filter[4].regexp['key5']).toBe('value5');
     expect(query.query.bool.filter[5].bool.must_not.regexp['key6']).toBe('value6');
   });
+
+  it('getTermsQuery should set correct sorting', () => {
+    const query = builder.getTermsQuery({});
+    expect(query.aggs['1'].terms.order._term).toBe('asc');
+  });
+
+  it('getTermsQuery es6.x should set correct sorting', () => {
+    const builder6x = new ElasticQueryBuilder({
+      timeField: '@timestamp',
+      esVersion: 60,
+    });
+    const query = builder6x.getTermsQuery({});
+    expect(query.aggs['1'].terms.order._key).toBe('asc');
+  });
 });

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

@@ -12,3 +12,12 @@ export interface PanelOptionsProps<T = any> {
   options: T;
   onChange: (options: T) => void;
 }
+
+export interface PanelMenuItem {
+  type?: 'submenu' | 'divider';
+  text?: string;
+  iconClassName?: string;
+  onClick?: () => void;
+  shortcut?: string;
+  subMenu?: PanelMenuItem[];
+}

+ 5 - 0
public/sass/components/_dropdown.scss

@@ -183,6 +183,11 @@
     display: block;
   }
 
+  & > .dropdown > .dropdown-menu {
+    // Panel menu. TODO: See if we can merge this with above
+    display: block;
+  }
+
   &.cascade-open {
     .dropdown-menu {
       display: block;

+ 0 - 1
public/sass/pages/_dashboard.scss

@@ -124,7 +124,6 @@ div.flot-text {
   padding: 3px 5px;
   visibility: hidden;
   opacity: 0;
-  position: absolute;
   width: 16px;
   height: 16px;
   left: 1px;

+ 0 - 43
scripts/build/Dockerfile

@@ -1,43 +0,0 @@
-FROM centos:6.6
-
-RUN yum install -y yum-plugin-ovl initscripts curl tar gcc libc6-dev git gcc-c++ openssl-devel && \
-    yum install -y g++ make automake autoconf curl-devel zlib-devel httpd-devel apr-devel apr-util-devel sqlite-devel && \
-    yum install -y wget yum-utils bzip2 bzip2-devel && \
-    yum install -y fontconfig freetype freetype-devel fontconfig-devel libstdc++ && \
-    yum install -y rpm-build patch readline readline-devel libtool bison lzma && \
-    yum install -y which tar
-
-# Install RUBY 1.9.3
-# install necessary utilities
-# RUN yum install -y which tar
-RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 && \
-    curl -sSl https://raw.githubusercontent.com/rvm/rvm/master/binscripts/rvm-installer | bash -s stable && \
-    source /etc/profile.d/rvm.sh && \
-    /bin/bash -l -c "rvm requirements" && \
-    /bin/bash -l -c "rvm install 2.1.9" && \
-    /bin/bash -l -c "rvm use 2.1.9 --default"
-
-# install nodejs
-RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - && \
-    yum install -y nodejs --nogpgcheck
-
-ENV GOLANG_VERSION 1.11
-
-RUN wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo && \
-    yum install -y yarn --nogpgcheck && \
-    wget https://storage.googleapis.com/golang/go${GOLANG_VERSION}.linux-amd64.tar.gz && \
-    tar -C /usr/local -xzf go${GOLANG_VERSION}.linux-amd64.tar.gz
-
-
-ENV PATH /usr/local/go/bin:$PATH
-
-RUN mkdir -p /go/src /go/bin && chmod -R 777 /go
-
-ENV GOPATH /go
-ENV PATH /go/bin:$PATH
-
-ADD ./build.sh /tmp/
-
-WORKDIR /tmp/
-
-CMD ["./build.sh"]

+ 0 - 17
scripts/build/build_container.sh

@@ -1,17 +0,0 @@
-#!/bin/bash
-
-docker info && docker version
-mkdir -p ~/docker
-
-echo "Circle branch: ${CIRCLE_BRANCH}"
-echo "Circle tag: ${CIRCLE_TAG}"
-
-# try to load docker container from cache
-if [[ -e ~/docker/centos.tar ]]; then
-  docker load -i ~/docker/centos.tar;
-else
-  docker build --rm=false --tag "grafana/buildcontainer" ./scripts/build/
-
-  # save docker container so we don't have to recreate it next run
-  docker save grafana/buildcontainer > ~/docker/centos.tar;
-fi