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

Merge pull request #10683 from grafana/7883_new_url_structure

New dashboard/folder url structure
Marcus Efraimsson 8 лет назад
Родитель
Сommit
f158a604a0
59 измененных файлов с 1947 добавлено и 601 удалено
  1. 7 1
      Gopkg.lock
  2. 4 0
      Gopkg.toml
  3. 2 1
      pkg/api/alerting.go
  4. 11 2
      pkg/api/api.go
  5. 55 7
      pkg/api/dashboard.go
  6. 341 22
      pkg/api/dashboard_test.go
  7. 1 0
      pkg/api/dtos/dashboard.go
  8. 46 0
      pkg/middleware/dashboard_redirect.go
  9. 56 0
      pkg/middleware/dashboard_redirect_test.go
  10. 14 0
      pkg/middleware/middleware_test.go
  11. 71 9
      pkg/models/dashboards.go
  12. 25 22
      pkg/services/alerting/eval_context.go
  13. 2 2
      pkg/services/alerting/notifier.go
  14. 2 1
      pkg/services/search/models.go
  15. 105 19
      pkg/services/sqlstore/dashboard.go
  16. 349 0
      pkg/services/sqlstore/dashboard_folder_test.go
  17. 196 335
      pkg/services/sqlstore/dashboard_test.go
  18. 2 2
      pkg/services/sqlstore/dashboard_version_test.go
  19. 17 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  20. 1 0
      pkg/services/sqlstore/search_builder.go
  21. 15 0
      pkg/util/shortid_generator.go
  22. 1 1
      public/app/containers/AlertRuleList/AlertRuleList.jest.tsx
  23. 1 1
      public/app/containers/AlertRuleList/AlertRuleList.tsx
  24. 2 2
      public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap
  25. 2 1
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  26. 2 2
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  27. 4 2
      public/app/containers/ManageDashboards/FolderSettings.tsx
  28. 9 9
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  29. 26 11
      public/app/core/services/backend_srv.ts
  30. 9 21
      public/app/core/services/bridge_srv.ts
  31. 8 16
      public/app/core/services/search_srv.ts
  32. 0 22
      public/app/core/specs/bridge_srv.jest.ts
  33. 16 0
      public/app/core/specs/location_util.jest.ts
  34. 8 8
      public/app/core/specs/manage_dashboards.jest.ts
  35. 14 0
      public/app/core/utils/location_util.ts
  36. 1 3
      public/app/features/dashboard/create_folder_ctrl.ts
  37. 3 3
      public/app/features/dashboard/dashboard_loader_srv.ts
  38. 2 0
      public/app/features/dashboard/dashboard_model.ts
  39. 2 3
      public/app/features/dashboard/dashboard_srv.ts
  40. 12 7
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  41. 5 8
      public/app/features/dashboard/folder_page_loader.ts
  42. 12 9
      public/app/features/dashboard/folder_permissions_ctrl.ts
  43. 18 14
      public/app/features/dashboard/folder_settings_ctrl.ts
  44. 1 1
      public/app/features/dashboard/partials/folder_dashboards.html
  45. 1 1
      public/app/features/dashboard/settings/settings.ts
  46. 2 0
      public/app/features/dashboard/shareModalCtrl.ts
  47. 13 2
      public/app/features/dashboard/specs/share_modal_ctrl_specs.ts
  48. 14 2
      public/app/features/panel/solo_panel_ctrl.ts
  49. 1 1
      public/app/plugins/panel/dashlist/module.html
  50. 21 3
      public/app/routes/dashboard_loaders.ts
  51. 15 3
      public/app/routes/routes.ts
  52. 9 8
      public/app/stores/FolderStore/FolderStore.ts
  53. 5 5
      public/app/stores/NavStore/NavStore.jest.ts
  54. 3 9
      public/app/stores/NavStore/NavStore.ts
  55. 1 0
      public/test/mocks/common.ts
  56. 1 0
      tests/test-app/dashboards/connections.json
  57. 1 0
      tests/test-app/dashboards/connections_result.json
  58. 18 0
      vendor/github.com/teris-io/shortid/LICENSE
  59. 362 0
      vendor/github.com/teris-io/shortid/shortid.go

+ 7 - 1
Gopkg.lock

@@ -412,6 +412,12 @@
   revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
   version = "1.6.3"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/teris-io/shortid"
+  packages = ["."]
+  revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c"
+
 [[projects]]
   name = "github.com/uber/jaeger-client-go"
   packages = [
@@ -625,6 +631,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01"
+  inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62"
   solver-name = "gps-cdcl"
   solver-version = 1

+ 4 - 0
Gopkg.toml

@@ -193,3 +193,7 @@ ignored = [
   non-go = true
   go-tests = true
   unused-packages = true
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/teris-io/shortid"

+ 2 - 1
pkg/api/alerting.go

@@ -105,7 +105,8 @@ func transformToDTOs(alerts []*models.Alert, c *middleware.Context) ([]*dtos.Ale
 	for _, alert := range alertDTOs {
 		for _, dash := range dashboardsQuery.Result {
 			if alert.DashboardId == dash.Id {
-				alert.DashbboardUri = "db/" + dash.Slug
+				alert.DashbboardUri = dash.GenerateUrl()
+				break
 			}
 		}
 	}

+ 11 - 2
pkg/api/api.go

@@ -15,6 +15,8 @@ func (hs *HttpServer) registerRoutes() {
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl()
+	redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl()
 	quota := middleware.Quota
 	bind := binding.Bind
 
@@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() {
 	r.Get("/plugins/:id/edit", reqSignedIn, Index)
 	r.Get("/plugins/:id/page/:page", reqSignedIn, Index)
 
-	r.Get("/dashboard/*", reqSignedIn, Index)
+	r.Get("/d/:uid/:slug", reqSignedIn, Index)
+	r.Get("/dashboard/db/:slug", reqSignedIn, redirectFromLegacyDashboardUrl, Index)
+	r.Get("/dashboard/script/*", reqSignedIn, Index)
 	r.Get("/dashboard-solo/snapshot/*", Index)
-	r.Get("/dashboard-solo/*", reqSignedIn, Index)
+	r.Get("/d-solo/:uid/:slug", reqSignedIn, Index)
+	r.Get("/dashboard-solo/db/:slug", reqSignedIn, redirectFromLegacyDashboardSoloUrl, Index)
+	r.Get("/dashboard-solo/script/*", reqSignedIn, Index)
 	r.Get("/import/dashboard", reqSignedIn, Index)
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
@@ -242,6 +248,9 @@ func (hs *HttpServer) registerRoutes() {
 
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
+			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
+			dashboardRoute.Delete("/uid/:uid", wrap(DeleteDashboardByUid))
+
 			dashboardRoute.Get("/db/:slug", wrap(GetDashboard))
 			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
 

+ 55 - 7
pkg/api/dashboard.go

@@ -44,7 +44,7 @@ func dashboardGuardianResponse(err error) Response {
 }
 
 func GetDashboard(c *middleware.Context) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, c.Params(":uid"))
 	if rsp != nil {
 		return rsp
 	}
@@ -89,6 +89,7 @@ func GetDashboard(c *middleware.Context) Response {
 		IsFolder:    dash.IsFolder,
 		FolderId:    dash.FolderId,
 		FolderTitle: "Root",
+		Url:         dash.GetUrl(),
 	}
 
 	// lookup folder title
@@ -124,8 +125,15 @@ func getUserLogin(userId int64) string {
 	}
 }
 
-func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) {
-	query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
+func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dashboard, Response) {
+	var query m.GetDashboardQuery
+
+	if len(uid) > 0 {
+		query = m.GetDashboardQuery{Uid: uid, Id: id, OrgId: orgId}
+	} else {
+		query = m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId}
+	}
+
 	if err := bus.Dispatch(&query); err != nil {
 		return nil, ApiError(404, "Dashboard not found", err)
 	}
@@ -133,7 +141,37 @@ func getDashboardHelper(orgId int64, slug string, id int64) (*m.Dashboard, Respo
 }
 
 func DeleteDashboard(c *middleware.Context) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0)
+	query := m.GetDashboardsBySlugQuery{OrgId: c.OrgId, Slug: c.Params(":slug")}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return ApiError(500, "Failed to retrieve dashboards by slug", err)
+	}
+
+	if len(query.Result) > 1 {
+		return Json(412, util.DynMap{"status": "multiple-slugs-exists", "message": m.ErrDashboardsWithSameSlugExists.Error()})
+	}
+
+	dash, rsp := getDashboardHelper(c.OrgId, c.Params(":slug"), 0, "")
+	if rsp != nil {
+		return rsp
+	}
+
+	guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		return dashboardGuardianResponse(err)
+	}
+
+	cmd := m.DeleteDashboardCommand{OrgId: c.OrgId, Id: dash.Id}
+	if err := bus.Dispatch(&cmd); err != nil {
+		return ApiError(500, "Failed to delete dashboard", err)
+	}
+
+	var resp = map[string]interface{}{"title": dash.Title}
+	return Json(200, resp)
+}
+
+func DeleteDashboardByUid(c *middleware.Context) Response {
+	dash, rsp := getDashboardHelper(c.OrgId, "", 0, c.Params(":uid"))
 	if rsp != nil {
 		return rsp
 	}
@@ -208,7 +246,7 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	}
 
 	if err != nil {
-		if err == m.ErrDashboardWithSameNameExists {
+		if err == m.ErrDashboardWithSameUIDExists {
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		if err == m.ErrDashboardVersionMismatch {
@@ -232,8 +270,17 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 
+	dashboard.IsFolder = dash.IsFolder
+
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
-	return Json(200, util.DynMap{"status": "success", "slug": dashboard.Slug, "version": dashboard.Version, "id": dashboard.Id})
+	return Json(200, util.DynMap{
+		"status":  "success",
+		"slug":    dashboard.Slug,
+		"version": dashboard.Version,
+		"id":      dashboard.Id,
+		"uid":     dashboard.Uid,
+		"url":     dashboard.GetUrl(),
+	})
 }
 
 func GetHomeDashboard(c *middleware.Context) Response {
@@ -400,7 +447,7 @@ func CalculateDashboardDiff(c *middleware.Context, apiOptions dtos.CalculateDiff
 
 // RestoreDashboardVersion restores a dashboard to the given version.
 func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
-	dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"))
+	dash, rsp := getDashboardHelper(c.OrgId, "", c.ParamsInt64(":dashboardId"), "")
 	if rsp != nil {
 		return rsp
 	}
@@ -423,6 +470,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard
 	saveCmd.UserId = c.UserId
 	saveCmd.Dashboard = version.Data
 	saveCmd.Dashboard.Set("version", dash.Version)
+	saveCmd.Dashboard.Set("uid", dash.Uid)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 
 	return PostDashboard(c, saveCmd)

+ 341 - 22
pkg/api/dashboard_test.go

@@ -39,8 +39,17 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.FolderId = 1
 		fakeDash.HasAcl = false
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeDash}
+			query.Result = dashboards
+			return nil
+		})
+
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 		})
 
@@ -77,9 +86,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Viewer", func() {
 			role := m.ROLE_VIEWER
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
@@ -87,9 +100,36 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
+				Convey("Should not be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeFalse)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -111,9 +151,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Editor", func() {
 			role := m.ROLE_EDITOR
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -121,9 +179,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -137,8 +208,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 
 			Convey("When saving a dashboard folder in another folder", func() {
@@ -172,6 +242,12 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.HasAcl = true
 		setting.ViewersCanEdit = false
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeDash}
+			query.Result = dashboards
+			return nil
+		})
+
 		aclMockResp := []*m.DashboardAclInfoDTO{
 			{
 				DashboardId: 1,
@@ -185,8 +261,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 		})
 
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 		})
 
@@ -215,18 +294,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
 			role := m.ROLE_VIEWER
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				sc.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -248,18 +357,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
 			role := m.ROLE_EDITOR
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				sc.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				sc.handlerFunc = GetDashboard
+				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
+				Convey("Should be denied access", func() {
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -290,9 +429,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 			})
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -300,9 +457,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -316,8 +486,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 		})
 
@@ -334,9 +503,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 			})
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+					So(dash.Meta.CanAdmin, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights but can save should be false", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeFalse)
@@ -344,9 +531,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 		})
 
@@ -362,9 +562,27 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 			})
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
+				Convey("Should be able to get dashboard with edit rights", func() {
+					So(dash.Meta.CanEdit, ShouldBeTrue)
+					So(dash.Meta.CanSave, ShouldBeTrue)
+					So(dash.Meta.CanAdmin, ShouldBeTrue)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeTrue)
@@ -372,9 +590,22 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -388,8 +619,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 
 			postDashboardScenario("When calling POST on", "/api/dashboards", "/api/dashboards", role, cmd, func(sc *scenarioContext) {
-				CallPostDashboard(sc)
-				So(sc.resp.Code, ShouldEqual, 200)
+				CallPostDashboardShouldReturnSuccess(sc)
 			})
 		})
 
