Pārlūkot izejas kodu

Merge branch 'master' into docs_v5.0

Marcus Efraimsson 7 gadi atpakaļ
vecāks
revīzija
2b31465254
65 mainītis faili ar 997 papildinājumiem un 416 dzēšanām
  1. 12 1
      CHANGELOG.md
  2. 17 2
      README.md
  3. 31 10
      docs/sources/guides/whats-new-in-v5.md
  4. 97 39
      docs/sources/index.md
  5. 7 7
      pkg/api/api.go
  6. 10 2
      pkg/api/dashboard.go
  7. 15 0
      pkg/api/dashboard_acl.go
  8. 42 0
      pkg/api/dashboard_acl_test.go
  9. 2 2
      pkg/api/login_oauth.go
  10. 11 7
      pkg/api/org_users.go
  11. 4 3
      pkg/api/team.go
  12. 2 2
      pkg/api/team_members.go
  13. 1 2
      pkg/middleware/auth.go
  14. 3 1
      pkg/middleware/middleware.go
  15. 1 1
      pkg/middleware/recovery.go
  16. 27 19
      pkg/models/dashboards.go
  17. 4 1
      pkg/models/org_user.go
  18. 5 1
      pkg/models/team.go
  19. 2 0
      pkg/models/team_member.go
  20. 1 0
      pkg/plugins/dashboard_importer.go
  21. 2 0
      pkg/plugins/dashboards.go
  22. 1 1
      pkg/services/guardian/guardian.go
  23. 92 61
      pkg/services/sqlstore/dashboard.go
  24. 2 1
      pkg/services/sqlstore/dashboard_folder_test.go
  25. 226 94
      pkg/services/sqlstore/dashboard_test.go
  26. 28 3
      pkg/services/sqlstore/datasource_test.go
  27. 5 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  28. 25 0
      pkg/services/sqlstore/org_test.go
  29. 23 1
      pkg/services/sqlstore/org_users.go
  30. 17 16
      pkg/services/sqlstore/team.go
  31. 19 16
      pkg/services/sqlstore/team_test.go
  32. 1 1
      pkg/social/github_oauth.go
  33. 4 4
      public/app/core/components/Picker/UserPicker.tsx
  34. 3 2
      public/app/core/components/ScrollBar/ScrollBar.tsx
  35. 6 0
      public/app/core/components/form_dropdown/form_dropdown.ts
  36. 0 1
      public/app/core/components/help/help.ts
  37. 3 7
      public/app/core/components/org_switcher.ts
  38. 6 0
      public/app/core/components/query_part/query_part_editor.ts
  39. 3 1
      public/app/core/components/scroll/scroll.ts
  40. 6 0
      public/app/core/directives/metric_segment.js
  41. 64 0
      public/app/core/specs/file_export.jest.ts
  42. 9 4
      public/app/core/specs/org_switcher.jest.ts
  43. 53 6
      public/app/core/utils/file_export.ts
  44. 2 1
      public/app/features/dashboard/create_folder_ctrl.ts
  45. 2 3
      public/app/features/dashboard/dashboard_import_ctrl.ts
  46. 10 6
      public/app/features/dashboard/dashboard_srv.ts
  47. 0 1
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  48. 2 1
      public/app/features/dashboard/history/history.ts
  49. 1 1
      public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts
  50. 1 1
      public/app/features/org/partials/team_details.html
  51. 3 1
      public/app/features/panel/panel_directive.ts
  52. 1 1
      public/app/features/panel/solo_panel_ctrl.ts
  53. 1 1
      public/app/features/plugins/import_list/import_list.html
  54. 1 1
      public/app/features/plugins/partials/ds_list.html
  55. 3 5
      public/app/plugins/datasource/graphite/partials/query.editor.html
  56. 1 0
      public/app/plugins/panel/graph/legend.ts
  57. 6 2
      public/app/plugins/panel/heatmap/heatmap_tooltip.ts
  58. 1 2
      public/app/routes/dashboard_loaders.ts
  59. 1 1
      public/app/stores/FolderStore/FolderStore.ts
  60. 2 0
      public/app/stores/PermissionsStore/PermissionsStore.ts
  61. 9 2
      public/sass/components/_dashboard_grid.scss
  62. 1 0
      public/sass/components/_panel_dashlist.scss
  63. 0 28
      public/views/407.html
  64. 0 39
      public/views/500.html
  65. 57 0
      public/views/error.html

+ 12 - 1
CHANGELOG.md

@@ -2,7 +2,7 @@
 
 
 # 5.0.0-beta1 (2018-02-05)
 # 5.0.0-beta1 (2018-02-05)
 
 
-Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5.
+Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=Izr0IBgoTZQ) of Grafana v5.
 
 
 ### New Major Features
 ### New Major Features
 - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
 - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611)
@@ -69,6 +69,17 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w:
 ## Tech
 ## Tech
 * **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
 * **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645)
 
 
+## Deprecation notes
+
+### HTTP API
+The following operations have been deprecated and will be removed in a future release:
+  - `GET /api/dashboards/db/:slug` -> Use `GET /api/dashboards/uid/:uid` instead
+  - `DELETE /api/dashboards/db/:slug` -> Use `DELETE /api/dashboards/uid/:uid` instead
+
+The following properties have been deprecated and will be removed in a future release:
+  - `uri` property in `GET /api/search` -> Use new `url` or `uid` property instead
+  - `meta.slug` property in `GET /api/dashboards/uid/:uid` and `GET /api/dashboards/db/:slug` -> Use new `meta.url` or `dashboard.uid` property instead
+
 # 4.6.3 (2017-12-14)
 # 4.6.3 (2017-12-14)
 
 
 ## Fixes
 ## Fixes

+ 17 - 2
README.md

@@ -80,8 +80,11 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode =
 
 
 ### Running tests
 ### Running tests
 
 
-- You can run backend Golang tests using "go test ./pkg/...".
-- Execute all frontend tests with "npm run test"
+#### Frontend
+Execute all frontend tests
+```bash
+npm run test
+```
 
 
 Writing & watching frontend tests (we have two test runners)
 Writing & watching frontend tests (we have two test runners)
 
 
@@ -92,6 +95,18 @@ Writing & watching frontend tests (we have two test runners)
   - Start watcher: `npm run karma`
   - Start watcher: `npm run karma`
   - Karma+Mocha runs all files that end with the name "_specs.ts".
   - Karma+Mocha runs all files that end with the name "_specs.ts".
 
 
+#### Backend
+```bash
+# Run Golang tests using sqlite3 as database (default)
+go test ./pkg/... 
+
+# Run Golang tests using mysql as database - convenient to use /docker/blocks/mysql_tests
+GRAFANA_TEST_DB=mysql go test ./pkg/... 
+
+# Run Golang tests using postgres as database - convenient to use /docker/blocks/postgres_tests
+GRAFANA_TEST_DB=postgres go test ./pkg/... 
+```
+
 ## Contribute
 ## Contribute
 
 
 If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
 If you have any idea for an improvement or found a bug, do not hesitate to open an issue.

+ 31 - 10
docs/sources/guides/whats-new-in-v5.md

@@ -12,7 +12,7 @@ weight = -6
 
 
 # What's New in Grafana v5.0
 # What's New in Grafana v5.0
 
 
