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

Merge branch 'docs_v5.0' of github.com:grafana/grafana into docs_v5.0

Torkel Ödegaard 7 лет назад
Родитель
Сommit
b8b26e6677
100 измененных файлов с 2546 добавлено и 918 удалено
  1. 3 0
      CHANGELOG.md
  2. 7 1
      Gopkg.lock
  3. 4 0
      Gopkg.toml
  4. 40 7
      docs/sources/administration/permissions.md
  5. 6 6
      docs/sources/guides/whats-new-in-v5.md
  6. 4 4
      docs/sources/http_api/alerting.md
  7. 1 1
      docs/sources/http_api/data_source.md
  8. 12 9
      docs/sources/http_api/index.md
  9. 0 25
      docs/sources/installation/configuration.md
  10. 4 4
      docs/sources/plugins/developing/datasources.md
  11. 5 0
      package.json
  12. 2 1
      pkg/api/alerting.go
  13. 11 2
      pkg/api/api.go
  14. 64 12
      pkg/api/dashboard.go
  15. 6 0
      pkg/api/dashboard_acl.go
  16. 341 22
      pkg/api/dashboard_test.go
  17. 1 1
      pkg/api/dtos/alerting.go
  18. 2 1
      pkg/api/dtos/dashboard.go
  19. 12 7
      pkg/components/renderer/renderer.go
  20. 24 4
      pkg/log/log.go
  21. 39 0
      pkg/log/log_writer.go
  22. 116 0
      pkg/log/log_writer_test.go
  23. 49 0
      pkg/middleware/dashboard_redirect.go
  24. 58 0
      pkg/middleware/dashboard_redirect_test.go
  25. 14 0
      pkg/middleware/middleware_test.go
  26. 5 0
      pkg/models/dashboard_acl.go
  27. 71 9
      pkg/models/dashboards.go
  28. 25 22
      pkg/services/alerting/eval_context.go
  29. 2 2
      pkg/services/alerting/notifier.go
  30. 4 2
      pkg/services/search/models.go
  31. 124 29
      pkg/services/sqlstore/dashboard.go
  32. 26 6
      pkg/services/sqlstore/dashboard_acl.go
  33. 349 0
      pkg/services/sqlstore/dashboard_folder_test.go
  34. 201 335
      pkg/services/sqlstore/dashboard_test.go
  35. 2 2
      pkg/services/sqlstore/dashboard_version_test.go
  36. 21 0
      pkg/services/sqlstore/migrations/dashboard_mig.go
  37. 2 0
      pkg/services/sqlstore/search_builder.go
  38. 15 0
      pkg/util/shortid_generator.go
  39. 1 1
      public/app/containers/AlertRuleList/AlertRuleList.jest.tsx
  40. 1 1
      public/app/containers/AlertRuleList/AlertRuleList.tsx
  41. 2 2
      public/app/containers/AlertRuleList/__snapshots__/AlertRuleList.jest.tsx.snap
  42. 27 3
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  43. 2 2
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  44. 4 2
      public/app/containers/ManageDashboards/FolderSettings.tsx
  45. 1 7
      public/app/core/angular_wrappers.ts
  46. 37 0
      public/app/core/components/Animations/SlideDown.tsx
  47. 90 0
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  48. 151 0
      public/app/core/components/Permissions/AddPermissions.tsx
  49. 34 10
      public/app/core/components/Permissions/DashboardPermissions.tsx
  50. 2 2
      public/app/core/components/Permissions/FolderInfo.ts
  51. 0 73
      public/app/core/components/Permissions/Permissions.jest.tsx
  52. 2 71
      public/app/core/components/Permissions/Permissions.tsx
  53. 1 1
      public/app/core/components/Permissions/PermissionsListItem.tsx
  54. 19 0
      public/app/core/components/Picker/TeamPicker.jest.tsx
  55. 7 2
      public/app/core/components/Picker/TeamPicker.tsx
  56. 6 3
      public/app/core/components/Picker/UserPicker.tsx
  57. 98 0
      public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap
  58. 1 1
      public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap
  59. 2 0
      public/app/core/components/Picker/withPicker.tsx
  60. 4 0
      public/app/core/components/grafana_app.ts
  61. 9 9
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  62. 0 4
      public/app/core/directives/dash_class.js
  63. 26 11
      public/app/core/services/backend_srv.ts
  64. 9 21
      public/app/core/services/bridge_srv.ts
  65. 9 17
      public/app/core/services/search_srv.ts
  66. 0 22
      public/app/core/specs/bridge_srv.jest.ts
  67. 16 0
      public/app/core/specs/location_util.jest.ts
  68. 18 23
      public/app/core/specs/manage_dashboards.jest.ts
  69. 2 2
      public/app/core/specs/search.jest.ts
  70. 7 0
      public/app/core/specs/search_srv.jest.ts
  71. 14 0
      public/app/core/utils/location_util.ts
  72. 2 4
      public/app/features/dashboard/create_folder_ctrl.ts
  73. 1 1
      public/app/features/dashboard/dashboard_import_ctrl.ts
  74. 3 3
      public/app/features/dashboard/dashboard_loader_srv.ts
  75. 38 6
      public/app/features/dashboard/dashboard_model.ts
  76. 2 3
      public/app/features/dashboard/dashboard_srv.ts
  77. 12 7
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  78. 5 8
      public/app/features/dashboard/folder_page_loader.ts
  79. 12 9
      public/app/features/dashboard/folder_permissions_ctrl.ts
  80. 9 6
      public/app/features/dashboard/folder_picker/folder_picker.ts
  81. 18 14
      public/app/features/dashboard/folder_settings_ctrl.ts
  82. 1 1
      public/app/features/dashboard/partials/folder_dashboards.html
  83. 1 0
      public/app/features/dashboard/save_as_modal.ts
  84. 5 4
      public/app/features/dashboard/settings/settings.html
  85. 16 2
      public/app/features/dashboard/settings/settings.ts
  86. 2 0
      public/app/features/dashboard/shareModalCtrl.ts
  87. 1 1
      public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts
  88. 19 0
      public/app/features/dashboard/specs/repeat.jest.ts
  89. 13 2
      public/app/features/dashboard/specs/share_modal_ctrl_specs.ts
  90. 36 8
      public/app/features/dashboard/validation_srv.ts
  91. 16 2
      public/app/features/panel/solo_panel_ctrl.ts
  92. 1 1
      public/app/plugins/panel/alertlist/module.html
  93. 1 1
      public/app/plugins/panel/dashlist/module.html
  94. 24 4
      public/app/routes/dashboard_loaders.ts
  95. 15 3
      public/app/routes/routes.ts
  96. 1 1
      public/app/stores/AlertListStore/AlertListStore.jest.ts
  97. 1 1
      public/app/stores/AlertListStore/AlertRule.ts
  98. 9 8
      public/app/stores/FolderStore/FolderStore.ts
  99. 5 5
      public/app/stores/NavStore/NavStore.jest.ts
  100. 3 9
      public/app/stores/NavStore/NavStore.ts

+ 3 - 0
CHANGELOG.md

@@ -18,6 +18,9 @@ From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when
 
 * **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222)
 
+* **HTTP API**
+- `GET /api/alerts` property dashboardUri renamed to url and is now the full url (that is including app sub url). 
+
 ## New Dashboard Grid
 
 The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution.

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

+ 40 - 7
docs/sources/administration/permissions.md

@@ -28,7 +28,7 @@ in that organization.
 
 Can do everything scoped to the organization. For example:
 
-- Add & Edit data data sources.
+- Add & Edit data sources.
 - Add & Edit organization users & teams.
 - Configure App plugins & set org settings.
 