@@ -405,18 +635,48 @@ func TestDashboardApiEndpoint(t *testing.T) {
 				return nil
 			})
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				dash := GetDashboardShouldReturn200(sc)
 
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 				})
 			})
 
-			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/2", "/api/dashboards/:id", role, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				dash := GetDashboardShouldReturn200(sc)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
+
+				Convey("Should not be able to edit or save dashboard", func() {
+					So(dash.Meta.CanEdit, ShouldBeFalse)
+					So(dash.Meta.CanSave, ShouldBeFalse)
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/child-dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by slug", func() {
+					So(getDashboardQueries[0].Slug, ShouldEqual, "child-dash")
+				})
+			})
+
+			loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/uid/abcdefghi", "/api/dashboards/uid/:uid", role, func(sc *scenarioContext) {
+				CallDeleteDashboardByUid(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+
+				Convey("Should lookup dashboard by uid", func() {
+					So(getDashboardQueries[0].Uid, ShouldEqual, "abcdefghi")
+				})
 			})
 
 			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/versions/1", "/api/dashboards/id/:dashboardId/versions/:id", role, func(sc *scenarioContext) {
@@ -435,6 +695,37 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			})
 		})
 	})
+
+	Convey("Given two dashboards with the same title in different folders", t, func() {
+		dashOne := m.NewDashboard("dash")
+		dashOne.Id = 2
+		dashOne.FolderId = 1
+		dashOne.HasAcl = false
+
+		dashTwo := m.NewDashboard("dash")
+		dashTwo.Id = 4
+		dashTwo.FolderId = 3
+		dashTwo.HasAcl = false
+
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{dashOne, dashTwo}
+			query.Result = dashboards
+			return nil
+		})
+
+		role := m.ROLE_EDITOR
+
+		loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/db/dash", "/api/dashboards/db/:slug", role, func(sc *scenarioContext) {
+			CallDeleteDashboard(sc)
+
+			Convey("Should result in 412 Precondition failed", func() {
+				So(sc.resp.Code, ShouldEqual, 412)
+				result := sc.ToJson()
+				So(result.Get("status").MustString(), ShouldEqual, "multiple-slugs-exists")
+				So(result.Get("message").MustString(), ShouldEqual, m.ErrDashboardsWithSameSlugExists.Error())
+			})
+		})
+	})
 }
 
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
@@ -479,6 +770,15 @@ func CallDeleteDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
 }
 
+func CallDeleteDashboardByUid(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.DeleteDashboardCommand) error {
+		return nil
+	})
+
+	sc.handlerFunc = DeleteDashboardByUid
+	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+}
+
 func CallPostDashboard(sc *scenarioContext) {
 	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
 		return nil
@@ -496,6 +796,18 @@ func CallPostDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
 }
 
+func CallPostDashboardShouldReturnSuccess(sc *scenarioContext) {
+	CallPostDashboard(sc)
+
+	So(sc.resp.Code, ShouldEqual, 200)
+	result := sc.ToJson()
+	So(result.Get("status").MustString(), ShouldEqual, "success")
+	So(result.Get("id").MustInt64(), ShouldBeGreaterThan, 0)
+	So(result.Get("uid").MustString(), ShouldNotBeNil)
+	So(result.Get("slug").MustString(), ShouldNotBeNil)
+	So(result.Get("url").MustString(), ShouldNotBeNil)
+}
+
 func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
@@ -518,3 +830,10 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
 		fn(sc)
 	})
 }
+
+func (sc *scenarioContext) ToJson() *simplejson.Json {
+	var result *simplejson.Json
+	err := json.NewDecoder(sc.resp.Body).Decode(&result)
+	So(err, ShouldBeNil)
+	return result
+}

+ 1 - 0
pkg/api/dtos/dashboard.go

@@ -16,6 +16,7 @@ type DashboardMeta struct {
 	CanAdmin    bool      `json:"canAdmin"`
 	CanStar     bool      `json:"canStar"`
 	Slug        string    `json:"slug"`
+	Url         string    `json:"url"`
 	Expires     time.Time `json:"expires"`
 	Created     time.Time `json:"created"`
 	Updated     time.Time `json:"updated"`

+ 46 - 0
pkg/middleware/dashboard_redirect.go

@@ -0,0 +1,46 @@
+package middleware
+
+import (
+	"strings"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"gopkg.in/macaron.v1"
+)
+
+func getDashboardUrlBySlug(orgId int64, slug string) (string, error) {
+	query := m.GetDashboardQuery{Slug: slug, OrgId: orgId}
+
+	if err := bus.Dispatch(&query); err != nil {
+		return "", m.ErrDashboardNotFound
+	}
+
+	return m.GetDashboardUrl(query.Result.Uid, query.Result.Slug), nil
+}
+
+func RedirectFromLegacyDashboardUrl() macaron.Handler {
+	return func(c *Context) {
+		slug := c.Params("slug")
+
+		if slug != "" {
+			if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
+				c.Redirect(url, 301)
+				return
+			}
+		}
+	}
+}
+
+func RedirectFromLegacyDashboardSoloUrl() macaron.Handler {
+	return func(c *Context) {
+		slug := c.Params("slug")
+
+		if slug != "" {
+			if url, err := getDashboardUrlBySlug(c.OrgId, slug); err == nil {
+				url = strings.Replace(url, "/d/", "/d-solo/", 1)
+				c.Redirect(url, 301)
+				return
+			}
+		}
+	}
+}

+ 56 - 0
pkg/middleware/dashboard_redirect_test.go

@@ -0,0 +1,56 @@
+package middleware
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestMiddlewareDashboardRedirect(t *testing.T) {
+	Convey("Given the dashboard redirect middleware", t, func() {
+		bus.ClearBusHandlers()
+		redirectFromLegacyDashboardUrl := RedirectFromLegacyDashboardUrl()
+		redirectFromLegacyDashboardSoloUrl := RedirectFromLegacyDashboardSoloUrl()
+
+		fakeDash := m.NewDashboard("Child dash")
+		fakeDash.Id = 1
+		fakeDash.FolderId = 1
+		fakeDash.HasAcl = false
+		fakeDash.Uid = util.GenerateShortUid()
+
+		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
+			query.Result = fakeDash
+			return nil
+		})
+
+		middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
+			sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
+
+			sc.fakeReqWithParams("GET", "/dashboard/db/dash", map[string]string{}).exec()
+
+			Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
+				So(sc.resp.Code, ShouldEqual, 301)
+				redirectUrl, _ := sc.resp.Result().Location()
+				So(redirectUrl.Path, ShouldEqual, m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug))
+			})
+		})
+
+		middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
+			sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
+
+			sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash", map[string]string{}).exec()
+
+			Convey("Should redirect to new dashboard url with a 301 Moved Permanently", func() {
+				So(sc.resp.Code, ShouldEqual, 301)
+				redirectUrl, _ := sc.resp.Result().Location()
+				expectedUrl := m.GetDashboardUrl(fakeDash.Uid, fakeDash.Slug)
+				expectedUrl = strings.Replace(expectedUrl, "/d/", "/d-solo/", 1)
+				So(redirectUrl.Path, ShouldEqual, expectedUrl)
+			})
+		})
+	})
+}

+ 14 - 0
pkg/middleware/middleware_test.go

@@ -399,6 +399,20 @@ func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext {
 	return sc
 }
 
+func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext {
+	sc.resp = httptest.NewRecorder()
+	req, err := http.NewRequest(method, url, nil)
+	q := req.URL.Query()
+	for k, v := range queryParams {
+		q.Add(k, v)
+	}
+	req.URL.RawQuery = q.Encode()
+	So(err, ShouldBeNil)
+	sc.req = req
+
+	return sc
+}
+
 func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
 	sc.handlerFunc = fn
 	return sc

+ 71 - 9
pkg/models/dashboards.go