-> Out in beta: [Download now!](https://www.youtube.com/watch?v=Izr0IBgoTZQ)
+> Out in beta: [Download now!](https://grafana.com/grafana/download/5.0.0-beta1)
 
 
 This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements.
 This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements.
 
 
@@ -24,10 +24,12 @@ This is the most substantial update that Grafana has ever seen. This article wil
 - [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system.
 - [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system.
 - [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files.
 - [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files.
 - [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files.
 - [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files.
+- [Persistent dashboard url's]({{< relref "#dashboard-model-persistent-url-s-and-api-changes" >}}) makes it possible to rename dashboards without breaking links.
+- [Graphite Tags & Integrated Function Docs]({{< relref "#graphite-tags-integrated-function-docs" >}}).
 
 
 ### Video showing new features
 ### Video showing new features
 
 
-<iframe height="215" src="https://www.youtube.com/embed/BC_YRNpqj5k?rel=0&amp;showinfo=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
+<iframe width="450" height="270" src="https://www.youtube.com/embed/Izr0IBgoTZQ?rel=0&amp;" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
 <br />
 <br />
 
 
 ## New Dashboard Layout Engine
 ## New Dashboard Layout Engine
@@ -51,7 +53,7 @@ Almost every page has seen significant UX improvements. All pages (except dashbo
 
 
 <div class="clearfix"></div>
 <div class="clearfix"></div>
 
 
-### Dashboard Settings
+## Dashboard Settings
 
 
 {{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}}
 {{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}}
 Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard
 Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard
@@ -95,7 +97,7 @@ data sources a user can access nor what queries a user can issue.
 
 
 <div class="clearfix"></div>
 <div class="clearfix"></div>
 
 
-# Provisioning from configuration
+## Provisioning from configuration
 
 
 In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
 In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
 But that required the service to be running before you started creating dashboards and you also needed to
 But that required the service to be running before you started creating dashboards and you also needed to
@@ -117,17 +119,36 @@ in sync with dashboards in Grafana's database. The dashboard provisioner has mul
 which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
 which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
 expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
 expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
 
 
-# Dashboard model, new url structure & API changes
+
+## Graphite Tags & Integrated Function Docs
+
+{{< docs-imagebox img="/img/docs/v50/graphite_tags.png" max-width="1000px" class="docs-image--right" >}}
+
+The Graphite query editor has been updated to support the latest Graphite version (v1.2) that adds
+many new functions and support for querying by tags. You can now also view function documentation right in the query editor!
+
+Read more on [Graphite Tag Support](http://graphite.readthedocs.io/en/latest/tags.html?highlight=tags).
+
+<div class="clearfix"></div>
+
+## Dashboard model, persistent url's and API changes
 
 
 We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically
 We are introducing a new unique identifier (`uid`) in the dashboard JSON model. It's automatically
 generated if not provided when creating a dashboard and will have a length of 9-12 characters.
 generated if not provided when creating a dashboard and will have a length of 9-12 characters.
 
 
-The unique identifier allows having consistent URL's for accessing dashboards and sharing them
-between instances. The new routes and API's for accessing dashboards will use the `uid` instead
-of the `slug`. We'll keep supporting the old routes for accessing dashboards for backward
-compatibility, but please note that we'll deprecate the old slug-based routes in the future.
-This means that changing the title of dashboards will not break any bookmarked links.
+The unique identifier allows having persistent URL's for accessing dashboards, sharing them
+between instances and when using [dashboard provisioning](#dashboards). This means that dashboard can
+be renamed without breaking any links. We're changing the url format for dashboards
+from `/dashboard/db/:slug` to `/d/:uid/:slug`. We'll keep supporting the old slug-based url's for dashboards
+and redirects to the new one for backward compatibility. Please note that the old slug-based url's
+have been deprecated and will be removed in a future release.
 
 
 Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough).
 Sharing dashboards between instances becomes much easier since the `uid` is unique (unique enough).
 This might seem like a small change, but we are incredibly excited about it since it will make it
 This might seem like a small change, but we are incredibly excited about it since it will make it
 much easier to manage, collaborate and navigate between dashboards.
 much easier to manage, collaborate and navigate between dashboards.
+
+### API changes
+New uid-based routes in the dashboard API have been introduced to retrieve and delete dashboards.
+The corresponding slug-based routes have been deprecated and will be removed in a future release.
+
+

+ 97 - 39
docs/sources/index.md

@@ -1,49 +1,107 @@
 +++
 +++
-title = "Docs Home"
-description = "Install guide for Grafana"
+title = "Grafana documentation"
+description = "Guides, Installation & Feature Documentation"
 keywords = ["grafana", "installation", "documentation"]
 keywords = ["grafana", "installation", "documentation"]
 type = "docs"
 type = "docs"
 aliases = ["v1.1", "guides/reference/admin"]
 aliases = ["v1.1", "guides/reference/admin"]
 +++
 +++
 
 
-# Welcome to the Grafana Documentation
+# Grafana Documentation
 
 
-Grafana is an open source metric analytics & visualization suite. It is most commonly used for
-visualizing time series data for infrastructure and application analytics but many use it in
-other domains including industrial sensors, home automation, weather, and process control.
+<h2>Installing Grafana</h2>
+<div class="nav-cards">
+    <a href="{{< relref "installation/debian.md" >}}" class="nav-cards__item nav-cards__item--install">
+        <div class="nav-cards__icon fa fa-linux">
+        </div>
+        <h5>Installing on Linux</h5>
+    </a>
+    <a href="{{< relref "installation/mac.md" >}}" class="nav-cards__item nav-cards__item--install">
+        <div class="nav-cards__icon fa fa-apple">
+        </div>
+        <h5>Installing on Mac OS X</h5>
+    </a>
+      <a href="{{< relref "installation/windows.md" >}}" class="nav-cards__item nav-cards__item--install">
+        <div class="nav-cards__icon fa fa-windows">
+        </div>
+        <h5>Installing on Windows</h5>
+    </a>
+    <a href="https://grafana.com/cloud/grafana" class="nav-cards__item nav-cards__item--install">
+        <div class="nav-cards__icon fa fa-cloud">
+        </div>
+        <h5>Grafana Cloud</h5>
+    </a>
+    <a href="https://grafana.com/grafana/download" class="nav-cards__item nav-cards__item--install">
+        <div class="nav-cards__icon fa fa-moon-o">
+        </div>
+        <h5>Nightly Builds</h5>
+    </a>
+    <div class="nav-cards__item nav-cards__item--install">
+        <h5>For other platforms Read the <a href="{{< relref "project/building_from_source.md" >}}">build from source</a>
+        instructions for more information.</h5>
+    </div>
+</div>
 
 
-## Installing Grafana
-- [Installing on Debian / Ubuntu](installation/debian)
-- [Installing on RPM-based Linux (CentOS, Fedora, OpenSuse, RedHat)](installation/rpm)
-- [Installing on Mac OS X](installation/mac)
-- [Installing on Windows](installation/windows)
-- [Installing on Docker](installation/docker)
-- [Installing using Provisioning (Chef, Puppet, Salt, Ansible, etc)](administration/provisioning#configuration-management-tools)
-- [Nightly Builds](https://grafana.com/grafana/download)
+<h2>Guides</h2>
 
 
-For other platforms Read the [build from source]({{< relref "project/building_from_source.md" >}})
-instructions for more information.
+<div class="nav-cards">
+    <a href="https://grafana.com/grafana" class="nav-cards__item nav-cards__item--guide">
+        <h4>What is Grafana?</h4>
+        <p>Grafana feature highlights.</p>
+    </a>
+    <a href="{{< relref "installation/configuration.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>Configure Grafana</h4>
+        <p>Article on all the Grafana configuration and setup options.</p>
+    </a>
+    <a href="{{< relref "guides/getting_started.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>Getting Started</h4>
+        <p>A guide that walks you through the basics of using Grafana</p>
+    </a>
+    <a href="{{< relref "administration/provisioning.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>Provisioning</h4>
+        <p>A guide to help you automate your Grafana setup & configuration.</p>
+    </a>
+    <a href="{{< relref "guides/whats-new-in-v5.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>What's new in v5.0</h4>
+        <p>Article on all the new cool features and enhancements in v5.0</p>
+    </a>
+    <a href="{{< relref "tutorials/screencasts.md" >}}" class="nav-cards__item nav-cards__item--guide">
+        <h4>Screencasts</h4>
+        <p>Video tutorials & guides</p>
+    </a>
+</div>
 
 
-## Configuring Grafana
-
-The back-end web server has a number of configuration options. Go the
-[Configuration]({{< relref "installation/configuration.md" >}}) page for details on all
-those options.
-
-
-## Getting Started
-
-- [Getting Started]({{< relref "guides/getting_started.md" >}})
-- [Basic Concepts]({{< relref "guides/basic_concepts.md" >}})
-- [Screencasts]({{< relref "tutorials/screencasts.md" >}})
-
-## Data Source Guides
-
-- [Graphite]({{< relref "features/datasources/graphite.md" >}})
-- [Elasticsearch]({{< relref "features/datasources/elasticsearch.md" >}})
-- [InfluxDB]({{< relref "features/datasources/influxdb.md" >}})
-- [Prometheus]({{< relref "features/datasources/prometheus.md" >}})
-- [OpenTSDB]({{< relref "features/datasources/opentsdb.md" >}})
-- [MySQL]({{< relref "features/datasources/mysql.md" >}})
-- [Postgres]({{< relref "features/datasources/postgres.md" >}})
-- [Cloudwatch]({{< relref "features/datasources/cloudwatch.md" >}})
+<h2>Data Source Guides</h2>
+<div class="nav-cards">
+    <a href="{{< relref "features/datasources/graphite.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_graphite.svg" >
+      <h5>Graphite</h5>
+    </a>
+    <a href="{{< relref "features/datasources/elasticsearch.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_elasticsearch.svg" >
+      <h5>Elasticsearch</h5>
+    </a>
+    <a href="{{< relref "features/datasources/influxdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_influxdb.svg" >
+      <h5>InfluxDB</h5>
+    </a>
+    <a href="{{< relref "features/datasources/prometheus.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_prometheus.svg" >
+      <h5>Prometheus</h5>
+    </a>
+    <a href="{{< relref "features/datasources/opentsdb.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_opentsdb.png" >
+      <h5>OpenTSDB</h5>
+    </a>
+    <a href="{{< relref "features/datasources/mysql.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_mysql.png" >
+      <h5>MySQL</h5>
+    </a>
+    <a href="{{< relref "features/datasources/postgres.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_postgres.svg" >
+      <h5>Postgres</h5>
+    </a>
+    <a href="{{< relref "features/datasources/cloudwatch.md" >}}" class="nav-cards__item nav-cards__item--ds">
+      <img src="/img/docs/logos/icon_cloudwatch.svg">
+      <h5>Cloudwatch</h5>
+    </a>
+</div>

+ 7 - 7
pkg/api/api.go

@@ -150,13 +150,13 @@ func (hs *HttpServer) registerRoutes() {
 		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
 		apiRoute.Group("/teams", func(teamsRoute RouteRegister) {
 			teamsRoute.Get("/:teamId", wrap(GetTeamById))
 			teamsRoute.Get("/:teamId", wrap(GetTeamById))
 			teamsRoute.Get("/search", wrap(SearchTeams))
 			teamsRoute.Get("/search", wrap(SearchTeams))
-			teamsRoute.Post("/", quota("teams"), reqOrgAdmin, bind(m.CreateTeamCommand{}), wrap(CreateTeam))
-			teamsRoute.Put("/:teamId", reqOrgAdmin, bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
-			teamsRoute.Delete("/:teamId", reqOrgAdmin, wrap(DeleteTeamById))
-			teamsRoute.Get("/:teamId/members", reqOrgAdmin, wrap(GetTeamMembers))
-			teamsRoute.Post("/:teamId/members", reqOrgAdmin, quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
-			teamsRoute.Delete("/:teamId/members/:userId", reqOrgAdmin, wrap(RemoveTeamMember))
-		})
+			teamsRoute.Post("/", quota("teams"), bind(m.CreateTeamCommand{}), wrap(CreateTeam))
+			teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), wrap(UpdateTeam))
+			teamsRoute.Delete("/:teamId", wrap(DeleteTeamById))
+			teamsRoute.Get("/:teamId/members", wrap(GetTeamMembers))
+			teamsRoute.Post("/:teamId/members", quota("teams"), bind(m.AddTeamMemberCommand{}), wrap(AddTeamMember))
+			teamsRoute.Delete("/:teamId/members/:userId", wrap(RemoveTeamMember))
+		}, reqOrgAdmin)
 
 
 		// org information available to all users.
 		// org information available to all users.
 		apiRoute.Group("/org", func(orgRoute RouteRegister) {
 		apiRoute.Group("/org", func(orgRoute RouteRegister) {

+ 10 - 2
pkg/api/dashboard.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
 	"path"
 	"path"
+	"strings"
 
 
 	"github.com/grafana/grafana/pkg/services/dashboards"
 	"github.com/grafana/grafana/pkg/services/dashboards"
 
 
@@ -217,6 +218,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
 		return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
 	}
 	}
 
 
+	if dash.IsFolder && strings.ToLower(dash.Title) == strings.ToLower(m.RootFolderName) {
+		return ApiError(400, "A folder already exists with that name", nil)
+	}
+
 	if dash.Id == 0 {
 	if dash.Id == 0 {
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		if err != nil {
 		if err != nil {
@@ -237,8 +242,11 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 
 	dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem)
 	dashboard, err := dashboards.GetRepository().SaveDashboard(dashItem)
 
 
-	if err == m.ErrDashboardTitleEmpty {
-		return ApiError(400, m.ErrDashboardTitleEmpty.Error(), nil)
+	if err == m.ErrDashboardTitleEmpty ||
+		err == m.ErrDashboardWithSameNameAsFolder ||
+		err == m.ErrDashboardFolderWithSameNameAsDashboard ||
+		err == m.ErrDashboardTypeMismatch {
+		return ApiError(400, err.Error(), nil)
 	}
 	}
 
 
 	if err == m.ErrDashboardContainsInvalidAlertData {
 	if err == m.ErrDashboardContainsInvalidAlertData {

+ 15 - 0
pkg/api/dashboard_acl.go

@@ -13,6 +13,11 @@ import (
 func GetDashboardAclList(c *middleware.Context) Response {
 func GetDashboardAclList(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 
 
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
@@ -36,6 +41,11 @@ func GetDashboardAclList(c *middleware.Context) Response {
 func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
 func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)
 		return dashboardGuardianResponse(err)
@@ -79,6 +89,11 @@ func DeleteDashboardAcl(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 	aclId := c.ParamsInt64(":aclId")
 	aclId := c.ParamsInt64(":aclId")
 
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)
 		return dashboardGuardianResponse(err)

+ 42 - 0
pkg/api/dashboard_acl_test.go

@@ -23,6 +23,14 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		}
 		}
 		dtoRes := transformDashboardAclsToDTOs(mockResult)
 		dtoRes := transformDashboardAclsToDTOs(mockResult)
 
 
+		getDashboardQueryResult := m.NewDashboard("Dash")
+		var getDashboardNotFoundError error
+
+		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+			query.Result = getDashboardQueryResult
+			return getDashboardNotFoundError
+		})
+
 		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
 			query.Result = dtoRes
 			query.Result = dtoRes
 			return nil
 			return nil
@@ -60,6 +68,40 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 					So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
 					So(respJSON.GetIndex(0).Get("permission").MustInt(), ShouldEqual, m.PERMISSION_VIEW)
 				})
 				})
 			})
 			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				getDashboardNotFoundError = m.ErrDashboardNotFound
+				sc.handlerFunc = GetDashboardAclList
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should not be able to access ACL", func() {
+					So(sc.resp.Code, ShouldEqual, 404)
+				})
+			})
+
+			Convey("Should not be able to update permissions for non-existing dashboard", func() {
+				cmd := dtos.UpdateDashboardAclCommand{
+					Items: []dtos.DashboardAclUpdateItem{
+						{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+					},
+				}
+
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) {
+					getDashboardNotFoundError = m.ErrDashboardNotFound
+					CallPostAcl(sc)
+					So(sc.resp.Code, ShouldEqual, 404)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/2/acl/6", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				getDashboardNotFoundError = m.ErrDashboardNotFound
+				sc.handlerFunc = DeleteDashboardAcl
+				sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+
+				Convey("Should not be able to delete non-existing dashboard", func() {
+					So(sc.resp.Code, ShouldEqual, 404)
+				})
+			})
 		})
 		})
 
 
 		Convey("When user is org editor and has admin permission in the ACL", func() {
 		Convey("When user is org editor and has admin permission in the ACL", func() {

+ 2 - 2
pkg/api/login_oauth.go

@@ -40,14 +40,14 @@ func GenStateString() string {
 
 
 func OAuthLogin(ctx *middleware.Context) {
 func OAuthLogin(ctx *middleware.Context) {
 	if setting.OAuthService == nil {
 	if setting.OAuthService == nil {
-		ctx.Handle(404, "login.OAuthLogin(oauth service not enabled)", nil)
+		ctx.Handle(404, "OAuth not enabled", nil)
 		return
 		return
 	}
 	}
 
 
 	name := ctx.Params(":name")
 	name := ctx.Params(":name")
 	connect, ok := social.SocialMap[name]
 	connect, ok := social.SocialMap[name]
 	if !ok {
 	if !ok {
-		ctx.Handle(404, "login.OAuthLogin(social login not enabled)", errors.New(name))
+		ctx.Handle(404, fmt.Sprintf("No OAuth with name %s configured", name), nil)
 		return
 		return
 	}
 	}
 
 

+ 11 - 7
pkg/api/org_users.go

@@ -46,26 +46,30 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 
 
 // GET /api/org/users
 // GET /api/org/users
 func GetOrgUsersForCurrentOrg(c *middleware.Context) Response {
 func GetOrgUsersForCurrentOrg(c *middleware.Context) Response {
-	return getOrgUsersHelper(c.OrgId)
+	return getOrgUsersHelper(c.OrgId, c.Params("query"), c.ParamsInt("limit"))
 }
 }
 
 
 // GET /api/orgs/:orgId/users
 // GET /api/orgs/:orgId/users
 func GetOrgUsers(c *middleware.Context) Response {
 func GetOrgUsers(c *middleware.Context) Response {
-	return getOrgUsersHelper(c.ParamsInt64(":orgId"))
+	return getOrgUsersHelper(c.ParamsInt64(":orgId"), "", 0)
 }
 }
 
 
-func getOrgUsersHelper(orgId int64) Response {
-	query := m.GetOrgUsersQuery{OrgId: orgId}
+func getOrgUsersHelper(orgId int64, query string, limit int) Response {
+	q := m.GetOrgUsersQuery{
+		OrgId: orgId,
+		Query: query,
+		Limit: limit,
+	}
 
 
-	if err := bus.Dispatch(&query); err != nil {
+	if err := bus.Dispatch(&q); err != nil {
 		return ApiError(500, "Failed to get account user", err)
 		return ApiError(500, "Failed to get account user", err)
 	}
 	}
 
 
-	for _, user := range query.Result {
+	for _, user := range q.Result {
 		user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
 		user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
 	}
 	}
 
 
-	return Json(200, query.Result)
+	return Json(200, q.Result)
 }
 }
 
 
 // PATCH /api/org/users/:userId
 // PATCH /api/org/users/:userId

+ 4 - 3
pkg/api/team.go

@@ -26,6 +26,7 @@ func CreateTeam(c *middleware.Context, cmd m.CreateTeamCommand) Response {
 
 
 // PUT /api/teams/:teamId
 // PUT /api/teams/:teamId
 func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
 func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
+	cmd.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":teamId")
 	cmd.Id = c.ParamsInt64(":teamId")
 	if err := bus.Dispatch(&cmd); err != nil {
 	if err := bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
 		if err == m.ErrTeamNameTaken {
@@ -39,7 +40,7 @@ func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
 
 
 // DELETE /api/teams/:teamId
 // DELETE /api/teams/:teamId
 func DeleteTeamById(c *middleware.Context) Response {
 func DeleteTeamById(c *middleware.Context) Response {
-	if err := bus.Dispatch(&m.DeleteTeamCommand{Id: c.ParamsInt64(":teamId")}); err != nil {
+	if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil {
 		if err == m.ErrTeamNotFound {
 		if err == m.ErrTeamNotFound {
 			return ApiError(404, "Failed to delete Team. ID not found", nil)
 			return ApiError(404, "Failed to delete Team. ID not found", nil)
 		}
 		}
@@ -60,11 +61,11 @@ func SearchTeams(c *middleware.Context) Response {
 	}
 	}
 
 
 	query := m.SearchTeamsQuery{
 	query := m.SearchTeamsQuery{
+		OrgId: c.OrgId,
 		Query: c.Query("query"),
 		Query: c.Query("query"),
 		Name:  c.Query("name"),
 		Name:  c.Query("name"),
 		Page:  page,
 		Page:  page,
 		Limit: perPage,
 		Limit: perPage,
-		OrgId: c.OrgId,
 	}
 	}
 
 
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
@@ -83,7 +84,7 @@ func SearchTeams(c *middleware.Context) Response {
 
 
 // GET /api/teams/:teamId
 // GET /api/teams/:teamId
 func GetTeamById(c *middleware.Context) Response {
 func GetTeamById(c *middleware.Context) Response {
-	query := m.GetTeamByIdQuery{Id: c.ParamsInt64(":teamId")}
+	query := m.GetTeamByIdQuery{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}
 
 
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
 		if err == m.ErrTeamNotFound {
 		if err == m.ErrTeamNotFound {

+ 2 - 2
pkg/api/team_members.go

@@ -10,7 +10,7 @@ import (
 
 
 // GET /api/teams/:teamId/members
 // GET /api/teams/:teamId/members
 func GetTeamMembers(c *middleware.Context) Response {
 func GetTeamMembers(c *middleware.Context) Response {
-	query := m.GetTeamMembersQuery{TeamId: c.ParamsInt64(":teamId")}
+	query := m.GetTeamMembersQuery{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId")}
 
 
 	if err := bus.Dispatch(&query); err != nil {
 	if err := bus.Dispatch(&query); err != nil {
 		return ApiError(500, "Failed to get Team Members", err)
 		return ApiError(500, "Failed to get Team Members", err)
@@ -42,7 +42,7 @@ func AddTeamMember(c *middleware.Context, cmd m.AddTeamMemberCommand) Response {
 
 
 // DELETE /api/teams/:teamId/members/:userId
 // DELETE /api/teams/:teamId/members/:userId
 func RemoveTeamMember(c *middleware.Context) Response {
 func RemoveTeamMember(c *middleware.Context) Response {
-	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
+	if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil {
 		return ApiError(500, "Failed to remove Member from Team", err)
 		return ApiError(500, "Failed to remove Member from Team", err)
 	}
 	}
 	return ApiSuccess("Team Member removed")
 	return ApiSuccess("Team Member removed")

+ 1 - 2
pkg/middleware/auth.go

@@ -42,8 +42,7 @@ func accessForbidden(c *Context) {
 		return
 		return
 	}
 	}
 
 
-	c.SetCookie("redirect_to", url.QueryEscape(setting.AppSubUrl+c.Req.RequestURI), 0, setting.AppSubUrl+"/")
-	c.Redirect(setting.AppSubUrl + "/login")
+	c.Redirect(setting.AppSubUrl + "/")
 }
 }
 
 
 func notAuthorized(c *Context) {
 func notAuthorized(c *Context) {

+ 3 - 1
pkg/middleware/middleware.go

@@ -206,7 +206,9 @@ func (ctx *Context) Handle(status int, title string, err error) {
 
 
 	ctx.Data["Title"] = title
 	ctx.Data["Title"] = title
 	ctx.Data["AppSubUrl"] = setting.AppSubUrl
 	ctx.Data["AppSubUrl"] = setting.AppSubUrl
-	ctx.HTML(status, strconv.Itoa(status))
+	ctx.Data["Theme"] = "dark"
+
+	ctx.HTML(status, "error")
 }
 }
 
 
 func (ctx *Context) JsonOK(message string) {
 func (ctx *Context) JsonOK(message string) {

+ 1 - 1
pkg/middleware/recovery.go

@@ -137,7 +137,7 @@ func Recovery() macaron.Handler {
 
 
 					c.JSON(500, resp)
 					c.JSON(500, resp)
 				} else {
 				} else {
-					c.HTML(500, "500")
+					c.HTML(500, "error")
 				}
 				}
 			}
 			}
 		}()
 		}()

+ 27 - 19
pkg/models/dashboards.go

@@ -13,17 +13,22 @@ import (
 
 
 // Typed errors
 // Typed errors
 var (
 var (
-	ErrDashboardNotFound                   = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound           = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameUIDExists          = errors.New("A dashboard with the same uid already exists")
-	ErrDashboardWithSameNameInFolderExists = errors.New("A dashboard with the same name in the folder already exists")
-	ErrDashboardVersionMismatch            = errors.New("The dashboard has been changed by someone else")
-	ErrDashboardTitleEmpty                 = errors.New("Dashboard title cannot be empty")
-	ErrDashboardFolderCannotHaveParent     = errors.New("A Dashboard Folder cannot be added to another folder")
-	ErrDashboardContainsInvalidAlertData   = errors.New("Invalid alert data. Cannot save dashboard")
-	ErrDashboardFailedToUpdateAlertData    = errors.New("Failed to save alert data")
-	ErrDashboardsWithSameSlugExists        = errors.New("Multiple dashboards with the same slug exists")
-	ErrDashboardFailedGenerateUniqueUid    = errors.New("Failed to generate unique dashboard id")
+	ErrDashboardNotFound                        = errors.New("Dashboard not found")
+	ErrDashboardSnapshotNotFound                = errors.New("Dashboard snapshot not found")
+	ErrDashboardWithSameUIDExists               = errors.New("A dashboard with the same uid already exists")
+	ErrDashboardWithSameNameInFolderExists      = errors.New("A dashboard with the same name in the folder already exists")
+	ErrDashboardVersionMismatch                 = errors.New("The dashboard has been changed by someone else")
+	ErrDashboardTitleEmpty                      = errors.New("Dashboard title cannot be empty")
+	ErrDashboardFolderCannotHaveParent          = errors.New("A Dashboard Folder cannot be added to another folder")
+	ErrDashboardContainsInvalidAlertData        = errors.New("Invalid alert data. Cannot save dashboard")
+	ErrDashboardFailedToUpdateAlertData         = errors.New("Failed to save alert data")
+	ErrDashboardsWithSameSlugExists             = errors.New("Multiple dashboards with the same slug exists")
+	ErrDashboardFailedGenerateUniqueUid         = errors.New("Failed to generate unique dashboard id")
+	ErrDashboardExistingCannotChangeToDashboard = errors.New("An existing folder cannot be changed to a dashboard")
+	ErrDashboardTypeMismatch                    = errors.New("Dashboard cannot be changed to a folder")
+	ErrDashboardFolderWithSameNameAsDashboard   = errors.New("Folder name cannot be the same as one of its dashboards")
+	ErrDashboardWithSameNameAsFolder            = errors.New("Dashboard name cannot be the same as folder")
+	RootFolderName                              = "General"
 )
 )
 
 
 type UpdatePluginDashboardError struct {
 type UpdatePluginDashboardError struct {
@@ -95,14 +100,21 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 	dash.Data = data
 	dash.Data = data
 	dash.Title = dash.Data.Get("title").MustString()
 	dash.Title = dash.Data.Get("title").MustString()
 	dash.UpdateSlug()
 	dash.UpdateSlug()
+	update := false
 
 
 	if id, err := dash.Data.Get("id").Float64(); err == nil {
 	if id, err := dash.Data.Get("id").Float64(); err == nil {
 		dash.Id = int64(id)
 		dash.Id = int64(id)
+		update = true
+	}
+
+	if uid, err := dash.Data.Get("uid").String(); err == nil {
+		dash.Uid = uid
+		update = true
+	}
 
 
-		if version, err := dash.Data.Get("version").Float64(); err == nil {
-			dash.Version = int(version)
-			dash.Updated = time.Now()
-		}
+	if version, err := dash.Data.Get("version").Float64(); err == nil && update {
+		dash.Version = int(version)
+		dash.Updated = time.Now()
 	} else {
 	} else {
 		dash.Data.Set("version", 0)
 		dash.Data.Set("version", 0)
 		dash.Created = time.Now()
 		dash.Created = time.Now()
@@ -113,10 +125,6 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 		dash.GnetId = int64(gnetId)
 		dash.GnetId = int64(gnetId)
 	}
 	}
 
 
-	if uid, err := dash.Data.Get("uid").String(); err == nil {
-		dash.Uid = uid
-	}
-
 	return dash
 	return dash
 }
 }
 
 

+ 4 - 1
pkg/models/org_user.go

@@ -95,7 +95,10 @@ type UpdateOrgUserCommand struct {
 // QUERIES
 // QUERIES
 
 
 type GetOrgUsersQuery struct {
 type GetOrgUsersQuery struct {
-	OrgId  int64
+	OrgId int64
+	Query string
+	Limit int
+
 	Result []*OrgUserDTO
 	Result []*OrgUserDTO
 }
 }
 
 

+ 5 - 1
pkg/models/team.go

@@ -37,18 +37,22 @@ type UpdateTeamCommand struct {
 	Id    int64
 	Id    int64
 	Name  string
 	Name  string
 	Email string
 	Email string
+	OrgId int64 `json:"-"`
 }
 }
 
 
 type DeleteTeamCommand struct {
 type DeleteTeamCommand struct {
-	Id int64
+	OrgId int64
+	Id    int64
 }
 }
 
 
 type GetTeamByIdQuery struct {
 type GetTeamByIdQuery struct {
+	OrgId  int64
 	Id     int64
 	Id     int64
 	Result *Team
 	Result *Team
 }
 }
 
 
 type GetTeamsByUserQuery struct {
 type GetTeamsByUserQuery struct {
+	OrgId  int64
 	UserId int64   `json:"userId"`
 	UserId int64   `json:"userId"`
 	Result []*Team `json:"teams"`
 	Result []*Team `json:"teams"`
 }
 }

+ 2 - 0
pkg/models/team_member.go

@@ -31,6 +31,7 @@ type AddTeamMemberCommand struct {
 }
 }
 
 
 type RemoveTeamMemberCommand struct {
 type RemoveTeamMemberCommand struct {
+	OrgId  int64 `json:"-"`
 	UserId int64
 	UserId int64
 	TeamId int64
 	TeamId int64
 }
 }
@@ -39,6 +40,7 @@ type RemoveTeamMemberCommand struct {
 // QUERIES
 // QUERIES
 
 
 type GetTeamMembersQuery struct {
 type GetTeamMembersQuery struct {
+	OrgId  int64
 	TeamId int64
 	TeamId int64
 	Result []*TeamMemberDTO
 	Result []*TeamMemberDTO
 }
 }

+ 1 - 0
pkg/plugins/dashboard_importer.go

@@ -82,6 +82,7 @@ func ImportDashboard(cmd *ImportDashboardCommand) error {
 		Path:             cmd.Path,
 		Path:             cmd.Path,
 		Revision:         dashboard.Data.Get("revision").MustInt64(1),
 		Revision:         dashboard.Data.Get("revision").MustInt64(1),
 		ImportedUri:      "db/" + saveCmd.Result.Slug,
 		ImportedUri:      "db/" + saveCmd.Result.Slug,
+		ImportedUrl:      saveCmd.Result.GetUrl(),
 		ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
 		ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
 		Imported:         true,
 		Imported:         true,
 	}
 	}

+ 2 - 0
pkg/plugins/dashboards.go

@@ -14,6 +14,7 @@ type PluginDashboardInfoDTO struct {
 	Title            string `json:"title"`
 	Title            string `json:"title"`
 	Imported         bool   `json:"imported"`
 	Imported         bool   `json:"imported"`
 	ImportedUri      string `json:"importedUri"`
 	ImportedUri      string `json:"importedUri"`
+	ImportedUrl      string `json:"importedUrl"`
 	Slug             string `json:"slug"`
 	Slug             string `json:"slug"`
 	DashboardId      int64  `json:"dashboardId"`
 	DashboardId      int64  `json:"dashboardId"`
 	ImportedRevision int64  `json:"importedRevision"`
 	ImportedRevision int64  `json:"importedRevision"`
@@ -64,6 +65,7 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 				res.DashboardId = existingDash.Id
 				res.DashboardId = existingDash.Id
 				res.Imported = true
 				res.Imported = true
 				res.ImportedUri = "db/" + existingDash.Slug
 				res.ImportedUri = "db/" + existingDash.Slug
+				res.ImportedUrl = existingDash.GetUrl()
 				res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
 				res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
 				existingMatches[existingDash.Id] = true
 				existingMatches[existingDash.Id] = true
 			}
 			}

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

@@ -160,7 +160,7 @@ func (g *DashboardGuardian) getTeams() ([]*m.Team, error) {
 		return g.groups, nil
 		return g.groups, nil
 	}
 	}
 
 
-	query := m.GetTeamsByUserQuery{UserId: g.user.UserId}
+	query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
 	err := bus.Dispatch(&query)
 	err := bus.Dispatch(&query)
 
 
 	g.groups = query.Result
 	g.groups = query.Result

+ 92 - 61
pkg/services/sqlstore/dashboard.go

@@ -32,47 +32,36 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 		dash := cmd.GetDashboardModel()
 		dash := cmd.GetDashboardModel()
 
 
-		// try get existing dashboard
-		var existing m.Dashboard
+		if err := getExistingDashboardForUpdate(sess, dash, cmd); err != nil {
+			return err
+		}
 
 
-		if dash.Id != 0 {
-			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
-			if err != nil {
-				return err
-			}
-			if !dashWithIdExists {
-				return m.ErrDashboardNotFound
-			}
+		var existingByTitleAndFolder m.Dashboard
 
 
-			// check for is someone else has written in between
-			if dash.Version != existing.Version {
-				if cmd.Overwrite {
-					dash.Version = existing.Version
-				} else {
-					return m.ErrDashboardVersionMismatch
+		dashWithTitleAndFolderExists, err := sess.Where("org_id=? AND slug=? AND (is_folder=? OR folder_id=?)", dash.OrgId, dash.Slug, dialect.BooleanStr(true), dash.FolderId).Get(&existingByTitleAndFolder)
+		if err != nil {
+			return err
+		}
+
+		if dashWithTitleAndFolderExists {
+			if dash.Id != existingByTitleAndFolder.Id {
+				if existingByTitleAndFolder.IsFolder && !cmd.IsFolder {
+					return m.ErrDashboardWithSameNameAsFolder
 				}
 				}
-			}
 
 
-			// do not allow plugin dashboard updates without overwrite flag
-			if existing.PluginId != "" && cmd.Overwrite == false {
-				return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
-			}
-		} else if dash.Uid != "" {
-			var sameUid m.Dashboard
-			sameUidExists, err := sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&sameUid)
-			if err != nil {
-				return err
-			}
+				if !existingByTitleAndFolder.IsFolder && cmd.IsFolder {
+					return m.ErrDashboardFolderWithSameNameAsDashboard
+				}
 
 
-			if sameUidExists {
-				// another dashboard with same uid
-				if dash.Id != sameUid.Id {
-					if cmd.Overwrite {
-						dash.Id = sameUid.Id
-						dash.Version = sameUid.Version
-					} else {
-						return m.ErrDashboardWithSameUIDExists
+				if cmd.Overwrite {
+					dash.Id = existingByTitleAndFolder.Id
+					dash.Version = existingByTitleAndFolder.Version
+
+					if dash.Uid == "" {
+						dash.Uid = existingByTitleAndFolder.Uid
 					}
 					}
+				} else {
+					return m.ErrDashboardWithSameNameInFolderExists
 				}
 				}
 			}
 			}
 		}
 		}
@@ -86,11 +75,6 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			dash.Data.Set("uid", uid)
 			dash.Data.Set("uid", uid)
 		}
 		}
 
 
-		err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
-		if err != nil {
-			return err
-		}
-
 		err = setHasAcl(sess, dash)
 		err = setHasAcl(sess, dash)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -162,6 +146,72 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	})
 	})
 }
 }
 
 
+func getExistingDashboardForUpdate(sess *DBSession, dash *m.Dashboard, cmd *m.SaveDashboardCommand) (err error) {
+	dashWithIdExists := false
+	var existingById m.Dashboard
+
+	if dash.Id > 0 {
+		dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existingById)
+		if err != nil {
+			return err
+		}
+
+		if !dashWithIdExists {
+			return m.ErrDashboardNotFound
+		}
+
+		if dash.Uid == "" {
+			dash.Uid = existingById.Uid
+		}
+	}
+
+	dashWithUidExists := false
+	var existingByUid m.Dashboard
+
+	if dash.Uid != "" {
+		dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgId, dash.Uid).Get(&existingByUid)
+		if err != nil {
+			return err
+		}
+	}
+
+	if !dashWithIdExists && !dashWithUidExists {
+		return nil
+	}
+
+	if dashWithIdExists && dashWithUidExists && existingById.Id != existingByUid.Id {
+		return m.ErrDashboardWithSameUIDExists
+	}
+
+	existing := existingById
+
+	if !dashWithIdExists && dashWithUidExists {
+		dash.Id = existingByUid.Id
+		existing = existingByUid
+	}
+
+	if (existing.IsFolder && !cmd.IsFolder) ||
+		(!existing.IsFolder && cmd.IsFolder) {
+		return m.ErrDashboardTypeMismatch
+	}
+
+	// check for is someone else has written in between
+	if dash.Version != existing.Version {
+		if cmd.Overwrite {
+			dash.Version = existing.Version
+		} else {
+			return m.ErrDashboardVersionMismatch
+		}
+	}
+
+	// do not allow plugin dashboard updates without overwrite flag
+	if existing.PluginId != "" && cmd.Overwrite == false {
+		return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
+	}
+
+	return nil
+}
+
 func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
 func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
 	for i := 0; i < 3; i++ {
 	for i := 0; i < 3; i++ {
 		uid := generateNewUid()
 		uid := generateNewUid()
@@ -179,23 +229,6 @@ func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
 	return "", m.ErrDashboardFailedGenerateUniqueUid
 	return "", m.ErrDashboardFailedGenerateUniqueUid
 }
 }
 
 
-func guaranteeDashboardNameIsUniqueInFolder(sess *DBSession, dash *m.Dashboard) error {
-	var sameNameInFolder m.Dashboard
-	sameNameInFolderExist, err := sess.Where("org_id=? AND title=? AND folder_id = ? AND uid <> ?",
-		dash.OrgId, dash.Title, dash.FolderId, dash.Uid).
-		Get(&sameNameInFolder)
-
-	if err != nil {
-		return err
-	}
-
-	if sameNameInFolderExist {
-		return m.ErrDashboardWithSameNameInFolderExists
-	}
-
-	return nil
-}
-
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 	// check if parent has acl
 	// check if parent has acl
 	if dash.FolderId > 0 {
 	if dash.FolderId > 0 {
@@ -363,10 +396,10 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
 
 
 	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
 	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
 		sql := `SELECT distinct d.id, d.title
 		sql := `SELECT distinct d.id, d.title
-		FROM dashboard AS d WHERE d.is_folder = ?
+		FROM dashboard AS d WHERE d.is_folder = ? AND d.org_id = ?
 		ORDER BY d.title ASC`
 		ORDER BY d.title ASC`
 
 
-		err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result)
+		err = x.Sql(sql, dialect.BooleanStr(true), query.OrgId).Find(&query.Result)
 	} else {
 	} else {
 		params := make([]interface{}, 0)
 		params := make([]interface{}, 0)
 		sql := `SELECT distinct d.id, d.title
 		sql := `SELECT distinct d.id, d.title
@@ -518,9 +551,7 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	params = append(params, query.UserId)
 	params = append(params, query.UserId)
 	params = append(params, dialect.BooleanStr(false))
 	params = append(params, dialect.BooleanStr(false))
 
 
-	x.ShowSQL(true)
 	err := x.Sql(sql, params...).Find(&query.Result)
 	err := x.Sql(sql, params...).Find(&query.Result)
-	x.ShowSQL(false)
 
 
 	for _, p := range query.Result {
 	for _, p := range query.Result {
 		p.PermissionName = p.Permission.String()
 		p.PermissionName = p.Permission.String()

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

@@ -219,13 +219,14 @@ func TestDashboardFolderDataAccess(t *testing.T) {
 
 
 			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
 			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
 			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
 			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			insertTestDashboard("folder in another org", 2, 0, true, "prod")
 
 
 			adminUser := createUser("admin", "Admin", true)
 			adminUser := createUser("admin", "Admin", true)
 			editorUser := createUser("editor", "Editor", false)
 			editorUser := createUser("editor", "Editor", false)
 			viewerUser := createUser("viewer", "Viewer", false)
 			viewerUser := createUser("viewer", "Viewer", false)
 
 
 			Convey("Admin users", func() {
 			Convey("Admin users", func() {
-				Convey("Should have write access to all dashboard folders", func() {
+				Convey("Should have write access to all dashboard folders in their org", func() {
 					query := m.GetFoldersForSignedInUserQuery{
 					query := m.GetFoldersForSignedInUserQuery{
 						OrgId:        1,
 						OrgId:        1,
 						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
 						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},

+ 226 - 94
pkg/services/sqlstore/dashboard_test.go

@@ -100,7 +100,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 			})
 			})
 
 
-			Convey("Should return error if no dashboard is updated", func() {
+			Convey("Should return not found error if no dashboard is found for update", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId:     1,
 					OrgId:     1,
 					Overwrite: true,
 					Overwrite: true,
@@ -112,7 +112,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
-				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, m.ErrDashboardNotFound)
 			})
 			})
 
 
 			Convey("Should not be able to overwrite dashboard in another org", func() {
 			Convey("Should not be able to overwrite dashboard in another org", func() {
@@ -130,108 +130,171 @@ func TestDashboardDataAccess(t *testing.T) {
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
-				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, m.ErrDashboardNotFound)
 			})
 			})
 
 
-			Convey("Should be able to search for dashboard folder", func() {
-				query := search.FindPersistedDashboardsQuery{
-					Title:        "1 test dash folder",
-					OrgId:        1,
-					SignedInUser: &m.SignedInUser{OrgId: 1},
+			Convey("Should be able to save dashboards with same name in different folders", func() {
+				firstSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "randomHash",
+					}),
+					FolderId: 3,
 				}
 				}
 
 
-				err := SearchDashboards(&query)
+				err := SaveDashboard(&firstSaveCmd)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				So(len(query.Result), ShouldEqual, 1)
-				hit := query.Result[0]
-				So(hit.Type, ShouldEqual, search.DashHitFolder)
-				So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
-				So(hit.FolderTitle, ShouldEqual, "")
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 1,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldBeNil)
+				So(firstSaveCmd.Result.Id, ShouldNotEqual, secondSaveCmd.Result.Id)
 			})
 			})
 
 
-			Convey("Should be able to search for a dashboard folder's children", func() {
-				query := search.FindPersistedDashboardsQuery{
-					OrgId:        1,
-					FolderIds:    []int64{savedFolder.Id},
-					SignedInUser: &m.SignedInUser{OrgId: 1},
+			Convey("Should be able to overwrite dashboard in same folder using title", func() {
+				insertTestDashboard("Dash", 1, 0, false, "prod", "webapp")
+				folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp")
+				dashInFolder := insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp")
+
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "Dash",
+					}),
+					FolderId:  folder.Id,
+					Overwrite: true,
 				}
 				}
 
 
-				err := SearchDashboards(&query)
+				err := SaveDashboard(&cmd)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
+				So(cmd.Result.Id, ShouldEqual, dashInFolder.Id)
+				So(cmd.Result.Uid, ShouldEqual, dashInFolder.Uid)
+			})
 
 
-				So(len(query.Result), ShouldEqual, 2)
-				hit := query.Result[0]
-				So(hit.Id, ShouldEqual, savedDash.Id)
-				So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
-				So(hit.FolderId, ShouldEqual, savedFolder.Id)
-				So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
-				So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
-				So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
+			Convey("Should be able to overwrite dashboard in General folder using title", func() {
+				dashInGeneral := insertTestDashboard("Dash", 1, 0, false, "prod", "webapp")
+				folder := insertTestDashboard("Folder", 1, 0, true, "prod", "webapp")
+				insertTestDashboard("Dash", 1, folder.Id, false, "prod", "webapp")
+
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "Dash",
+					}),
+					FolderId:  0,
+					Overwrite: true,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.Id, ShouldEqual, dashInGeneral.Id)
+				So(cmd.Result.Uid, ShouldEqual, dashInGeneral.Uid)
 			})
 			})
 
 
-			Convey("Should be able to search for dashboard by dashboard ids", func() {
-				Convey("should be able to find two dashboards by id", func() {
-					query := search.FindPersistedDashboardsQuery{
-						DashboardIds: []int64{2, 3},
-						SignedInUser: &m.SignedInUser{OrgId: 1},
-					}
+			Convey("Should not be able to overwrite folder with dashboard in general folder using title", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": savedFolder.Title,
+					}),
+					FolderId:  0,
+					IsFolder:  false,
+					Overwrite: true,
+				}
 
 
-					err := SearchDashboards(&query)
-					So(err, ShouldBeNil)
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder)
+			})
 
 
-					So(len(query.Result), ShouldEqual, 2)
+			Convey("Should not be able to overwrite folder with dashboard in folder using title", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": savedFolder.Title,
+					}),
+					FolderId:  savedFolder.Id,
+					IsFolder:  false,
+					Overwrite: true,
+				}
 
 
-					hit := query.Result[0]
-					So(len(hit.Tags), ShouldEqual, 2)
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameNameAsFolder)
+			})
 
 
-					hit2 := query.Result[1]
-					So(len(hit2.Tags), ShouldEqual, 1)
-				})
+			Convey("Should not be able to overwrite folder with dashboard using id", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    savedFolder.Id,
+						"title": "new title",
+					}),
+					IsFolder:  false,
+					Overwrite: true,
+				}
 
 
-				Convey("DashboardIds that does not exists should not cause errors", func() {
-					query := search.FindPersistedDashboardsQuery{
-						DashboardIds: []int64{1000},
-						SignedInUser: &m.SignedInUser{OrgId: 1},
-					}
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardTypeMismatch)
+			})
 
 
-					err := SearchDashboards(&query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 0)
-				})
+			Convey("Should not be able to overwrite dashboard with folder using id", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    savedDash.Id,
+						"title": "new folder title",
+					}),
+					IsFolder:  true,
+					Overwrite: true,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardTypeMismatch)
 			})
 			})
 
 