@@ -52,6 +52,8 @@ This admin flag makes a user a `Super Admin`. This means they can access the `Se
 
 ### Dashboard & Folder Permissions
 
+> Introduced in Grafana v5.0
+
 {{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
 
 For dashboards and dashboard folders there is a **Permissions** page that make it possible to
@@ -63,12 +65,43 @@ Permission levels:
 
 - **Admin**: Can edit & create dashboards and edit permissions.
 - **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
-- **View**: Can only view existing dashboars/folders.
+- **View**: Can only view existing dashboards/folders.
+
+#### Restricting Access
+
+The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL).
+
+- You cannot override permissions for users with the **Org Admin Role**. Admins always have access to everything.
+- A more specific permission with a lower permission level will not have any effect if a more general rule exists with higher permission level. You need to remove or lower the permission level of the more general rule.
+
+#### How Grafana Resolves Multiple Permissions - Examples
+
+##### Example 1 (`user1` has the Editor Role)
+
+Permissions for a dashboard:
+
+- `Everyone with Editor Role Can Edit`
+- `user1 Can View`
 
-#### Restricting access
+Result: `user1` has Edit permission as the highest permission always wins.
 
-The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the
-Access Control List (ACL).
+##### Example 2 (`user1` has the Viewer Role and is a member of `team1`)
 
-- You cannot override permissions for users with **Org Admin Role**
-- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
+Permissions for a dashboard:
+
+- `Everyone with Viewer Role Can View`
+- `user1 Can Edit`
+- `team1 Can Admin`
+
+Result: `user1` has Admin permission as the highest permission always wins.
+
+##### Example 3
+
+Permissions for a dashboard:
+
+- `user1 Can Admin (inherited from parent folder)`
+- `user1 Can Edit`
+
+
+Result: You cannot override to a lower permission. `user1` has Admin permission as the highest permission always wins.
+- **View**: Can only view existing dashboars/folders.

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

@@ -36,7 +36,7 @@ The new dashboard layout engine allows for much easier movement and sizing of pa
 a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens
 up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video
 above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the
-new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible
+new position system and look close to identical. The new panel position makes dashboards saved in v5.0 incompatible
 with older versions of Grafana.
 
 <div class="clearfix"></div>
@@ -61,7 +61,7 @@ settings views have been combined with a side nav which allows you to easily mov
 
 {{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}}
 
-This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result.
+This theme has not seen a lot of love in recent years and we felt it was time to give it a major overhaul. We are very happy with the result.
 
 <div class="clearfix"></div>
 
@@ -78,7 +78,7 @@ which is very useful if you have a lot of dashboards or multiple teams.
 
 ## Teams
 
-A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams.
+A team is a new concept in Grafana v5. They are simply a group of users that can be used in the new permission system for dashboards and folders. Only an admin can create teams.
 We hope to do more with teams in future releases like integration with LDAP and a team landing page.
 
 ## Permissions
@@ -93,7 +93,7 @@ You can assign permissions to folders and dashboards. The default user role-base
 
 In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
 But that required the service to be running before you started creating dashboards and you also needed to
-set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active
+set up credentials for the HTTP API. In v5.0 we decided to improve this experience by adding a new active
 provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can
 be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs
 and alerts as well.
@@ -105,9 +105,9 @@ It's also possible to update and delete data sources from the config file. More
 
 ### Dashboards
 
-We also deprecated the [dashboard.json] in favor of our new dashboard provisioner that keeps dashboards on disk
+We also deprecated the `[dashboard.json]` in favor of our new dashboard provisioner that keeps dashboards on disk
 in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old
-[dashboard.json] feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
+`[dashboard.json]` feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
 which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
 expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
 

+ 4 - 4
docs/sources/http_api/alerting.md

@@ -62,7 +62,7 @@ Content-Type: application/json
       }
     "newStateDate": "2016-12-25",
     "executionError": "",
-    "dashboardUri": "http://grafana.com/dashboard/db/sensors"
+    "url": "http://grafana.com/dashboard/db/sensors"
   }
 ]
 ```
@@ -94,7 +94,7 @@ Content-Type: application/json
   "state": "alerting",
   "newStateDate": "2016-12-25",
   "executionError": "",