@@ -2,23 +2,28 @@ package models
 
 import (
 	"errors"
+	"fmt"
 	"strings"
 	"time"
 
 	"github.com/gosimple/slug"
 	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
 // Typed errors
 var (
-	ErrDashboardNotFound                 = errors.New("Dashboard not found")
-	ErrDashboardSnapshotNotFound         = errors.New("Dashboard snapshot not found")
-	ErrDashboardWithSameNameExists       = errors.New("A dashboard with the same name 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")
+	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")
 )
 
 type UpdatePluginDashboardError struct {
@@ -39,6 +44,7 @@ var (
 // Dashboard model
 type Dashboard struct {
 	Id       int64
+	Uid      string
 	Slug     string
 	OrgId    int64
 	GnetId   int64
@@ -107,6 +113,10 @@ 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
 }
 
@@ -147,6 +157,40 @@ func SlugifyTitle(title string) string {
 	return slug.Make(strings.ToLower(title))
 }
 
+// GetUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func (dash *Dashboard) GetUrl() string {
+	return GetDashboardFolderUrl(dash.IsFolder, dash.Uid, dash.Slug)
+}
+
+// Return the html url for a dashboard
+func (dash *Dashboard) GenerateUrl() string {
+	return GetDashboardUrl(dash.Uid, dash.Slug)
+}
+
+// GetDashboardFolderUrl return the html url for a folder if it's folder, otherwise for a dashboard
+func GetDashboardFolderUrl(isFolder bool, uid string, slug string) string {
+	if isFolder {
+		return GetFolderUrl(uid, slug)
+	}
+
+	return GetDashboardUrl(uid, slug)
+}
+
+// Return the html url for a dashboard
+func GetDashboardUrl(uid string, slug string) string {
+	return fmt.Sprintf("%s/d/%s/%s", setting.AppSubUrl, uid, slug)
+}
+
+// Return the full url for a dashboard
+func GetFullDashboardUrl(uid string, slug string) string {
+	return fmt.Sprintf("%s%s", setting.AppUrl, GetDashboardUrl(uid, slug))
+}
+
+// GetFolderUrl return the html url for a folder
+func GetFolderUrl(folderUid string, slug string) string {
+	return fmt.Sprintf("%s/dashboards/f/%s/%s", setting.AppSubUrl, folderUid, slug)
+}
+
 //
 // COMMANDS
 //
@@ -177,8 +221,9 @@ type DeleteDashboardCommand struct {
 //
 
 type GetDashboardQuery struct {
-	Slug  string // required if no Id is specified
+	Slug  string // required if no Id or Uid is specified
 	Id    int64  // optional if slug is set
+	Uid   string // optional if slug is set
 	OrgId int64
 
 	Result *Dashboard
@@ -218,6 +263,13 @@ type GetDashboardSlugByIdQuery struct {
 	Result string
 }
 
+type GetDashboardsBySlugQuery struct {
+	OrgId int64
+	Slug  string
+
+	Result []*Dashboard
+}
+
 type GetFoldersForSignedInUserQuery struct {
 	OrgId        int64
 	SignedInUser *SignedInUser
@@ -235,3 +287,13 @@ type DashboardPermissionForUser struct {
 	Permission     PermissionType `json:"permission"`
 	PermissionName string         `json:"permissionName"`
 }
+
+type DashboardRef struct {
+	Uid  string
+	Slug string
+}
+
+type GetDashboardUIDByIdQuery struct {
+	Id     int64
+	Result *DashboardRef
+}

+ 25 - 22
pkg/services/alerting/eval_context.go

@@ -12,17 +12,19 @@ import (
 )
 
 type EvalContext struct {
-	Firing          bool
-	IsTestRun       bool
-	EvalMatches     []*EvalMatch
-	Logs            []*ResultLogEntry
-	Error           error
-	ConditionEvals  string
-	StartTime       time.Time
-	EndTime         time.Time
-	Rule            *Rule
-	log             log.Logger
-	dashboardSlug   string
+	Firing         bool
+	IsTestRun      bool
+	EvalMatches    []*EvalMatch
+	Logs           []*ResultLogEntry
+	Error          error
+	ConditionEvals string
+	StartTime      time.Time
+	EndTime        time.Time
+	Rule           *Rule
+	log            log.Logger
+
+	dashboardRef *m.DashboardRef
+
 	ImagePublicUrl  string
 	ImageOnDiskPath string
 	NoDataFound     bool
@@ -83,29 +85,30 @@ func (c *EvalContext) GetNotificationTitle() string {
 	return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
 }
 
-func (c *EvalContext) GetDashboardSlug() (string, error) {
-	if c.dashboardSlug != "" {
-		return c.dashboardSlug, nil
+func (c *EvalContext) GetDashboardUID() (*m.DashboardRef, error) {
+	if c.dashboardRef != nil {
+		return c.dashboardRef, nil
 	}
 
-	slugQuery := &m.GetDashboardSlugByIdQuery{Id: c.Rule.DashboardId}
-	if err := bus.Dispatch(slugQuery); err != nil {
-		return "", err
+	uidQuery := &m.GetDashboardUIDByIdQuery{Id: c.Rule.DashboardId}
+	if err := bus.Dispatch(uidQuery); err != nil {
+		return nil, err
 	}
 
-	c.dashboardSlug = slugQuery.Result
-	return c.dashboardSlug, nil
+	c.dashboardRef = uidQuery.Result
+	return c.dashboardRef, nil
 }
 
+const urlFormat = "%s?fullscreen=true&edit=true&tab=alert&panelId=%d&orgId=%d"
+
 func (c *EvalContext) GetRuleUrl() (string, error) {
 	if c.IsTestRun {
 		return setting.AppUrl, nil
 	}
 
-	if slug, err := c.GetDashboardSlug(); err != nil {
+	if ref, err := c.GetDashboardUID(); err != nil {
 		return "", err
 	} else {
-		ruleUrl := fmt.Sprintf("%sdashboard/db/%s?fullscreen&edit&tab=alert&panelId=%d&orgId=%d", setting.AppUrl, slug, c.Rule.PanelId, c.Rule.OrgId)
-		return ruleUrl, nil
+		return fmt.Sprintf(urlFormat, m.GetFullDashboardUrl(ref.Uid, ref.Slug), c.Rule.PanelId, c.Rule.OrgId), nil
 	}
 }

+ 2 - 2
pkg/services/alerting/notifier.go

@@ -87,10 +87,10 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) {
 		IsAlertContext: true,
 	}
 
-	if slug, err := context.GetDashboardSlug(); err != nil {
+	if ref, err := context.GetDashboardUID(); err != nil {
 		return err
 	} else {
-		renderOpts.Path = fmt.Sprintf("dashboard-solo/db/%s?&panelId=%d", slug, context.Rule.PanelId)
+		renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?panelId=%d", ref.Uid, ref.Slug, context.Rule.PanelId)
 	}
 
 	if imagePath, err := renderer.RenderToPng(renderOpts); err != nil {

+ 2 - 1
pkg/services/search/models.go

@@ -13,9 +13,10 @@ const (
 
 type Hit struct {
 	Id          int64    `json:"id"`
+	Uid         string   `json:"uid"`
 	Title       string   `json:"title"`
 	Uri         string   `json:"uri"`
-	Slug        string   `json:"slug"`
+	Url         string   `json:"url"`
 	Type        HitType  `json:"type"`
 	Tags        []string `json:"tags"`
 	IsStarred   bool     `json:"isStarred"`

+ 105 - 19
pkg/services/sqlstore/dashboard.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/util"
 )
 
 func init() {
@@ -18,19 +19,23 @@ func init() {
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardSlugById)
+	bus.AddHandler("sql", GetDashboardUIDById)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
 	bus.AddHandler("sql", GetFoldersForSignedInUser)
 	bus.AddHandler("sql", GetDashboardPermissionsForUser)
+	bus.AddHandler("sql", GetDashboardsBySlug)
 }
 
+var generateNewUid func() string = util.GenerateShortUid
+
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		dash := cmd.GetDashboardModel()
 
 		// try get existing dashboard
-		var existing, sameTitle m.Dashboard
+		var existing m.Dashboard
 
-		if dash.Id > 0 {
+		if dash.Id != 0 {
 			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 			if err != nil {
 				return err
@@ -52,23 +57,38 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			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
+			}
 
-		sameTitleExists, err := sess.Where("org_id=? AND slug=?", dash.OrgId, dash.Slug).Get(&sameTitle)
-		if err != nil {
-			return err
+			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 sameTitleExists {
-			// another dashboard with same name
-			if dash.Id != sameTitle.Id {
-				if cmd.Overwrite {
-					dash.Id = sameTitle.Id
-					dash.Version = sameTitle.Version
-				} else {
-					return m.ErrDashboardWithSameNameExists
-				}
+		if dash.Uid == "" {
+			uid, err := generateNewDashboardUid(sess, dash.OrgId)
+			if err != nil {
+				return err
 			}
+			dash.Uid = uid
+			dash.Data.Set("uid", uid)
+		}
+
+		err := guaranteeDashboardNameIsUniqueInFolder(sess, dash)
+		if err != nil {
+			return err
 		}
 
 		err = setHasAcl(sess, dash)
@@ -92,7 +112,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 				dash.Updated = cmd.UpdatedAt
 			}
 
-			affectedRows, err = sess.MustCols("folder_id", "has_acl").Id(dash.Id).Update(dash)
+			affectedRows, err = sess.MustCols("folder_id", "has_acl").ID(dash.Id).Update(dash)
 		}
 
 		if err != nil {
@@ -142,6 +162,40 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	})
 }
 
+func generateNewDashboardUid(sess *DBSession, orgId int64) (string, error) {
+	for i := 0; i < 3; i++ {
+		uid := generateNewUid()
+
+		exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.Dashboard{})
+		if err != nil {
+			return "", err
+		}
+
+		if !exists {
+			return uid, nil
+		}
+	}
+
+	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 {
@@ -168,7 +222,7 @@ func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 }
 
 func GetDashboard(query *m.GetDashboardQuery) error {
-	dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id}
+	dashboard := m.Dashboard{Slug: query.Slug, OrgId: query.OrgId, Id: query.Id, Uid: query.Uid}
 	has, err := x.Get(&dashboard)
 
 	if err != nil {
@@ -178,12 +232,14 @@ func GetDashboard(query *m.GetDashboardQuery) error {
 	}
 
 	dashboard.Data.Set("id", dashboard.Id)
+	dashboard.Data.Set("uid", dashboard.Uid)
 	query.Result = &dashboard
 	return nil
 }
 
 type DashboardSearchProjection struct {
 	Id          int64
+	Uid         string
 	Title       string
 	Slug        string
 	Term        string
@@ -261,15 +317,17 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
 		if !exists {
 			hit = &search.Hit{
 				Id:          item.Id,
+				Uid:         item.Uid,
 				Title:       item.Title,
 				Uri:         "db/" + item.Slug,
-				Slug:        item.Slug,
+				Url:         m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
 				Type:        getHitType(item),
 				FolderId:    item.FolderId,
 				FolderTitle: item.FolderTitle,
 				FolderSlug:  item.FolderSlug,
 				Tags:        []string{},
 			}
+
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = hit
 		}
@@ -488,7 +546,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	var rawSql = `SELECT slug from dashboard WHERE Id=?`
 	var slug = DashboardSlugDTO{}
 
-	exists, err := x.Sql(rawSql, query.Id).Get(&slug)
+	exists, err := x.SQL(rawSql, query.Id).Get(&slug)
 
 	if err != nil {
 		return err
@@ -499,3 +557,31 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	query.Result = slug.Slug
 	return nil
 }
+
+func GetDashboardsBySlug(query *m.GetDashboardsBySlugQuery) error {
+	var dashboards []*m.Dashboard
+
+	if err := x.Where("org_id=? AND slug=?", query.OrgId, query.Slug).Find(&dashboards); err != nil {
+		return err
+	}
+
+	query.Result = dashboards
+	return nil
+}
+
+func GetDashboardUIDById(query *m.GetDashboardUIDByIdQuery) error {
+	var rawSql = `SELECT uid, slug from dashboard WHERE Id=?`
+
+	us := &m.DashboardRef{}
+
+	exists, err := x.SQL(rawSql, query.Id).Get(us)
+
+	if err != nil {
+		return err
+	} else if exists == false {
+		return m.ErrDashboardNotFound
+	}
+
+	query.Result = us
+	return nil
+}

+ 349 - 0
pkg/services/sqlstore/dashboard_folder_test.go

@@ -0,0 +1,349 @@
+package sqlstore
+
+import (
+	"testing"
+
+	"github.com/go-xorm/xorm"
+	. "github.com/smartystreets/goconvey/convey"
+
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+func TestDashboardFolderDataAccess(t *testing.T) {
+	var x *xorm.Engine
+
+	Convey("Testing DB", t, func() {
+		x = InitTestDB(t)
+
+		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
+			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
+			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
+			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+
+			Convey("and no acls are set", func() {
+				Convey("should return all dashboards", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder.Id)
+					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission", func() {
+					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to access folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+
+			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
+				var otherUser int64 = 999
+				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
+				removeAcl(aclId)
+				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("should not return folder or child", func() {
+					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+				})
+
+				Convey("when the user is given permission to child", func() {
+					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
+
+					Convey("should be able to search for child dashboard but not folder", func() {
+						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 2)
+						So(query.Result[0].Id, ShouldEqual, childDash.Id)
+						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("when the user is an admin", func() {
+					Convey("should be able to search for child dash and folder", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{
+								UserId:  currentUser.Id,
+								OrgId:   1,
+								OrgRole: m.ROLE_ADMIN,
+							},
+							OrgId:        1,
+							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
+			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
+			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
+			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
+
+			currentUser := createUser("viewer", "Viewer", false)
+			var rootFolderId int64 = 0
+
+			Convey("and one folder is expanded, the other collapsed", func() {
+				Convey("should return dashboards in root and expanded folder", func() {
+					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
+					err := SearchDashboards(query)
+					So(err, ShouldBeNil)
+					So(len(query.Result), ShouldEqual, 4)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
+					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+				})
+			})
+
+			Convey("and acl is set for one dashboard folder", func() {
+				var otherUser int64 = 999
+				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
+
+				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
+					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should not return folder with acl or its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 1)
+						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeFalse)
+
+					Convey("should return folder without acl and its children", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 4)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
+						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+
+				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
+					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
+					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
+					So(movedDash.HasAcl, ShouldBeTrue)
+
+					Convey("should return folder without acl but not the dashboard with acl", func() {
+						query := &search.FindPersistedDashboardsQuery{
+							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
+							OrgId:        1,
+							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
+						}
+						err := SearchDashboards(query)
+						So(err, ShouldBeNil)
+						So(len(query.Result), ShouldEqual, 3)
+						So(query.Result[0].Id, ShouldEqual, folder2.Id)
+						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
+						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
+					})
+				})
+			})
+		})
+
+		Convey("Given two dashboard folders", func() {
+
+			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
+			folder2 := insertTestDashboard("2 test dash folder", 1, 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() {
+					query := m.GetFoldersForSignedInUserQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
+					}
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have write access to all folders and dashboards", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       adminUser.Id,
+						OrgRole:      m.ROLE_ADMIN,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN)
+				})
+			})
+
+			Convey("Editor users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
+				}
+
+				Convey("Should have write access to all dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+					So(query.Result[1].Id, ShouldEqual, folder2.Id)
+				})
+
+				Convey("should have edit access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       editorUser.Id,
+						OrgRole:      m.ROLE_EDITOR,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT)
+				})
+
+				Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder2.Id)
+				})
+
+			})
+
+			Convey("Viewer users", func() {
+				query := m.GetFoldersForSignedInUserQuery{
+					OrgId:        1,
+					SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
+				}
+
+				Convey("Should have no write access to any dashboard folders with default ACL", func() {
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 0)
+				})
+
+				Convey("should have view access to folders with default ACL", func() {
+					query := m.GetDashboardPermissionsForUserQuery{
+						DashboardIds: []int64{folder1.Id, folder2.Id},
+						OrgId:        1,
+						UserId:       viewerUser.Id,
+						OrgRole:      m.ROLE_VIEWER,
+					}
+
+					err := GetDashboardPermissionsForUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 2)
+					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
+					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW)
+					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
+					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW)
+				})
+
+				Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
+					updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
+
+					err := GetFoldersForSignedInUser(&query)
+					So(err, ShouldBeNil)
+
+					So(len(query.Result), ShouldEqual, 1)
+					So(query.Result[0].Id, ShouldEqual, folder1.Id)
+				})
+			})
+		})
+	})
+}

+ 196 - 335
pkg/services/sqlstore/dashboard_test.go