-			Convey("Should be able to save dashboards with same name in different folders", func() {
-				firstSaveCmd := m.SaveDashboardCommand{
+			Convey("Should not be able to overwrite folder with dashboard using uid", func() {
+				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    nil,
-						"title": "test dash folder and title",
-						"tags":  []interface{}{},
-						"uid":   "randomHash",
+						"uid":   savedFolder.Uid,
+						"title": "new title",
 					}),
 					}),
-					FolderId: 3,
+					IsFolder:  false,
+					Overwrite: true,
 				}
 				}
 
 
-				err := SaveDashboard(&firstSaveCmd)
-				So(err, ShouldBeNil)
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardTypeMismatch)
+			})
 
 
-				secondSaveCmd := m.SaveDashboardCommand{
+			Convey("Should not be able to overwrite dashboard with folder using uid", func() {
+				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    nil,
-						"title": "test dash folder and title",
-						"tags":  []interface{}{},
-						"uid":   "moreRandomHash",
+						"uid":   savedDash.Uid,
+						"title": "new folder title",
 					}),
 					}),
-					FolderId: 1,
+					IsFolder:  true,
+					Overwrite: true,
 				}
 				}
 
 
-				err = SaveDashboard(&secondSaveCmd)
-				So(err, ShouldBeNil)
+				err := SaveDashboard(&cmd)
+				So(err, ShouldEqual, m.ErrDashboardTypeMismatch)
 			})
 			})
 
 