-  "dashboardUri": "http://grafana.com/dashboard/db/sensors"
+  "url": "http://grafana.com/dashboard/db/sensors"
 }
 ```
 
@@ -196,7 +196,7 @@ Content-Type: application/json
 
 ## Create alert notification
 
-You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page. 
+You can find the full list of [supported notifers](/alerting/notifications/#all-supported-notifier) at the alert notifiers page.
 
 `POST /api/alert-notifications`
 
@@ -294,4 +294,4 @@ Content-Type: application/json
 {
   "message": "Notification deleted"
 }
-```
+```

+ 1 - 1
docs/sources/http_api/data_source.md

@@ -90,7 +90,7 @@ Content-Type: application/json
 
 ## Get a single data source by Name
 
-`GET /api/datasources/name/:name`
+`GET /api/datasources/:name`
 
 **Example Request**:
 

+ 12 - 9
docs/sources/http_api/index.md

@@ -18,12 +18,15 @@ dashboards, creating users and updating data sources.
 ## Supported HTTP APIs:
 
 
-* [Authentication API]({{< relref "auth.md" >}})
-* [Dashboard API]({{< relref "dashboard.md" >}})
-* [Data Source API]({{< relref "data_source.md" >}})
-* [Organisation API]({{< relref "org.md" >}})
-* [User API]({{< relref "user.md" >}})
-* [Admin API]({{< relref "admin.md" >}})
-* [Snapshot API]({{< relref "snapshot.md" >}})
-* [Preferences API]({{< relref "preferences.md" >}})
-* [Other API]({{< relref "other.md" >}})
+* [Authentication API]({{< relref "/http_api/auth.md" >}})
+* [Dashboard API]({{< relref "/http_api/dashboard.md" >}})
+* [Dashboard Versions API]({{< relref "http_api/dashboard_versions.md" >}})
+* [Data Source API]({{< relref "http_api/data_source.md" >}})
+* [Organisation API]({{< relref "http_api/org.md" >}})
+* [Snapshot API]({{< relref "http_api/snapshot.md" >}})
+* [Annotations API]({{< relref "http_api/annotations.md" >}})
+* [Alerting API]({{< relref "http_api/alerting.md" >}})
+* [User API]({{< relref "http_api/user.md" >}})
+* [Admin API]({{< relref "http_api/admin.md" >}})
+* [Preferences API]({{< relref "http_api/preferences.md" >}})
+* [Other API]({{< relref "http_api/other.md" >}})

+ 0 - 25
docs/sources/installation/configuration.md

@@ -671,31 +671,6 @@ session provider you have configured.
 - **memcache:** ex:  127.0.0.1:11211
 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
 
-If you use MySQL or Postgres as the session store you need to create the
-session table manually.
-
-Mysql Example:
-
-```bash
-CREATE TABLE `session` (
-    `key`       CHAR(16) NOT NULL,
-    `data`      BLOB,
-    `expiry`    INT(11) UNSIGNED NOT NULL,
-    PRIMARY KEY (`key`)
-) ENGINE=MyISAM DEFAULT CHARSET=utf8;
-```
-
-Postgres Example:
-
-```bash
-CREATE TABLE session (
-    key       CHAR(16) NOT NULL,
-    data      BYTEA,
-    expiry    INTEGER NOT NULL,
-    PRIMARY KEY (key)
-);
-```
-
 Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
 
 ### cookie_name

+ 4 - 4
docs/sources/plugins/developing/datasources.md

@@ -84,15 +84,15 @@ An array of:
   {
     "target":"upper_75",
     "datapoints":[
-      [622,1450754160000],
-      [365,1450754220000]
+      [622, 1450754160000],
+      [365, 1450754220000]
     ]
   },
   {
     "target":"upper_90",
     "datapoints":[
-      [861,1450754160000],
-      [767,1450754220000]
+      [861, 1450754160000],
+      [767, 1450754220000]
     ]
   }
 ]

+ 5 - 0
package.json

@@ -115,6 +115,10 @@
     "*.scss": [
       "prettier --write",
       "git add"
+    ],
+    "*.go": [
+      "gofmt -w -s",
+      "git add"
     ]
   },
   "prettier": {
@@ -153,6 +157,7 @@
     "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
+    "react-transition-group": "^2.2.1",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",

+ 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.Url = 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))
 

+ 64 - 12
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
 	}
@@ -88,7 +88,8 @@ func GetDashboard(c *middleware.Context) Response {
 		HasAcl:      dash.HasAcl,
 		IsFolder:    dash.IsFolder,
 		FolderId:    dash.FolderId,
-		FolderTitle: "Root",
+		Url:         dash.GetUrl(),
+		FolderTitle: "General",
 	}
 
 	// lookup folder title
@@ -98,7 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
 			return ApiError(500, "Dashboard folder could not be read", err)
 		}
 		meta.FolderTitle = query.Result.Title
-		meta.FolderSlug = query.Result.Slug
+		meta.FolderUrl = query.Result.GetUrl()
 	}
 
 	// make sure db version is in sync with json model version
@@ -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,10 @@ 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.ErrDashboardWithSameNameInFolderExists {
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		if err == m.ErrDashboardVersionMismatch {
@@ -232,8 +273,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 {
@@ -243,10 +293,11 @@ func GetHomeDashboard(c *middleware.Context) Response {
 	}
 
 	if prefsQuery.Result.HomeDashboardId != 0 {
-		slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
+		slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
 		err := bus.Dispatch(&slugQuery)
 		if err == nil {
-			dashRedirect := dtos.DashboardRedirect{RedirectUri: "db/" + slugQuery.Result}
+			url := m.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug)
+			dashRedirect := dtos.DashboardRedirect{RedirectUri: url}
 			return Json(200, &dashRedirect)
 		} else {
 			log.Warn("Failed to get slug from database, %s", err.Error())
@@ -262,7 +313,7 @@ func GetHomeDashboard(c *middleware.Context) Response {
 	dash := dtos.DashboardFullWithMeta{}
 	dash.Meta.IsHome = true
 	dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR)
-	dash.Meta.FolderTitle = "Root"
+	dash.Meta.FolderTitle = "General"
 
 	jsonParser := json.NewDecoder(file)
 	if err := jsonParser.Decode(&dash.Dashboard); err != nil {
@@ -400,7 +451,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 +474,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)

+ 6 - 0
pkg/api/dashboard_acl.go

@@ -24,6 +24,12 @@ func GetDashboardAclList(c *middleware.Context) Response {
 		return ApiError(500, "Failed to get dashboard acl", err)
 	}
 
+	for _, perm := range acl {
+		if perm.Slug != "" {
+			perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
+		}
+	}
+
 	return Json(200, acl)
 }
 

+ 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 - 1
pkg/api/dtos/alerting.go

@@ -19,7 +19,7 @@ type AlertRule struct {
 	EvalDate       time.Time        `json:"evalDate"`
 	EvalData       *simplejson.Json `json:"evalData"`
 	ExecutionError string           `json:"executionError"`
-	DashbboardUri  string           `json:"dashboardUri"`
+	Url            string           `json:"url"`
 	CanEdit        bool             `json:"canEdit"`
 }
 

+ 2 - 1
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"`
@@ -26,7 +27,7 @@ type DashboardMeta struct {
 	IsFolder    bool      `json:"isFolder"`
 	FolderId    int64     `json:"folderId"`
 	FolderTitle string    `json:"folderTitle"`
-	FolderSlug  string    `json:"folderSlug"`
+	FolderUrl   string    `json:"folderUrl"`
 }
 
 type DashboardFullWithMeta struct {

+ 12 - 7
pkg/components/renderer/renderer.go

@@ -91,9 +91,15 @@ func RenderToPng(params *RenderOpts) (string, error) {
 		timeout = 15
 	}
 
+	phantomDebugArg := "--debug=false"
+	if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
+		phantomDebugArg = "--debug=true"
+	}
+
 	cmdArgs := []string{
 		"--ignore-ssl-errors=true",
 		"--web-security=false",
+		phantomDebugArg,
 		scriptPath,
 		"url=" + url,
 		"width=" + params.Width,
@@ -109,15 +115,13 @@ func RenderToPng(params *RenderOpts) (string, error) {
 	}
 
 	cmd := exec.Command(binPath, cmdArgs...)
-	stdout, err := cmd.StdoutPipe()
+	output, err := cmd.StdoutPipe()
 
 	if err != nil {
+		rendererLog.Error("Could not acquire stdout pipe", err)
 		return "", err
 	}
-	stderr, err := cmd.StderrPipe()
-	if err != nil {
-		return "", err
-	}
+	cmd.Stderr = cmd.Stdout
 
 	if params.Timezone != "" {
 		baseEnviron := os.Environ()
@@ -126,11 +130,12 @@ func RenderToPng(params *RenderOpts) (string, error) {
 
 	err = cmd.Start()
 	if err != nil {
+		rendererLog.Error("Could not start command", err)
 		return "", err
 	}
 
-	go io.Copy(os.Stdout, stdout)
-	go io.Copy(os.Stdout, stderr)
+	logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
+	go io.Copy(logWriter, output)
 
 	done := make(chan error)
 	go func() {

+ 24 - 4
pkg/log/log.go

@@ -21,6 +21,7 @@ import (
 
 var Root log15.Logger
 var loggersToClose []DisposableHandler
+var filters map[string]log15.Lvl
 
 func init() {
 	loggersToClose = make([]DisposableHandler, 0)
@@ -114,6 +115,25 @@ func Close() {
 	loggersToClose = make([]DisposableHandler, 0)
 }
 
+func GetLogLevelFor(name string) Lvl {
+	if level, ok := filters[name]; ok {
+		switch level {
+		case log15.LvlWarn:
+			return LvlWarn
+		case log15.LvlInfo:
+			return LvlInfo
+		case log15.LvlError:
+			return LvlError
+		case log15.LvlCrit:
+			return LvlCrit
+		default:
+			return LvlDebug
+		}
+	}
+
+	return LvlInfo
+}
+
 var logLevels = map[string]log15.Lvl{
 	"trace":    log15.LvlDebug,
 	"debug":    log15.LvlDebug,
@@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 
 		// Log level.
 		_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
-		modeFilters := getFilters(util.SplitString(sec.Key("filters").String()))
+		filters := getFilters(util.SplitString(sec.Key("filters").String()))
 		format := getLogFormat(sec.Key("format").MustString(""))
 
 		var handler log15.Handler
@@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 		}
 
 		for key, value := range defaultFilters {
-			if _, exist := modeFilters[key]; !exist {
-				modeFilters[key] = value
+			if _, exist := filters[key]; !exist {
+				filters[key] = value
 			}
 		}
 
-		handler = LogFilterHandler(level, modeFilters, handler)
+		handler = LogFilterHandler(level, filters, handler)
 		handlers = append(handlers, handler)
 	}
 

+ 39 - 0
pkg/log/log_writer.go

@@ -0,0 +1,39 @@
+package log
+
+import (
+	"io"
+	"strings"
+)
+
+type logWriterImpl struct {
+	log    Logger
+	level  Lvl
+	prefix string
+}
+
+func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer {
+	return &logWriterImpl{
+		log:    log,
+		level:  level,
+		prefix: prefix,
+	}
+}
+
+func (l *logWriterImpl) Write(p []byte) (n int, err error) {
+	message := l.prefix + strings.TrimSpace(string(p))
+
+	switch l.level {
+	case LvlCrit:
+		l.log.Crit(message)
+	case LvlError:
+		l.log.Error(message)
+	case LvlWarn:
+		l.log.Warn(message)
+	case LvlInfo:
+		l.log.Info(message)
+	default:
+		l.log.Debug(message)
+	}
+
+	return len(p), nil
+}

+ 116 - 0
pkg/log/log_writer_test.go

@@ -0,0 +1,116 @@
+package log
+
+import (
+	"testing"
+
+	"github.com/inconshreveable/log15"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+type FakeLogger struct {
+	debug string
+	info  string
+	warn  string
+	err   string
+	crit  string
+}
+
+func (f *FakeLogger) New(ctx ...interface{}) log15.Logger {
+	return nil
+}
+
+func (f *FakeLogger) Debug(msg string, ctx ...interface{}) {
+	f.debug = msg
+}
+
+func (f *FakeLogger) Info(msg string, ctx ...interface{}) {
+	f.info = msg
+}
+
+func (f *FakeLogger) Warn(msg string, ctx ...interface{}) {
+	f.warn = msg
+}
+
+func (f *FakeLogger) Error(msg string, ctx ...interface{}) {
+	f.err = msg
+}
+
+func (f *FakeLogger) Crit(msg string, ctx ...interface{}) {
+	f.crit = msg
+}
+
+func (f *FakeLogger) GetHandler() log15.Handler {
+	return nil
+}
+
+func (f *FakeLogger) SetHandler(l log15.Handler) {}
+
+func TestLogWriter(t *testing.T) {
+	Convey("When writing to a LogWriter", t, func() {
+		Convey("Should write using the correct level [crit]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlCrit, "")
+			n, err := crit.Write([]byte("crit"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.crit, ShouldEqual, "crit")
+		})
+
+		Convey("Should write using the correct level [error]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlError, "")
+			n, err := crit.Write([]byte("error"))
+
+			So(n, ShouldEqual, 5)
+			So(err, ShouldBeNil)
+			So(fake.err, ShouldEqual, "error")
+		})
+
+		Convey("Should write using the correct level [warn]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlWarn, "")
+			n, err := crit.Write([]byte("warn"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.warn, ShouldEqual, "warn")
+		})
+
+		Convey("Should write using the correct level [info]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlInfo, "")
+			n, err := crit.Write([]byte("info"))
+
+			So(n, ShouldEqual, 4)
+			So(err, ShouldBeNil)
+			So(fake.info, ShouldEqual, "info")
+		})
+
+		Convey("Should write using the correct level [debug]", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlDebug, "")
+			n, err := crit.Write([]byte("debug"))
+
+			So(n, ShouldEqual, 5)
+			So(err, ShouldBeNil)
+			So(fake.debug, ShouldEqual, "debug")
+		})
+
+		Convey("Should prefix the output with the prefix", func() {
+			fake := &FakeLogger{}
+
+			crit := NewLogWriter(fake, LvlDebug, "prefix")
+			n, err := crit.Write([]byte("debug"))
+
+			So(n, ShouldEqual, 5) // n is how much of input consumed
+			So(err, ShouldBeNil)
+			So(fake.debug, ShouldEqual, "prefixdebug")
+		})
+	})
+}

+ 49 - 0
pkg/middleware/dashboard_redirect.go

@@ -0,0 +1,49 @@
+package middleware
+
+import (
+	"fmt"
+	"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 {
+				url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
+				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)
+				url = fmt.Sprintf("%s?%s", url, c.Req.URL.RawQuery)
+				c.Redirect(url, 301)
+				return
+			}
+		}
+	}
+}