@@ -1,15 +1,16 @@
 package sqlstore
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/go-xorm/xorm"
-	. "github.com/smartystreets/goconvey/convey"
-
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
 )
 
 func TestDashboardDataAccess(t *testing.T) {
@@ -30,15 +31,33 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(savedDash.Id, ShouldNotEqual, 0)
 				So(savedDash.IsFolder, ShouldBeFalse)
 				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
+				So(len(savedDash.Uid), ShouldBeGreaterThan, 0)
 
 				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
 				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
 				So(savedFolder.Id, ShouldNotEqual, 0)
 				So(savedFolder.IsFolder, ShouldBeTrue)
 				So(savedFolder.FolderId, ShouldEqual, 0)
+				So(len(savedFolder.Uid), ShouldBeGreaterThan, 0)
+			})
+
+			Convey("Should be able to get dashboard by id", func() {
+				query := m.GetDashboardQuery{
+					Id:    savedDash.Id,
+					OrgId: 1,
+				}
+
+				err := GetDashboard(&query)
+				So(err, ShouldBeNil)
+
+				So(query.Result.Title, ShouldEqual, "test dash 23")
+				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
+				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 
-			Convey("Should be able to get dashboard", func() {
+			Convey("Should be able to get dashboard by slug", func() {
 				query := m.GetDashboardQuery{
 					Slug:  "test-dash-23",
 					OrgId: 1,
@@ -49,6 +68,24 @@ func TestDashboardDataAccess(t *testing.T) {
 
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
+				So(query.Result.IsFolder, ShouldBeFalse)
+			})
+
+			Convey("Should be able to get dashboard by uid", func() {
+				query := m.GetDashboardQuery{
+					Uid:   savedDash.Uid,
+					OrgId: 1,
+				}
+
+				err := GetDashboard(&query)
+				So(err, ShouldBeNil)
+
+				So(query.Result.Title, ShouldEqual, "test dash 23")
+				So(query.Result.Slug, ShouldEqual, "test-dash-23")
+				So(query.Result.Id, ShouldEqual, savedDash.Id)
+				So(query.Result.Uid, ShouldEqual, savedDash.Uid)
 				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 
@@ -109,6 +146,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				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))
 			})
 
 			Convey("Should be able to search for a dashboard folder's children", func() {
@@ -124,6 +162,7 @@ func TestDashboardDataAccess(t *testing.T) {
 				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))
 			})
 
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
@@ -157,20 +196,172 @@ func TestDashboardDataAccess(t *testing.T) {
 				})
 			})
 
-			Convey("Should not be able to save dashboard with same name", func() {
+			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 := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				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)
+			})
+
+			Convey("Should not be able to save dashboard with same name in the same folder", 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 := SaveDashboard(&firstSaveCmd)
+				So(err, ShouldBeNil)
+
+				secondSaveCmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":    nil,
+						"title": "test dash folder and title",
+						"tags":  []interface{}{},
+						"uid":   "moreRandomHash",
+					}),
+					FolderId: 3,
+				}
+
+				err = SaveDashboard(&secondSaveCmd)
+				So(err, ShouldEqual, m.ErrDashboardWithSameNameInFolderExists)
+			})
+
+			Convey("Should not be able to save dashboard with same uid", func() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
 						"title": "test dash 23",
-						"tags":  []interface{}{},
+						"uid":   "dsfalkjngailuedt",
 					}),
 				}
 
 				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				err = SaveDashboard(&cmd)
 				So(err, ShouldNotBeNil)
 			})
 
+			Convey("Should be able to update dashboard with the same title and folder id", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"uid":   "randomHash",
+						"title": "folderId",
+						"style": "light",
+						"tags":  []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.FolderId, ShouldEqual, 2)
+
+				cmd = m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"id":      cmd.Result.Id,
+						"uid":     "randomHash",
+						"title":   "folderId",
+						"style":   "dark",
+						"version": cmd.Result.Version,
+						"tags":    []interface{}{},
+					}),
+					FolderId: 2,
+				}
+
+				err = SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+			})
+
+			Convey("Should not be able to update using just uid", 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,
+				}
+
+				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)
+			})
+
+			Convey("Should retry generation of uid once if it fails.", func() {
+				timesCalled := 0
+				generateNewUid = func() string {
+					timesCalled += 1
+					if timesCalled <= 2 {
+						return savedDash.Uid
+					} else {
+						return util.GenerateShortUid()
+					}
+				}
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "new dash 12334",
+						"tags":  []interface{}{},
+					}),
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+
+				generateNewUid = util.GenerateShortUid
+			})
+
 			Convey("Should be able to update dashboard and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
@@ -260,336 +451,6 @@ func TestDashboardDataAccess(t *testing.T) {
 			})
 		})
 