-			Convey("Should not be able to save dashboard with same name in the same folder", func() {
+			Convey("Should not be able to save dashboard with same name in the same folder without overwrite", func() {
 				firstSaveCmd := m.SaveDashboardCommand{
 				firstSaveCmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
@@ -261,20 +324,49 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
 				So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
 			})
 			})
 
 
-			Convey("Should not be able to save dashboard with same uid", func() {
+			Convey("Should be able to save and update dashboard using same uid", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
 						"id":    nil,
-						"title": "test dash 23",
 						"uid":   "dsfalkjngailuedt",
 						"uid":   "dsfalkjngailuedt",
+						"title": "test dash 23",
 					}),
 					}),
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				err = SaveDashboard(&cmd)
 				err = SaveDashboard(&cmd)
-				So(err, ShouldNotBeNil)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should be able to update dashboard using uid", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":   savedDash.Uid,
+						"title": "new title",
+					}),
+					FolderId:  0,
+					Overwrite: true,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+
+				Convey("Should be able to get updated dashboard by uid", func() {
+					query := m.GetDashboardQuery{
+						Uid:   savedDash.Uid,
+						OrgId: 1,
+					}
+
+					err := GetDashboard(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result.Id, ShouldEqual, savedDash.Id)
+					So(query.Result.Title, ShouldEqual, "new title")
+					So(query.Result.FolderId, ShouldEqual, 0)
+				})
 			})
 			})
 
 
 			Convey("Should be able to update dashboard with the same title and folder id", func() {
 			Convey("Should be able to update dashboard with the same title and folder id", func() {
@@ -310,7 +402,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 			})
 			})
 
 
