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

Merge branch 'master' into docs_v5.0

Marcus Efraimsson 7 лет назад
Родитель
Сommit
2b31465254
65 измененных файлов с 997 добавлено и 416 удалено
  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)
 
-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
 - **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
 * **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)
 
 ## Fixes

+ 17 - 2
README.md

@@ -80,8 +80,11 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode =
 
 ### 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)
 
@@ -92,6 +95,18 @@ Writing & watching frontend tests (we have two test runners)
   - Start watcher: `npm run karma`
   - 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
 
 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
 
-> 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.
 
@@ -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.
 - [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.
+- [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
 
-<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 />
 
 ## New Dashboard Layout Engine
@@ -51,7 +53,7 @@ Almost every page has seen significant UX improvements. All pages (except dashbo
 
 <div class="clearfix"></div>
 
-### Dashboard Settings
+## Dashboard Settings
 
 {{< 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
@@ -95,7 +97,7 @@ data sources a user can access nor what queries a user can issue.
 
 <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.
 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
 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
 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).
 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.
+
+### 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"]
 type = "docs"
 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) {
 			teamsRoute.Get("/:teamId", wrap(GetTeamById))
 			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.
 		apiRoute.Group("/org", func(orgRoute RouteRegister) {

+ 10 - 2
pkg/api/dashboard.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"path"
+	"strings"
 
 	"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)
 	}
 
+	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 {
 		limitReached, err := middleware.QuotaReached(c, "dashboard")
 		if err != nil {
@@ -237,8 +242,11 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 
 	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 {

+ 15 - 0
pkg/api/dashboard_acl.go

@@ -13,6 +13,11 @@ import (
 func GetDashboardAclList(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 
 	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 {
 	dashId := c.ParamsInt64(":dashboardId")
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)
@@ -79,6 +89,11 @@ func DeleteDashboardAcl(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	aclId := c.ParamsInt64(":aclId")
 
+	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
+	if rsp != nil {
+		return rsp
+	}
+
 	guardian := guardian.NewDashboardGuardian(dashId, c.OrgId, c.SignedInUser)
 	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
 		return dashboardGuardianResponse(err)

+ 42 - 0
pkg/api/dashboard_acl_test.go

@@ -23,6 +23,14 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		}
 		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 {
 			query.Result = dtoRes
 			return nil
@@ -60,6 +68,40 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 					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() {

+ 2 - 2
pkg/api/login_oauth.go

@@ -40,14 +40,14 @@ func GenStateString() string {
 
 func OAuthLogin(ctx *middleware.Context) {
 	if setting.OAuthService == nil {
-		ctx.Handle(404, "login.OAuthLogin(oauth service not enabled)", nil)
+		ctx.Handle(404, "OAuth not enabled", nil)
 		return
 	}
 
 	name := ctx.Params(":name")
 	connect, ok := social.SocialMap[name]
 	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
 	}
 

+ 11 - 7
pkg/api/org_users.go

@@ -46,26 +46,30 @@ func addOrgUserHelper(cmd m.AddOrgUserCommand) Response {
 
 // GET /api/org/users
 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
 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)
 	}
 
-	for _, user := range query.Result {
+	for _, user := range q.Result {
 		user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
 	}
 
-	return Json(200, query.Result)
+	return Json(200, q.Result)
 }
 
 // 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
 func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
+	cmd.OrgId = c.OrgId
 	cmd.Id = c.ParamsInt64(":teamId")
 	if err := bus.Dispatch(&cmd); err != nil {
 		if err == m.ErrTeamNameTaken {
@@ -39,7 +40,7 @@ func UpdateTeam(c *middleware.Context, cmd m.UpdateTeamCommand) Response {
 
 // DELETE /api/teams/:teamId
 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 {
 			return ApiError(404, "Failed to delete Team. ID not found", nil)
 		}
@@ -60,11 +61,11 @@ func SearchTeams(c *middleware.Context) Response {
 	}
 
 	query := m.SearchTeamsQuery{
+		OrgId: c.OrgId,
 		Query: c.Query("query"),
 		Name:  c.Query("name"),
 		Page:  page,
 		Limit: perPage,
-		OrgId: c.OrgId,
 	}
 
 	if err := bus.Dispatch(&query); err != nil {
@@ -83,7 +84,7 @@ func SearchTeams(c *middleware.Context) Response {
 
 // GET /api/teams/:teamId
 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 == m.ErrTeamNotFound {

+ 2 - 2
pkg/api/team_members.go

@@ -10,7 +10,7 @@ import (
 
 // GET /api/teams/:teamId/members
 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 {
 		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
 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 ApiSuccess("Team Member removed")

+ 1 - 2
pkg/middleware/auth.go

@@ -42,8 +42,7 @@ func accessForbidden(c *Context) {
 		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) {

+ 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["AppSubUrl"] = setting.AppSubUrl
-	ctx.HTML(status, strconv.Itoa(status))
+	ctx.Data["Theme"] = "dark"
+
+	ctx.HTML(status, "error")
 }
 
 func (ctx *Context) JsonOK(message string) {

+ 1 - 1
pkg/middleware/recovery.go

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

+ 27 - 19
pkg/models/dashboards.go

@@ -13,17 +13,22 @@ import (
 
 // Typed errors
 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 {
@@ -95,14 +100,21 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 	dash.Data = data
 	dash.Title = dash.Data.Get("title").MustString()
 	dash.UpdateSlug()
+	update := false
 
 	if id, err := dash.Data.Get("id").Float64(); err == nil {
 		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 {
 		dash.Data.Set("version", 0)
 		dash.Created = time.Now()
@@ -113,10 +125,6 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 		dash.GnetId = int64(gnetId)
 	}
 
-	if uid, err := dash.Data.Get("uid").String(); err == nil {
-		dash.Uid = uid
-	}
-
 	return dash
 }
 

+ 4 - 1
pkg/models/org_user.go

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

+ 5 - 1
pkg/models/team.go

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

+ 2 - 0
pkg/models/team_member.go

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

+ 1 - 0
pkg/plugins/dashboard_importer.go

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

+ 2 - 0
pkg/plugins/dashboards.go

@@ -14,6 +14,7 @@ type PluginDashboardInfoDTO struct {
 	Title            string `json:"title"`
 	Imported         bool   `json:"imported"`
 	ImportedUri      string `json:"importedUri"`
+	ImportedUrl      string `json:"importedUrl"`
 	Slug             string `json:"slug"`
 	DashboardId      int64  `json:"dashboardId"`
 	ImportedRevision int64  `json:"importedRevision"`
@@ -64,6 +65,7 @@ func GetPluginDashboards(orgId int64, pluginId string) ([]*PluginDashboardInfoDT
 				res.DashboardId = existingDash.Id
 				res.Imported = true
 				res.ImportedUri = "db/" + existingDash.Slug
+				res.ImportedUrl = existingDash.GetUrl()
 				res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
 				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
 	}
 
-	query := m.GetTeamsByUserQuery{UserId: g.user.UserId}
+	query := m.GetTeamsByUserQuery{OrgId: g.orgId, UserId: g.user.UserId}
 	err := bus.Dispatch(&query)
 
 	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 {
 		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)
 		}
 
-		err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
-		if err != nil {
-			return err
-		}
-
 		err = setHasAcl(sess, dash)
 		if err != nil {
 			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) {
 	for i := 0; i < 3; i++ {
 		uid := generateNewUid()
@@ -179,23 +229,6 @@ func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
 	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 {
 	// check if parent has acl
 	if dash.FolderId > 0 {
@@ -363,10 +396,10 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
 
 	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
 		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`
 
-		err = x.Sql(sql, dialect.BooleanStr(true)).Find(&query.Result)
+		err = x.Sql(sql, dialect.BooleanStr(true), query.OrgId).Find(&query.Result)
 	} else {
 		params := make([]interface{}, 0)
 		sql := `SELECT distinct d.id, d.title
@@ -518,9 +551,7 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	params = append(params, query.UserId)
 	params = append(params, dialect.BooleanStr(false))
 
-	x.ShowSQL(true)
 	err := x.Sql(sql, params...).Find(&query.Result)
-	x.ShowSQL(false)
 
 	for _, p := range query.Result {
 		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")
 			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			insertTestDashboard("folder in another org", 2, 0, true, "prod")
 
 			adminUser := createUser("admin", "Admin", true)
 			editorUser := createUser("editor", "Editor", false)
 			viewerUser := createUser("viewer", "Viewer", false)
 
 			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{
 						OrgId:        1,
 						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)
 			})
 
-			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{
 					OrgId:     1,
 					Overwrite: true,
@@ -112,7 +112,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				}
 
 				err := SaveDashboard(&cmd)
-				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, m.ErrDashboardNotFound)
 			})
 
 			Convey("Should not be able to overwrite dashboard in another org", func() {
@@ -130,108 +130,171 @@ func TestDashboardDataAccess(t *testing.T) {
 				}
 
 				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(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(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,
 					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,
 					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{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
@@ -261,20 +324,49 @@ func TestDashboardDataAccess(t *testing.T) {
 				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{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
-						"title": "test dash 23",
 						"uid":   "dsfalkjngailuedt",
+						"title": "test dash 23",
 					}),
 				}
 
 				err := SaveDashboard(&cmd)
 				So(err, ShouldBeNil)
 				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() {
@@ -310,7 +402,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				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{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
@@ -322,23 +414,6 @@ func TestDashboardDataAccess(t *testing.T) {
 					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)
 				So(err, ShouldBeNil)
 			})
@@ -367,11 +442,11 @@ func TestDashboardDataAccess(t *testing.T) {
 				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{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    1,
+						"id":    savedDash.Id,
 						"title": "folderId",
 						"tags":  []interface{}{},
 					}),
@@ -386,7 +461,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				cmd = m.SaveDashboardCommand{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
-						"id":    1,
+						"id":    savedDash.Id,
 						"title": "folderId",
 						"tags":  []interface{}{},
 					}),
@@ -398,7 +473,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(err, ShouldBeNil)
 
 				query := m.GetDashboardQuery{
-					Slug:  cmd.Result.Slug,
+					Id:    savedDash.Id,
 					OrgId: 1,
 				}
 
@@ -433,6 +508,63 @@ func TestDashboardDataAccess(t *testing.T) {
 				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() {
 				starredDash := insertTestDashboard("starred dash", 1, 0, false)
 				StarDashboard(&m.StarDashboardCommand{

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

@@ -1,6 +1,8 @@
 package sqlstore
 
 import (
+	"os"
+	"strings"
 	"testing"
 
 	"github.com/go-xorm/xorm"
@@ -11,10 +13,33 @@ import (
 	"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
 )
 
+var (
+	dbSqlite   = "sqlite"
+	dbMySql    = "mysql"
+	dbPostgres = "postgres"
+)
+
 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()
 

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

@@ -168,7 +168,12 @@ func addDashboardMigration(mg *Migrator) {
 		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{
 		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")
 				})
 
+				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() {
 					cmd := m.SetUsingOrgCommand{UserId: ac2.Id, OrgId: ac1.Id}
 					err := SetUsingOrg(&cmd)

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

@@ -2,6 +2,7 @@ package sqlstore
 
 import (
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -69,9 +70,30 @@ func UpdateOrgUser(cmd *m.UpdateOrgUserCommand) error {
 
 func GetOrgUsers(query *m.GetOrgUsersQuery) error {
 	query.Result = make([]*m.OrgUserDTO, 0)
+
 	sess := x.Table("org_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.Asc("user.email", "user.login")
 

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

@@ -25,7 +25,7 @@ func init() {
 func CreateTeam(cmd *m.CreateTeamCommand) 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
 		} else if isNameTaken {
 			return m.ErrTeamNameTaken
@@ -50,7 +50,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error {
 func UpdateTeam(cmd *m.UpdateTeamCommand) 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
 		} else if isNameTaken {
 			return m.ErrTeamNameTaken
@@ -80,20 +80,20 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
 
 func DeleteTeam(cmd *m.DeleteTeamCommand) 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
 		} else if len(res) != 1 {
 			return m.ErrTeamNotFound
 		}
 
 		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 {
-			_, err := sess.Exec(sql, cmd.Id)
+			_, err := sess.Exec(sql, cmd.OrgId, cmd.Id)
 			if err != nil {
 				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
-	exists, err := sess.Where("name=?", name).Get(&team)
+	exists, err := sess.Where("org_id=? and name=?", orgId, name).Get(&team)
 
 	if err != nil {
 		return false, nil
@@ -128,6 +128,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
 
 	sql.WriteString(`select
 		team.id as id,
+		team.org_id,
 		team.name as name,
 		team.email as email,
 		(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 {
 	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 {
 		return err
 	}
@@ -194,7 +195,7 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
 
 	sess := x.Table("team")
 	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)
 	if err != nil {
@@ -206,13 +207,13 @@ func GetTeamsByUser(query *m.GetTeamsByUserQuery) error {
 
 func AddTeamMember(cmd *m.AddTeamMemberCommand) 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
 		} else if len(res) == 1 {
 			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
 		} else if len(res) != 1 {
 			return m.ErrTeamNotFound
@@ -233,8 +234,8 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error {
 
 func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) 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 {
 			return err
 		}
@@ -247,7 +248,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error {
 	query.Result = make([]*m.TeamMemberDTO, 0)
 	sess := x.Table("team_member")
 	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.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)
 			}
 
-			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)
 			So(err, ShouldBeNil)
@@ -36,7 +37,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 			So(err, ShouldBeNil)
 
 			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)
 				So(err, ShouldBeNil)
 				So(query.Page, ShouldEqual, 1)
@@ -44,25 +45,27 @@ func TestTeamCommandsAndQueries(t *testing.T) {
 				team1 := query.Result.Teams[0]
 				So(team1.Name, ShouldEqual, "group1 name")
 				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)
 
-				q1 := &m.GetTeamMembersQuery{TeamId: team1.Id}
+				q1 := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team1.Id}
 				err = GetTeamMembers(q1)
 				So(err, ShouldBeNil)
 				So(q1.Result[0].TeamId, ShouldEqual, team1.Id)
 				So(q1.Result[0].Login, ShouldEqual, "loginuser0")
+				So(q1.Result[0].OrgId, ShouldEqual, testOrgId)
 			})
 
 			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)
 				So(err, ShouldBeNil)
 				So(len(query.Result.Teams), ShouldEqual, 2)
 				So(query.Result.TotalCount, ShouldEqual, 2)
 
-				query2 := &m.SearchTeamsQuery{Query: ""}
+				query2 := &m.SearchTeamsQuery{OrgId: testOrgId, Query: ""}
 				err = SearchTeams(query2)
 				So(err, ShouldBeNil)
 				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() {
 				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)
 				So(err, ShouldBeNil)
 				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() {
-				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)
 
 				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() {
 				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)
-				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)
-				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)
 
-				query := &m.GetTeamByIdQuery{Id: groupId}
+				query := &m.GetTeamByIdQuery{OrgId: testOrgId, Id: groupId}
 				err = GetTeamById(query)
 				So(err, ShouldEqual, m.ErrTeamNotFound)
 
-				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: 1}
+				permQuery := &m.GetDashboardAclInfoListQuery{DashboardId: 1, OrgId: testOrgId}
 				err = GetDashboardAclInfoList(permQuery)
 				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 {
 		return nil, fmt.Errorf("Error getting user info: %s", err)
 	}
-
+	data.OrganizationsUrl = s.apiUrl + "/user/orgs"
 	userInfo := &BasicUserInfo{
 		Name:  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, {
       leading: true,
-      trailing: false,
+      trailing: true,
     });
   }
 
@@ -39,10 +39,10 @@ class UserPicker extends Component<IProps, any> {
     const { toggleLoading, backendSrv } = this.props;
 
     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 {
-          id: user.id,
+          id: user.userId,
           label: `${user.login} - ${user.email}`,
           avatarUrl: user.avatarUrl,
           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> {
-
   private container: any;
   private ps: PerfectScrollbar;
 
@@ -16,7 +15,9 @@ export default class ScrollBar extends React.Component<Props, any> {
   }
 
   componentDidMount() {
-    this.ps = new PerfectScrollbar(this.container);
+    this.ps = new PerfectScrollbar(this.container, {
+      wheelPropagation: true,
+    });
   }
 
   componentDidUpdate() {

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

@@ -34,6 +34,7 @@ export class FormDropdownCtrl {
   lookupText: boolean;
   placeholder: any;
   startOpen: any;
+  debounce: number;
 
   /** @ngInject **/
   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));
     };
 
+    if (this.debounce) {
+      typeahead.lookup = _.debounce(typeahead.lookup, 500, { leading: true });
+    }
+
     this.linkElement.keydown(evt => {
       // trigger typeahead on down arrow or enter key
       if (evt.keyCode === 40 || evt.keyCode === 13) {
@@ -263,6 +268,7 @@ export function formDropdownDirective() {
       lookupText: '@',
       placeholder: '@',
       startOpen: '@',
+      debounce: '@',
     },
   };
 }

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

@@ -19,7 +19,6 @@ export class HelpCtrl {
       ],
       Dashboard: [
         { keys: ['mod+s'], description: 'Save dashboard' },
-        { keys: ['mod+h'], description: 'Hide row controls' },
         { keys: ['d', 'r'], description: 'Refresh all panels' },
         { keys: ['d', 's'], description: 'Dashboard settings' },
         { 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 { contextSrv } from 'app/core/services/context_srv';
+import config from 'app/core/config';
 
 const template = `
 <div class="modal-body">
@@ -60,16 +61,11 @@ export class OrgSwitchCtrl {
 
   setUsingOrg(org) {
     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;
   }
 }

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

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

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

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

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

@@ -22,6 +22,7 @@ function (_, $, coreModule) {
         segment: "=",
         getOptions: "&",
         onChange: "&",
+        debounce: "@",
       },
       link: function($scope, elem) {
         var $input = $(inputTemplate);
@@ -30,6 +31,7 @@ function (_, $, coreModule) {
         var options = null;
         var cancelBlur = null;
         var linkMode = true;
+        var debounceLookup = $scope.debounce;
 
         $input.appendTo(elem);
         $button.appendTo(elem);
@@ -135,6 +137,10 @@ function (_, $, coreModule) {
           return items ? this.process(items) : items;
         };
 
+        if (debounceLookup) {
+          typeahead.lookup = _.debounce(typeahead.lookup, 500, {leading: true});
+        }
+
         $button.keydown(function(evt) {
           // trigger typeahead on down arrow or enter key
           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('when switching org', () => {
     let expectedHref;
@@ -25,8 +31,7 @@ describe('OrgSwitcher', () => {
 
       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 });
     });
@@ -35,8 +40,8 @@ describe('OrgSwitcher', () => {
       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';
 
 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';
   _.each(seriesList, function(series) {
     _.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');
 }
 
-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
   _.each(seriesList, function(series) {
     text += series.alias + ';';
@@ -24,14 +32,15 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
   text += '\n';
 
   // process data
+  seriesList = mergeSeriesByTime(seriesList);
   var dataArr = [[]];
   var sIndex = 1;
   _.each(seriesList, function(series) {
     var cIndex = 0;
     dataArr.push([]);
     _.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++;
     });
     sIndex++;
@@ -46,6 +55,44 @@ export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAUL
     text = text.substring(0, text.length - 1);
     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');
 }
 

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

@@ -1,4 +1,5 @@
 import appEvents from 'app/core/app_events';
+import locationUtil from 'app/core/utils/location_util';
 
 export class CreateFolderCtrl {
   title = '';
@@ -19,7 +20,7 @@ export class CreateFolderCtrl {
 
     return this.backendSrv.createDashboardFolder(this.title).then(result => {
       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;
 
   /** @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.step = 1;
@@ -124,8 +124,7 @@ export class DashboardImportCtrl {
         inputs: inputs,
       })
       .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 { DashboardModel } from './dashboard_model';
+import locationUtil from 'app/core/utils/location_util';
 
 export class DashboardSrv {
   dash: any;
@@ -19,7 +20,10 @@ export class DashboardSrv {
     return this.dash;
   }
 
-  handleSaveDashboardError(clone, err) {
+  handleSaveDashboardError(clone, options, err) {
+    options = options || {};
+    options.overwrite = true;
+
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
 
@@ -30,7 +34,7 @@ export class DashboardSrv {
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         onConfirm: () => {
-          this.save(clone, { overwrite: true });
+          this.save(clone, options);
         },
       });
     }
@@ -40,12 +44,12 @@ export class DashboardSrv {
 
       this.$rootScope.appEvent('confirm-modal', {
         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?',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         onConfirm: () => {
-          this.save(clone, { overwrite: true });
+          this.save(clone, options);
         },
       });
     }
@@ -74,7 +78,7 @@ export class DashboardSrv {
     this.dash.version = data.version;
 
     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);
@@ -90,7 +94,7 @@ export class DashboardSrv {
     return this.backendSrv
       .saveDashboard(clone, options)
       .then(this.postSave.bind(this, clone))
-      .catch(this.handleSaveDashboardError.bind(this, clone));
+      .catch(this.handleSaveDashboardError.bind(this, clone, options));
   }
 
   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) {
-    console.log('render panel', index);
     return (
       <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} />

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

@@ -4,6 +4,7 @@ import _ from 'lodash';
 import angular from 'angular';
 import moment from 'moment';
 
+import locationUtil from 'app/core/utils/location_util';
 import { DashboardModel } from '../dashboard_model';
 import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
 
@@ -185,7 +186,7 @@ export class HistoryListCtrl {
     return this.historySrv
       .restoreDashboard(this.dashboard, version)
       .then(response => {
-        this.$location.path('dashboard/db/' + response.slug);
+        this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace();
         this.$route.reload();
         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()),
     };
 
-    ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});
+    ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {});
   });
 
   describe('when uploading json', function() {

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

@@ -33,7 +33,7 @@
 				Old 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>
     </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
       ctrl.events.on('component-did-mount', () => {
         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() {
       contextSrv.sidemenu = false;
-      appEvents.emit('toggle-sidemenu');
+      appEvents.emit('toggle-sidemenu-hidden');
 
       var params = $location.search();
       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>
 				</td>
 				<td>
-					<a href="dashboard/{{dash.importedUri}}" ng-show="dash.imported">
+					<a href="{{dash.importedUrl}}" ng-show="dash.imported">
 						{{dash.title}}
 					</a>
 					<span ng-show="!dash.imported">

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

@@ -52,7 +52,7 @@
 		<empty-list-cta model="{
 			title: 'There are no data sources defined yet',
 			buttonIcon: 'gicon gicon-add-datasources',
-			buttonLink: '/datasources/new',
+			buttonLink: 'datasources/new',
 			buttonTitle: 'Add data source',
 			proTip: 'You can also define data sources through configuration files.',
 			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">
         <gf-form-dropdown
           model="tag.key"
-          lookup-text="false"
           allow-custom="true"
           label-mode="true"
+          debounce="true"
           placeholder="Tag key"
           css-class="query-segment-key"
           get-options="ctrl.getTags($index, $query)"
@@ -23,8 +23,6 @@
         />
         <gf-form-dropdown
           model="tag.operator"
-          lookup-text="false"
-          allow-custom="false"
           label-mode="true"
           css-class="query-segment-operator"
           get-options="ctrl.getTagOperators()"
@@ -33,9 +31,9 @@
         />
         <gf-form-dropdown
           model="tag.value"
-          lookup-text="false"
           allow-custom="true"
           label-mode="true"
+          debounce="true"
           css-class="query-segment-value"
           placeholder="Tag value"
           get-options="ctrl.getTagValues(tag, $index, $query)"
@@ -45,7 +43,7 @@
       </div>
 
       <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 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.
           scrollYMarginOffset: 2,
           suppressScrollX: true,
+          wheelPropagation: true,
         };
 
         if (!legendScrollbar) {

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

@@ -153,8 +153,12 @@ export class HeatmapTooltip {
 
   getXBucketIndex(offsetX, data) {
     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) {

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

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

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

@@ -53,6 +53,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
       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.isInRoot = isInRoot;
         self.dashboardId = dashboardId;
+        self.items.clear();
+
         const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         self.items = items;

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

@@ -41,8 +41,15 @@
 
 .theme-dark {
   .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 {
   margin-bottom: $spacer;
+  padding-top: 3px;
 }
 
 .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>