+ 58 - 0
pkg/middleware/dashboard_redirect_test.go

@@ -0,0 +1,58 @@
+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?orgId=1&panelId=2", 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))
+				So(len(redirectUrl.Query()), ShouldEqual, 2)
+			})
+		})
+
+		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?orgId=1&panelId=2", 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)
+				So(len(redirectUrl.Query()), ShouldEqual, 2)
+			})
+		})
+	})
+}

+ 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

+ 5 - 0
pkg/models/dashboard_acl.go

@@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct {
 	Role           *RoleType      `json:"role,omitempty"`
 	Permission     PermissionType `json:"permission"`
 	PermissionName string         `json:"permissionName"`
+	Uid            string         `json:"uid"`
+	Title          string         `json:"title"`
+	Slug           string         `json:"slug"`
+	IsFolder       bool           `json:"isFolder"`
+	Url            string         `json:"url"`
 }
 
 //

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

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

@@ -13,15 +13,17 @@ 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"`
 	FolderId    int64    `json:"folderId,omitempty"`
+	FolderUid   string   `json:"folderUid,omitempty"`
 	FolderTitle string   `json:"folderTitle,omitempty"`
-	FolderSlug  string   `json:"folderSlug,omitempty"`
+	FolderUrl   string   `json:"folderUrl,omitempty"`
 }
 
 type HitList []*Hit

+ 124 - 29
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,17 +232,20 @@ 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
 	IsFolder    bool
 	FolderId    int64
+	FolderUid   string
 	FolderSlug  string
 	FolderTitle string
 }
@@ -261,15 +318,21 @@ 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,
+				FolderUid:   item.FolderUid,
 				FolderTitle: item.FolderTitle,
-				FolderSlug:  item.FolderSlug,
 				Tags:        []string{},
 			}
+
+			if item.FolderId > 0 {
+				hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug)
+			}
+
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = hit
 		}
@@ -316,16 +379,19 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
 		params = append(params, query.SignedInUser.UserId)
 		params = append(params, query.OrgId)
 
-		sql += `WHERE
+		sql += ` WHERE
 			d.org_id = ? AND
-			d.is_folder = 1 AND
+			d.is_folder = ? AND
 			(
-				(d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
-				OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
+				(d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
+				OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
 			)`
 		params = append(params, query.OrgId)
+		params = append(params, dialect.BooleanStr(true))
+		params = append(params, dialect.BooleanStr(true))
 		params = append(params, query.SignedInUser.UserId)
 		params = append(params, query.SignedInUser.UserId)
+		params = append(params, dialect.BooleanStr(false))
 
 		if len(query.Title) > 0 {
 			sql += " AND d.title " + dialect.LikeStr() + " ?"
@@ -333,7 +399,6 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
 		}
 
 		sql += ` ORDER BY d.title ASC`
-
 		err = x.Sql(sql, params...).Find(&query.Result)
 	}
 
@@ -430,9 +495,9 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	params = append(params, query.OrgId)
 
 	sql += `
-		LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS 'role'
-			UNION SELECT 2 AS permission, 'Editor' AS 'role'
-			UNION SELECT 4 AS permission, 'Admin' AS 'role') pt ON ouRole.role = pt.role
+		LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role
+			UNION SELECT 2 AS permission, 'Editor' AS role
+			UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role
 	WHERE
 	d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
 	for _, id := range query.DashboardIds {
@@ -447,13 +512,15 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	)
 	group by d.id
 	order by d.id asc`
-	params = append(params, dialect.BooleanStr(true))
 	params = append(params, query.OrgId)
+	params = append(params, dialect.BooleanStr(true))
 	params = append(params, query.UserId)
 	params = append(params, query.UserId)
 	params = append(params, dialect.BooleanStr(false))
 
+	x.ShowSQL(true)
 	err := x.Sql(sql, params...).Find(&query.Result)
+	x.ShowSQL(false)
 
 	for _, p := range query.Result {
 		p.PermissionName = p.Permission.String()
@@ -484,7 +551,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
@@ -495,3 +562,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.GetDashboardRefByIdQuery) 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
+}

+ 26 - 6
pkg/services/sqlstore/dashboard_acl.go

@@ -113,6 +113,7 @@ func SetDashboardAcl(cmd *m.SetDashboardAclCommand) error {
 	})
 }
 
+// RemoveDashboardAcl removes a specified permission from the dashboard acl
 func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 		var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?"
@@ -125,6 +126,11 @@ func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
 	})
 }
 
+// GetDashboardAclInfoList returns a list of permissions for a dashboard. They can be fetched from three
+// different places.
+// 1) Permissions for the dashboard
+// 2) permissions for its parent folder
+// 3) if no specific permissions have been set for the dashboard or its parent folder then get the default permissions
 func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 	var err error
 
@@ -141,7 +147,11 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		da.updated,
 		'' as user_login,
 		'' as user_email,
-		'' as team
+		'' as team,
+		'' as title,
+		'' as slug,
+		'' as uid,` +
+			dialect.BooleanStr(false) + ` AS is_folder
 		FROM dashboard_acl as da
 		WHERE da.dashboard_id = -1`
 		query.Result = make([]*m.DashboardAclInfoDTO, 0)
@@ -155,6 +165,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		  )`, query.DashboardId, query.DashboardId)
 
 		rawSQL := `
+			-- get permissions for the dashboard and its parent folder
 			SELECT
 				da.id,
 				da.org_id,
@@ -167,13 +178,18 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 				da.updated,
 				u.login AS user_login,
 				u.email AS user_email,
-				ug.name AS team
+				ug.name AS team,
+				d.title,
+				d.slug,
+				d.uid,
+				d.is_folder
 		  FROM` + dialect.Quote("dashboard_acl") + ` as da
 				LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
 				LEFT OUTER JOIN team ug on ug.id = da.team_id
+				LEFT OUTER JOIN dashboard d on da.dashboard_id = d.id
 			WHERE dashboard_id ` + dashboardFilter + ` AND da.org_id = ?
 
-			-- Also include default permission if has_acl = 0
+			-- Also include default permissions if folder or dashboard field "has_acl" is false
 
 			UNION
 				SELECT
@@ -188,10 +204,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 					da.updated,
 					'' as user_login,
 					'' as user_email,
-					'' as team
-					FROM dashboard_acl as da,
+					'' as team,
+					folder.title,
+					folder.slug,
+					folder.uid,
+					folder.is_folder
+				FROM dashboard_acl as da,
 				dashboard as dash
-				LEFT JOIN dashboard folder on dash.folder_id = folder.id
+				LEFT OUTER JOIN dashboard folder on dash.folder_id = folder.id
 					WHERE
 						dash.id = ? AND (
 							dash.has_acl = ` + dialect.BooleanStr(false) + ` or

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

+ 201 - 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,8 @@ 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))
+				So(hit.FolderTitle, ShouldEqual, "")
 			})
 
 			Convey("Should be able to search for a dashboard folder's children", func() {
@@ -124,6 +163,11 @@ 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))
+				So(hit.FolderId, ShouldEqual, savedFolder.Id)
+				So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
+				So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
+				So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
 			})
 
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
@@ -157,20 +201,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 +456,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)

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

@@ -150,4 +150,25 @@ 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,
+	}))
+
+	mg.AddMigration("Add unique index for dashboard_org_id_title_folder_id", NewAddIndexMigration(dashboardV2, &Index{
+		Cols: []string{"org_id", "folder_id", "title"}, Type: UniqueIndex,
+	}))
 }

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