-			Convey("Should not be able to update using just uid", func() {
+			Convey("Should be able to update using uid without id and overwrite", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
@@ -322,23 +414,6 @@ func TestDashboardDataAccess(t *testing.T) {
 					FolderId: savedDash.FolderId,
 					FolderId: savedDash.FolderId,
 				}
 				}
 
 
-				err := SaveDashboard(&cmd)
-				So(err, ShouldEqual, m.ErrDashboardWithSameUIDExists)
-			})
-
-			Convey("Should be able to update using just uid with overwrite", func() {
-				cmd := m.SaveDashboardCommand{
-					OrgId: 1,
-					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"uid":     savedDash.Uid,
-						"title":   "folderId",
-						"version": savedDash.Version,
-						"tags":    []interface{}{},
-					}),
-					FolderId:  savedDash.FolderId,
-					Overwrite: true,
-				}
-
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 			})
 			})
@@ -367,11 +442,11 @@ func TestDashboardDataAccess(t *testing.T) {
 				generateNewUid = util.GenerateShortUid
 				generateNewUid = util.GenerateShortUid
 			})
 			})
 
 
-			Convey("Should be able to update dashboard and remove folderId", func() {
+			Convey("Should be able to update dashboard by id and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    1,
+						"id":    savedDash.Id,
 						"title": "folderId",
 						"title": "folderId",
 						"tags":  []interface{}{},
 						"tags":  []interface{}{},
 					}),
 					}),
@@ -386,7 +461,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				cmd = m.SaveDashboardCommand{
 				cmd = m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    1,
+						"id":    savedDash.Id,
 						"title": "folderId",
 						"title": "folderId",
 						"tags":  []interface{}{},
 						"tags":  []interface{}{},
 					}),
 					}),
@@ -398,7 +473,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
 				query := m.GetDashboardQuery{
 				query := m.GetDashboardQuery{
-					Slug:  cmd.Result.Slug,
+					Id:    savedDash.Id,
 					OrgId: 1,
 					OrgId: 1,
 				}
 				}
 
 
@@ -433,6 +508,63 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(len(query.Result), ShouldEqual, 2)
 				So(len(query.Result), ShouldEqual, 2)
 			})
 			})
 
 
+			Convey("Should be able to search for dashboard folder", func() {
+				query := search.FindPersistedDashboardsQuery{
+					Title:        "1 test dash folder",
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{OrgId: 1},
+				}
+
+				err := SearchDashboards(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 1)
+				hit := query.Result[0]
+				So(hit.Type, ShouldEqual, search.DashHitFolder)
+				So(hit.Url, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
+				So(hit.FolderTitle, ShouldEqual, "")
+			})
+
+			Convey("Should be able to search for a dashboard folder's children", func() {
+				query := search.FindPersistedDashboardsQuery{
+					OrgId:        1,
+					FolderIds:    []int64{savedFolder.Id},
+					SignedInUser: &m.SignedInUser{OrgId: 1},
+				}
+
+				err := SearchDashboards(&query)
+				So(err, ShouldBeNil)
+
+				So(len(query.Result), ShouldEqual, 2)
+				hit := query.Result[0]
+				So(hit.Id, ShouldEqual, savedDash.Id)
+				So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
+				So(hit.FolderId, ShouldEqual, savedFolder.Id)
+				So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
+				So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
+				So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
+			})
+
+			Convey("Should be able to search for dashboard by dashboard ids", func() {
+				Convey("should be able to find two dashboards by id", func() {
+					query := search.FindPersistedDashboardsQuery{
+						DashboardIds: []int64{2, 3},
+						SignedInUser: &m.SignedInUser{OrgId: 1},
+					}
+
+					err := SearchDashboards(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+
+					hit := query.Result[0]
+					So(len(hit.Tags), ShouldEqual, 2)
+
+					hit2 := query.Result[1]
+					So(len(hit2.Tags), ShouldEqual, 1)
+				})
+			})
+
 			Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() {
 			Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() {
 				starredDash := insertTestDashboard("starred dash", 1, 0, false)
 				starredDash := insertTestDashboard("starred dash", 1, 0, false)
 				StarDashboard(&m.StarDashboardCommand{
 				StarDashboard(&m.StarDashboardCommand{

+ 28 - 3
pkg/services/sqlstore/datasource_test.go

@@ -1,6 +1,8 @@
 package sqlstore
 package sqlstore
 
 
 import (
 import (
+	"os"
+	"strings"
 	"testing"
 	"testing"
 
 
 	"github.com/go-xorm/xorm"
 	"github.com/go-xorm/xorm"
@@ -11,10 +13,33 @@ import (
 	"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
 	"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
 )
 )
 
 
+var (
+	dbSqlite   = "sqlite"
+	dbMySql    = "mysql"
+	dbPostgres = "postgres"
+)
+
 func InitTestDB(t *testing.T) *xorm.Engine {
 func InitTestDB(t *testing.T) *xorm.Engine {
-	x, err := xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr)
-	//x, err := xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
-	//x, err := xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
+	selectedDb := dbSqlite
+	//selectedDb := dbMySql
+	//selectedDb := dbPostgres
+
+	var x *xorm.Engine
+	var err error
+
+	// environment variable present for test db?
+	if db, present := os.LookupEnv("GRAFANA_TEST_DB"); present {
+		selectedDb = db
+	}
+
+	switch strings.ToLower(selectedDb) {
+	case dbMySql:
+		x, err = xorm.NewEngine(sqlutil.TestDB_Mysql.DriverName, sqlutil.TestDB_Mysql.ConnStr)
+	case dbPostgres:
+		x, err = xorm.NewEngine(sqlutil.TestDB_Postgres.DriverName, sqlutil.TestDB_Postgres.ConnStr)
+	default:
+		x, err = xorm.NewEngine(sqlutil.TestDB_Sqlite3.DriverName, sqlutil.TestDB_Sqlite3.ConnStr)
+	}
 
 
 	// x.ShowSQL()
 	// x.ShowSQL()
 
 

+ 5 - 0
pkg/services/sqlstore/migrations/dashboard_mig.go

@@ -168,7 +168,12 @@ func addDashboardMigration(mg *Migrator) {
 		Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
 		Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
 	}))
 	}))
 
 
+	mg.AddMigration("Update dashboard title length", NewTableCharsetMigration("dashboard", []*Column{
+		{Name: "title", Type: DB_NVarchar, Length: 189, Nullable: false},
+	}))
+
 	mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{
 	mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{
 		Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex,
 		Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex,
 	}))
 	}))
+
 }
 }

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

@@ -123,6 +123,31 @@ func TestAccountDataAccess(t *testing.T) {
 					So(query.Result[0].Role, ShouldEqual, "Admin")
 					So(query.Result[0].Role, ShouldEqual, "Admin")
 				})
 				})
 
 