-		Convey("Given one dashboard folder with two dashboards and one dashboard in the root folder", func() {
-			folder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
-			childDash := insertTestDashboard("test dash 23", 1, folder.Id, false, "prod", "webapp")
-			insertTestDashboard("test dash 45", 1, folder.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-
-			Convey("and no acls are set", func() {
-				Convey("should return all dashboards", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder.Id)
-					So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission", func() {
-					updateTestDashboardWithAcl(folder.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to access folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-
-			Convey("and acl is set for dashboard child and folder has all permissions removed", func() {
-				var otherUser int64 = 999
-				aclId := updateTestDashboardWithAcl(folder.Id, otherUser, m.PERMISSION_EDIT)
-				removeAcl(aclId)
-				updateTestDashboardWithAcl(childDash.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("should not return folder or child", func() {
-					query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-				})
-
-				Convey("when the user is given permission to child", func() {
-					updateTestDashboardWithAcl(childDash.Id, currentUser.Id, m.PERMISSION_EDIT)
-
-					Convey("should be able to search for child dashboard but not folder", func() {
-						query := &search.FindPersistedDashboardsQuery{SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1, DashboardIds: []int64{folder.Id, childDash.Id, dashInRoot.Id}}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 2)
-						So(query.Result[0].Id, ShouldEqual, childDash.Id)
-						So(query.Result[1].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("when the user is an admin", func() {
-					Convey("should be able to search for child dash and folder", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{
-								UserId:  currentUser.Id,
-								OrgId:   1,
-								OrgRole: m.ROLE_ADMIN,
-							},
-							OrgId:        1,
-							DashboardIds: []int64{folder.Id, dashInRoot.Id, childDash.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
-		Convey("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func() {
-			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
-			folder2 := insertTestDashboard("2 test dash folder", 1, 0, true, "prod")
-			dashInRoot := insertTestDashboard("test dash 67", 1, 0, false, "prod")
-			childDash1 := insertTestDashboard("child dash 1", 1, folder1.Id, false, "prod")
-			childDash2 := insertTestDashboard("child dash 2", 1, folder2.Id, false, "prod")
-
-			currentUser := createUser("viewer", "Viewer", false)
-			var rootFolderId int64 = 0
-
-			Convey("and one folder is expanded, the other collapsed", func() {
-				Convey("should return dashboards in root and expanded folder", func() {
-					query := &search.FindPersistedDashboardsQuery{FolderIds: []int64{rootFolderId, folder1.Id}, SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1}, OrgId: 1}
-					err := SearchDashboards(query)
-					So(err, ShouldBeNil)
-					So(len(query.Result), ShouldEqual, 4)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-					So(query.Result[2].Id, ShouldEqual, childDash1.Id)
-					So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-				})
-			})
-
-			Convey("and acl is set for one dashboard folder", func() {
-				var otherUser int64 = 999
-				updateTestDashboardWithAcl(folder1.Id, otherUser, m.PERMISSION_EDIT)
-
-				Convey("and a dashboard is moved from folder without acl to the folder with an acl", func() {
-					movedDash := moveDashboard(1, childDash2.Data, folder1.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should not return folder with acl or its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder1.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 1)
-						So(query.Result[0].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard is moved from folder with acl to the folder without an acl", func() {
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeFalse)
-
-					Convey("should return folder without acl and its children", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 4)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash1.Id)
-						So(query.Result[2].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[3].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-
-				Convey("and a dashboard with an acl is moved to the folder without an acl", func() {
-					updateTestDashboardWithAcl(childDash1.Id, otherUser, m.PERMISSION_EDIT)
-					movedDash := moveDashboard(1, childDash1.Data, folder2.Id)
-					So(movedDash.HasAcl, ShouldBeTrue)
-
-					Convey("should return folder without acl but not the dashboard with acl", func() {
-						query := &search.FindPersistedDashboardsQuery{
-							SignedInUser: &m.SignedInUser{UserId: currentUser.Id, OrgId: 1},
-							OrgId:        1,
-							DashboardIds: []int64{folder2.Id, childDash1.Id, childDash2.Id, dashInRoot.Id},
-						}
-						err := SearchDashboards(query)
-						So(err, ShouldBeNil)
-						So(len(query.Result), ShouldEqual, 3)
-						So(query.Result[0].Id, ShouldEqual, folder2.Id)
-						So(query.Result[1].Id, ShouldEqual, childDash2.Id)
-						So(query.Result[2].Id, ShouldEqual, dashInRoot.Id)
-					})
-				})
-			})
-		})
-
-		Convey("Given two dashboard folders", func() {
-
-			folder1 := insertTestDashboard("1 test dash folder", 1, 0, true, "prod")
-			folder2 := insertTestDashboard("2 test dash folder", 1, 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() {
-					query := m.GetFoldersForSignedInUserQuery{
-						OrgId:        1,
-						SignedInUser: &m.SignedInUser{UserId: adminUser.Id, OrgRole: m.ROLE_ADMIN},
-					}
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-				})
-
-				Convey("should have write access to all folders and dashboards", func() {
-					query := m.GetDashboardPermissionsForUserQuery{
-						DashboardIds: []int64{folder1.Id, folder2.Id},
-						OrgId:        1,
-						UserId:       adminUser.Id,
-						OrgRole:      m.ROLE_ADMIN,
-					}
-
-					err := GetDashboardPermissionsForUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
-					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN)
-					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
-					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_ADMIN)
-				})
-			})
-
-			Convey("Editor users", func() {
-				query := m.GetFoldersForSignedInUserQuery{
-					OrgId:        1,
-					SignedInUser: &m.SignedInUser{UserId: editorUser.Id, OrgRole: m.ROLE_EDITOR},
-				}
-
-				Convey("Should have write access to all dashboard folders with default ACL", func() {
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-					So(query.Result[1].Id, ShouldEqual, folder2.Id)
-				})
-
-				Convey("should have edit access to folders with default ACL", func() {
-					query := m.GetDashboardPermissionsForUserQuery{
-						DashboardIds: []int64{folder1.Id, folder2.Id},
-						OrgId:        1,
-						UserId:       editorUser.Id,
-						OrgRole:      m.ROLE_EDITOR,
-					}
-
-					err := GetDashboardPermissionsForUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
-					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_EDIT)
-					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
-					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_EDIT)
-				})
-
-				Convey("Should have write access to one dashboard folder if default role changed to view for one folder", func() {
-					updateTestDashboardWithAcl(folder1.Id, editorUser.Id, m.PERMISSION_VIEW)
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, folder2.Id)
-				})
-
-			})
-
-			Convey("Viewer users", func() {
-				query := m.GetFoldersForSignedInUserQuery{
-					OrgId:        1,
-					SignedInUser: &m.SignedInUser{UserId: viewerUser.Id, OrgRole: m.ROLE_VIEWER},
-				}
-
-				Convey("Should have no write access to any dashboard folders with default ACL", func() {
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 0)
-				})
-
-				Convey("should have view access to folders with default ACL", func() {
-					query := m.GetDashboardPermissionsForUserQuery{
-						DashboardIds: []int64{folder1.Id, folder2.Id},
-						OrgId:        1,
-						UserId:       viewerUser.Id,
-						OrgRole:      m.ROLE_VIEWER,
-					}
-
-					err := GetDashboardPermissionsForUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 2)
-					So(query.Result[0].DashboardId, ShouldEqual, folder1.Id)
-					So(query.Result[0].Permission, ShouldEqual, m.PERMISSION_VIEW)
-					So(query.Result[1].DashboardId, ShouldEqual, folder2.Id)
-					So(query.Result[1].Permission, ShouldEqual, m.PERMISSION_VIEW)
-				})
-
-				Convey("Should be able to get one dashboard folder if default role changed to edit for one folder", func() {
-					updateTestDashboardWithAcl(folder1.Id, viewerUser.Id, m.PERMISSION_EDIT)
-
-					err := GetFoldersForSignedInUser(&query)
-					So(err, ShouldBeNil)
-
-					So(len(query.Result), ShouldEqual, 1)
-					So(query.Result[0].Id, ShouldEqual, folder1.Id)
-				})
-			})
-		})
-
 		Convey("Given a plugin with imported dashboards", func() {
 			pluginId := "test-app"
 

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

@@ -12,7 +12,7 @@ import (
 )
 
 func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
-	data["title"] = dashboard.Title
+	data["uid"] = dashboard.Uid
 
 	saveCmd := m.SaveDashboardCommand{
 		OrgId:     dashboard.OrgId,
@@ -44,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) {
 
 			dashCmd := m.GetDashboardQuery{
 				OrgId: savedDash.OrgId,
-				Slug:  savedDash.Slug,
+				Uid:   savedDash.Uid,
 			}
 
 			err = GetDashboard(&dashCmd)

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

@@ -150,4 +150,21 @@ func addDashboardMigration(mg *Migrator) {
 	mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
 		Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
 	}))
+
+	mg.AddMigration("Add column uid in dashboard", NewAddColumnMigration(dashboardV2, &Column{
+		Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true,
+	}))
+
+	mg.AddMigration("Update uid column values in dashboard", new(RawSqlMigration).
+		Sqlite("UPDATE dashboard SET uid=printf('%09d',id) WHERE uid IS NULL;").
+		Postgres("UPDATE dashboard SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;").
+		Mysql("UPDATE dashboard SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
+
+	mg.AddMigration("Add unique index dashboard_org_id_uid", NewAddIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "uid"}, Type: UniqueIndex,
+	}))
+
+	mg.AddMigration("Remove unique index org_id_slug", NewDropIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "slug"}, Type: UniqueIndex,
+	}))
 }

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

@@ -101,6 +101,7 @@ func (sb *SearchBuilder) buildSelect() {
 	sb.sql.WriteString(
 		`SELECT
 			dashboard.id,
+			dashboard.uid,
 			dashboard.title,
 			dashboard.slug,
 			dashboard_tag.term,

+ 15 - 0
pkg/util/shortid_generator.go

@@ -0,0 +1,15 @@
+package util
+
+import (
+	"github.com/teris-io/shortid"
+)
+
+func init() {
+	gen, _ := shortid.New(1, shortid.DefaultABC, 1)
+	shortid.SetDefault(gen)
+}
+
+// GenerateShortUid generates a short unique identifier.
+func GenerateShortUid() string {
+	return shortid.MustGenerate()
+}

+ 1 - 1
public/app/containers/AlertRuleList/AlertRuleList.jest.tsx

@@ -23,7 +23,7 @@ describe('AlertRuleList', () => {
             .format(),
           evalData: {},
           executionError: '',
-          dashboardUri: 'db/mygool',
+          dashboardUri: 'd/ufkcofof/my-goal',
           canEdit: true,
         },
       ])

+ 1 - 1
public/app/containers/AlertRuleList/AlertRuleList.tsx

@@ -137,7 +137,7 @@ export class AlertRuleItem extends React.Component<AlertRuleItemProps, any> {
       'fa-pause': !rule.isPaused,
     });
 
-    let ruleUrl = `dashboard/${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
+    let ruleUrl = `${rule.dashboardUri}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
 
     return (
       <li className="alert-rule-item">

+ 2 - 2
public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap

@@ -21,7 +21,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
         className="alert-rule-item__name"
       >
         <a
-          href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
+          href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
         >
           <Highlighter
             highlightClassName="highlight-search-match"
@@ -92,7 +92,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
     </button>
     <a
       className="btn btn-small btn-inverse alert-list__btn width-2"
-      href="dashboard/db/mygool?panelId=3&fullscreen&edit&tab=alert"
+      href="d/ufkcofof/my-goal?panelId=3&fullscreen=true&edit=true&tab=alert"
       title="Edit alert rule"
     >
       <i

+ 2 - 1
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -19,7 +19,8 @@ export class FolderPermissions extends Component<IContainerProps, any> {
 
   loadStore() {
     const { nav, folder, view } = this.props;
-    return folder.load(view.routeParams.get('slug') as string).then(res => {
+    return folder.load(view.routeParams.get('uid') as string).then(res => {
+      view.updatePathAndQuery(`${res.meta.url}/permissions`, {}, {});
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
     });
   }

+ 2 - 2
public/app/containers/ManageDashboards/FolderSettings.jest.tsx

@@ -9,14 +9,14 @@ describe('FolderSettings', () => {
   let page;
 
   beforeAll(() => {
-    backendSrv.getDashboard.mockReturnValue(
+    backendSrv.getDashboardByUid.mockReturnValue(
       Promise.resolve({
         dashboard: {
           id: 1,
           title: 'Folder Name',
         },
         meta: {
-          slug: 'folder-name',
+          url: '/dashboards/f/uid/folder-name',
           canSave: true,
         },
       })

+ 4 - 2
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -20,10 +20,12 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
   loadStore() {
     const { nav, folder, view } = this.props;
 
-    return folder.load(view.routeParams.get('slug') as string).then(res => {
+    return folder.load(view.routeParams.get('uid') as string).then(res => {
       this.formSnapshot = getSnapshot(folder);
       this.dashboard = res.dashboard;
 
+      view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
+
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
     });
   }
@@ -51,7 +53,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     folder
       .saveFolder(this.dashboard, { overwrite: false })
       .then(newUrl => {
-        view.updatePathAndQuery(newUrl, '', '');
+        view.updatePathAndQuery(newUrl, {}, {});
 
         appEvents.emit('dashboard-saved');
         appEvents.emit('alert-success', ['Folder saved']);

+ 9 - 9
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -34,7 +34,7 @@ export class ManageDashboardsCtrl {
 
   // used when managing dashboards for a specific folder
   folderId?: number;
-  folderSlug?: string;
+  folderUid?: string;
 
   // if user can add new folders and/or add new dashboards
   canSave: boolean;
@@ -74,11 +74,11 @@ export class ManageDashboardsCtrl {
         return this.initDashboardList(result);
       })
       .then(() => {
-        if (!this.folderSlug) {
+        if (!this.folderUid) {
           return;
         }
 
-        return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
+        return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
           this.canSave = dash.meta.canSave;
         });
       });
@@ -130,10 +130,10 @@ export class ManageDashboardsCtrl {
 
     for (const section of this.sections) {
       if (section.checked && section.id !== 0) {
-        selectedDashboards.folders.push(section.slug);
+        selectedDashboards.folders.push(section.uid);
       } else {
         const selected = _.filter(section.items, { checked: true });
-        selectedDashboards.dashboards.push(..._.map(selected, 'slug'));
+        selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
       }
     }
 
@@ -179,8 +179,8 @@ export class ManageDashboardsCtrl {
     });
   }
 
-  private deleteFoldersAndDashboards(slugs) {
-    this.backendSrv.deleteDashboards(slugs).then(result => {
+  private deleteFoldersAndDashboards(uids) {
+    this.backendSrv.deleteDashboards(uids).then(result => {
       const folders = _.filter(result, dash => dash.meta.isFolder);
       const folderCount = folders.length;
       const dashboards = _.filter(result, dash => !dash.meta.isFolder);
@@ -224,7 +224,7 @@ export class ManageDashboardsCtrl {
 
     for (const section of this.sections) {
       const selected = _.filter(section.items, { checked: true });
-      selectedDashboards.push(..._.map(selected, 'slug'));
+      selectedDashboards.push(..._.map(selected, 'uid'));
     }
 
     return selectedDashboards;
@@ -334,7 +334,7 @@ export function manageDashboardsDirective() {
     controllerAs: 'ctrl',
     scope: {
       folderId: '=',
-      folderSlug: '=',
+      folderUid: '=',
     },
   };
 }

+ 26 - 11
public/app/core/services/backend_srv.ts

@@ -225,6 +225,10 @@ export class BackendSrv {
     return this.get('/api/dashboards/' + type + '/' + slug);
   }
 
+  getDashboardByUid(uid: string) {
+    return this.get(`/api/dashboards/uid/${uid}`);
+  }
+
   saveDashboard(dash, options) {
     options = options || {};
 
@@ -253,11 +257,22 @@ export class BackendSrv {
     });
   }
 
-  deleteDashboard(slug) {
+  saveFolder(dash, options) {
+    options = options || {};
+
+    return this.post('/api/dashboards/db/', {
+      dashboard: dash,
+      isFolder: true,
+      overwrite: options.overwrite === true,
+      message: options.message || '',
+    });
+  }
+
+  deleteDashboard(uid) {
     let deferred = this.$q.defer();
 
-    this.getDashboard('db', slug).then(fullDash => {
-      this.delete(`/api/dashboards/db/${slug}`)
+    this.getDashboardByUid(uid).then(fullDash => {
+      this.delete(`/api/dashboards/uid/${uid}`)
         .then(() => {
           deferred.resolve(fullDash);
         })
@@ -269,21 +284,21 @@ export class BackendSrv {
     return deferred.promise;
   }
 
-  deleteDashboards(dashboardSlugs) {
+  deleteDashboards(dashboardUids) {
     const tasks = [];
 
-    for (let slug of dashboardSlugs) {
-      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, slug));
+    for (let uid of dashboardUids) {
+      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid));
     }
 
     return this.executeInOrder(tasks, []);
   }
 
-  moveDashboards(dashboardSlugs, toFolder) {
+  moveDashboards(dashboardUids, toFolder) {
     const tasks = [];
 
-    for (let slug of dashboardSlugs) {
-      tasks.push(this.createTask(this.moveDashboard.bind(this), true, slug, toFolder));
+    for (let uid of dashboardUids) {
+      tasks.push(this.createTask(this.moveDashboard.bind(this), true, uid, toFolder));
     }
 
     return this.executeInOrder(tasks, []).then(result => {
@@ -295,10 +310,10 @@ export class BackendSrv {
     });
   }
 
-  private moveDashboard(slug, toFolder) {
+  private moveDashboard(uid, toFolder) {
     let deferred = this.$q.defer();
 
-    this.getDashboard('db', slug).then(fullDash => {
+    this.getDashboardByUid(uid).then(fullDash => {
       const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
 
       if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {

+ 9 - 21
public/app/core/services/bridge_srv.ts

@@ -1,30 +1,18 @@
 import coreModule from 'app/core/core_module';
-import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
 import { store } from 'app/stores/store';
 import { reaction } from 'mobx';
+import locationUtil from 'app/core/utils/location_util';
 
 // Services that handles angular -> mobx store sync & other react <-> angular sync
 export class BridgeSrv {
-  private appSubUrl;
   private fullPageReloadRoutes;
 
   /** @ngInject */
   constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
-    this.appSubUrl = config.appSubUrl;
     this.fullPageReloadRoutes = ['/logout'];
   }
 
-  // Angular's $location does not like <base href...> and absolute urls
-  stripBaseFromUrl(url = '') {
-    const appSubUrl = this.appSubUrl;
-    const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
-    const urlWithoutBase =
-      url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
-
-    return urlWithoutBase;
-  }
-
   init() {
     this.$rootScope.$on('$routeUpdate', (evt, data) => {
       let angularUrl = this.$location.url();
@@ -34,25 +22,25 @@ export class BridgeSrv {
     });
 
     this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
-      let angularUrl = this.$location.url();
-      if (store.view.currentUrl !== angularUrl) {
-        store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
-      }
+      store.view.updatePathAndQuery(this.$location.path(), this.$location.search(), this.$route.current.params);
     });
 
     reaction(
       () => store.view.currentUrl,
       currentUrl => {
         let angularUrl = this.$location.url();
-        if (angularUrl !== currentUrl) {
-          this.$location.url(currentUrl);
-          console.log('store updating angular $location.url', currentUrl);
+        const url = locationUtil.stripBaseFromUrl(currentUrl);
+        if (angularUrl !== url) {
+          this.$timeout(() => {
+            this.$location.url(url);
+          });
+          console.log('store updating angular $location.url', url);
         }
       }
     );
 
     appEvents.on('location-change', payload => {
-      const urlWithoutBase = this.stripBaseFromUrl(payload.href);
+      const urlWithoutBase = locationUtil.stripBaseFromUrl(payload.href);
       if (this.fullPageReloadRoutes.indexOf(urlWithoutBase) > -1) {
         this.$window.location.href = payload.href;
         return;

+ 8 - 16
public/app/core/services/search_srv.ts

@@ -41,10 +41,7 @@ export class SearchSrv {
         .map(orderId => {
           return _.find(result, { id: orderId });
         })
-        .filter(hit => hit && !hit.isStarred)
-        .map(hit => {
-          return this.transformToViewModel(hit);
-        });
+        .filter(hit => hit && !hit.isStarred);
     });
   }
 
@@ -81,17 +78,12 @@ export class SearchSrv {
           score: -2,
           expanded: this.starredIsOpen,
           toggle: this.toggleStarred.bind(this),
-          items: result.map(this.transformToViewModel),
+          items: result,
         };
       }
     });
   }
 
-  private transformToViewModel(hit) {
-    hit.url = 'dashboard/db/' + hit.slug;
-    return hit;
-  }
-
   search(options) {
     let sections: any = {};
     let promises = [];
@@ -136,12 +128,12 @@ export class SearchSrv {
       if (hit.type === 'dash-folder') {
         sections[hit.id] = {
           id: hit.id,
+          uid: hit.uid,
           title: hit.title,
           expanded: false,
           items: [],
           toggle: this.toggleFolder.bind(this),
-          url: `dashboards/folder/${hit.id}/${hit.slug}`,
-          slug: hit.slug,
+          url: hit.url,
           icon: 'fa fa-folder',
           score: _.keys(sections).length,
         };
@@ -158,9 +150,9 @@ export class SearchSrv {
         if (hit.folderId) {
           section = {
             id: hit.folderId,
+            uid: hit.uid,
             title: hit.folderTitle,
-            url: `dashboards/folder/${hit.folderId}/${hit.folderSlug}`,
-            slug: hit.slug,
+            url: hit.url,
             items: [],
             icon: 'fa fa-folder-open',
             toggle: this.toggleFolder.bind(this),
@@ -181,7 +173,7 @@ export class SearchSrv {
       }
 
       section.expanded = true;
-      section.items.push(this.transformToViewModel(hit));
+      section.items.push(hit);
     }
   }
 
@@ -198,7 +190,7 @@ export class SearchSrv {
     };
 
     return this.backendSrv.search(query).then(results => {
-      section.items = _.map(results, this.transformToViewModel);
+      section.items = results;
       return Promise.resolve(section);
     });
   }

+ 0 - 22
public/app/core/specs/bridge_srv.jest.ts

@@ -1,22 +0,0 @@
-import { BridgeSrv } from 'app/core/services/bridge_srv';
-
-jest.mock('app/core/config', () => {
-  return {
-    appSubUrl: '/subUrl',
-  };
-});
-
-describe('BridgeSrv', () => {
-  let searchSrv;
-
-  beforeEach(() => {
-    searchSrv = new BridgeSrv(null, null, null, null, null);
-  });
-
-  describe('With /subUrl as appSubUrl', () => {
-    it('/subUrl should be stripped', () => {
-      const urlWithoutMaster = searchSrv.stripBaseFromUrl('/subUrl/grafana/');
-      expect(urlWithoutMaster).toBe('/grafana/');
-    });
-  });
-});

+ 16 - 0
public/app/core/specs/location_util.jest.ts

@@ -0,0 +1,16 @@
+import locationUtil from 'app/core/utils/location_util';
+
+jest.mock('app/core/config', () => {
+  return {
+    appSubUrl: '/subUrl',
+  };
+});
+
+describe('locationUtil', () => {
+  describe('With /subUrl as appSubUrl', () => {
+    it('/subUrl should be stripped', () => {
+      const urlWithoutMaster = locationUtil.stripBaseFromUrl('/subUrl/grafana/');
+      expect(urlWithoutMaster).toBe('/grafana/');
+    });
+  });
+});

+ 8 - 8
public/app/core/specs/manage_dashboards.jest.ts

@@ -483,22 +483,22 @@ describe('ManageDashboards', () => {
       ctrl.sections = [
         {
           id: 1,
+          uid: 'folder',
           title: 'folder',
-          items: [{ id: 2, checked: true, slug: 'folder-dash' }],
+          items: [{ id: 2, checked: true, uid: 'folder-dash' }],
           checked: true,
-          slug: 'folder',
         },
         {
           id: 3,
           title: 'folder-2',
-          items: [{ id: 3, checked: true, slug: 'folder-2-dash' }],
+          items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
           checked: false,
-          slug: 'folder-2',
+          uid: 'folder-2',
         },
         {
           id: 0,
           title: 'Root',
-          items: [{ id: 3, checked: true, slug: 'root-dash' }],
+          items: [{ id: 3, checked: true, uid: 'root-dash' }],
           checked: true,
         },
       ];
@@ -535,14 +535,14 @@ describe('ManageDashboards', () => {
         {
           id: 1,
           title: 'folder',
-          items: [{ id: 2, checked: true, slug: 'dash' }],
+          items: [{ id: 2, checked: true, uid: 'dash' }],
           checked: false,
-          slug: 'folder',
+          uid: 'folder',
         },
         {
           id: 0,
           title: 'Root',
-          items: [{ id: 3, checked: true, slug: 'dash-2' }],
+          items: [{ id: 3, checked: true, uid: 'dash-2' }],
           checked: false,
         },
       ];

+ 14 - 0
public/app/core/utils/location_util.ts

@@ -0,0 +1,14 @@
+import config from 'app/core/config';
+
+const _stripBaseFromUrl = url => {
+  const appSubUrl = config.appSubUrl;
+  const stripExtraChars = appSubUrl.endsWith('/') ? 1 : 0;
+  const urlWithoutBase =
+    url.length > 0 && url.indexOf(appSubUrl) === 0 ? url.slice(appSubUrl.length - stripExtraChars) : url;
+
+  return urlWithoutBase;
+};
+
+export default {
+  stripBaseFromUrl: _stripBaseFromUrl,
+};

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

@@ -19,9 +19,7 @@ export class CreateFolderCtrl {
 
     return this.backendSrv.createDashboardFolder(this.title).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
-
-      var folderUrl = `dashboards/folder/${result.dashboard.id}/${result.meta.slug}`;
-      this.$location.url(folderUrl);
+      this.$location.url(result.meta.url);
     });
   }
 

+ 3 - 3
public/app/features/dashboard/dashboard_loader_srv.ts

@@ -35,18 +35,18 @@ export class DashboardLoaderSrv {
     };
   }
 
-  loadDashboard(type, slug) {
+  loadDashboard(type, slug, uid) {
     var promise;
 
     if (type === 'script') {
       promise = this._loadScriptedDashboard(slug);
     } else if (type === 'snapshot') {
-      promise = this.backendSrv.get('/api/snapshots/' + this.$routeParams.slug).catch(() => {
+      promise = this.backendSrv.get('/api/snapshots/' + slug).catch(() => {
         return this._dashboardLoadFailed('Snapshot not found', true);
       });
     } else {
       promise = this.backendSrv
-        .getDashboard(this.$routeParams.type, this.$routeParams.slug)
+        .getDashboardByUid(uid)
         .then(result => {
           if (result.meta.isFolder) {
             this.$rootScope.appEvent('alert-error', ['Dashboard not found']);

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

@@ -12,6 +12,7 @@ import { DashboardMigrator } from './dashboard_migration';
 
 export class DashboardModel {
   id: any;
+  uid: any;
   title: any;
   autoUpdate: any;
   description: any;
@@ -56,6 +57,7 @@ export class DashboardModel {
 
     this.events = new Emitter();
     this.id = data.id || null;
+    this.uid = data.uid || null;
     this.revision = data.revision;
     this.title = data.title || 'No Title';
     this.autoUpdate = data.autoUpdate;

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

@@ -73,9 +73,8 @@ export class DashboardSrv {
   postSave(clone, data) {
     this.dash.version = data.version;
 
-    var dashboardUrl = '/dashboard/db/' + data.slug;
-    if (dashboardUrl !== this.$location.path()) {
-      this.$location.url(dashboardUrl);
+    if (data.url !== this.$location.path()) {
+      this.$location.url(data.url);
     }
 
     this.$rootScope.appEvent('dashboard-saved', this.dash);

+ 12 - 7
public/app/features/dashboard/folder_dashboards_ctrl.ts

@@ -1,19 +1,24 @@
 import { FolderPageLoader } from './folder_page_loader';
+import locationUtil from 'app/core/utils/location_util';
 
 export class FolderDashboardsCtrl {
   navModel: any;
   folderId: number;
-  folderSlug: string;
+  uid: string;
 
   /** @ngInject */
-  constructor(private backendSrv, navModelSrv, private $routeParams) {
-    if (this.$routeParams.folderId && this.$routeParams.slug) {
-      this.folderId = $routeParams.folderId;
+  constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
+    if (this.$routeParams.uid) {
+      this.uid = $routeParams.uid;
 
-      const loader = new FolderPageLoader(this.backendSrv, this.$routeParams);
+      const loader = new FolderPageLoader(this.backendSrv);
 
-      loader.load(this, this.folderId, 'manage-folder-dashboards').then(result => {
-        this.folderSlug = result.meta.slug;
+      loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
+        const url = locationUtil.stripBaseFromUrl(folder.meta.url);
+
+        if (url !== $location.path()) {
+          $location.path(url).replace();
+        }
       });
     }
   }

+ 5 - 8
public/app/features/dashboard/folder_page_loader.ts

@@ -1,7 +1,7 @@
 export class FolderPageLoader {
-  constructor(private backendSrv, private $routeParams) {}
+  constructor(private backendSrv) {}
 
-  load(ctrl, folderId, activeChildId) {
+  load(ctrl, uid, activeChildId) {
     ctrl.navModel = {
       main: {
         icon: 'fa fa-folder-open',
@@ -36,11 +36,12 @@ export class FolderPageLoader {
       },
     };
 
-    return this.backendSrv.getDashboard('db', this.$routeParams.slug).then(result => {
+    return this.backendSrv.getDashboardByUid(uid).then(result => {
+      ctrl.folderId = result.dashboard.id;
       const folderTitle = result.dashboard.title;
+      const folderUrl = result.meta.url;
       ctrl.navModel.main.text = folderTitle;
 
-      const folderUrl = this.createFolderUrl(folderId, result.meta.slug);
       const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       dashTab.url = folderUrl;
 
@@ -57,8 +58,4 @@ export class FolderPageLoader {
       return result;
     });
   }
-
-  createFolderUrl(folderId: number, slug: string) {
-    return `dashboards/folder/${folderId}/${slug}`;
-  }
 }

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

@@ -3,20 +3,23 @@ import { FolderPageLoader } from './folder_page_loader';
 export class FolderPermissionsCtrl {
   navModel: any;
   folderId: number;
+  uid: string;
   dashboard: any;
   meta: any;
 
   /** @ngInject */
-  constructor(private backendSrv, navModelSrv, private $routeParams) {
-    if (this.$routeParams.folderId && this.$routeParams.slug) {
-      this.folderId = $routeParams.folderId;
+  constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
+    if (this.$routeParams.uid) {
+      this.uid = $routeParams.uid;
 
-      new FolderPageLoader(this.backendSrv, this.$routeParams)
-        .load(this, this.folderId, 'manage-folder-permissions')
-        .then(result => {
-          this.dashboard = result.dashboard;
-          this.meta = result.meta;
-        });
+      new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
+        if ($location.path() !== folder.meta.url) {
+          $location.path(`${folder.meta.url}/permissions`).replace();
+        }
+
+        this.dashboard = folder.dashboard;
+        this.meta = folder.meta;
+      });
     }
   }
 }

+ 18 - 14
public/app/features/dashboard/folder_settings_ctrl.ts

@@ -5,6 +5,7 @@ export class FolderSettingsCtrl {
   folderPageLoader: FolderPageLoader;
   navModel: any;
   folderId: number;
+  uid: string;
   canSave = false;
   dashboard: any;
   meta: any;
@@ -13,14 +14,18 @@ export class FolderSettingsCtrl {
 
   /** @ngInject */
   constructor(private backendSrv, navModelSrv, private $routeParams, private $location) {
-    if (this.$routeParams.folderId && this.$routeParams.slug) {
-      this.folderId = $routeParams.folderId;
-
-      this.folderPageLoader = new FolderPageLoader(this.backendSrv, this.$routeParams);
-      this.folderPageLoader.load(this, this.folderId, 'manage-folder-settings').then(result => {
-        this.dashboard = result.dashboard;
-        this.meta = result.meta;
-        this.canSave = result.meta.canSave;
+    if (this.$routeParams.uid) {
+      this.uid = $routeParams.uid;
+
+      this.folderPageLoader = new FolderPageLoader(this.backendSrv);
+      this.folderPageLoader.load(this, this.uid, 'manage-folder-settings').then(folder => {
+        if ($location.path() !== folder.meta.url) {
+          $location.path(`${folder.meta.url}/settings`).replace();
+        }
+
+        this.dashboard = folder.dashboard;
+        this.meta = folder.meta;
+        this.canSave = folder.meta.canSave;
         this.title = this.dashboard.title;
       });
     }
@@ -36,11 +41,10 @@ export class FolderSettingsCtrl {
     this.dashboard.title = this.title.trim();
 
     return this.backendSrv
-      .saveDashboard(this.dashboard, { overwrite: false })
+      .updateDashboardFolder(this.dashboard, { overwrite: false })
       .then(result => {
-        var folderUrl = this.folderPageLoader.createFolderUrl(this.folderId, result.slug);
-        if (folderUrl !== this.$location.path()) {
-          this.$location.url(folderUrl + '/settings');
+        if (result.url !== this.$location.path()) {
+          this.$location.url(result.url + '/settings');
         }
 
         appEvents.emit('dashboard-saved');
@@ -65,7 +69,7 @@ export class FolderSettingsCtrl {
       icon: 'fa-trash',
       yesText: 'Delete',
       onConfirm: () => {
-        return this.backendSrv.deleteDashboard(this.meta.slug).then(() => {
+        return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
           appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
           this.$location.url('dashboards');
         });
@@ -84,7 +88,7 @@ export class FolderSettingsCtrl {
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         onConfirm: () => {
-          this.backendSrv.saveDashboard(this.dashboard, { overwrite: true });
+          this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true });
         },
       });
     }

+ 1 - 1
public/app/features/dashboard/partials/folder_dashboards.html

@@ -1,5 +1,5 @@
 <page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
 
 <div class="page-container page-body">
-    <manage-dashboards ng-if="ctrl.folderId && ctrl.folderSlug" folder-id="ctrl.folderId" folder-slug="ctrl.folderSlug" />
+    <manage-dashboards ng-if="ctrl.folderId && ctrl.uid" folder-id="ctrl.folderId" folder-uid="ctrl.uid" />
 </div>

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

@@ -186,7 +186,7 @@ export class SettingsCtrl {
   }
 
   deleteDashboardConfirmed() {
-    this.backendSrv.deleteDashboard(this.dashboard.meta.slug).then(() => {
+    this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
       appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
       this.$location.url('/');
     });

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

@@ -74,6 +74,7 @@ export class ShareModalCtrl {
       $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
 
       var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
+      soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
       delete params.fullscreen;
       delete params.edit;
       soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
@@ -84,6 +85,7 @@ export class ShareModalCtrl {
         config.appSubUrl + '/dashboard-solo/',
         config.appSubUrl + '/render/dashboard-solo/'
       );
+      $scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
       $scope.imageUrl += '&width=1000';
       $scope.imageUrl += '&height=500';
       $scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format('Z'));

+ 13 - 2
public/app/features/dashboard/specs/share_modal_ctrl_specs.ts

@@ -43,12 +43,23 @@ describe('ShareModalCtrl', function() {
     });
 
     it('should generate render url', function() {
-      ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/db/my-dash';
+      ctx.$location.$$absUrl = 'http://dashboards.grafana.com/d/abcdefghi/my-dash';
 
       ctx.scope.panel = { id: 22 };
 
       ctx.scope.init();
-      var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash';
+      var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
+      var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
+      expect(ctx.scope.imageUrl).to.contain(base + params);
+    });
+
+    it('should generate render url for scripted dashboard', function() {
+      ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/script/my-dash.js';
+
+      ctx.scope.panel = { id: 22 };
+
+      ctx.scope.init();
+      var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
       var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
       expect(ctx.scope.imageUrl).to.contain(base + params);
     });

+ 14 - 2
public/app/features/panel/solo_panel_ctrl.ts

@@ -1,9 +1,10 @@
 import angular from 'angular';
+import locationUtil from 'app/core/utils/location_util';
 import appEvents from 'app/core/app_events';
 
 export class SoloPanelCtrl {
   /** @ngInject */
-  constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv) {
+  constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
     var panelId;
 
     $scope.init = function() {
@@ -15,7 +16,18 @@ export class SoloPanelCtrl {
 
       $scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
 
-      dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
+      // if no uid, redirect to new route based on slug
+      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.replace('/d/', '/d-solo/'));
+            $location.path(url).replace();
+          }
+        });
+        return;
+      }
+
+      dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
         result.meta.soloMode = true;
         $scope.initDashboard(result, $scope);
       });

+ 1 - 1
public/app/plugins/panel/dashlist/module.html

@@ -4,7 +4,7 @@
       {{group.header}}
     </h6>
     <div class="dashlist-item" ng-repeat="dash in group.list">
-      <a class="dashlist-link dashlist-link-{{dash.type}}" href="dashboard/{{dash.uri}}">
+      <a class="dashlist-link dashlist-link-{{dash.type}}" href="{{dash.url}}">
         <span class="dashlist-title">
           {{dash.title}}
         </span>

+ 21 - 3
public/app/routes/dashboard_loaders.ts

@@ -1,11 +1,12 @@
 import coreModule from 'app/core/core_module';
+import locationUtil from 'app/core/utils/location_util';
 
 export class LoadDashboardCtrl {
   /** @ngInject */
-  constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location) {
+  constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location, $browser) {
     $scope.appEvent('dashboard-fetch-start');
 
-    if (!$routeParams.slug) {
+    if (!$routeParams.uid && !$routeParams.slug) {
       backendSrv.get('/api/dashboards/home').then(function(homeDash) {
         if (homeDash.redirectUri) {
           $location.path('dashboard/' + homeDash.redirectUri);
@@ -18,7 +19,24 @@ export class LoadDashboardCtrl {
       return;
     }
 
-    dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
+    // if no uid, redirect to new route based on slug
+    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();
+        }
+      });
+      return;
+    }
+
+    dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
+      const url = locationUtil.stripBaseFromUrl(result.meta.url);
+
+      if (url !== $location.path()) {
+        $location.path(url).replace();
+      }
+
       if ($routeParams.keepRows) {
         result.meta.keepRows = true;
       }

+ 15 - 3
public/app/routes/routes.ts

@@ -16,12 +16,24 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       reloadOnSearch: false,
       pageClass: 'page-dashboard',
     })
+    .when('/d/:uid/:slug', {
+      templateUrl: 'public/app/partials/dashboard.html',
+      controller: 'LoadDashboardCtrl',
+      reloadOnSearch: false,
+      pageClass: 'page-dashboard',
+    })
     .when('/dashboard/:type/:slug', {
       templateUrl: 'public/app/partials/dashboard.html',
       controller: 'LoadDashboardCtrl',
       reloadOnSearch: false,
       pageClass: 'page-dashboard',
     })
+    .when('/d-solo/:uid/:slug', {
+      templateUrl: 'public/app/features/panel/partials/soloPanel.html',
+      controller: 'SoloPanelCtrl',
+      reloadOnSearch: false,
+      pageClass: 'page-dashboard',
+    })
     .when('/dashboard-solo/:type/:slug', {
       templateUrl: 'public/app/features/panel/partials/soloPanel.html',
       controller: 'SoloPanelCtrl',
@@ -69,19 +81,19 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controller: 'CreateFolderCtrl',
       controllerAs: 'ctrl',
     })
-    .when('/dashboards/folder/:folderId/:slug/permissions', {
+    .when('/dashboards/f/:uid/:slug/permissions', {
       template: '<react-container />',
       resolve: {
         component: () => FolderPermissions,
       },
     })
-    .when('/dashboards/folder/:folderId/:slug/settings', {
+    .when('/dashboards/f/:uid/:slug/settings', {
       template: '<react-container />',
       resolve: {
         component: () => FolderSettings,
       },
     })
-    .when('/dashboards/folder/:folderId/:slug', {
+    .when('/dashboards/f/:uid/:slug', {
       templateUrl: 'public/app/features/dashboard/partials/folder_dashboards.html',
       controller: 'FolderDashboardsCtrl',
       controllerAs: 'ctrl',

+ 9 - 8
public/app/stores/FolderStore/FolderStore.ts

@@ -2,8 +2,8 @@ import { types, getEnv, flow } from 'mobx-state-tree';
 
 export const Folder = types.model('Folder', {
   id: types.identifier(types.number),
-  slug: types.string,
   title: types.string,
+  url: types.string,
   canSave: types.boolean,
   hasChanged: types.boolean,
 });
@@ -13,13 +13,13 @@ export const FolderStore = types
     folder: types.maybe(Folder),
   })
   .actions(self => ({
-    load: flow(function* load(slug: string) {
+    load: flow(function* load(uid: string) {
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboard('db', slug);
+      const res = yield backendSrv.getDashboardByUid(uid);
       self.folder = Folder.create({
         id: res.dashboard.id,
         title: res.dashboard.title,
-        slug: res.meta.slug,
+        url: res.meta.url,
         canSave: res.meta.canSave,
         hasChanged: false,
       });
@@ -35,14 +35,15 @@ export const FolderStore = types
       const backendSrv = getEnv(self).backendSrv;
       dashboard.title = self.folder.title.trim();
 
-      const res = yield backendSrv.saveDashboard(dashboard, options);
-      self.folder.slug = res.slug;
-      return `dashboards/folder/${self.folder.id}/${res.slug}/settings`;
+      const res = yield backendSrv.saveFolder(dashboard, options);
+      self.folder.url = res.url;
+
+      return `${self.folder.url}/settings`;
     }),
 
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
 
-      return backendSrv.deleteDashboard(self.folder.slug);
+      return backendSrv.deleteDashboard(self.folder.url);
     }),
   }));

+ 5 - 5
public/app/stores/NavStore/NavStore.jest.ts

@@ -3,12 +3,12 @@ import { NavStore } from './NavStore';
 describe('NavStore', () => {
   const folderId = 1;
   const folderTitle = 'Folder Name';
-  const folderSlug = 'folder-name';
+  const folderUrl = '/dashboards/f/uid/folder-name';
   const canAdmin = true;
 
   const folder = {
     id: folderId,
-    slug: folderSlug,
+    url: folderUrl,
     title: folderTitle,
     canAdmin: canAdmin,
   };
@@ -33,9 +33,9 @@ describe('NavStore', () => {
 
   it('Should set correct urls for each tab', () => {
     expect(store.main.children.length).toBe(3);
-    expect(store.main.children[0].url).toBe(`dashboards/folder/${folderId}/${folderSlug}`);
-    expect(store.main.children[1].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/permissions`);
-    expect(store.main.children[2].url).toBe(`dashboards/folder/${folderId}/${folderSlug}/settings`);
+    expect(store.main.children[0].url).toBe(folderUrl);
+    expect(store.main.children[1].url).toBe(`${folderUrl}/permissions`);
+    expect(store.main.children[2].url).toBe(`${folderUrl}/settings`);
   });
 
   it('Should set active tab', () => {

+ 3 - 9
public/app/stores/NavStore/NavStore.ts

@@ -41,8 +41,6 @@ export const NavStore = types
     },
 
     initFolderNav(folder: any, activeChildId: string) {
-      const folderUrl = createFolderUrl(folder.id, folder.slug);
-
       let main = {
         icon: 'fa fa-folder-open',
         id: 'manage-folder',
@@ -56,21 +54,21 @@ export const NavStore = types
             icon: 'fa fa-fw fa-th-large',
             id: 'manage-folder-dashboards',
             text: 'Dashboards',
-            url: folderUrl,
+            url: folder.url,
           },
           {
             active: activeChildId === 'manage-folder-permissions',
             icon: 'fa fa-fw fa-lock',
             id: 'manage-folder-permissions',
             text: 'Permissions',
-            url: folderUrl + '/permissions',
+            url: `${folder.url}/permissions`,
           },
           {
             active: activeChildId === 'manage-folder-settings',
             icon: 'fa fa-fw fa-cog',
             id: 'manage-folder-settings',
             text: 'Settings',
-            url: folderUrl + '/settings',
+            url: `${folder.url}/settings`,
           },
         ],
       };
@@ -118,7 +116,3 @@ export const NavStore = types
       self.main = NavItem.create(main);
     },
   }));