@@ -101,11 +101,13 @@ func (sb *SearchBuilder) buildSelect() {
 	sb.sql.WriteString(
 		`SELECT
 			dashboard.id,
+			dashboard.uid,
 			dashboard.title,
 			dashboard.slug,
 			dashboard_tag.term,
 			dashboard.is_folder,
 			dashboard.folder_id,
+			folder.uid as folder_uid,
 			folder.slug as folder_slug,
 			folder.title as folder_title
 		FROM `)

+ 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',
+          url: '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.url}?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

+ 27 - 3
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -6,21 +6,35 @@ import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 @inject('nav', 'folder', 'view', 'permissions')
 @observer
 export class FolderPermissions extends Component<IContainerProps, any> {
   constructor(props) {
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.loadStore();
   }
 
+  componentWillUnmount() {
+    const { permissions } = this.props;
+    permissions.hideAddPermissions();
+  }
+
   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');
     });
   }
 
+  handleAddPermission() {
+    const { permissions } = this.props;
+    permissions.toggleAddPermissions();
+  }
+
   render() {
     const { nav, folder, permissions, backendSrv } = this.props;
 
@@ -34,13 +48,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
       <div>
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
-          <div className="page-sub-heading">
+          <div className="page-action-bar">
             <h2 className="d-inline-block">Folder Permissions</h2>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
           </div>
-
+          <SlideDown in={permissions.isAddPermissionsVisible}>
+            <AddPermissions permissions={permissions} backendSrv={backendSrv} />
+          </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>
       </div>

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

+ 1 - 7
public/app/core/angular_wrappers.ts

@@ -20,11 +20,5 @@ export function registerAngularDirectives() {
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
   react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
-  react2AngularDirective('dashboardPermissions', DashboardPermissions, [
-    'backendSrv',
-    'dashboardId',
-    'folderTitle',
-    'folderSlug',
-    'folderId',
-  ]);
+  react2AngularDirective('dashboardPermissions', DashboardPermissions, ['backendSrv', 'dashboardId', 'folder']);
 }

+ 37 - 0
public/app/core/components/Animations/SlideDown.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import Transition from 'react-transition-group/Transition';
+
+const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
+// If this is not enough, pass in <SlideDown maxHeight="....
+const defaultDuration = 200;
+const defaultStyle = {
+  transition: `max-height ${defaultDuration}ms ease-in-out`,
+  overflow: 'hidden',
+};
+
+export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
+  // There are 4 main states a Transition can be in:
+  // ENTERING, ENTERED, EXITING, EXITED
+  // https://reactcommunity.org/react-transition-group/
+  const transitionStyles = {
+    exited: { maxHeight: 0 },
+    entering: { maxHeight: maxHeight },
+    entered: { maxHeight: maxHeight, overflow: 'visible' },
+    exiting: { maxHeight: 0 },
+  };
+
+  return (
+    <Transition in={inProp} timeout={defaultDuration}>
+      {state => (
+        <div
+          style={{
+            ...defaultStyle,
+            ...transitionStyles[state],
+          }}
+        >
+          {children}
+        </div>
+      )}
+    </Transition>
+  );
+};

+ 90 - 0
public/app/core/components/Permissions/AddPermissions.jest.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import AddPermissions from './AddPermissions';
+import { RootStore } from 'app/stores/RootStore/RootStore';
+import { backendSrv } from 'test/mocks/common';
+import { shallow } from 'enzyme';
+
+describe('AddPermissions', () => {
+  let wrapper;
+  let store;
+  let instance;
+
+  beforeAll(() => {
+    backendSrv.get.mockReturnValue(
+      Promise.resolve([
+        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+      ])
+    );
+
+    backendSrv.post = jest.fn();
+
+    store = RootStore.create(
+      {},
+      {
+        backendSrv: backendSrv,
+      }
+    );
+
+    wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} />);
+    instance = wrapper.instance();
+    return store.permissions.load(1, true, false);
+  });
+
+  describe('when permission for a user is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'User',
+        },
+      };
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      instance.typeChanged(evt);
+      instance.userPicked(userItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      wrapper.find('form').simulate('submit', { preventDefault() {} });
+
+      expect(backendSrv.post.mock.calls.length).toBe(1);
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    });
+  });
+
+  describe('when permission for team is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'Group',
+        },
+      };
+
+      const teamItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      instance.typeChanged(evt);
+      instance.teamPicked(teamItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      wrapper.find('form').simulate('submit', { preventDefault() {} });
+
+      expect(backendSrv.post.mock.calls.length).toBe(1);
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    });
+  });
+
+  afterEach(() => {
+    backendSrv.post.mockClear();
+  });
+});

+ 151 - 0
public/app/core/components/Permissions/AddPermissions.tsx