+				Convey("Can get organization users with query", func() {
+					query := m.GetOrgUsersQuery{
+						OrgId: ac1.OrgId,
+						Query: "ac1",
+					}
+					err := GetOrgUsers(&query)
+
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Email, ShouldEqual, ac1.Email)
+				})
+
+				Convey("Can get organization users with query and limit", func() {
+					query := m.GetOrgUsersQuery{
+						OrgId: ac1.OrgId,
+						Query: "ac",
+						Limit: 1,
+					}
+					err := GetOrgUsers(&query)
+
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Email, ShouldEqual, ac1.Email)
+				})
+
 				Convey("Can set using org", func() {
 				Convey("Can set using org", func() {
 					cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id}
 					cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id}
 					err := SetUsingOrg(&cmd)
 					err := SetUsingOrg(&cmd)

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

@@ -2,6 +2,7 @@ package sqlstore
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/grafana/grafana/pkg/bus"
 	"github.com/grafana/grafana/pkg/bus"
@@ -69,9 +70,30 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error {
 
 
 func GetOrgUsers(query *m.GetOrgUsersQuery) error {
 func GetOrgUsers(query *m.GetOrgUsersQuery) error {
 	query.Result = make([]*m.OrgUserDTO, 0)
 	query.Result = make([]*m.OrgUserDTO, 0)
+
 	sess := x.Table("org_user")
 	sess := x.Table("org_user")
 	sess.Join("INNER", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user")))
 	sess.Join("INNER", "user", fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user")))
-	sess.Where("org_user.org_id=?", query.OrgId)
+
+	whereConditions := make([]string, 0)
+	whereParams := make([]interface{}, 0)
+
+	whereConditions = append(whereConditions, "org_user.org_id = ?")
+	whereParams = append(whereParams, query.OrgId)
+
+	if query.Query != "" {
+		queryWithWildcards := "%" + query.Query + "%"
+		whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
+		whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
+	}
+
+	if len(whereConditions) > 0 {
+		sess.Where(strings.Join(whereConditions, " AND "), whereParams...)
+	}
+
+	if query.Limit > 0 {
+		sess.Limit(query.Limit, 0)
+	}
+
 	sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role", "user.last_seen_at")
 	sess.Cols("org_user.org_id", "org_user.user_id", "user.email", "user.login", "org_user.role", "user.last_seen_at")
 	sess.Asc("user.email", "user.login")
 	sess.Asc("user.email", "user.login")
 
 

+ 17 - 16
pkg/services/sqlstore/team.go

@@ -25,7 +25,7 @@ func init() {
 func CreateTeam(cmd *m.CreateTeamCommand) error {
 func CreateTeam(cmd *m.CreateTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 
 
-		if isNameTaken, err := isTeamNameTaken(cmd.Name, 0, sess); err != nil {
+		if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, 0, sess); err != nil {
 			return err
 			return err
 		} else if isNameTaken {
 		} else if isNameTaken {
 			return m.ErrTeamNameTaken
 			return m.ErrTeamNameTaken
@@ -50,7 +50,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error {
 func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 
 
-		if isNameTaken, err := isTeamNameTaken(cmd.Name, cmd.Id, sess); err != nil {
+		if isNameTaken, err := isTeamNameTaken(cmd.OrgId, cmd.Name, cmd.Id, sess); err != nil {
 			return err
 			return err
 		} else if isNameTaken {
 		} else if isNameTaken {
 			return m.ErrTeamNameTaken
 			return m.ErrTeamNameTaken
@@ -80,20 +80,20 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 
 
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.Id); err != nil {
+		if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, cmd.Id); err != nil {
 			return err
 			return err
 		} else if len(res) != 1 {
 		} else if len(res) != 1 {
 			return m.ErrTeamNotFound
 			return m.ErrTeamNotFound
 		}
 		}
 
 
 		deletes := []string{
 		deletes := []string{
-			"DELETE FROM team_member WHERE team_id = ?",
-			"DELETE FROM team WHERE id = ?",
-			"DELETE FROM dashboard_acl WHERE team_id = ?",
+			"DELETE FROM team_member WHERE org_id=? and team_id = ?",
+			"DELETE FROM team WHERE org_id=? and id = ?",
+			"DELETE FROM dashboard_acl WHERE org_id=? and team_id = ?",
 		}
 		}
 
 
 		for _, sql := range deletes {
 		for _, sql := range deletes {
-			_, err := sess.Exec(sql, cmd.Id)
+			_, err := sess.Exec(sql, cmd.OrgId, cmd.Id)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
@@ -102,9 +102,9 @@ func DeleteTeam(cmd *m.DeleteTeamCommand) error {
 	})
 	})
 }
 }
 
 
-func isTeamNameTaken(name string, existingId int64, sess *DBSession) (bool, error) {
+func isTeamNameTaken(orgId int64, name string, existingId int64, sess *DBSession) (bool, error) {
 	var team m.Team
 	var team m.Team
-	exists, err := sess.Where("name=?", name).Get(&team)
+	exists, err := sess.Where("org_id=? and name=?", orgId, name).Get(&team)
 
 
 	if err != nil {
 	if err != nil {
 		return false, nil
 		return false, nil
@@ -128,6 +128,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 
 
 	sql.WriteString(`select
 	sql.WriteString(`select
 		team.id as id,
 		team.id as id,
+		team.org_id,
 		team.name as name,
 		team.name as name,
 		team.email as email,
 		team.email as email,
 		(select count(*) from team_member where team_member.team_id = team.id) as member_count
 		(select count(*) from team_member where team_member.team_id = team.id) as member_count
@@ -176,7 +177,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 
 
 func GetTeamById(query *m.GetTeamByIdQuery) error {
 func GetTeamById(query *m.GetTeamByIdQuery) error {
 	var team m.Team
 	var team m.Team
-	exists, err := x.Id(query.Id).Get(&team)
+	exists, err := x.Where("org_id=? and id=?", query.OrgId, query.Id).Get(&team)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -194,7 +195,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
 
 
 	sess := x.Table("team")
 	sess := x.Table("team")
 	sess.Join("INNER", "team_member", "team.id=team_member.team_id")
 	sess.Join("INNER", "team_member", "team.id=team_member.team_id")
-	sess.Where("team_member.user_id=?", query.UserId)
+	sess.Where("team.org_id=? and team_member.user_id=?", query.OrgId, query.UserId)
 
 
 	err := sess.Find(&query.Result)
 	err := sess.Find(&query.Result)
 	if err != nil {
 	if err != nil {
@@ -206,13 +207,13 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
 
 
 func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		if res, err := sess.Query("SELECT 1 from team_member WHERE team_id=? and user_id=?", cmd.TeamId, cmd.UserId); err != nil {
+		if res, err := sess.Query("SELECT 1 from team_member WHERE org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId); err != nil {
 			return err
 			return err
 		} else if len(res) == 1 {
 		} else if len(res) == 1 {
 			return m.ErrTeamMemberAlreadyAdded
 			return m.ErrTeamMemberAlreadyAdded
 		}
 		}
 
 
-		if res, err := sess.Query("SELECT 1 from team WHERE id=?", cmd.TeamId); err != nil {
+		if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", cmd.OrgId, cmd.TeamId); err != nil {
 			return err
 			return err
 		} else if len(res) != 1 {
 		} else if len(res) != 1 {
 			return m.ErrTeamNotFound
 			return m.ErrTeamNotFound
@@ -233,8 +234,8 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 
 
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		var rawSql = "DELETE FROM team_member WHERE team_id=? and user_id=?"
-		_, err := sess.Exec(rawSql, cmd.TeamId, cmd.UserId)
+		var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?"
+		_, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -247,7 +248,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
 	query.Result = make([]*m.TeamMemberDTO, 0)
 	sess := x.Table("team_member")
 	sess := x.Table("team_member")
 	sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user")))
 	sess.Join("INNER", "user", fmt.Sprintf("team_member.user_id=%s.id", x.Dialect().Quote("user")))
-	sess.Where("team_member.team_id=?", query.TeamId)
+	sess.Where("team_member.org_id=? and team_member.team_id=?", query.OrgId, query.TeamId)
 	sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login")
 	sess.Cols("user.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login")
 	sess.Asc("user.login", "user.email")
 	sess.Asc("user.login", "user.email")
 
 

+ 19 - 16
pkg/services/sqlstore/team_test.go

@@ -27,8 +27,9 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				userIds = append(userIds, userCmd.Result.Id)
 				userIds = append(userIds, userCmd.Result.Id)
 			}
 			}
 
 
-			group1 := m.CreateTeamCommand{Name: "group1 name", Email: "test1@test.com"}
-			group2 := m.CreateTeamCommand{Name: "group2 name", Email: "test2@test.com"}
+			var testOrgId int64 = 1
+			group1 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group1 name", Email: "test1@test.com"}
+			group2 := m.CreateTeamCommand{OrgId: testOrgId, Name: "group2 name", Email: "test2@test.com"}
 
 
 			err := CreateTeam(&group1)
 			err := CreateTeam(&group1)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
@@ -36,7 +37,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
 
 
 			Convey("Should be able to create teams and add users", func() {
 			Convey("Should be able to create teams and add users", func() {
-				query := &m.SearchTeamsQuery{Name: "group1 name", Page: 1, Limit: 10}
+				query := &m.SearchTeamsQuery{OrgId: testOrgId, Name: "group1 name", Page: 1, Limit: 10}
 				err = SearchTeams(query)
 				err = SearchTeams(query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(query.Page, ShouldEqual, 1)
 				So(query.Page, ShouldEqual, 1)
@@ -44,25 +45,27 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				team1 := query.Result.Teams[0]
 				team1 := query.Result.Teams[0]
 				So(team1.Name, ShouldEqual, "group1 name")
 				So(team1.Name, ShouldEqual, "group1 name")
 				So(team1.Email, ShouldEqual, "test1@test.com")
 				So(team1.Email, ShouldEqual, "test1@test.com")
+				So(team1.OrgId, ShouldEqual, testOrgId)
 
 
-				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]})
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team1.Id, UserId: userIds[0]})
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				q1 := &m.GetTeamMembersQuery{TeamId: team1.Id}
+				q1 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id}
 				err = GetTeamMembers(q1)
 				err = GetTeamMembers(q1)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(q1.Result[0].TeamId, ShouldEqual, team1.Id)
 				So(q1.Result[0].TeamId, ShouldEqual, team1.Id)
 				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
 				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
+				So(q1.Result[0].OrgId, ShouldEqual, testOrgId)
 			})
 			})
 
 
 			Convey("Should be able to search for teams", func() {
 			Convey("Should be able to search for teams", func() {
-				query := &m.SearchTeamsQuery{Query: "group", Page: 1}
+				query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1}
 				err = SearchTeams(query)
 				err = SearchTeams(query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(len(query.Result.Teams), ShouldEqual, 2)
 				So(len(query.Result.Teams), ShouldEqual, 2)
 				So(query.Result.TotalCount, ShouldEqual, 2)
 				So(query.Result.TotalCount, ShouldEqual, 2)
 
 
-				query2 := &m.SearchTeamsQuery{Query: ""}
+				query2 := &m.SearchTeamsQuery{OrgId: testOrgId, Query: ""}
 				err = SearchTeams(query2)
 				err = SearchTeams(query2)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(len(query2.Result.Teams), ShouldEqual, 2)
 				So(len(query2.Result.Teams), ShouldEqual, 2)
@@ -70,9 +73,9 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 
 
 			Convey("Should be able to return all teams a user is member of", func() {
 			Convey("Should be able to return all teams a user is member of", func() {
 				groupId := group2.Result.Id
 				groupId := group2.Result.Id
-				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[0]})
+				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[0]})
 
 
-				query := &m.GetTeamsByUserQuery{UserId: userIds[0]}
+				query := &m.GetTeamsByUserQuery{OrgId: testOrgId, UserId: userIds[0]}
 				err = GetTeamsByUser(query)
 				err = GetTeamsByUser(query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(len(query.Result), ShouldEqual, 1)
 				So(len(query.Result), ShouldEqual, 1)
@@ -81,7 +84,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 			})
 			})
 
 
 			Convey("Should be able to remove users from a group", func() {
 			Convey("Should be able to remove users from a group", func() {
-				err = RemoveTeamMember(&m.RemoveTeamMemberCommand{TeamId: group1.Result.Id, UserId: userIds[0]})
+				err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0]})
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
 				q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id}
 				q1 := &m.GetTeamMembersQuery{TeamId: group1.Result.Id}
@@ -92,20 +95,20 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 
 
 			Convey("Should be able to remove a group with users and permissions", func() {
 			Convey("Should be able to remove a group with users and permissions", func() {
 				groupId := group2.Result.Id
 				groupId := group2.Result.Id
-				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[1]})
+				err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]})
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
-				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: groupId, UserId: userIds[2]})
+				err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[2]})
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
-				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: 1, Permission: m.PERMISSION_EDIT, TeamId: groupId})
+				err = SetDashboardAcl(&m.SetDashboardAclCommand{DashboardId: 1, OrgId: testOrgId, Permission: m.PERMISSION_EDIT, TeamId: groupId})
 
 
-				err = DeleteTeam(&m.DeleteTeamCommand{Id: groupId})
+				err = DeleteTeam(&m.DeleteTeamCommand{OrgId: testOrgId, Id: groupId})
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 
-				query := &m.GetTeamByIdQuery{Id: groupId}
+				query := &m.GetTeamByIdQuery{OrgId: testOrgId, Id: groupId}
 				err = GetTeamById(query)
 				err = GetTeamById(query)
 				So(err, ShouldEqual, m.ErrTeamNotFound)
 				So(err, ShouldEqual, m.ErrTeamNotFound)
 
 
-				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
+				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: testOrgId}
 				err = GetDashboardAclInfoList(permQuery)
 				err = GetDashboardAclInfoList(permQuery)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 
 

+ 1 - 1
pkg/social/github_oauth.go

@@ -210,7 +210,7 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("Error getting user info: %s", err)
 		return nil, fmt.Errorf("Error getting user info: %s", err)
 	}
 	}
-
+	data.OrganizationsUrl = s.apiUrl + "/user/orgs"
 	userInfo := &BasicUserInfo{
 	userInfo := &BasicUserInfo{
 		Name:  data.Login,
 		Name:  data.Login,
 		Login: data.Login,
 		Login: data.Login,

+ 4 - 4
public/app/core/components/Picker/UserPicker.tsx

@@ -31,7 +31,7 @@ class UserPicker extends Component<IProps, any> {
 
 
     this.debouncedSearch = debounce(this.search, 300, {
     this.debouncedSearch = debounce(this.search, 300, {
       leading: true,
       leading: true,
-      trailing: false,
+      trailing: true,
     });
     });
   }
   }
 
 
@@ -39,10 +39,10 @@ class UserPicker extends Component<IProps, any> {
     const { toggleLoading, backendSrv } = this.props;
     const { toggleLoading, backendSrv } = this.props;
 
 
     toggleLoading(true);
     toggleLoading(true);
-    return backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
-      const users = result.users.map(user => {
+    return backendSrv.get(`/api/org/users?query=${query}&limit=10`).then(result => {
+      const users = result.map(user => {
         return {
         return {
-          id: user.id,
+          id: user.userId,
           label: `${user.login} - ${user.email}`,
           label: `${user.login} - ${user.email}`,
           avatarUrl: user.avatarUrl,
           avatarUrl: user.avatarUrl,
           login: user.login,
           login: user.login,

+ 3 - 2
public/app/core/components/ScrollBar/ScrollBar.tsx

@@ -7,7 +7,6 @@ export interface Props {
 }
 }
 
 
 export default class ScrollBar extends React.Component<Props, any> {
 export default class ScrollBar extends React.Component<Props, any> {
-
   private container: any;
   private container: any;
   private ps: PerfectScrollbar;
   private ps: PerfectScrollbar;
 
 
@@ -16,7 +15,9 @@ export default class ScrollBar extends React.Component<Props, any> {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    this.ps = new PerfectScrollbar(this.container);
+    this.ps = new PerfectScrollbar(this.container, {
+      wheelPropagation: true,
+    });
   }
   }
 
 
   componentDidUpdate() {
   componentDidUpdate() {

+ 6 - 0
public/app/core/components/form_dropdown/form_dropdown.ts

@@ -34,6 +34,7 @@ export class FormDropdownCtrl {
   lookupText: boolean;
   lookupText: boolean;
   placeholder: any;
   placeholder: any;
   startOpen: any;
   startOpen: any;
+  debounce: number;
 
 
   /** @ngInject **/
   /** @ngInject **/
   constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
   constructor(private $scope, $element, private $sce, private templateSrv, private $q) {
@@ -72,6 +73,10 @@ export class FormDropdownCtrl {
       this.source(this.query, this.process.bind(this));
       this.source(this.query, this.process.bind(this));
     };
     };
 
 
+    if (this.debounce) {
+      typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
+    }
+
     this.linkElement.keydown(evt => {
     this.linkElement.keydown(evt => {
       // trigger typeahead on down arrow or enter key
       // trigger typeahead on down arrow or enter key
       if (evt.keyCode === 40 || evt.keyCode === 13) {
       if (evt.keyCode === 40 || evt.keyCode === 13) {
@@ -263,6 +268,7 @@ export function formDropdownDirective() {
       lookupText: '@',
       lookupText: '@',
       placeholder: '@',
       placeholder: '@',
       startOpen: '@',
       startOpen: '@',
+      debounce: '@',
     },
     },
   };
   };
 }
 }

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

@@ -19,7 +19,6 @@ export class HelpCtrl {
       ],
       ],
       Dashboard: [
       Dashboard: [
         { keys: ['mod+s'], description: 'Save dashboard' },
         { keys: ['mod+s'], description: 'Save dashboard' },
-        { keys: ['mod+h'], description: 'Hide row controls' },
         { keys: ['d', 'r'], description: 'Refresh all panels' },
         { keys: ['d', 'r'], description: 'Refresh all panels' },
         { keys: ['d', 's'], description: 'Dashboard settings' },
         { keys: ['d', 's'], description: 'Dashboard settings' },
         { keys: ['d', 'v'], description: 'Toggle in-active / view mode' },
         { keys: ['d', 'v'], description: 'Toggle in-active / view mode' },

+ 3 - 7
public/app/core/components/org_switcher.ts

@@ -1,5 +1,6 @@
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import { contextSrv } from 'app/core/services/context_srv';
 import { contextSrv } from 'app/core/services/context_srv';
+import config from 'app/core/config';
 
 
 const template = `
 const template = `
 <div class="modal-body">
 <div class="modal-body">
@@ -60,16 +61,11 @@ export class OrgSwitchCtrl {
 
 
   setUsingOrg(org) {
   setUsingOrg(org) {
     return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
     return this.backendSrv.post('/api/user/using/' + org.orgId).then(() => {
-      const re = /orgId=\d+/gi;
-      this.setWindowLocationHref(this.getWindowLocationHref().replace(re, 'orgId=' + org.orgId));
+      this.setWindowLocation(config.appSubUrl + (config.appSubUrl.endsWith('/') ? '' : '/') + '?orgId=' + org.orgId);
     });
     });
   }
   }
 
 
-  getWindowLocationHref() {
-    return window.location.href;
-  }
-
-  setWindowLocationHref(href: string) {
+  setWindowLocation(href: string) {
     window.location.href = href;
     window.location.href = href;
   }
   }
 }
 }

+ 6 - 0
public/app/core/components/query_part/query_part_editor.ts

@@ -23,11 +23,13 @@ export function queryPartEditorDirective($compile, templateSrv) {
     scope: {
     scope: {
       part: '=',
       part: '=',
       handleEvent: '&',
       handleEvent: '&',
+      debounce: '@',
     },
     },
     link: function postLink($scope, elem) {
     link: function postLink($scope, elem) {
       var part = $scope.part;
       var part = $scope.part;
       var partDef = part.def;
       var partDef = part.def;
       var $paramsContainer = elem.find('.query-part-parameters');
       var $paramsContainer = elem.find('.query-part-parameters');
+      var debounceLookup = $scope.debounce;
 
 
       $scope.partActions = [];
       $scope.partActions = [];
 
 
@@ -128,6 +130,10 @@ export function queryPartEditorDirective($compile, templateSrv) {
           var items = this.source(this.query, $.proxy(this.process, this));
           var items = this.source(this.query, $.proxy(this.process, this));
           return items ? this.process(items) : items;
           return items ? this.process(items) : items;
         };
         };
+
+        if (debounceLookup) {
+          typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
+        }
       }
       }
 
 
       $scope.showActionsMenu = function() {
       $scope.showActionsMenu = function() {

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

@@ -6,7 +6,9 @@ export function geminiScrollbar() {
   return {
   return {
     restrict: 'A',
     restrict: 'A',
     link: function(scope, elem, attrs) {
     link: function(scope, elem, attrs) {
-      let scrollbar = new PerfectScrollbar(elem[0]);
+      let scrollbar = new PerfectScrollbar(elem[0], {
+        wheelPropagation: true,
+      });
       let lastPos = 0;
       let lastPos = 0;
 
 
       appEvents.on(
       appEvents.on(

+ 6 - 0
public/app/core/directives/metric_segment.js

@@ -22,6 +22,7 @@ function (_, $, coreModule) {
         segment: "=",
         segment: "=",
         getOptions: "&",
         getOptions: "&",
         onChange: "&",
         onChange: "&",
+        debounce: "@",
       },
       },
       link: function($scope, elem) {
       link: function($scope, elem) {
         var $input = $(inputTemplate);
         var $input = $(inputTemplate);
@@ -30,6 +31,7 @@ function (_, $, coreModule) {
         var options = null;
         var options = null;
         var cancelBlur = null;
         var cancelBlur = null;
         var linkMode = true;
         var linkMode = true;
+        var debounceLookup = $scope.debounce;
 
 
         $input.appendTo(elem);
         $input.appendTo(elem);
         $button.appendTo(elem);
         $button.appendTo(elem);
@@ -135,6 +137,10 @@ function (_, $, coreModule) {
           return items ? this.process(items) : items;
           return items ? this.process(items) : items;
         };
         };
 
 
+        if (debounceLookup) {
+          typeahead.lookup = _.debounce(typeahead.lookup, 500, {leading: true});
+        }
+
         $button.keydown(function(evt) {
         $button.keydown(function(evt) {
           // trigger typeahead on down arrow or enter key
           // trigger typeahead on down arrow or enter key
           if (evt.keyCode === 40 || evt.keyCode === 13) {
           if (evt.keyCode === 40 || evt.keyCode === 13) {

+ 64 - 0
public/app/core/specs/file_export.jest.ts

@@ -0,0 +1,64 @@
+import * as fileExport from '../utils/file_export';
+import { beforeEach, expect } from 'test/lib/common';
+
+describe('file_export', () => {
+  let ctx: any = {};
+
+  beforeEach(() => {
+    ctx.seriesList = [
+      {
+        alias: 'series_1',
+        datapoints: [
+          [1, 1500026100000],
+          [2, 1500026200000],
+          [null, 1500026300000],
+          [null, 1500026400000],
+          [null, 1500026500000],
+          [6, 1500026600000],
+        ],
+      },
+      {
+        alias: 'series_2',
+        datapoints: [[11, 1500026100000], [12, 1500026200000], [13, 1500026300000], [15, 1500026500000]],
+      },
+    ];
+
+    ctx.timeFormat = 'X'; // Unix timestamp (seconds)
+  });
+
+  describe('when exporting series as rows', () => {
+    it('should export points in proper order', () => {
+      let text = fileExport.convertSeriesListToCsv(ctx.seriesList, ctx.timeFormat);
+      const expectedText =
+        'Series;Time;Value\n' +
+        'series_1;1500026100;1\n' +
+        'series_1;1500026200;2\n' +
+        'series_1;1500026300;null\n' +
+        'series_1;1500026400;null\n' +
+        'series_1;1500026500;null\n' +
+        'series_1;1500026600;6\n' +
+        'series_2;1500026100;11\n' +
+        'series_2;1500026200;12\n' +
+        'series_2;1500026300;13\n' +
+        'series_2;1500026500;15\n';
+
+      expect(text).toBe(expectedText);
+    });
+  });
+
+  describe('when exporting series as columns', () => {
+    it('should export points in proper order', () => {
+      let text = fileExport.convertSeriesListToCsvColumns(ctx.seriesList, ctx.timeFormat);
+      const expectedText =
+        'Time;series_1;series_2\n' +
+        '1500026100;1;11\n' +
+        '1500026200;2;12\n' +
+        '1500026300;null;13\n' +
+        '1500026400;null;null\n' +
+        '1500026500;null;15\n' +
+        '1500026600;6;null\n';
+
+      expect(text).toBe(expectedText);
+    });
+  });
+});

+ 9 - 4
public/app/core/specs/org_switcher.jest.ts

@@ -7,6 +7,12 @@ jest.mock('app/core/services/context_srv', () => ({
   },
   },
 }));
 }));
 
 
+jest.mock('app/core/config', () => {
+  return {
+    appSubUrl: '/subUrl',
+  };
+});
+
 describe('OrgSwitcher', () => {
 describe('OrgSwitcher', () => {
   describe('when switching org', () => {
   describe('when switching org', () => {
     let expectedHref;
     let expectedHref;
@@ -25,8 +31,7 @@ describe('OrgSwitcher', () => {
 
 
       const orgSwitcherCtrl = new OrgSwitchCtrl(backendSrvStub);
       const orgSwitcherCtrl = new OrgSwitchCtrl(backendSrvStub);
 
 
-      orgSwitcherCtrl.getWindowLocationHref = () => 'http://localhost:3000?orgId=1&from=now-3h&to=now';
-      orgSwitcherCtrl.setWindowLocationHref = href => (expectedHref = href);
+      orgSwitcherCtrl.setWindowLocation = href => (expectedHref = href);
 
 
       return orgSwitcherCtrl.setUsingOrg({ orgId: 2 });
       return orgSwitcherCtrl.setUsingOrg({ orgId: 2 });
     });
     });
@@ -35,8 +40,8 @@ describe('OrgSwitcher', () => {
       expect(expectedUsingUrl).toBe('/api/user/using/2');
       expect(expectedUsingUrl).toBe('/api/user/using/2');
     });
     });
 
 
-    it('should switch orgId in url', () => {
-      expect(expectedHref).toBe('http://localhost:3000?orgId=2&from=now-3h&to=now');
+    it('should switch orgId in url and redirect to home page', () => {
+      expect(expectedHref).toBe('/subUrl/?orgId=2');
     });
     });
   });
   });
 });
 });

+ 53 - 6
public/app/core/utils/file_export.ts

@@ -3,19 +3,27 @@ import moment from 'moment';
 import { saveAs } from 'file-saver';
 import { saveAs } from 'file-saver';
 
 
 const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
 const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
+const POINT_TIME_INDEX = 1;
+const POINT_VALUE_INDEX = 0;
 
 
-export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
+export function convertSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
   var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
   var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
   _.each(seriesList, function(series) {
   _.each(seriesList, function(series) {
     _.each(series.datapoints, function(dp) {
     _.each(series.datapoints, function(dp) {
-      text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
+      text +=
+        series.alias + ';' + moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat) + ';' + dp[POINT_VALUE_INDEX] + '\n';
     });
     });
   });
   });
+  return text;
+}
+
+export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
+  var text = convertSeriesListToCsv(seriesList, dateTimeFormat, excel);
   saveSaveBlob(text, 'grafana_data_export.csv');
   saveSaveBlob(text, 'grafana_data_export.csv');
 }
 }
 
 
-export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
-  var text = (excel ? 'sep=;\n' : '') + 'Time;';
+export function convertSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
+  let text = (excel ? 'sep=;\n' : '') + 'Time;';
   // add header
   // add header
   _.each(seriesList, function(series) {
   _.each(seriesList, function(series) {
     text += series.alias + ';';
     text += series.alias + ';';
@@ -24,14 +32,15 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
   text += '\n';
   text += '\n';
 
 
   // process data
   // process data
+  seriesList = mergeSeriesByTime(seriesList);
   var dataArr = [[]];
   var dataArr = [[]];
   var sIndex = 1;
   var sIndex = 1;
   _.each(seriesList, function(series) {
   _.each(seriesList, function(series) {
     var cIndex = 0;
     var cIndex = 0;
     dataArr.push([]);
     dataArr.push([]);
     _.each(series.datapoints, function(dp) {
     _.each(series.datapoints, function(dp) {
-      dataArr[0][cIndex] = moment(dp[1]).format(dateTimeFormat);
-      dataArr[sIndex][cIndex] = dp[0];
+      dataArr[0][cIndex] = moment(dp[POINT_TIME_INDEX]).format(dateTimeFormat);
+      dataArr[sIndex][cIndex] = dp[POINT_VALUE_INDEX];
       cIndex++;
       cIndex++;
     });
     });
     sIndex++;
     sIndex++;
@@ -46,6 +55,44 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
     text = text.substring(0, text.length - 1);
     text = text.substring(0, text.length - 1);
     text += '\n';
     text += '\n';
   }
   }
+
+  return text;
+}
+
+/**
+ * Collect all unique timestamps from series list and use it to fill
+ * missing points by null.
+ */
+function mergeSeriesByTime(seriesList) {
+  let timestamps = [];
+  for (let i = 0; i < seriesList.length; i++) {
+    let seriesPoints = seriesList[i].datapoints;
+    for (let j = 0; j < seriesPoints.length; j++) {
+      timestamps.push(seriesPoints[j][POINT_TIME_INDEX]);
+    }
+  }
+  timestamps = _.sortedUniq(timestamps.sort());
+
+  for (let i = 0; i < seriesList.length; i++) {
+    let seriesPoints = seriesList[i].datapoints;
+    let seriesTimestamps = _.map(seriesPoints, p => p[POINT_TIME_INDEX]);
+    let extendedSeries = [];
+    let pointIndex;
+    for (let j = 0; j < timestamps.length; j++) {
+      pointIndex = _.sortedIndexOf(seriesTimestamps, timestamps[j]);
+      if (pointIndex !== -1) {
+        extendedSeries.push(seriesPoints[pointIndex]);
+      } else {
+        extendedSeries.push([null, timestamps[j]]);
+      }
+    }
+    seriesList[i].datapoints = extendedSeries;
+  }
+  return seriesList;
+}
+
+export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
+  let text = convertSeriesListToCsvColumns(seriesList, dateTimeFormat, excel);
   saveSaveBlob(text, 'grafana_data_export.csv');
   saveSaveBlob(text, 'grafana_data_export.csv');
 }
 }
 
 

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

@@ -1,4 +1,5 @@
 import appEvents from 'app/core/app_events';
 import appEvents from 'app/core/app_events';
+import locationUtil from 'app/core/utils/location_util';
 
 
 export class CreateFolderCtrl {
 export class CreateFolderCtrl {
   title = '';
   title = '';
@@ -19,7 +20,7 @@ export class CreateFolderCtrl {
 
 
     return this.backendSrv.createDashboardFolder(this.title).then(result => {
     return this.backendSrv.createDashboardFolder(this.title).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
-      this.$location.url(result.meta.url);
+      this.$location.url(locationUtil.stripBaseFromUrl(result.meta.url));
     });
     });
   }
   }
 
 

+ 2 - 3
public/app/features/dashboard/dashboard_import_ctrl.ts

@@ -18,7 +18,7 @@ export class DashboardImportCtrl {
   nameValidationError: any;
   nameValidationError: any;
 
 
   /** @ngInject */
   /** @ngInject */
-  constructor(private backendSrv, private validationSrv, navModelSrv, private $location, private $scope, $routeParams) {
+  constructor(private backendSrv, private validationSrv, navModelSrv, private $location, $routeParams) {
     this.navModel = navModelSrv.getNav('create', 'import');
     this.navModel = navModelSrv.getNav('create', 'import');
 
 
     this.step = 1;
     this.step = 1;
@@ -124,8 +124,7 @@ export class DashboardImportCtrl {
         inputs: inputs,
         inputs: inputs,
       })
       })
       .then(res => {
       .then(res => {
-        this.$location.url('dashboard/' + res.importedUri);
-        this.$scope.dismiss();
+        this.$location.url(res.importedUrl);
       });
       });
   }
   }
 
 

+ 10 - 6
public/app/features/dashboard/dashboard_srv.ts

@@ -1,5 +1,6 @@
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import { DashboardModel } from './dashboard_model';
 import { DashboardModel } from './dashboard_model';
+import locationUtil from 'app/core/utils/location_util';
 
 
 export class DashboardSrv {
 export class DashboardSrv {
   dash: any;
   dash: any;
@@ -19,7 +20,10 @@ export class DashboardSrv {
     return this.dash;
     return this.dash;
   }
   }
 
 
-  handleSaveDashboardError(clone, err) {
+  handleSaveDashboardError(clone, options, err) {
+    options = options || {};
+    options.overwrite = true;
+
     if (err.data && err.data.status === 'version-mismatch') {
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
       err.isHandled = true;
 
 
@@ -30,7 +34,7 @@ export class DashboardSrv {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.save(clone, { overwrite: true });
+          this.save(clone, options);
         },
         },
       });
       });
     }
     }
@@ -40,12 +44,12 @@ export class DashboardSrv {
 
 
       this.$rootScope.appEvent('confirm-modal', {
       this.$rootScope.appEvent('confirm-modal', {
         title: 'Conflict',
         title: 'Conflict',
-        text: 'Dashboard with the same name exists.',
+        text: 'A dashboard with the same name in selected folder already exists.',
         text2: 'Would you still like to save this dashboard?',
         text2: 'Would you still like to save this dashboard?',
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.save(clone, { overwrite: true });
+          this.save(clone, options);
         },
         },
       });
       });
     }
     }
@@ -74,7 +78,7 @@ export class DashboardSrv {
     this.dash.version = data.version;
     this.dash.version = data.version;
 
 
     if (data.url !== this.$location.path()) {
     if (data.url !== this.$location.path()) {
-      this.$location.url(data.url);
+      this.$location.url(locationUtil.stripBaseFromUrl(data.url)).replace();
     }
     }
 
 
     this.$rootScope.appEvent('dashboard-saved', this.dash);
     this.$rootScope.appEvent('dashboard-saved', this.dash);
@@ -90,7 +94,7 @@ export class DashboardSrv {
     return this.backendSrv
     return this.backendSrv
       .saveDashboard(clone, options)
       .saveDashboard(clone, options)
       .then(this.postSave.bind(this, clone))
       .then(this.postSave.bind(this, clone))
-      .catch(this.handleSaveDashboardError.bind(this, clone));
+      .catch(this.handleSaveDashboardError.bind(this, clone, options));
   }
   }
 
 
   saveDashboard(options, clone) {
   saveDashboard(options, clone) {

+ 0 - 1
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx

@@ -93,7 +93,6 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
   }
   }
 
 
   renderPanelItem(panel, index) {
   renderPanelItem(panel, index) {
-    console.log('render panel', index);
     return (
     return (
       <div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
       <div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
         <img className="add-panel__item-img" src={panel.info.logos.small} />
         <img className="add-panel__item-img" src={panel.info.logos.small} />

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

@@ -4,6 +4,7 @@ import _ from 'lodash';
 import angular from 'angular';
 import angular from 'angular';
 import moment from 'moment';
 import moment from 'moment';
 
 
+import locationUtil from 'app/core/utils/location_util';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
 import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
 import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
 
 
@@ -185,7 +186,7 @@ export class HistoryListCtrl {
     return this.historySrv
     return this.historySrv
       .restoreDashboard(this.dashboard, version)
       .restoreDashboard(this.dashboard, version)
       .then(response => {
       .then(response => {
-        this.$location.path('dashboard/db/' + response.slug);
+        this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace();
         this.$route.reload();
         this.$route.reload();
         this.$rootScope.appEvent('alert-success', ['Dashboard restored', 'Restored from version ' + version]);
         this.$rootScope.appEvent('alert-success', ['Dashboard restored', 'Restored from version ' + version]);
       })
       })

+ 1 - 1
public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts

@@ -22,7 +22,7 @@ describe('DashboardImportCtrl', function() {
       validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
       validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
     };
     };
 
 
-    ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});
+    ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {});
   });
   });
 
 
   describe('when uploading json', function() {
   describe('when uploading json', function() {

+ 1 - 1
public/app/features/org/partials/team_details.html

@@ -33,7 +33,7 @@
 				Old picker
 				Old picker
 				<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
 				<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
 				-->
 				-->
-				<select-user-picker handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
+				<select-user-picker  class="width-7" handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
       </div>
       </div>
     </form>
     </form>
 
 

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

@@ -100,7 +100,9 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
       // update scrollbar after mounting
       // update scrollbar after mounting
       ctrl.events.on('component-did-mount', () => {
       ctrl.events.on('component-did-mount', () => {
         if (ctrl.__proto__.constructor.scrollable) {
         if (ctrl.__proto__.constructor.scrollable) {
-          panelScrollbar = new PerfectScrollbar(panelContent[0]);
+          panelScrollbar = new PerfectScrollbar(panelContent[0], {
+            wheelPropagation: true,
+          });
         }
         }
       });
       });
 
 

+ 1 - 1
public/app/features/panel/solo_panel_ctrl.ts

@@ -9,7 +9,7 @@ export class SoloPanelCtrl {
 
 
     $scope.init = function() {
     $scope.init = function() {
       contextSrv.sidemenu = false;
       contextSrv.sidemenu = false;
-      appEvents.emit('toggle-sidemenu');
+      appEvents.emit('toggle-sidemenu-hidden');
 
 
       var params = $location.search();
       var params = $location.search();
       panelId = parseInt(params.panelId);
       panelId = parseInt(params.panelId);

+ 1 - 1
public/app/features/plugins/import_list/import_list.html

@@ -6,7 +6,7 @@
 					<i class="icon-gf icon-gf-dashboard"></i>
 					<i class="icon-gf icon-gf-dashboard"></i>
 				</td>
 				</td>
 				<td>
 				<td>
-					<a href="dashboard/{{dash.importedUri}}" ng-show="dash.imported">
+					<a href="{{dash.importedUrl}}" ng-show="dash.imported">
 						{{dash.title}}
 						{{dash.title}}
 					</a>
 					</a>
 					<span ng-show="!dash.imported">
 					<span ng-show="!dash.imported">

+ 1 - 1
public/app/features/plugins/partials/ds_list.html

@@ -52,7 +52,7 @@
 		<empty-list-cta model="{
 		<empty-list-cta model="{
 			title: 'There are no data sources defined yet',
 			title: 'There are no data sources defined yet',
 			buttonIcon: 'gicon gicon-add-datasources',
 			buttonIcon: 'gicon gicon-add-datasources',
-			buttonLink: '/datasources/new',
+			buttonLink: 'datasources/new',
 			buttonTitle: 'Add data source',
 			buttonTitle: 'Add data source',
 			proTip: 'You can also define data sources through configuration files.',
 			proTip: 'You can also define data sources through configuration files.',
 			proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',
 			proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',

+ 3 - 5
public/app/plugins/datasource/graphite/partials/query.editor.html

@@ -13,9 +13,9 @@
       <div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
       <div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
         <gf-form-dropdown
         <gf-form-dropdown
           model="tag.key"
           model="tag.key"
-          lookup-text="false"
           allow-custom="true"
           allow-custom="true"
           label-mode="true"
           label-mode="true"
+          debounce="true"
           placeholder="Tag key"
           placeholder="Tag key"
           css-class="query-segment-key"
           css-class="query-segment-key"
           get-options="ctrl.getTags($index, $query)"
           get-options="ctrl.getTags($index, $query)"
@@ -23,8 +23,6 @@
         />
         />
         <gf-form-dropdown
         <gf-form-dropdown
           model="tag.operator"
           model="tag.operator"
-          lookup-text="false"
-          allow-custom="false"
           label-mode="true"
           label-mode="true"
           css-class="query-segment-operator"
           css-class="query-segment-operator"
           get-options="ctrl.getTagOperators()"
           get-options="ctrl.getTagOperators()"
@@ -33,9 +31,9 @@
         />
         />
         <gf-form-dropdown
         <gf-form-dropdown
           model="tag.value"
           model="tag.value"
-          lookup-text="false"
           allow-custom="true"
           allow-custom="true"
           label-mode="true"
           label-mode="true"
+          debounce="true"
           css-class="query-segment-value"
           css-class="query-segment-value"
           placeholder="Tag value"
           placeholder="Tag value"
           get-options="ctrl.getTagValues(tag, $index, $query)"
           get-options="ctrl.getTagValues(tag, $index, $query)"
@@ -45,7 +43,7 @@
       </div>
       </div>
 
 
       <div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
       <div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
-        <metric-segment segment="segment" get-options="ctrl.getTagsAsSegments($query)" on-change="ctrl.addNewTag(segment)" />
+        <metric-segment segment="segment" get-options="ctrl.getTagsAsSegments($query)" on-change="ctrl.addNewTag(segment)" debounce="true" />
       </div>
       </div>
 
 
       <div ng-if="!ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">
       <div ng-if="!ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">

+ 1 - 0
public/app/plugins/panel/graph/legend.ts

@@ -246,6 +246,7 @@ module.directive('graphLegend', function(popoverSrv, $timeout) {
           // Number of pixels the content height can surpass the container height without enabling the scroll bar.
           // Number of pixels the content height can surpass the container height without enabling the scroll bar.
           scrollYMarginOffset: 2,
           scrollYMarginOffset: 2,
           suppressScrollX: true,
           suppressScrollX: true,
+          wheelPropagation: true,
         };
         };
 
 
         if (!legendScrollbar) {
         if (!legendScrollbar) {

+ 6 - 2
public/app/plugins/panel/heatmap/heatmap_tooltip.ts

@@ -153,8 +153,12 @@ export class HeatmapTooltip {
 
 
   getXBucketIndex(offsetX, data) {
   getXBucketIndex(offsetX, data) {
     let x = this.scope.xScale.invert(offsetX - this.scope.yAxisWidth).valueOf();
     let x = this.scope.xScale.invert(offsetX - this.scope.yAxisWidth).valueOf();
-    let xBucketIndex = getValueBucketBound(x, data.xBucketSize, 1);
-    return xBucketIndex;
+    // First try to find X bucket by checking x pos is in the
+    // [bucket.x, bucket.x + xBucketSize] interval
+    let xBucket = _.find(data.buckets, bucket => {
+      return x > bucket.x && x - bucket.x <= data.xBucketSize;
+    });
+    return xBucket ? xBucket.x : getValueBucketBound(x, data.xBucketSize, 1);
   }
   }
 
 
   getYBucketIndex(offsetY, data) {
   getYBucketIndex(offsetY, data) {

+ 1 - 2
public/app/routes/dashboard_loaders.ts

@@ -23,8 +23,7 @@ export class LoadDashboardCtrl {
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
       backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
       backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
         if (res) {
         if (res) {
-          const url = locationUtil.stripBaseFromUrl(res.meta.url);
-          $location.path(url).replace();
+          $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
         }
         }
       });
       });
       return;
       return;

+ 1 - 1
public/app/stores/FolderStore/FolderStore.ts

@@ -53,6 +53,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
 
 
-      return backendSrv.deleteDashboard(self.folder.url);
+      return backendSrv.deleteDashboard(self.folder.uid);
     }),
     }),
   }));
   }));

+ 2 - 0
public/app/stores/PermissionsStore/PermissionsStore.ts

@@ -108,6 +108,8 @@ export const PermissionsStore = types
         self.isFolder = isFolder;
         self.isFolder = isFolder;
         self.isInRoot = isInRoot;
         self.isInRoot = isInRoot;
         self.dashboardId = dashboardId;
         self.dashboardId = dashboardId;
+        self.items.clear();
+
         const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
         const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         self.items = items;
         self.items = items;

+ 9 - 2
public/sass/components/_dashboard_grid.scss

@@ -41,8 +41,15 @@
 
 
 .theme-dark {
 .theme-dark {
   .react-grid-item > .react-resizable-handle::after {
   .react-grid-item > .react-resizable-handle::after {
-    border-right: 2px solid rgba(255, 255, 255, 0.4);
-    border-bottom: 2px solid rgba(255, 255, 255, 0.4);
+    border-right: 2px solid $gray-4;
+    border-bottom: 2px solid $gray-4;
+  }
+}
+
+.theme-light {
+  .react-grid-item > .react-resizable-handle::after {
+    border-right: 2px solid $gray-3;
+    border-bottom: 2px solid $gray-3;
   }
   }
 }
 }
 
 

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

@@ -5,6 +5,7 @@
 
 
 .dashlist-section {
 .dashlist-section {
   margin-bottom: $spacer;
   margin-bottom: $spacer;
+  padding-top: 3px;
 }
 }
 
 
 .dashlist-link {
 .dashlist-link {

+ 0 - 28
public/views/407.html

@@ -1,28 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
-    <meta name="viewport" content="width=device-width">
-
-    <title>Grafana</title>
-    <base href="[[.AppSubUrl]]/" />
-
-    <link rel="stylesheet" href="public/build/grafana.dark.min.css" title="Dark">
-    <link rel="icon" type="image/png" href="public/img/fav32.png">
-  </head>
-
-	<body>
-		<div class="gf-box" style="margin: 200px auto 0 auto; width: 500px;">
-			<div class="gf-box-header">
-				<span class="gf-box-title">
-					Proxy authentication required
-				</span>
-			</div>
-
-			<div class="gf-box-body">
-				<h4>Proxy authenticaion required</h4>
-			</div>
-		</div>
-  </body>
-</html>

+ 0 - 39
public/views/500.html

@@ -1,39 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
-    <meta name="viewport" content="width=device-width">
-    <title>Grafana - Error</title>
-
-    <base href="[[.AppSubUrl]]/" />
-
-    <link href='public/css/fonts.min.css' rel='stylesheet' type='text/css'>
-		<link rel="stylesheet" href="public/build/grafana.dark.min.css">
-    <link rel="icon" type="image/png" href="public/img/fav32.png">
-  </head>
-
-  <body>
-    <div class="page-container">
-      <div class="page-header">
-        <h1>Server side error :(</h1>
-      </div>
-      <div class="panel-container" style="padding: 2rem">
-        <div class="alert">
-          <div class="alert-icon"><i class="fa fa-exclamation-triangle"></i></div>
-					<div class="alert-body">
-						<div class="alert-title">[[.Title]]</div>
-						<div class="alert-text">
-              [[if .ErrorMsg]]
-                <pre>[[.ErrorMsg]]</pre>
-              [[end]]
-            </div>
-					</div>
-        </div>
-        <div style="padding: 2rem 0 0">
-          <p>Check the Grafana server logs for the detailed error message.</p>
-        </div>
-      </div>
-    </div>
-  </body>
-</html>

+ 57 - 0
public/views/error.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <meta name="viewport" content="width=device-width">
+    <meta name="theme-color" content="#000">
+
+    <title>Grafana - Error</title>
+
+    <base href="[[.AppSubUrl]]/" />
+
+    <link rel="stylesheet" href="public/build/grafana.[[ .Theme ]].css?v[[ .BuildVersion ]]">
+
+    <link rel="icon" type="image/png" href="public/img/fav32.png">
+    <link rel="mask-icon" href="public/img/grafana_mask_icon.svg" color="#F05A28">
+
+  </head>
+
+  <body class="theme-[[ .Theme ]]">
+    <div class="main-view">
+      <div class="page-container">
+        <div class="page-header">
+          <div class="page-header__inner">
+            <span class="page-header__logo">
+              <i class="page-header__icon fa fa-frown-o"></i>
+            </span>
+            <div class="page-header__info-block">
+              <h1 class="page-header__title">
+                <a class="text-link" href="login">Grafana</a><span> / Server Error</span><span></span>
+              </h1>
+              <div class="page-header__sub-title">Sadly something went wrong</div>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="page-container page-body ng-scope" style="padding: 2rem">
+        <div class="alert">
+          <div class="alert-icon"><i class="fa fa-exclamation-triangle"></i></div>
+          <div class="alert-body">
+            <div class="alert-title">[[.Title]]</div>
+          </div>
+        </div>
+        <br />
+        [[if .ErrorMsg]]
+          <h4 class="page-heading">Error details</h4>
+          <div class="alert-text">
+            <pre>[[.ErrorMsg]]</pre>
+          </div>
+        [[end]]
+        <div style="padding: 2rem 0 0">
+          <p>Check the Grafana server logs for the detailed error message.</p>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>