-
-function createFolderUrl(folderId: number, slug: string) {
-  return `dashboards/folder/${folderId}/${slug}`;
-}

+ 1 - 0
public/test/mocks/common.ts

@@ -1,6 +1,7 @@
 export const backendSrv = {
   get: jest.fn(),
   getDashboard: jest.fn(),
+  getDashboardByUid: jest.fn(),
   post: jest.fn(),
 };
 

+ 1 - 0
tests/test-app/dashboards/connections.json

@@ -7,6 +7,7 @@
     }
   ],
 
+  "uid": "1MHHlVjzz",
   "title": "Nginx Connections",
   "revision": 25,
   "schemaVersion": 11,

+ 1 - 0
tests/test-app/dashboards/connections_result.json

@@ -16,5 +16,6 @@
   ],
   "schemaVersion": 11,
   "title": "Nginx Connections",
+  "uid": "1MHHlVjzz",
   "version": 0
 }

+ 18 - 0
vendor/github.com/teris-io/shortid/LICENSE

@@ -0,0 +1,18 @@
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this
+software and associated documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be included in all copies
+or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 362 - 0
vendor/github.com/teris-io/shortid/shortid.go

@@ -0,0 +1,362 @@
+// Copyright (c) 2016-2017. Oleg Sklyar & teris.io. All rights reserved.
+// See the LICENSE file in the project root for licensing information.
+
+// Original algorithm:
+// Copyright (c) 2015 Dylan Greene, contributors: https://github.com/dylang/shortid.
+// MIT-license as found in the LICENSE file.
+
+// Seed computation: based on The Central Randomizer 1.3
+// Copyright (c) 1997 Paul Houle (houle@msc.cornell.edu)
+
+// Package shortid enables the generation of short, unique, non-sequential and by default URL friendly
+// Ids. The package is heavily inspired by the node.js https://github.com/dylang/shortid library.
+//
+// Id Length
+//
+// The standard Id length is 9 symbols when generated at a rate of 1 Id per millisecond,
+// occasionally it reaches 11 (at the rate of a few thousand Ids per millisecond) and very-very
+// rarely it can go beyond that during continuous generation at full throttle on high-performant
+// hardware. A test generating 500k Ids at full throttle on conventional hardware generated the
+// following Ids at the head and the tail (length > 9 is expected for this test):
+//
+//  -NDveu-9Q
+//  iNove6iQ9J
+//  NVDve6-9Q
+//  VVDvc6i99J
+//  NVovc6-QQy
+//  VVoveui9QC
+//  ...
+//  tFmGc6iQQs
+//  KpTvcui99k
+//  KFTGcuiQ9p
+//  KFmGeu-Q9O
+//  tFTvcu-QQt
+//  tpTveu-99u
+//
+// Life span
+//
+// The package guarantees the generation of unique Ids with zero collisions for 34 years
+// (1/1/2016-1/1/2050) using the same worker Id within a single (although concurrent) application if
+// application restarts take longer than 1 millisecond. The package supports up to 32 works, all
+// providing unique sequences.
+//
+// Implementation details
+//
+// Although heavily inspired by the node.js shortid library this is
+// not a simple Go port. In addition it
+//
+//  - is safe to concurrency;
+//  - does not require any yearly version/epoch resets;
+//  - provides stable Id size over a long period at the rate of 1ms;
+//  - guarantees no collisions (due to guaranteed fixed size of Ids between milliseconds and because
+//    multiple requests within the same ms lead to longer Ids with the prefix unique to the ms);
+//  - supports 32 over 16 workers.
+//
+// The algorithm uses less randomness than the original node.js implementation, which permits to
+// extend the life span as well as reduce and guarantee the length. In general terms, each Id
+// has the following 3 pieces of information encoded: the millisecond (first 8 symbols), the worker
+// Id (9th symbol), running concurrent counter within the same millisecond, only if required, over
+// all remaining symbols. The element of randomness per symbol is 1/2 for the worker and the
+// millisecond and 0 for the counter. Here 0 means no randomness, i.e. every value is encoded using
+// a 64-base alphabet; 1/2 means one of two matching symbols of the supplied alphabet, 1/4 one of
+// four matching symbols. The original algorithm of the node.js module uses 1/4 throughout.
+//
+// All methods accepting the parameters that govern the randomness are exported and can be used
+// to directly implement an algorithm with e.g. more randomness, but with longer Ids and shorter
+// life spans.
+package shortid
+
+import (
+	randc "crypto/rand"
+	"errors"
+	"fmt"
+	"math"
+	randm "math/rand"
+	"sync"
+	"sync/atomic"
+	"time"
+	"unsafe"
+)
+
+// Version defined the library version.
+const Version = 1.1
+
+// DefaultABC is the default URL-friendly alphabet.
+const DefaultABC = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
+
+// Abc represents a shuffled alphabet used to generate the Ids and provides methods to
+// encode data.
+type Abc struct {
+	alphabet []rune
+}
+
+// Shortid type represents a short Id generator working with a given alphabet.
+type Shortid struct {
+	abc    Abc
+	worker uint
+	epoch  time.Time  // ids can be generated for 34 years since this date
+	ms     uint       // ms since epoch for the last id
+	count  uint       // request count within the same ms
+	mx     sync.Mutex // locks access to ms and count
+}
+
+var shortid *Shortid
+
+func init() {
+	shortid = MustNew(0, DefaultABC, 1)
+}
+
+// GetDefault retrieves the default short Id generator initialised with the default alphabet,
+// worker=0 and seed=1. The default can be overwritten using SetDefault.
+func GetDefault() *Shortid {
+	return (*Shortid)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&shortid))))
+}
+
+// SetDefault overwrites the default generator.
+func SetDefault(sid *Shortid) {
+	target := (*unsafe.Pointer)(unsafe.Pointer(&shortid))
+	source := unsafe.Pointer(sid)
+	atomic.SwapPointer(target, source)
+}
+
+// Generate generates an Id using the default generator.
+func Generate() (string, error) {
+	return shortid.Generate()
+}
+
+// MustGenerate acts just like Generate, but panics instead of returning errors.
+func MustGenerate() string {
+	id, err := Generate()
+	if err == nil {
+		return id
+	}
+	panic(err)
+}
+
+// New constructs an instance of the short Id generator for the given worker number [0,31], alphabet
+// (64 unique symbols) and seed value (to shuffle the alphabet). The worker number should be
+// different for multiple or distributed processes generating Ids into the same data space. The
+// seed, on contrary, should be identical.
+func New(worker uint8, alphabet string, seed uint64) (*Shortid, error) {
+	if worker > 31 {
+		return nil, errors.New("expected worker in the range [0,31]")
+	}
+	abc, err := NewAbc(alphabet, seed)
+	if err == nil {
+		sid := &Shortid{
+			abc:    abc,
+			worker: uint(worker),
+			epoch:  time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC),
+			ms:     0,
+			count:  0,
+		}
+		return sid, nil
+	}
+	return nil, err
+}
+
+// MustNew acts just like New, but panics instead of returning errors.
+func MustNew(worker uint8, alphabet string, seed uint64) *Shortid {
+	sid, err := New(worker, alphabet, seed)
+	if err == nil {
+		return sid
+	}
+	panic(err)
+}
+
+// Generate generates a new short Id.
+func (sid *Shortid) Generate() (string, error) {
+	return sid.GenerateInternal(nil, sid.epoch)
+}
+
+// MustGenerate acts just like Generate, but panics instead of returning errors.
+func (sid *Shortid) MustGenerate() string {
+	id, err := sid.Generate()
+	if err == nil {
+		return id
+	}
+	panic(err)
+}
+
+// GenerateInternal should only be used for testing purposes.
+func (sid *Shortid) GenerateInternal(tm *time.Time, epoch time.Time) (string, error) {
+	ms, count := sid.getMsAndCounter(tm, epoch)
+	idrunes := make([]rune, 9)
+	if tmp, err := sid.abc.Encode(ms, 8, 5); err == nil {
+		copy(idrunes, tmp) // first 8 symbols
+	} else {
+		return "", err
+	}
+	if tmp, err := sid.abc.Encode(sid.worker, 1, 5); err == nil {
+		idrunes[8] = tmp[0]
+	} else {
+		return "", err
+	}
+	if count > 0 {
+		if countrunes, err := sid.abc.Encode(count, 0, 6); err == nil {
+			// only extend if really need it
+			idrunes = append(idrunes, countrunes...)
+		} else {
+			return "", err
+		}
+	}
+	return string(idrunes), nil
+}
+
+func (sid *Shortid) getMsAndCounter(tm *time.Time, epoch time.Time) (uint, uint) {
+	sid.mx.Lock()
+	defer sid.mx.Unlock()
+	var ms uint
+	if tm != nil {
+		ms = uint(tm.Sub(epoch).Nanoseconds() / 1000000)
+	} else {
+		ms = uint(time.Now().Sub(epoch).Nanoseconds() / 1000000)
+	}
+	if ms == sid.ms {
+		sid.count++
+	} else {
+		sid.count = 0
+		sid.ms = ms
+	}
+	return sid.ms, sid.count
+}
+
+// String returns a string representation of the short Id generator.
+func (sid *Shortid) String() string {
+	return fmt.Sprintf("Shortid(worker=%v, epoch=%v, abc=%v)", sid.worker, sid.epoch, sid.abc)
+}
+
+// Abc returns the instance of alphabet used for representing the Ids.
+func (sid *Shortid) Abc() Abc {
+	return sid.abc
+}
+
+// Epoch returns the value of epoch used as the beginning of millisecond counting (normally
+// 2016-01-01 00:00:00 local time)
+func (sid *Shortid) Epoch() time.Time {
+	return sid.epoch
+}
+
+// Worker returns the value of worker for this short Id generator.
+func (sid *Shortid) Worker() uint {
+	return sid.worker
+}
+
+// NewAbc constructs a new instance of shuffled alphabet to be used for Id representation.
+func NewAbc(alphabet string, seed uint64) (Abc, error) {
+	runes := []rune(alphabet)
+	if len(runes) != len(DefaultABC) {
+		return Abc{}, fmt.Errorf("alphabet must contain %v unique characters", len(DefaultABC))
+	}
+	if nonUnique(runes) {
+		return Abc{}, errors.New("alphabet must contain unique characters only")
+	}
+	abc := Abc{alphabet: nil}
+	abc.shuffle(alphabet, seed)
+	return abc, nil
+}
+
+// MustNewAbc acts just like NewAbc, but panics instead of returning errors.
+func MustNewAbc(alphabet string, seed uint64) Abc {
+	res, err := NewAbc(alphabet, seed)
+	if err == nil {
+		return res
+	}
+	panic(err)
+}
+
+func nonUnique(runes []rune) bool {
+	found := make(map[rune]struct{})
+	for _, r := range runes {
+		if _, seen := found[r]; !seen {
+			found[r] = struct{}{}
+		}
+	}
+	return len(found) < len(runes)
+}
+
+func (abc *Abc) shuffle(alphabet string, seed uint64) {
+	source := []rune(alphabet)
+	for len(source) > 1 {
+		seed = (seed*9301 + 49297) % 233280
+		i := int(seed * uint64(len(source)) / 233280)
+
+		abc.alphabet = append(abc.alphabet, source[i])
+		source = append(source[:i], source[i+1:]...)
+	}
+	abc.alphabet = append(abc.alphabet, source[0])
+}
+
+// Encode encodes a given value into a slice of runes of length nsymbols. In case nsymbols==0, the
+// length of the result is automatically computed from data. Even if fewer symbols is required to
+// encode the data than nsymbols, all positions are used encoding 0 where required to guarantee
+// uniqueness in case further data is added to the sequence. The value of digits [4,6] represents
+// represents n in 2^n, which defines how much randomness flows into the algorithm: 4 -- every value
+// can be represented by 4 symbols in the alphabet (permitting at most 16 values), 5 -- every value
+// can be represented by 2 symbols in the alphabet (permitting at most 32 values), 6 -- every value
+// is represented by exactly 1 symbol with no randomness (permitting 64 values).
+func (abc *Abc) Encode(val, nsymbols, digits uint) ([]rune, error) {
+	if digits < 4 || 6 < digits {
+		return nil, fmt.Errorf("allowed digits range [4,6], found %v", digits)
+	}
+
+	var computedSize uint = 1
+	if val >= 1 {
+		computedSize = uint(math.Log2(float64(val)))/digits + 1
+	}
+	if nsymbols == 0 {
+		nsymbols = computedSize
+	} else if nsymbols < computedSize {
+		return nil, fmt.Errorf("cannot accommodate data, need %v digits, got %v", computedSize, nsymbols)
+	}
+
+	mask := 1<<digits - 1
+
+	random := make([]int, int(nsymbols))
+	// no random component if digits == 6
+	if digits < 6 {
+		copy(random, maskedRandomInts(len(random), 0x3f-mask))
+	}
+
+	res := make([]rune, int(nsymbols))
+	for i := range res {
+		shift := digits * uint(i)
+		index := (int(val>>shift) & mask) | random[i]
+		res[i] = abc.alphabet[index]
+	}
+	return res, nil
+}
+
+// MustEncode acts just like Encode, but panics instead of returning errors.
+func (abc *Abc) MustEncode(val, size, digits uint) []rune {
+	res, err := abc.Encode(val, size, digits)
+	if err == nil {
+		return res
+	}
+	panic(err)
+}
+
+func maskedRandomInts(size, mask int) []int {
+	ints := make([]int, size)
+	bytes := make([]byte, size)
+	if _, err := randc.Read(bytes); err == nil {
+		for i, b := range bytes {
+			ints[i] = int(b) & mask
+		}
+	} else {
+		for i := range ints {
+			ints[i] = randm.Intn(0xff) & mask
+		}
+	}
+	return ints
+}
+
+// String returns a string representation of the Abc instance.
+func (abc Abc) String() string {
+	return fmt.Sprintf("Abc{alphabet='%v')", abc.Alphabet())
+}
+
+// Alphabet returns the alphabet used as an immutable string.
+func (abc Abc) Alphabet() string {
+	return string(abc.alphabet)
+}