@@ -0,0 +1,151 @@
+import React, { Component } from 'react';
+import { observer } from 'mobx-react';
+import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
+import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
+import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
+import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
+
+export interface IProps {
+  permissions: any;
+  backendSrv: any;
+}
+@observer
+class AddPermissions extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.userPicked = this.userPicked.bind(this);
+    this.teamPicked = this.teamPicked.bind(this);
+    this.permissionPicked = this.permissionPicked.bind(this);
+    this.typeChanged = this.typeChanged.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  componentWillMount() {
+    const { permissions } = this.props;
+    permissions.resetNewType();
+  }
+
+  typeChanged(evt) {
+    const { value } = evt.target;
+    const { permissions } = this.props;
+
+    permissions.setNewType(value);
+  }
+
+  userPicked(user: User) {
+    const { permissions } = this.props;
+    if (!user) {
+      permissions.newItem.setUser(null, null);
+      return;
+    }
+    return permissions.newItem.setUser(user.id, user.login);
+  }
+
+  teamPicked(team: Team) {
+    const { permissions } = this.props;
+    if (!team) {
+      permissions.newItem.setTeam(null, null);
+      return;
+    }
+    return permissions.newItem.setTeam(team.id, team.name);
+  }
+
+  permissionPicked(permission: OptionWithDescription) {
+    const { permissions } = this.props;
+    return permissions.newItem.setPermission(permission.value);
+  }
+
+  resetNewType() {
+    const { permissions } = this.props;
+    return permissions.resetNewType();
+  }
+
+  handleSubmit(evt) {
+    evt.preventDefault();
+    const { permissions } = this.props;
+    permissions.addStoreItem();
+  }
+
+  render() {
+    const { permissions, backendSrv } = this.props;
+    const newItem = permissions.newItem;
+    const pickerClassName = 'width-20';
+
+    const isValid = newItem.isValid();
+
+    return (
+      <div className="gf-form-inline cta-form">
+        <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
+          <i className="fa fa-close" />
+        </button>
+        <form name="addPermission" onSubmit={this.handleSubmit}>
+          <h6>Add Permission For</h6>
+          <div className="gf-form-inline">
+            <div className="gf-form">
+              <div className="gf-form-select-wrapper">
+                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
+                  {aclTypes.map((option, idx) => {
+                    return (
+                      <option key={idx} value={option.value}>
+                        {option.text}
+                      </option>
+                    );
+                  })}
+                </select>
+              </div>
+            </div>
+
+            {newItem.type === 'User' ? (
+              <div className="gf-form">
+                <UserPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.userPicked}
+                  value={newItem.userId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            {newItem.type === 'Group' ? (
+              <div className="gf-form">
+                <TeamPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.teamPicked}
+                  value={newItem.teamId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            <div className="gf-form">
+              <DescriptionPicker
+                optionsWithDesc={permissionOptions}
+                handlePicked={this.permissionPicked}
+                value={newItem.permission}
+                disabled={false}
+                className={'gf-form-input--form-dropdown-right'}
+              />
+            </div>
+
+            <div className="gf-form">
+              <button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
+                Save
+              </button>
+            </div>
+          </div>
+        </form>
+        {permissions.error ? (
+          <div className="gf-form width-17">
+            <span ng-if="ctrl.error" className="text-error p-l-1">
+              <i className="fa fa-warning" />
+              {permissions.error}
+            </span>
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+}
+
+export default AddPermissions;

+ 34 - 10
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -1,41 +1,65 @@
 import React, { Component } from 'react';
+import { observer } from 'mobx-react';
 import { store } from 'app/stores/store';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
+import { FolderInfo } from './FolderInfo';
 
 export interface IProps {
   dashboardId: number;
-  folderId: number;
-  folderTitle: string;
-  folderSlug: string;
+  folder?: FolderInfo;
   backendSrv: any;
 }
-
+@observer
 class DashboardPermissions extends Component<IProps, any> {
   permissions: any;
 
   constructor(props) {
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.permissions = store.permissions;
   }
 
+  handleAddPermission() {
+    this.permissions.toggleAddPermissions();
+  }
+
+  componentWillUnmount() {
+    this.permissions.hideAddPermissions();
+  }
+
   render() {
-    const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
+    const { dashboardId, folder, backendSrv } = this.props;
 
     return (
       <div>
         <div className="dashboard-settings__header">
-          <h3 className="d-inline-block">Permissions</h3>
-          <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
-            <i className="gicon gicon-question gicon--has-hover" />
-          </Tooltip>
+          <div className="page-action-bar">
+            <h3 className="d-inline-block">Permissions</h3>
+            <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
+              <i className="gicon gicon-question gicon--has-hover" />
+            </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={this.permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
+          </div>
         </div>
+        <SlideDown in={this.permissions.isAddPermissionsVisible}>
+          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
+        </SlideDown>
         <Permissions
           permissions={this.permissions}
           isFolder={false}
           dashboardId={dashboardId}
-          folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
+          folderInfo={folder}
           backendSrv={backendSrv}
         />
       </div>

+ 2 - 2
public/app/core/components/Permissions/FolderInfo.ts

@@ -1,5 +1,5 @@
 export interface FolderInfo {
-  title: string;
   id: number;
-  slug: string;
+  title: string;
+  url: string;
 }

+ 0 - 73
public/app/core/components/Permissions/Permissions.jest.tsx

@@ -1,73 +0,0 @@
-import React from 'react';
-import Permissions from './Permissions';
-import { RootStore } from 'app/stores/RootStore/RootStore';
-import { backendSrv } from 'test/mocks/common';
-import { shallow } from 'enzyme';
-
-describe('Permissions', () => {
-  let wrapper;
-
-  beforeAll(() => {
-    backendSrv.get.mockReturnValue(
-      Promise.resolve([
-        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
-        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
-        {
-          id: 4,
-          dashboardId: 1,
-          userId: 2,
-          userLogin: 'danlimerick',
-          userEmail: 'dan.limerick@gmail.com',
-          permission: 4,
-          permissionName: 'Admin',
-        },
-      ])
-    );
-
-    backendSrv.post = jest.fn();
-
-    const store = RootStore.create(
-      {},
-      {
-        backendSrv: backendSrv,
-      }
-    );
-
-    wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
-    return wrapper.instance().loadStore(1, true);
-  });
-
-  describe('when permission for a user is added', () => {
-    it('should save permission to db', () => {
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-
-      wrapper
-        .instance()
-        .userPicked(userItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-
-  describe('when permission for team is added', () => {
-    it('should save permission to db', () => {
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-
-      wrapper
-        .instance()
-        .teamPicked(teamItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-});

+ 2 - 71
public/app/core/components/Permissions/Permissions.tsx

@@ -1,9 +1,6 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import PermissionsList from './PermissionsList';
 import { observer } from 'mobx-react';
-import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
-import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
-import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
 import { FolderInfo } from './FolderInfo';
 
 export interface DashboardAcl {
@@ -40,8 +37,6 @@ class Permissions extends Component<IProps, any> {
     this.permissionChanged = this.permissionChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.removeItem = this.removeItem.bind(this);
-    this.userPicked = this.userPicked.bind(this);
-    this.teamPicked = this.teamPicked.bind(this);
     this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
   }
 
@@ -77,28 +72,8 @@ class Permissions extends Component<IProps, any> {
     permissions.setNewType(value);
   }
 
-  userPicked(user: User) {
-    const { permissions, dashboardId } = this.props;
-    return permissions.addStoreItem({
-      userId: user.id,
-      userLogin: user.login,
-      permission: 1,
-      dashboardId: dashboardId,
-    });
-  }
-
-  teamPicked(team: Team) {
-    const { permissions, dashboardId } = this.props;
-    return permissions.addStoreItem({
-      teamId: team.id,
-      team: team.name,
-      permission: 1,
-      dashboardId: dashboardId,
-    });
-  }
-
   render() {
-    const { permissions, folderInfo, backendSrv } = this.props;
+    const { permissions, folderInfo } = this.props;
 
     return (
       <div className="gf-form-group">
@@ -109,50 +84,6 @@ class Permissions extends Component<IProps, any> {
           fetching={permissions.fetching}
           folderInfo={folderInfo}
         />
-        <div className="gf-form-inline">
-          <form name="addPermission" className="gf-form-group">
-            <h6 className="muted">Add Permission For</h6>
-            <div className="gf-form-inline">
-              <div className="gf-form">
-                <div className="gf-form-select-wrapper">
-                  <select
-                    className="gf-form-input gf-size-auto"
-                    value={permissions.newType}
-                    onChange={this.typeChanged}
-                  >
-                    {aclTypes.map((option, idx) => {
-                      return (
-                        <option key={idx} value={option.value}>
-                          {option.text}
-                        </option>
-                      );
-                    })}
-                  </select>
-                </div>
-              </div>
-
-              {permissions.newType === 'User' ? (
-                <div className="gf-form">
-                  <UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
-                </div>
-              ) : null}
-
-              {permissions.newType === 'Group' ? (
-                <div className="gf-form">
-                  <TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
-                </div>
-              ) : null}
-            </div>
-          </form>
-          {permissions.error ? (
-            <div className="gf-form width-17">
-              <span ng-if="ctrl.error" className="text-error p-l-1">
-                <i className="fa fa-warning" />
-                {permissions.error}
-              </span>
-            </div>
-          ) : null}
-        </div>
       </div>
     );
   }

+ 1 - 1
public/app/core/components/Permissions/PermissionsListItem.tsx

@@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
           folderInfo && (
             <em className="muted no-wrap">
               Inherited from folder{' '}
-              <a className="text-link" href={`dashboards/folder/${folderInfo.id}/${folderInfo.slug}/permissions`}>
+              <a className="text-link" href={`${folderInfo.url}/permissions`}>
                 {folderInfo.title}
               </a>{' '}
             </em>

+ 19 - 0
public/app/core/components/Picker/TeamPicker.jest.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import TeamPicker from './TeamPicker';
+
+const model = {
+  backendSrv: {
+    get: () => {
+      return new Promise((resolve, reject) => {});
+    },
+  },
+  handlePicked: () => {},
+};
+
+describe('TeamPicker', () => {
+  it('renders correctly', () => {
+    const tree = renderer.create(<TeamPicker {...model} />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+});

+ 7 - 2
public/app/core/components/Picker/TeamPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   toggleLoading: any;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 
 export interface Team {
@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
 
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
+    const { isLoading, handlePicked, value, className } = this.props;
 
     return (
       <div className="user-picker">
@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
           isLoading={isLoading}
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
+          noResultsText="No teams found"
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
       </div>
     );

+ 6 - 3
public/app/core/components/Picker/UserPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   toggleLoading: any;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 
 export interface User {
@@ -53,8 +55,7 @@ class UserPicker extends Component<IProps, any> {
 
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
-
+    const { isLoading, handlePicked, value, className } = this.props;
     return (
       <div className="user-picker">
         <AsyncComponent
@@ -67,9 +68,11 @@ class UserPicker extends Component<IProps, any> {
           loadingPlaceholder="Loading..."
           noResultsText="No users found"
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
       </div>
     );

+ 98 - 0
public/app/core/components/Picker/__snapshots__/TeamPicker.jest.tsx.snap

@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TeamPicker renders correctly 1`] = `
+<div
+  className="user-picker"
+>
+  <div
+    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
+    style={undefined}
+  >
+    <div
+      className="Select-control"
+      onKeyDown={[Function]}
+      onMouseDown={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      style={undefined}
+    >
+      <span
+        className="Select-multi-value-wrapper"
+        id="react-select-2--value"
+      >
+        <div
+          className="Select-placeholder"
+        >
+          Loading...
+        </div>
+        <div
+          className="Select-input"
+          style={
+            Object {
+              "display": "inline-block",
+            }
+          }
+        >
+          <input
+            aria-activedescendant="react-select-2--value"
+            aria-describedby={undefined}
+            aria-expanded="false"
+            aria-haspopup="false"
+            aria-label={undefined}
+            aria-labelledby={undefined}
+            aria-owns=""
+            className={undefined}
+            id={undefined}
+            onBlur={[Function]}
+            onChange={[Function]}
+            onFocus={[Function]}
+            required={false}
+            role="combobox"
+            style={
+              Object {
+                "boxSizing": "content-box",
+                "width": "5px",
+              }
+            }
+            tabIndex={undefined}
+            value=""
+          />
+          <div
+            style={
+              Object {
+                "height": 0,
+                "left": 0,
+                "overflow": "scroll",
+                "position": "absolute",
+                "top": 0,
+                "visibility": "hidden",
+                "whiteSpace": "pre",
+              }
+            }
+          >
+            
+          </div>
+        </div>
+      </span>
+      <span
+        aria-hidden="true"
+        className="Select-loading-zone"
+      >
+        <span
+          className="Select-loading"
+        />
+      </span>
+      <span
+        className="Select-arrow-zone"
+        onMouseDown={[Function]}
+      >
+        <span
+          className="Select-arrow"
+          onMouseDown={[Function]}
+        />
+      </span>
+    </div>
+  </div>
+</div>
+`;

+ 1 - 1
public/app/core/components/Picker/__snapshots__/UserPicker.jest.tsx.snap

@@ -5,7 +5,7 @@ exports[`UserPicker renders correctly 1`] = `
   className="user-picker"
 >
   <div
-    className="Select width-8 gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
+    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
     style={undefined}
   >
     <div

+ 2 - 0
public/app/core/components/Picker/withPicker.tsx

@@ -3,6 +3,8 @@
 export interface IProps {
   backendSrv: any;
   handlePicked: (data) => void;
+  value?: string;
+  className?: string;
 }
 
 export default function withPicker(WrappedComponent) {

+ 4 - 0
public/app/core/components/grafana_app.ts

@@ -83,6 +83,10 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
         body.toggleClass('sidemenu-hidden');
       });
 
+      scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
+        elem.toggleClass('playlist-active', newValue === true);
+      });
+
       // tooltip removal fix
       // manage page classes
       var pageClass;

+ 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: '=',
     },
   };
 }

+ 0 - 4
public/app/core/directives/dash_class.js

@@ -18,10 +18,6 @@ function (_, $, coreModule) {
           elem.toggleClass('panel-in-fullscreen', false);
         });
 
-        $scope.$watch('ctrl.playlistSrv.isPlaying', function(newValue) {
-          elem.toggleClass('playlist-active', newValue === true);
-        });
-
         $scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
           if (newValue) {
             elem.toggleClass('dashboard-page--settings-opening', _.isString(newValue));

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

+ 9 - 17
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.folderUid,
             title: hit.folderTitle,
-            url: `dashboards/folder/${hit.folderId}/${hit.folderSlug}`,
-            slug: hit.slug,
+            url: hit.folderUrl,
             items: [],
             icon: 'fa fa-folder-open',
             toggle: this.toggleFolder.bind(this),
@@ -169,7 +161,7 @@ export class SearchSrv {
         } else {
           section = {
             id: 0,
-            title: 'Root',
+            title: 'General',
             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/');
+    });
+  });
+});

+ 18 - 23
public/app/core/specs/manage_dashboards.jest.ts

@@ -20,9 +20,6 @@ describe('ManageDashboards', () => {
               icon: 'fa fa-folder',
               tags: [],
               isStarred: false,
-              folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
             },
           ],
           tags: [],
@@ -30,7 +27,7 @@ describe('ManageDashboards', () => {
         },
         {
           id: 0,
-          title: 'Root',
+          title: 'General',
           icon: 'fa fa-folder-open',
           uri: 'db/something-else',
           type: 'dash-db',
@@ -77,9 +74,6 @@ describe('ManageDashboards', () => {
               icon: 'fa fa-folder',
               tags: [],
               isStarred: false,
-              folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
             },
           ],
           tags: [],
@@ -112,8 +106,9 @@ describe('ManageDashboards', () => {
               tags: [],
               isStarred: false,
               folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
+              folderUid: 'uid',
+              folderTitle: 'Folder',
+              folderUrl: '/dashboards/f/uid/folder',
             },
             {
               id: 500,
@@ -363,7 +358,7 @@ describe('ManageDashboards', () => {
           },
           {
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: true }],
             checked: false,
           },
@@ -391,7 +386,7 @@ describe('ManageDashboards', () => {
           },
           {
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: false }],
             checked: false,
           },
@@ -420,7 +415,7 @@ describe('ManageDashboards', () => {
           },
           {
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: true }],
             checked: false,
           },
@@ -455,7 +450,7 @@ describe('ManageDashboards', () => {
           },
           {
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: false }],
             checked: false,
           },
@@ -483,22 +478,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' }],
+          title: 'General',
+          items: [{ id: 3, checked: true, uid: 'root-dash' }],
           checked: true,
         },
       ];
@@ -535,14 +530,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' }],
+          title: 'General',
+          items: [{ id: 3, checked: true, uid: 'dash-2' }],
           checked: false,
         },
       ];

+ 2 - 2
public/app/core/specs/search.jest.ts

@@ -49,7 +49,7 @@ describe('SearchCtrl', () => {
         },
         {
           id: 0,
-          title: 'Root',
+          title: 'General',
           items: [{ id: 3, selected: false }, { id: 5, selected: false }],
           selected: false,
           expanded: true,
@@ -146,7 +146,7 @@ describe('SearchCtrl', () => {
         },
         {
           id: 0,
-          title: 'Root',
+          title: 'General',
           items: [{ id: 3, selected: false }, { id: 5, selected: false }],
           selected: false,
           expanded: true,

+ 7 - 0
public/app/core/specs/search_srv.jest.ts

@@ -190,7 +190,9 @@ describe('SearchSrv', () => {
             title: 'dash in folder1 1',
             type: 'dash-db',
             folderId: 1,
+            folderUid: 'uid',
             folderTitle: 'folder1',
+            folderUrl: '/dashboards/f/uid/folder1',
           },
         ])
       );
@@ -206,6 +208,11 @@ describe('SearchSrv', () => {
 
     it('should group results by folder', () => {
       expect(results).toHaveLength(2);
+      expect(results[0].id).toEqual(0);
+      expect(results[1].id).toEqual(1);
+      expect(results[1].uid).toEqual('uid');
+      expect(results[1].title).toEqual('folder1');
+      expect(results[1].url).toEqual('/dashboards/f/uid/folder1');
     });
   });
 

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

+ 2 - 4
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);
     });
   }
 
@@ -29,7 +27,7 @@ export class CreateFolderCtrl {
     this.titleTouched = true;
 
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.title)
+      .validateNewFolderName(this.title)
       .then(() => {
         this.hasValidationError = false;
       })

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

@@ -93,7 +93,7 @@ export class DashboardImportCtrl {
     this.nameExists = false;
 
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.dash.title)
+      .validateNewDashboardName(0, this.dash.title)
       .then(() => {
         this.hasNameValidationError = false;
       })

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

+ 38 - 6
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;
@@ -279,6 +281,40 @@ export class DashboardModel {
     this.events.emit('repeats-processed');
   }
 
+  cleanUpRowRepeats(rowPanels) {
+    let panelsToRemove = [];
+    for (let i = 0; i < rowPanels.length; i++) {
+      let panel = rowPanels[i];
+      if (!panel.repeat && panel.repeatPanelId) {
+        panelsToRemove.push(panel);
+      }
+    }
+    _.pull(rowPanels, ...panelsToRemove);
+    _.pull(this.panels, ...panelsToRemove);
+  }
+
+  processRowRepeats(row: PanelModel) {
+    if (this.snapshot || this.templating.list.length === 0) {
+      return;
+    }
+
+    let rowPanels = row.panels;
+    if (!row.collapsed) {
+      let rowPanelIndex = _.findIndex(this.panels, p => p.id === row.id);
+      rowPanels = this.getRowPanels(rowPanelIndex);
+    }
+
+    this.cleanUpRowRepeats(rowPanels);
+
+    for (let i = 0; i < rowPanels.length; i++) {
+      let panel = rowPanels[i];
+      if (panel.repeat) {
+        let panelIndex = _.findIndex(this.panels, p => p.id === panel.id);
+        this.repeatPanel(panel, panelIndex);
+      }
+    }
+  }
+
   getPanelRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
     // if first clone return source
     if (valueIndex === 0) {
@@ -569,7 +605,7 @@ export class DashboardModel {
 
     if (row.collapsed) {
       row.collapsed = false;
-      let hasRepeat = false;
+      let hasRepeat = _.some(row.panels, p => p.repeat);
 
       if (row.panels.length > 0) {
         // Use first panel to figure out if it was moved or pushed
@@ -590,10 +626,6 @@ export class DashboardModel {
           // update insert post and y max
           insertPos += 1;
           yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
-
-          if (panel.repeat) {
-            hasRepeat = true;
-          }
         }
 
         const pushDownAmount = yMax - row.gridPos.y;
@@ -606,7 +638,7 @@ export class DashboardModel {
         row.panels = [];
 
         if (hasRepeat) {
-          this.processRepeats();
+          this.processRowRepeats(row);
         }
       }
 

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

+ 9 - 6
public/app/features/dashboard/folder_picker/folder_picker.ts

@@ -12,7 +12,7 @@ export class FolderPickerCtrl {
   enterFolderCreation: any;
   exitFolderCreation: any;
   enableCreateNew: boolean;
-  rootName = 'Root';
+  rootName = 'General';
   folder: any;
   createNewFolder: boolean;
   newFolderName: string;
@@ -33,10 +33,13 @@ export class FolderPickerCtrl {
     return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
       if (
         query === '' ||
-        query.toLowerCase() === 'r' ||
-        query.toLowerCase() === 'ro' ||
-        query.toLowerCase() === 'roo' ||
-        query.toLowerCase() === 'root'
+        query.toLowerCase() === 'g' ||
+        query.toLowerCase() === 'ge' ||
+        query.toLowerCase() === 'gen' ||
+        query.toLowerCase() === 'gene' ||
+        query.toLowerCase() === 'gener' ||
+        query.toLowerCase() === 'genera' ||
+        query.toLowerCase() === 'general'
       ) {
         result.unshift({ title: this.rootName, id: 0 });
       }
@@ -64,7 +67,7 @@ export class FolderPickerCtrl {
     this.newFolderNameTouched = true;
 
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.newFolderName)
+      .validateNewFolderName(this.newFolderName)
       .then(() => {
         this.hasValidationError = false;
       })

+ 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 - 0
public/app/features/dashboard/save_as_modal.ts

@@ -46,6 +46,7 @@ export class SaveDashboardAsModalCtrl {
     var dashboard = this.dashboardSrv.getCurrent();
     this.clone = dashboard.getSaveModelClone();
     this.clone.id = null;
+    this.clone.uid = '';
     this.clone.title += ' Copy';
     this.clone.editable = true;
     this.clone.hideControls = false;

+ 5 - 4
public/app/features/dashboard/settings/settings.html

@@ -96,13 +96,14 @@
 </div>
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
-  <dashboard-permissions ng-if="ctrl.dashboard"
+  <dashboard-permissions ng-if="ctrl.dashboard && !ctrl.hasUnsavedFolderChange"
     dashboardId="ctrl.dashboard.id"
     backendSrv="ctrl.backendSrv"
-    folderTitle="ctrl.dashboard.meta.folderTitle"
-    folderSlug="ctrl.dashboard.meta.folderSlug"
-    folderId="ctrl.dashboard.meta.folderId"
+    folder="ctrl.getFolder()"
   />
+  <div ng-if="ctrl.hasUnsavedFolderChange">
+    <h5>You have changed folder, please save to view permissions.</h5>
+  </div>
 </div>
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">

+ 16 - 2
public/app/features/dashboard/settings/settings.ts

@@ -14,6 +14,7 @@ export class SettingsCtrl {
   canSave: boolean;
   canDelete: boolean;
   sections: any[];
+  hasUnsavedFolderChange: boolean;
 
   /** @ngInject */
   constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
@@ -38,6 +39,7 @@ export class SettingsCtrl {
 
     this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
     this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
+    this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope);
   }
 
   buildSectionList() {
@@ -135,6 +137,10 @@ export class SettingsCtrl {
     this.dashboardSrv.saveDashboard();
   }
 
+  onPostSave() {
+    this.hasUnsavedFolderChange = false;
+  }
+
   hideSettings() {
     var urlParams = this.$location.search();
     delete urlParams.editview;
@@ -186,7 +192,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('/');
     });
@@ -195,7 +201,15 @@ export class SettingsCtrl {
   onFolderChange(folder) {
     this.dashboard.meta.folderId = folder.id;
     this.dashboard.meta.folderTitle = folder.title;
-    this.dashboard.meta.folderSlug = folder.slug;
+    this.hasUnsavedFolderChange = true;
+  }
+
+  getFolder() {
+    return {
+      id: this.dashboard.meta.folderId,
+      title: this.dashboard.meta.folderTitle,
+      url: this.dashboard.meta.folderUrl,
+    };
   }
 }
 

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

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

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

+ 19 - 0
public/app/features/dashboard/specs/repeat.jest.ts

@@ -629,4 +629,23 @@ describe('given dashboard with row and panel repeat', () => {
       region: { text: 'reg2', value: 'reg2' },
     });
   });
+
+  it('should repeat panels when row is expanding', function() {
+    dashboard = new DashboardModel(dashboardJSON);
+    dashboard.processRepeats();
+
+    expect(dashboard.panels.length).toBe(6);
+
+    // toggle row
+    dashboard.toggleRow(dashboard.panels[0]);
+    dashboard.toggleRow(dashboard.panels[1]);
+    expect(dashboard.panels.length).toBe(2);
+
+    // change variable
+    dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3'];
+
+    // toggle row back
+    dashboard.toggleRow(dashboard.panels[1]);
+    expect(dashboard.panels.length).toBe(4);
+  });
 });

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

+ 36 - 8
public/app/features/dashboard/validation_srv.ts

@@ -1,13 +1,27 @@
 import coreModule from 'app/core/core_module';
 
+const hitTypes = {
+  FOLDER: 'dash-folder',
+  DASHBOARD: 'dash-db',
+};
+
 export class ValidationSrv {
-  rootName = 'root';
+  rootName = 'general';
 
   /** @ngInject */
   constructor(private $q, private backendSrv) {}
 
-  validateNewDashboardOrFolderName(name) {
+  validateNewDashboardName(folderId, name) {
+    return this.validate(folderId, name, 'A dashboard in this folder with the same name already exists');
+  }
+
+  validateNewFolderName(name) {
+    return this.validate(0, name, 'A folder or dashboard in the general folder with the same name already exists');
+  }
+
+  private validate(folderId, name, existingErrorMessage) {
     name = (name || '').trim();
+    const nameLowerCased = name.toLowerCase();
 
     if (name.length === 0) {
       return this.$q.reject({
@@ -16,21 +30,35 @@ export class ValidationSrv {
       });
     }
 
-    if (name.toLowerCase() === this.rootName) {
+    if (folderId === 0 && nameLowerCased === this.rootName) {
       return this.$q.reject({
         type: 'EXISTING',
-        message: 'A folder or dashboard with the same name already exists',
+        message: 'This is a reserved name and cannot be used for a folder.',
       });
     }
 
     let deferred = this.$q.defer();
 
-    this.backendSrv.search({ query: name }).then(res => {
-      for (let hit of res) {
-        if (name.toLowerCase() === hit.title.toLowerCase()) {
+    const promises = [];
+    promises.push(this.backendSrv.search({ type: hitTypes.FOLDER, folderIds: [folderId], query: name }));
+    promises.push(this.backendSrv.search({ type: hitTypes.DASHBOARD, folderIds: [folderId], query: name }));
+
+    this.$q.all(promises).then(res => {
+      let hits = [];
+
+      if (res.length > 0 && res[0].length > 0) {
+        hits = res[0];
+      }
+
+      if (res.length > 1 && res[1].length > 0) {
+        hits = hits.concat(res[1]);
+      }
+
+      for (let hit of hits) {
+        if (nameLowerCased === hit.title.toLowerCase()) {
           deferred.reject({
             type: 'EXISTING',
-            message: 'A folder or dashboard with the same name already exists',
+            message: existingErrorMessage,
           });
           break;
         }

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

@@ -1,19 +1,33 @@
 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() {
       contextSrv.sidemenu = false;
+      appEvents.emit('toggle-sidemenu');
 
       var params = $location.search();
       panelId = parseInt(params.panelId);
 
       $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/alertlist/module.html

@@ -12,7 +12,7 @@
         <div class="alert-rule-item__body">
           <div class="alert-rule-item__header">
             <p class="alert-rule-item__name">
-              <a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
+              <a href="{{alert.url}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
                 {{alert.name}}
               </a>
             </p>

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

+ 24 - 4
public/app/routes/dashboard_loaders.ts

@@ -1,14 +1,15 @@
 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);
+          $location.path(homeDash.redirectUri);
         } else {
           var meta = homeDash.meta;
           meta.canSave = meta.canShare = meta.canStar = false;
@@ -18,7 +19,26 @@ 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) {
+      if (result.meta.url) {
+        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',

+ 1 - 1
public/app/stores/AlertListStore/AlertListStore.jest.ts

@@ -14,7 +14,7 @@ function getRule(name, state, info) {
       .format(),
     evalData: {},
     executionError: '',
-    dashboardUri: 'db/mygool',
+    url: 'db/mygool',
     stateText: state,
     stateIcon: 'fa',
     stateClass: 'asd',

+ 1 - 1
public/app/stores/AlertListStore/AlertRule.ts

@@ -13,7 +13,7 @@ export const AlertRule = types
     stateClass: types.string,
     stateAge: types.string,
     info: types.optional(types.string, ''),
-    dashboardUri: types.string,
+    url: types.string,
     canEdit: types.boolean,
   })
   .views(self => ({

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

Некоторые файлы не были показаны из-за большого количества измененных файлов