Преглед изворни кода

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

Torkel Ödegaard пре 8 година
родитељ
комит
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)
 * **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
 ## 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.
 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"
   revision = "9e8dc3f972df6c8fcc0375ef492c24d0bb204857"
   version = "1.6.3"
   version = "1.6.3"
 
 
+[[projects]]
+  branch = "master"
+  name = "github.com/teris-io/shortid"
+  packages = ["."]
+  revision = "771a37caa5cf0c81f585d7b6df4dfc77e0615b5c"
+
 [[projects]]
 [[projects]]
   name = "github.com/uber/jaeger-client-go"
   name = "github.com/uber/jaeger-client-go"
   packages = [
   packages = [
@@ -625,6 +631,6 @@
 [solve-meta]
 [solve-meta]
   analyzer-name = "dep"
   analyzer-name = "dep"
   analyzer-version = 1
   analyzer-version = 1
-  inputs-digest = "98e8d8f5fb21fe448aeb3db41c9fed85fe3bf80400e553211cf39a9c05720e01"
+  inputs-digest = "4de68f1342ba98a637ec8ca7496aeeae2021bf9e4c7c80db7924e14709151a62"
   solver-name = "gps-cdcl"
   solver-name = "gps-cdcl"
   solver-version = 1
   solver-version = 1

+ 4 - 0
Gopkg.toml

@@ -193,3 +193,7 @@ ignored = [
   non-go = true
   non-go = true
   go-tests = true
   go-tests = true
   unused-packages = 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:
 Can do everything scoped to the organization. For example:
 
 
-- Add & Edit data data sources.
+- Add & Edit data sources.
 - Add & Edit organization users & teams.
 - Add & Edit organization users & teams.
 - Configure App plugins & set org settings.
 - 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
 ### Dashboard & Folder Permissions
 
 
+> Introduced in Grafana v5.0
+
 {{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
 {{< 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
 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.
 - **Admin**: Can edit & create dashboards and edit permissions.
 - **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard 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
 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
 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
 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.
 with older versions of Grafana.
 
 
 <div class="clearfix"></div>
 <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" >}}
 {{< 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>
 <div class="clearfix"></div>
 
 
@@ -78,7 +78,7 @@ which is very useful if you have a lot of dashboards or multiple teams.
 
 
 ## 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.
 We hope to do more with teams in future releases like integration with LDAP and a team landing page.
 
 
 ## Permissions
 ## 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.
 In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
 But that required the service to be running before you started creating dashboards and you also needed to
 But that required the service to be running before you started creating dashboards and you also needed to
-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
 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
 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.
 and alerts as well.
@@ -105,9 +105,9 @@ It's also possible to update and delete data sources from the config file. More
 
 
 ### Dashboards
 ### 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
 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
 which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
 expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
 expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
 
 

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

@@ -62,7 +62,7 @@ Content-Type: application/json
       }
       }
     "newStateDate": "2016-12-25",
     "newStateDate": "2016-12-25",
     "executionError": "",
     "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",
   "state": "alerting",
   "newStateDate": "2016-12-25",
   "newStateDate": "2016-12-25",
   "executionError": "",
   "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
 ## 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`
 `POST /api/alert-notifications`
 
 
@@ -294,4 +294,4 @@ Content-Type: application/json
 {
 {
   "message": "Notification deleted"
   "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 a single data source by Name
 
 
-`GET /api/datasources/name/:name`
+`GET /api/datasources/:name`
 
 
 **Example Request**:
 **Example Request**:
 
 

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

@@ -18,12 +18,15 @@ dashboards, creating users and updating data sources.
 ## Supported HTTP APIs:
 ## 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
 - **memcache:** ex:  127.0.0.1:11211
 - **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
 - **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).
 Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
 
 
 ### cookie_name
 ### cookie_name

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

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

+ 5 - 0
package.json

@@ -115,6 +115,10 @@
     "*.scss": [
     "*.scss": [
       "prettier --write",
       "prettier --write",
       "git add"
       "git add"
+    ],
+    "*.go": [
+      "gofmt -w -s",
+      "git add"
     ]
     ]
   },
   },
   "prettier": {
   "prettier": {
@@ -153,6 +157,7 @@
     "react-popper": "^0.7.5",
     "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
     "react-sizeme": "^2.3.6",
+    "react-transition-group": "^2.2.1",
     "remarkable": "^1.7.1",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",
     "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 _, alert := range alertDTOs {
 		for _, dash := range dashboardsQuery.Result {
 		for _, dash := range dashboardsQuery.Result {
 			if alert.DashboardId == dash.Id {
 			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})
 	reqGrafanaAdmin := middleware.Auth(&middleware.AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqEditorRole := middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
 	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
 	reqOrgAdmin := middleware.RoleAuth(m.ROLE_ADMIN)
+	redirectFromLegacyDashboardUrl := middleware.RedirectFromLegacyDashboardUrl()
+	redirectFromLegacyDashboardSoloUrl := middleware.RedirectFromLegacyDashboardSoloUrl()
 	quota := middleware.Quota
 	quota := middleware.Quota
 	bind := binding.Bind
 	bind := binding.Bind
 
 
@@ -63,9 +65,13 @@ func (hs *HttpServer) registerRoutes() {
 	r.Get("/plugins/:id/edit", reqSignedIn, Index)
 	r.Get("/plugins/:id/edit", reqSignedIn, Index)
 	r.Get("/plugins/:id/page/:page", 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/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("/import/dashboard", reqSignedIn, Index)
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
 	r.Get("/dashboards/*", reqSignedIn, Index)
@@ -242,6 +248,9 @@ func (hs *HttpServer) registerRoutes() {
 
 
 		// Dashboard
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 		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.Get("/db/:slug", wrap(GetDashboard))
 			dashboardRoute.Delete("/db/:slug", wrap(DeleteDashboard))
 			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 {
 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 {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
@@ -88,7 +88,8 @@ func GetDashboard(c *middleware.Context) Response {
 		HasAcl:      dash.HasAcl,
 		HasAcl:      dash.HasAcl,
 		IsFolder:    dash.IsFolder,
 		IsFolder:    dash.IsFolder,
 		FolderId:    dash.FolderId,
 		FolderId:    dash.FolderId,
-		FolderTitle: "Root",
+		Url:         dash.GetUrl(),
+		FolderTitle: "General",
 	}
 	}
 
 
 	// lookup folder title
 	// lookup folder title
@@ -98,7 +99,7 @@ func GetDashboard(c *middleware.Context) Response {
 			return ApiError(500, "Dashboard folder could not be read", err)
 			return ApiError(500, "Dashboard folder could not be read", err)
 		}
 		}
 		meta.FolderTitle = query.Result.Title
 		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
 	// 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 {
 	if err := bus.Dispatch(&query); err != nil {
 		return nil, ApiError(404, "Dashboard not found", err)
 		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 {
 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 {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
@@ -208,7 +246,10 @@ func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 	}
 	}
 
 
 	if err != nil {
 	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()})
 			return Json(412, util.DynMap{"status": "name-exists", "message": err.Error()})
 		}
 		}
 		if err == m.ErrDashboardVersionMismatch {
 		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)
 		return ApiError(500, "Invalid alert data. Cannot save dashboard", err)
 	}
 	}
 
 
+	dashboard.IsFolder = dash.IsFolder
+
 	c.TimeRequest(metrics.M_Api_Dashboard_Save)
 	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 {
 func GetHomeDashboard(c *middleware.Context) Response {
@@ -243,10 +293,11 @@ func GetHomeDashboard(c *middleware.Context) Response {
 	}
 	}
 
 
 	if prefsQuery.Result.HomeDashboardId != 0 {
 	if prefsQuery.Result.HomeDashboardId != 0 {
-		slugQuery := m.GetDashboardSlugByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
+		slugQuery := m.GetDashboardRefByIdQuery{Id: prefsQuery.Result.HomeDashboardId}
 		err := bus.Dispatch(&slugQuery)
 		err := bus.Dispatch(&slugQuery)
 		if err == nil {
 		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)
 			return Json(200, &dashRedirect)
 		} else {
 		} else {
 			log.Warn("Failed to get slug from database, %s", err.Error())
 			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 := dtos.DashboardFullWithMeta{}
 	dash.Meta.IsHome = true
 	dash.Meta.IsHome = true
 	dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR)
 	dash.Meta.CanEdit = c.SignedInUser.HasRole(m.ROLE_EDITOR)
-	dash.Meta.FolderTitle = "Root"
+	dash.Meta.FolderTitle = "General"
 
 
 	jsonParser := json.NewDecoder(file)
 	jsonParser := json.NewDecoder(file)
 	if err := jsonParser.Decode(&dash.Dashboard); err != nil {
 	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.
 // RestoreDashboardVersion restores a dashboard to the given version.
 func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboardVersionCommand) Response {
 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 {
 	if rsp != nil {
 		return rsp
 		return rsp
 	}
 	}
@@ -423,6 +474,7 @@ func RestoreDashboardVersion(c *middleware.Context, apiCmd dtos.RestoreDashboard
 	saveCmd.UserId = c.UserId
 	saveCmd.UserId = c.UserId
 	saveCmd.Dashboard = version.Data
 	saveCmd.Dashboard = version.Data
 	saveCmd.Dashboard.Set("version", dash.Version)
 	saveCmd.Dashboard.Set("version", dash.Version)
+	saveCmd.Dashboard.Set("uid", dash.Uid)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 	saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
 
 
 	return PostDashboard(c, saveCmd)
 	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)
 		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)
 	return Json(200, acl)
 }
 }
 
 

+ 341 - 22
pkg/api/dashboard_test.go

@@ -39,8 +39,17 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.FolderId = 1
 		fakeDash.FolderId = 1
 		fakeDash.HasAcl = false
 		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 {
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 			return nil
 		})
 		})
 
 
@@ -77,9 +86,13 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		Convey("When user is an Org Viewer", func() {
 		Convey("When user is an Org Viewer", func() {
 			role := m.ROLE_VIEWER
 			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)
 				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() {
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, 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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				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) {
 			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() {
 		Convey("When user is an Org Editor", func() {
 			role := m.ROLE_EDITOR
 			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)
 				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() {
 				Convey("Should be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, 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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				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) {
 			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) {
 			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() {
 			Convey("When saving a dashboard folder in another folder", func() {
@@ -172,6 +242,12 @@ func TestDashboardApiEndpoint(t *testing.T) {
 		fakeDash.HasAcl = true
 		fakeDash.HasAcl = true
 		setting.ViewersCanEdit = false
 		setting.ViewersCanEdit = false
 
 
+		bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error {
+			dashboards := []*m.Dashboard{fakeDash}
+			query.Result = dashboards
+			return nil
+		})
+
 		aclMockResp := []*m.DashboardAclInfoDTO{
 		aclMockResp := []*m.DashboardAclInfoDTO{
 			{
 			{
 				DashboardId: 1,
 				DashboardId: 1,
@@ -185,8 +261,11 @@ func TestDashboardApiEndpoint(t *testing.T) {
 			return nil
 			return nil
 		})
 		})
 
 
+		var getDashboardQueries []*m.GetDashboardQuery
+
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 		bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
 			query.Result = fakeDash
 			query.Result = fakeDash
+			getDashboardQueries = append(getDashboardQueries, query)
 			return nil
 			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() {
 		Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
 			role := m.ROLE_VIEWER
 			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.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				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() {
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 					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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				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) {
 			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() {
 		Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
 			role := m.ROLE_EDITOR
 			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.handlerFunc = GetDashboard
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				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() {
 				Convey("Should be denied access", func() {
 					So(sc.resp.Code, ShouldEqual, 403)
 					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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				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) {
 			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
 				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)
 				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() {
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, 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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				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) {
 			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) {
 			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
 				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)
 				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() {
 				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.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, ShouldBeFalse)
 					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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				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
 				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)
 				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() {
 				Convey("Should be able to get dashboard with edit rights", func() {
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanEdit, ShouldBeTrue)
 					So(dash.Meta.CanSave, 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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 200)
 				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) {
 			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) {
 			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
 				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)
 				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() {
 				Convey("Should not be able to edit or save dashboard", func() {
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanEdit, ShouldBeFalse)
 					So(dash.Meta.CanSave, 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)
 				CallDeleteDashboard(sc)
 				So(sc.resp.Code, ShouldEqual, 403)
 				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) {
 			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 {
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
@@ -479,6 +770,15 @@ func CallDeleteDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
 	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) {
 func CallPostDashboard(sc *scenarioContext) {
 	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
 	bus.AddHandler("test", func(cmd *alerting.ValidateDashboardAlertsCommand) error {
 		return nil
 		return nil
@@ -496,6 +796,18 @@ func CallPostDashboard(sc *scenarioContext) {
 	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
 	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) {
 func postDashboardScenario(desc string, url string, routePattern string, role m.RoleType, cmd m.SaveDashboardCommand, fn scenarioFunc) {
 	Convey(desc+" "+url, func() {
 	Convey(desc+" "+url, func() {
 		defer bus.ClearBusHandlers()
 		defer bus.ClearBusHandlers()
@@ -518,3 +830,10 @@ func postDashboardScenario(desc string, url string, routePattern string, role m.
 		fn(sc)
 		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"`
 	EvalDate       time.Time        `json:"evalDate"`
 	EvalData       *simplejson.Json `json:"evalData"`
 	EvalData       *simplejson.Json `json:"evalData"`
 	ExecutionError string           `json:"executionError"`
 	ExecutionError string           `json:"executionError"`
-	DashbboardUri  string           `json:"dashboardUri"`
+	Url            string           `json:"url"`
 	CanEdit        bool             `json:"canEdit"`
 	CanEdit        bool             `json:"canEdit"`
 }
 }
 
 

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

@@ -16,6 +16,7 @@ type DashboardMeta struct {
 	CanAdmin    bool      `json:"canAdmin"`
 	CanAdmin    bool      `json:"canAdmin"`
 	CanStar     bool      `json:"canStar"`
 	CanStar     bool      `json:"canStar"`
 	Slug        string    `json:"slug"`
 	Slug        string    `json:"slug"`
+	Url         string    `json:"url"`
 	Expires     time.Time `json:"expires"`
 	Expires     time.Time `json:"expires"`
 	Created     time.Time `json:"created"`
 	Created     time.Time `json:"created"`
 	Updated     time.Time `json:"updated"`
 	Updated     time.Time `json:"updated"`
@@ -26,7 +27,7 @@ type DashboardMeta struct {
 	IsFolder    bool      `json:"isFolder"`
 	IsFolder    bool      `json:"isFolder"`
 	FolderId    int64     `json:"folderId"`
 	FolderId    int64     `json:"folderId"`
 	FolderTitle string    `json:"folderTitle"`
 	FolderTitle string    `json:"folderTitle"`
-	FolderSlug  string    `json:"folderSlug"`
+	FolderUrl   string    `json:"folderUrl"`
 }
 }
 
 
 type DashboardFullWithMeta struct {
 type DashboardFullWithMeta struct {

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

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

+ 24 - 4
pkg/log/log.go

@@ -21,6 +21,7 @@ import (
 
 
 var Root log15.Logger
 var Root log15.Logger
 var loggersToClose []DisposableHandler
 var loggersToClose []DisposableHandler
+var filters map[string]log15.Lvl
 
 
 func init() {
 func init() {
 	loggersToClose = make([]DisposableHandler, 0)
 	loggersToClose = make([]DisposableHandler, 0)
@@ -114,6 +115,25 @@ func Close() {
 	loggersToClose = make([]DisposableHandler, 0)
 	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{
 var logLevels = map[string]log15.Lvl{
 	"trace":    log15.LvlDebug,
 	"trace":    log15.LvlDebug,
 	"debug":    log15.LvlDebug,
 	"debug":    log15.LvlDebug,
@@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 
 
 		// Log level.
 		// Log level.
 		_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
 		_, 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(""))
 		format := getLogFormat(sec.Key("format").MustString(""))
 
 
 		var handler log15.Handler
 		var handler log15.Handler
@@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
 		}
 		}
 
 
 		for key, value := range defaultFilters {
 		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)
 		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
 	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 {
 func (sc *scenarioContext) handler(fn handlerFunc) *scenarioContext {
 	sc.handlerFunc = fn
 	sc.handlerFunc = fn
 	return sc
 	return sc

+ 5 - 0
pkg/models/dashboard_acl.go

@@ -59,6 +59,11 @@ type DashboardAclInfoDTO struct {
 	Role           *RoleType      `json:"role,omitempty"`
 	Role           *RoleType      `json:"role,omitempty"`
 	Permission     PermissionType `json:"permission"`
 	Permission     PermissionType `json:"permission"`
 	PermissionName string         `json:"permissionName"`
 	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 (
 import (
 	"errors"
 	"errors"
+	"fmt"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
 	"github.com/gosimple/slug"
 	"github.com/gosimple/slug"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
+	"github.com/grafana/grafana/pkg/setting"
 )
 )
 
 
 // Typed errors
 // Typed errors
 var (
 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 {
 type UpdatePluginDashboardError struct {
@@ -39,6 +44,7 @@ var (
 // Dashboard model
 // Dashboard model
 type Dashboard struct {
 type Dashboard struct {
 	Id       int64
 	Id       int64
+	Uid      string
 	Slug     string
 	Slug     string
 	OrgId    int64
 	OrgId    int64
 	GnetId   int64
 	GnetId   int64
@@ -107,6 +113,10 @@ func NewDashboardFromJson(data *simplejson.Json) *Dashboard {
 		dash.GnetId = int64(gnetId)
 		dash.GnetId = int64(gnetId)
 	}
 	}
 
 
+	if uid, err := dash.Data.Get("uid").String(); err == nil {
+		dash.Uid = uid
+	}
+
 	return dash
 	return dash
 }
 }
 
 
@@ -147,6 +157,40 @@ func SlugifyTitle(title string) string {
 	return slug.Make(strings.ToLower(title))
 	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
 // COMMANDS
 //
 //
@@ -177,8 +221,9 @@ type DeleteDashboardCommand struct {
 //
 //
 
 
 type GetDashboardQuery 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
 	Id    int64  // optional if slug is set
+	Uid   string // optional if slug is set
 	OrgId int64
 	OrgId int64
 
 
 	Result *Dashboard
 	Result *Dashboard
@@ -218,6 +263,13 @@ type GetDashboardSlugByIdQuery struct {
 	Result string
 	Result string
 }
 }
 
 
+type GetDashboardsBySlugQuery struct {
+	OrgId int64
+	Slug  string
+
+	Result []*Dashboard
+}
+
 type GetFoldersForSignedInUserQuery struct {
 type GetFoldersForSignedInUserQuery struct {
 	OrgId        int64
 	OrgId        int64
 	SignedInUser *SignedInUser
 	SignedInUser *SignedInUser
@@ -235,3 +287,13 @@ type DashboardPermissionForUser struct {
 	Permission     PermissionType `json:"permission"`
 	Permission     PermissionType `json:"permission"`
 	PermissionName string         `json:"permissionName"`
 	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 {
 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
 	ImagePublicUrl  string
 	ImageOnDiskPath string
 	ImageOnDiskPath string
 	NoDataFound     bool
 	NoDataFound     bool
@@ -83,29 +85,30 @@ func (c *EvalContext) GetNotificationTitle() string {
 	return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
 	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) {
 func (c *EvalContext) GetRuleUrl() (string, error) {
 	if c.IsTestRun {
 	if c.IsTestRun {
 		return setting.AppUrl, nil
 		return setting.AppUrl, nil
 	}
 	}
 
 
-	if slug, err := c.GetDashboardSlug(); err != nil {
+	if ref, err := c.GetDashboardUID(); err != nil {
 		return "", err
 		return "", err
 	} else {
 	} 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,
 		IsAlertContext: true,
 	}
 	}
 
 
-	if slug, err := context.GetDashboardSlug(); err != nil {
+	if ref, err := context.GetDashboardUID(); err != nil {
 		return err
 		return err
 	} else {
 	} 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 {
 	if imagePath, err := renderer.RenderToPng(renderOpts); err != nil {

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

@@ -13,15 +13,17 @@ const (
 
 
 type Hit struct {
 type Hit struct {
 	Id          int64    `json:"id"`
 	Id          int64    `json:"id"`
+	Uid         string   `json:"uid"`
 	Title       string   `json:"title"`
 	Title       string   `json:"title"`
 	Uri         string   `json:"uri"`
 	Uri         string   `json:"uri"`
-	Slug        string   `json:"slug"`
+	Url         string   `json:"url"`
 	Type        HitType  `json:"type"`
 	Type        HitType  `json:"type"`
 	Tags        []string `json:"tags"`
 	Tags        []string `json:"tags"`
 	IsStarred   bool     `json:"isStarred"`
 	IsStarred   bool     `json:"isStarred"`
 	FolderId    int64    `json:"folderId,omitempty"`
 	FolderId    int64    `json:"folderId,omitempty"`
+	FolderUid   string   `json:"folderUid,omitempty"`
 	FolderTitle string   `json:"folderTitle,omitempty"`
 	FolderTitle string   `json:"folderTitle,omitempty"`
-	FolderSlug  string   `json:"folderSlug,omitempty"`
+	FolderUrl   string   `json:"folderUrl,omitempty"`
 }
 }
 
 
 type HitList []*Hit
 type HitList []*Hit

+ 124 - 29
pkg/services/sqlstore/dashboard.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/services/search"
+	"github.com/grafana/grafana/pkg/util"
 )
 )
 
 
 func init() {
 func init() {
@@ -18,19 +19,23 @@ func init() {
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", SearchDashboards)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardTags)
 	bus.AddHandler("sql", GetDashboardSlugById)
 	bus.AddHandler("sql", GetDashboardSlugById)
+	bus.AddHandler("sql", GetDashboardUIDById)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
 	bus.AddHandler("sql", GetDashboardsByPluginId)
 	bus.AddHandler("sql", GetFoldersForSignedInUser)
 	bus.AddHandler("sql", GetFoldersForSignedInUser)
 	bus.AddHandler("sql", GetDashboardPermissionsForUser)
 	bus.AddHandler("sql", GetDashboardPermissionsForUser)
+	bus.AddHandler("sql", GetDashboardsBySlug)
 }
 }
 
 
+var generateNewUid func() string = util.GenerateShortUid
+
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 		dash := cmd.GetDashboardModel()
 		dash := cmd.GetDashboardModel()
 
 
 		// try get existing dashboard
 		// 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)
 			dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.Id, dash.OrgId).Get(&existing)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
@@ -52,23 +57,38 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 			if existing.PluginId != "" && cmd.Overwrite == false {
 			if existing.PluginId != "" && cmd.Overwrite == false {
 				return m.UpdatePluginDashboardError{PluginId: existing.PluginId}
 				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)
 		err = setHasAcl(sess, dash)
@@ -92,7 +112,7 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 				dash.Updated = cmd.UpdatedAt
 				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 {
 		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 {
 func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 	// check if parent has acl
 	// check if parent has acl
 	if dash.FolderId > 0 {
 	if dash.FolderId > 0 {
@@ -168,7 +222,7 @@ func setHasAcl(sess *DBSession, dash *m.Dashboard) error {
 }
 }
 
 
 func GetDashboard(query *m.GetDashboardQuery) 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)
 	has, err := x.Get(&dashboard)
 
 
 	if err != nil {
 	if err != nil {
@@ -178,17 +232,20 @@ func GetDashboard(query *m.GetDashboardQuery) error {
 	}
 	}
 
 
 	dashboard.Data.Set("id", dashboard.Id)
 	dashboard.Data.Set("id", dashboard.Id)
+	dashboard.Data.Set("uid", dashboard.Uid)
 	query.Result = &dashboard
 	query.Result = &dashboard
 	return nil
 	return nil
 }
 }
 
 
 type DashboardSearchProjection struct {
 type DashboardSearchProjection struct {
 	Id          int64
 	Id          int64
+	Uid         string
 	Title       string
 	Title       string
 	Slug        string
 	Slug        string
 	Term        string
 	Term        string
 	IsFolder    bool
 	IsFolder    bool
 	FolderId    int64
 	FolderId    int64
+	FolderUid   string
 	FolderSlug  string
 	FolderSlug  string
 	FolderTitle string
 	FolderTitle string
 }
 }
@@ -261,15 +318,21 @@ func makeQueryResult(query *search.FindPersistedDashboardsQuery, res []Dashboard
 		if !exists {
 		if !exists {
 			hit = &search.Hit{
 			hit = &search.Hit{
 				Id:          item.Id,
 				Id:          item.Id,
+				Uid:         item.Uid,
 				Title:       item.Title,
 				Title:       item.Title,
 				Uri:         "db/" + item.Slug,
 				Uri:         "db/" + item.Slug,
-				Slug:        item.Slug,
+				Url:         m.GetDashboardFolderUrl(item.IsFolder, item.Uid, item.Slug),
 				Type:        getHitType(item),
 				Type:        getHitType(item),
 				FolderId:    item.FolderId,
 				FolderId:    item.FolderId,
+				FolderUid:   item.FolderUid,
 				FolderTitle: item.FolderTitle,
 				FolderTitle: item.FolderTitle,
-				FolderSlug:  item.FolderSlug,
 				Tags:        []string{},
 				Tags:        []string{},
 			}
 			}
+
+			if item.FolderId > 0 {
+				hit.FolderUrl = m.GetFolderUrl(item.FolderUid, item.FolderSlug)
+			}
+
 			query.Result = append(query.Result, hit)
 			query.Result = append(query.Result, hit)
 			hits[item.Id] = 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.SignedInUser.UserId)
 		params = append(params, query.OrgId)
 		params = append(params, query.OrgId)
 
 
-		sql += `WHERE
+		sql += ` WHERE
 			d.org_id = ? AND
 			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, 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, query.SignedInUser.UserId)
 		params = append(params, query.SignedInUser.UserId)
+		params = append(params, dialect.BooleanStr(false))
 
 
 		if len(query.Title) > 0 {
 		if len(query.Title) > 0 {
 			sql += " AND d.title " + dialect.LikeStr() + " ?"
 			sql += " AND d.title " + dialect.LikeStr() + " ?"
@@ -333,7 +399,6 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
 		}
 		}
 
 
 		sql += ` ORDER BY d.title ASC`
 		sql += ` ORDER BY d.title ASC`
-
 		err = x.Sql(sql, params...).Find(&query.Result)
 		err = x.Sql(sql, params...).Find(&query.Result)
 	}
 	}
 
 
@@ -430,9 +495,9 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	params = append(params, query.OrgId)
 	params = append(params, query.OrgId)
 
 
 	sql += `
 	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
 	WHERE
 	d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
 	d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
 	for _, id := range query.DashboardIds {
 	for _, id := range query.DashboardIds {
@@ -447,13 +512,15 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
 	)
 	)
 	group by d.id
 	group by d.id
 	order by d.id asc`
 	order by d.id asc`
-	params = append(params, dialect.BooleanStr(true))
 	params = append(params, query.OrgId)
 	params = append(params, query.OrgId)
+	params = append(params, dialect.BooleanStr(true))
 	params = append(params, query.UserId)
 	params = append(params, query.UserId)
 	params = append(params, query.UserId)
 	params = append(params, query.UserId)
 	params = append(params, dialect.BooleanStr(false))
 	params = append(params, dialect.BooleanStr(false))
 
 
+	x.ShowSQL(true)
 	err := x.Sql(sql, params...).Find(&query.Result)
 	err := x.Sql(sql, params...).Find(&query.Result)
+	x.ShowSQL(false)
 
 
 	for _, p := range query.Result {
 	for _, p := range query.Result {
 		p.PermissionName = p.Permission.String()
 		p.PermissionName = p.Permission.String()
@@ -484,7 +551,7 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	var rawSql = `SELECT slug from dashboard WHERE Id=?`
 	var rawSql = `SELECT slug from dashboard WHERE Id=?`
 	var slug = DashboardSlugDTO{}
 	var slug = DashboardSlugDTO{}
 
 
-	exists, err := x.Sql(rawSql, query.Id).Get(&slug)
+	exists, err := x.SQL(rawSql, query.Id).Get(&slug)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -495,3 +562,31 @@ func GetDashboardSlugById(query *m.GetDashboardSlugByIdQuery) error {
 	query.Result = slug.Slug
 	query.Result = slug.Slug
 	return nil
 	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 {
 func RemoveDashboardAcl(cmd *m.RemoveDashboardAclCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
 		var rawSQL = "DELETE FROM " + dialect.Quote("dashboard_acl") + " WHERE org_id =? and id=?"
 		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 {
 func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 	var err error
 	var err error
 
 
@@ -141,7 +147,11 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		da.updated,
 		da.updated,
 		'' as user_login,
 		'' as user_login,
 		'' as user_email,
 		'' as user_email,
-		'' as team
+		'' as team,
+		'' as title,
+		'' as slug,
+		'' as uid,` +
+			dialect.BooleanStr(false) + ` AS is_folder
 		FROM dashboard_acl as da
 		FROM dashboard_acl as da
 		WHERE da.dashboard_id = -1`
 		WHERE da.dashboard_id = -1`
 		query.Result = make([]*m.DashboardAclInfoDTO, 0)
 		query.Result = make([]*m.DashboardAclInfoDTO, 0)
@@ -155,6 +165,7 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 		  )`, query.DashboardId, query.DashboardId)
 		  )`, query.DashboardId, query.DashboardId)
 
 
 		rawSQL := `
 		rawSQL := `
+			-- get permissions for the dashboard and its parent folder
 			SELECT
 			SELECT
 				da.id,
 				da.id,
 				da.org_id,
 				da.org_id,
@@ -167,13 +178,18 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 				da.updated,
 				da.updated,
 				u.login AS user_login,
 				u.login AS user_login,
 				u.email AS user_email,
 				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
 		  FROM` + dialect.Quote("dashboard_acl") + ` as da
 				LEFT OUTER JOIN ` + dialect.Quote("user") + ` AS u ON u.id = da.user_id
 				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 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 = ?
 			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
 			UNION
 				SELECT
 				SELECT
@@ -188,10 +204,14 @@ func GetDashboardAclInfoList(query *m.GetDashboardAclInfoListQuery) error {
 					da.updated,
 					da.updated,
 					'' as user_login,
 					'' as user_login,
 					'' as user_email,
 					'' 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
 				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
 					WHERE
 						dash.id = ? AND (
 						dash.id = ? AND (
 							dash.has_acl = ` + dialect.BooleanStr(false) + ` or
 							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
 package sqlstore
 
 
 import (
 import (
+	"fmt"
 	"testing"
 	"testing"
 
 
 	"github.com/go-xorm/xorm"
 	"github.com/go-xorm/xorm"
-	. "github.com/smartystreets/goconvey/convey"
-
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/services/search"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
+	"github.com/grafana/grafana/pkg/util"
+	. "github.com/smartystreets/goconvey/convey"
 )
 )
 
 
 func TestDashboardDataAccess(t *testing.T) {
 func TestDashboardDataAccess(t *testing.T) {
@@ -30,15 +31,33 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(savedDash.Id, ShouldNotEqual, 0)
 				So(savedDash.Id, ShouldNotEqual, 0)
 				So(savedDash.IsFolder, ShouldBeFalse)
 				So(savedDash.IsFolder, ShouldBeFalse)
 				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
 				So(savedDash.FolderId, ShouldBeGreaterThan, 0)
+				So(len(savedDash.Uid), ShouldBeGreaterThan, 0)
 
 
 				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
 				So(savedFolder.Title, ShouldEqual, "1 test dash folder")
 				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
 				So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
 				So(savedFolder.Id, ShouldNotEqual, 0)
 				So(savedFolder.Id, ShouldNotEqual, 0)
 				So(savedFolder.IsFolder, ShouldBeTrue)
 				So(savedFolder.IsFolder, ShouldBeTrue)
 				So(savedFolder.FolderId, ShouldEqual, 0)
 				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{
 				query := m.GetDashboardQuery{
 					Slug:  "test-dash-23",
 					Slug:  "test-dash-23",
 					OrgId: 1,
 					OrgId: 1,
@@ -49,6 +68,24 @@ func TestDashboardDataAccess(t *testing.T) {
 
 
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Title, ShouldEqual, "test dash 23")
 				So(query.Result.Slug, 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)
 				So(query.Result.IsFolder, ShouldBeFalse)
 			})
 			})
 
 
@@ -109,6 +146,8 @@ func TestDashboardDataAccess(t *testing.T) {
 				So(len(query.Result), ShouldEqual, 1)
 				So(len(query.Result), ShouldEqual, 1)
 				hit := query.Result[0]
 				hit := query.Result[0]
 				So(hit.Type, ShouldEqual, search.DashHitFolder)
 				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() {
 			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)
 				So(len(query.Result), ShouldEqual, 2)
 				hit := query.Result[0]
 				hit := query.Result[0]
 				So(hit.Id, ShouldEqual, savedDash.Id)
 				So(hit.Id, ShouldEqual, savedDash.Id)
+				So(hit.Url, ShouldEqual, fmt.Sprintf("/d/%s/%s", savedDash.Uid, savedDash.Slug))
+				So(hit.FolderId, ShouldEqual, savedFolder.Id)
+				So(hit.FolderUid, ShouldEqual, savedFolder.Uid)
+				So(hit.FolderTitle, ShouldEqual, savedFolder.Title)
+				So(hit.FolderUrl, ShouldEqual, fmt.Sprintf("/dashboards/f/%s/%s", savedFolder.Uid, savedFolder.Slug))
 			})
 			})
 
 
 			Convey("Should be able to search for dashboard by dashboard ids", func() {
 			Convey("Should be able to 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{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					Dashboard: simplejson.NewFromAny(map[string]interface{}{
 						"id":    nil,
 						"id":    nil,
 						"title": "test dash 23",
 						"title": "test dash 23",
-						"tags":  []interface{}{},
+						"uid":   "dsfalkjngailuedt",
 					}),
 					}),
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				err = SaveDashboard(&cmd)
 				So(err, ShouldNotBeNil)
 				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() {
 			Convey("Should be able to update dashboard and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					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() {
 		Convey("Given a plugin with imported dashboards", func() {
 			pluginId := "test-app"
 			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{}) {
 func updateTestDashboard(dashboard *m.Dashboard, data map[string]interface{}) {
-	data["title"] = dashboard.Title
+	data["uid"] = dashboard.Uid
 
 
 	saveCmd := m.SaveDashboardCommand{
 	saveCmd := m.SaveDashboardCommand{
 		OrgId:     dashboard.OrgId,
 		OrgId:     dashboard.OrgId,
@@ -44,7 +44,7 @@ func TestGetDashboardVersion(t *testing.T) {
 
 
 			dashCmd := m.GetDashboardQuery{
 			dashCmd := m.GetDashboardQuery{
 				OrgId: savedDash.OrgId,
 				OrgId: savedDash.OrgId,
-				Slug:  savedDash.Slug,
+				Uid:   savedDash.Uid,
 			}
 			}
 
 
 			err = GetDashboard(&dashCmd)
 			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{
 	mg.AddMigration("Add column has_acl in dashboard", NewAddColumnMigration(dashboardV2, &Column{
 		Name: "has_acl", Type: DB_Bool, Nullable: false, Default: "0",
 		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(
 	sb.sql.WriteString(
 		`SELECT
 		`SELECT
 			dashboard.id,
 			dashboard.id,
+			dashboard.uid,
 			dashboard.title,
 			dashboard.title,
 			dashboard.slug,
 			dashboard.slug,
 			dashboard_tag.term,
 			dashboard_tag.term,
 			dashboard.is_folder,
 			dashboard.is_folder,
 			dashboard.folder_id,
 			dashboard.folder_id,
+			folder.uid as folder_uid,
 			folder.slug as folder_slug,
 			folder.slug as folder_slug,
 			folder.title as folder_title
 			folder.title as folder_title
 		FROM `)
 		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(),
             .format(),
           evalData: {},
           evalData: {},
           executionError: '',
           executionError: '',
-          dashboardUri: 'db/mygool',
+          url: 'd/ufkcofof/my-goal',
           canEdit: true,
           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,
       '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 (
     return (
       <li className="alert-rule-item">
       <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"
         className="alert-rule-item__name"
       >
       >
         <a
         <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
           <Highlighter
             highlightClassName="highlight-search-match"
             highlightClassName="highlight-search-match"
@@ -92,7 +92,7 @@ exports[`AlertRuleList should render 1 rule 1`] = `
     </button>
     </button>
     <a
     <a
       className="btn btn-small btn-inverse alert-list__btn width-2"
       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"
       title="Edit alert rule"
     >
     >
       <i
       <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 Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
 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')
 @inject('nav', 'folder', 'view', 'permissions')
 @observer
 @observer
 export class FolderPermissions extends Component<IContainerProps, any> {
 export class FolderPermissions extends Component<IContainerProps, any> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.loadStore();
     this.loadStore();
   }
   }
 
 
+  componentWillUnmount() {
+    const { permissions } = this.props;
+    permissions.hideAddPermissions();
+  }
+
   loadStore() {
   loadStore() {
     const { nav, folder, view } = this.props;
     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');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-permissions');
     });
     });
   }
   }
 
 
+  handleAddPermission() {
+    const { permissions } = this.props;
+    permissions.toggleAddPermissions();
+  }
+
   render() {
   render() {
     const { nav, folder, permissions, backendSrv } = this.props;
     const { nav, folder, permissions, backendSrv } = this.props;
 
 
@@ -34,13 +48,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
       <div>
       <div>
         <PageHeader model={nav as any} />
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
         <div className="page-container page-body">
-          <div className="page-sub-heading">
+          <div className="page-action-bar">
             <h2 className="d-inline-block">Folder Permissions</h2>
             <h2 className="d-inline-block">Folder Permissions</h2>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
             </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>
           </div>
-
+          <SlideDown in={permissions.isAddPermissionsVisible}>
+            <AddPermissions permissions={permissions} backendSrv={backendSrv} />
+          </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>
         </div>
       </div>
       </div>

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

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

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

@@ -20,10 +20,12 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
   loadStore() {
   loadStore() {
     const { nav, folder, view } = this.props;
     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.formSnapshot = getSnapshot(folder);
       this.dashboard = res.dashboard;
       this.dashboard = res.dashboard;
 
 
+      view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
+
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
     });
     });
   }
   }
@@ -51,7 +53,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     folder
     folder
       .saveFolder(this.dashboard, { overwrite: false })
       .saveFolder(this.dashboard, { overwrite: false })
       .then(newUrl => {
       .then(newUrl => {
-        view.updatePathAndQuery(newUrl, '', '');
+        view.updatePathAndQuery(newUrl, {}, {});
 
 
         appEvents.emit('dashboard-saved');
         appEvents.emit('dashboard-saved');
         appEvents.emit('alert-success', ['Folder 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' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
   ]);
   react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'handlePicked']);
   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 React, { Component } from 'react';
+import { observer } from 'mobx-react';
 import { store } from 'app/stores/store';
 import { store } from 'app/stores/store';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
 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 {
 export interface IProps {
   dashboardId: number;
   dashboardId: number;
-  folderId: number;
-  folderTitle: string;
-  folderSlug: string;
+  folder?: FolderInfo;
   backendSrv: any;
   backendSrv: any;
 }
 }
-
+@observer
 class DashboardPermissions extends Component<IProps, any> {
 class DashboardPermissions extends Component<IProps, any> {
   permissions: any;
   permissions: any;
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.permissions = store.permissions;
     this.permissions = store.permissions;
   }
   }
 
 
+  handleAddPermission() {
+    this.permissions.toggleAddPermissions();
+  }
+
+  componentWillUnmount() {
+    this.permissions.hideAddPermissions();
+  }
+
   render() {
   render() {
-    const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
+    const { dashboardId, folder, backendSrv } = this.props;
 
 
     return (
     return (
       <div>
       <div>
         <div className="dashboard-settings__header">
         <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>
         </div>
+        <SlideDown in={this.permissions.isAddPermissionsVisible}>
+          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} />
+        </SlideDown>
         <Permissions
         <Permissions
           permissions={this.permissions}
           permissions={this.permissions}
           isFolder={false}
           isFolder={false}
           dashboardId={dashboardId}
           dashboardId={dashboardId}
-          folderInfo={{ title: folderTitle, slug: folderSlug, id: folderId }}
+          folderInfo={folder}
           backendSrv={backendSrv}
           backendSrv={backendSrv}
         />
         />
       </div>
       </div>

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

@@ -1,5 +1,5 @@
 export interface FolderInfo {
 export interface FolderInfo {
-  title: string;
   id: number;
   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 PermissionsList from './PermissionsList';
 import { observer } from 'mobx-react';
 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';
 import { FolderInfo } from './FolderInfo';
 
 
 export interface DashboardAcl {
 export interface DashboardAcl {
@@ -40,8 +37,6 @@ class Permissions extends Component<IProps, any> {
     this.permissionChanged = this.permissionChanged.bind(this);
     this.permissionChanged = this.permissionChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.removeItem = this.removeItem.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);
     this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
   }
   }
 
 
@@ -77,28 +72,8 @@ class Permissions extends Component<IProps, any> {
     permissions.setNewType(value);
     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() {
   render() {
-    const { permissions, folderInfo, backendSrv } = this.props;
+    const { permissions, folderInfo } = this.props;
 
 
     return (
     return (
       <div className="gf-form-group">
       <div className="gf-form-group">
@@ -109,50 +84,6 @@ class Permissions extends Component<IProps, any> {
           fetching={permissions.fetching}
           fetching={permissions.fetching}
           folderInfo={folderInfo}
           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>
       </div>
     );
     );
   }
   }

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

@@ -30,7 +30,7 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
           folderInfo && (
           folderInfo && (
             <em className="muted no-wrap">
             <em className="muted no-wrap">
               Inherited from folder{' '}
               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}
                 {folderInfo.title}
               </a>{' '}
               </a>{' '}
             </em>
             </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;
   isLoading: boolean;
   toggleLoading: any;
   toggleLoading: any;
   handlePicked: (user) => void;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 }
 
 
 export interface Team {
 export interface Team {
@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
 
 
   render() {
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
+    const { isLoading, handlePicked, value, className } = this.props;
 
 
     return (
     return (
       <div className="user-picker">
       <div className="user-picker">
@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
           isLoading={isLoading}
           isLoading={isLoading}
           loadOptions={this.debouncedSearch}
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
           loadingPlaceholder="Loading..."
+          noResultsText="No teams found"
           onChange={handlePicked}
           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}
           optionComponent={PickerOption}
           placeholder="Choose"
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
         />
       </div>
       </div>
     );
     );

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

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

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

@@ -3,6 +3,8 @@
 export interface IProps {
 export interface IProps {
   backendSrv: any;
   backendSrv: any;
   handlePicked: (data) => void;
   handlePicked: (data) => void;
+  value?: string;
+  className?: string;
 }
 }
 
 
 export default function withPicker(WrappedComponent) {
 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');
         body.toggleClass('sidemenu-hidden');
       });
       });
 
 
+      scope.$watch(() => playlistSrv.isPlaying, function(newValue) {
+        elem.toggleClass('playlist-active', newValue === true);
+      });
+
       // tooltip removal fix
       // tooltip removal fix
       // manage page classes
       // manage page classes
       var pageClass;
       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
   // used when managing dashboards for a specific folder
   folderId?: number;
   folderId?: number;
-  folderSlug?: string;
+  folderUid?: string;
 
 
   // if user can add new folders and/or add new dashboards
   // if user can add new folders and/or add new dashboards
   canSave: boolean;
   canSave: boolean;
@@ -74,11 +74,11 @@ export class ManageDashboardsCtrl {
         return this.initDashboardList(result);
         return this.initDashboardList(result);
       })
       })
       .then(() => {
       .then(() => {
-        if (!this.folderSlug) {
+        if (!this.folderUid) {
           return;
           return;
         }
         }
 
 
-        return this.backendSrv.getDashboard('db', this.folderSlug).then(dash => {
+        return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
           this.canSave = dash.meta.canSave;
           this.canSave = dash.meta.canSave;
         });
         });
       });
       });
@@ -130,10 +130,10 @@ export class ManageDashboardsCtrl {
 
 
     for (const section of this.sections) {
     for (const section of this.sections) {
       if (section.checked && section.id !== 0) {
       if (section.checked && section.id !== 0) {
-        selectedDashboards.folders.push(section.slug);
+        selectedDashboards.folders.push(section.uid);
       } else {
       } else {
         const selected = _.filter(section.items, { checked: true });
         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 folders = _.filter(result, dash => dash.meta.isFolder);
       const folderCount = folders.length;
       const folderCount = folders.length;
       const dashboards = _.filter(result, dash => !dash.meta.isFolder);
       const dashboards = _.filter(result, dash => !dash.meta.isFolder);
@@ -224,7 +224,7 @@ export class ManageDashboardsCtrl {
 
 
     for (const section of this.sections) {
     for (const section of this.sections) {
       const selected = _.filter(section.items, { checked: true });
       const selected = _.filter(section.items, { checked: true });
-      selectedDashboards.push(..._.map(selected, 'slug'));
+      selectedDashboards.push(..._.map(selected, 'uid'));
     }
     }
 
 
     return selectedDashboards;
     return selectedDashboards;
@@ -334,7 +334,7 @@ export function manageDashboardsDirective() {
     controllerAs: 'ctrl',
     controllerAs: 'ctrl',
     scope: {
     scope: {
       folderId: '=',
       folderId: '=',
-      folderSlug: '=',
+      folderUid: '=',
     },
     },
   };
   };
 }
 }

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

@@ -18,10 +18,6 @@ function (_, $, coreModule) {
           elem.toggleClass('panel-in-fullscreen', false);
           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) {
         $scope.$watch('ctrl.dashboardViewState.state.editview', function(newValue) {
           if (newValue) {
           if (newValue) {
             elem.toggleClass('dashboard-page--settings-opening', _.isString(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);
     return this.get('/api/dashboards/' + type + '/' + slug);
   }
   }
 
 
+  getDashboardByUid(uid: string) {
+    return this.get(`/api/dashboards/uid/${uid}`);
+  }
+
   saveDashboard(dash, options) {
   saveDashboard(dash, options) {
     options = 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();
     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(() => {
         .then(() => {
           deferred.resolve(fullDash);
           deferred.resolve(fullDash);
         })
         })
@@ -269,21 +284,21 @@ export class BackendSrv {
     return deferred.promise;
     return deferred.promise;
   }
   }
 
 
-  deleteDashboards(dashboardSlugs) {
+  deleteDashboards(dashboardUids) {
     const tasks = [];
     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, []);
     return this.executeInOrder(tasks, []);
   }
   }
 
 
-  moveDashboards(dashboardSlugs, toFolder) {
+  moveDashboards(dashboardUids, toFolder) {
     const tasks = [];
     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 => {
     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();
     let deferred = this.$q.defer();
 
 
-    this.getDashboard('db', slug).then(fullDash => {
+    this.getDashboardByUid(uid).then(fullDash => {
       const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
       const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
 
 
       if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) {
       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 coreModule from 'app/core/core_module';
-import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
 import appEvents from 'app/core/app_events';
 import { store } from 'app/stores/store';
 import { store } from 'app/stores/store';
 import { reaction } from 'mobx';
 import { reaction } from 'mobx';
+import locationUtil from 'app/core/utils/location_util';
 
 
 // Services that handles angular -> mobx store sync & other react <-> angular sync
 // Services that handles angular -> mobx store sync & other react <-> angular sync
 export class BridgeSrv {
 export class BridgeSrv {
-  private appSubUrl;
   private fullPageReloadRoutes;
   private fullPageReloadRoutes;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
   constructor(private $location, private $timeout, private $window, private $rootScope, private $route) {
-    this.appSubUrl = config.appSubUrl;
     this.fullPageReloadRoutes = ['/logout'];
     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() {
   init() {
     this.$rootScope.$on('$routeUpdate', (evt, data) => {
     this.$rootScope.$on('$routeUpdate', (evt, data) => {
       let angularUrl = this.$location.url();
       let angularUrl = this.$location.url();
@@ -34,25 +22,25 @@ export class BridgeSrv {
     });
     });
 
 
     this.$rootScope.$on('$routeChangeSuccess', (evt, data) => {
     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(
     reaction(
       () => store.view.currentUrl,
       () => store.view.currentUrl,
       currentUrl => {
       currentUrl => {
         let angularUrl = this.$location.url();
         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 => {
     appEvents.on('location-change', payload => {
-      const urlWithoutBase = this.stripBaseFromUrl(payload.href);
+      const urlWithoutBase = locationUtil.stripBaseFromUrl(payload.href);
       if (this.fullPageReloadRoutes.indexOf(urlWithoutBase) > -1) {
       if (this.fullPageReloadRoutes.indexOf(urlWithoutBase) > -1) {
         this.$window.location.href = payload.href;
         this.$window.location.href = payload.href;
         return;
         return;

+ 9 - 17
public/app/core/services/search_srv.ts

@@ -41,10 +41,7 @@ export class SearchSrv {
         .map(orderId => {
         .map(orderId => {
           return _.find(result, { id: 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,
           score: -2,
           expanded: this.starredIsOpen,
           expanded: this.starredIsOpen,
           toggle: this.toggleStarred.bind(this),
           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) {
   search(options) {
     let sections: any = {};
     let sections: any = {};
     let promises = [];
     let promises = [];
@@ -136,12 +128,12 @@ export class SearchSrv {
       if (hit.type === 'dash-folder') {
       if (hit.type === 'dash-folder') {
         sections[hit.id] = {
         sections[hit.id] = {
           id: hit.id,
           id: hit.id,
+          uid: hit.uid,
           title: hit.title,
           title: hit.title,
           expanded: false,
           expanded: false,
           items: [],
           items: [],
           toggle: this.toggleFolder.bind(this),
           toggle: this.toggleFolder.bind(this),
-          url: `dashboards/folder/${hit.id}/${hit.slug}`,
-          slug: hit.slug,
+          url: hit.url,
           icon: 'fa fa-folder',
           icon: 'fa fa-folder',
           score: _.keys(sections).length,
           score: _.keys(sections).length,
         };
         };
@@ -158,9 +150,9 @@ export class SearchSrv {
         if (hit.folderId) {
         if (hit.folderId) {
           section = {
           section = {
             id: hit.folderId,
             id: hit.folderId,
+            uid: hit.folderUid,
             title: hit.folderTitle,
             title: hit.folderTitle,
-            url: `dashboards/folder/${hit.folderId}/${hit.folderSlug}`,
-            slug: hit.slug,
+            url: hit.folderUrl,
             items: [],
             items: [],
             icon: 'fa fa-folder-open',
             icon: 'fa fa-folder-open',
             toggle: this.toggleFolder.bind(this),
             toggle: this.toggleFolder.bind(this),
@@ -169,7 +161,7 @@ export class SearchSrv {
         } else {
         } else {
           section = {
           section = {
             id: 0,
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [],
             items: [],
             icon: 'fa fa-folder-open',
             icon: 'fa fa-folder-open',
             toggle: this.toggleFolder.bind(this),
             toggle: this.toggleFolder.bind(this),
@@ -181,7 +173,7 @@ export class SearchSrv {
       }
       }
 
 
       section.expanded = true;
       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 => {
     return this.backendSrv.search(query).then(results => {
-      section.items = _.map(results, this.transformToViewModel);
+      section.items = results;
       return Promise.resolve(section);
       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',
               icon: 'fa fa-folder',
               tags: [],
               tags: [],
               isStarred: false,
               isStarred: false,
-              folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
             },
             },
           ],
           ],
           tags: [],
           tags: [],
@@ -30,7 +27,7 @@ describe('ManageDashboards', () => {
         },
         },
         {
         {
           id: 0,
           id: 0,
-          title: 'Root',
+          title: 'General',
           icon: 'fa fa-folder-open',
           icon: 'fa fa-folder-open',
           uri: 'db/something-else',
           uri: 'db/something-else',
           type: 'dash-db',
           type: 'dash-db',
@@ -77,9 +74,6 @@ describe('ManageDashboards', () => {
               icon: 'fa fa-folder',
               icon: 'fa fa-folder',
               tags: [],
               tags: [],
               isStarred: false,
               isStarred: false,
-              folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
             },
             },
           ],
           ],
           tags: [],
           tags: [],
@@ -112,8 +106,9 @@ describe('ManageDashboards', () => {
               tags: [],
               tags: [],
               isStarred: false,
               isStarred: false,
               folderId: 410,
               folderId: 410,
-              folderTitle: 'afolder',
-              folderSlug: 'afolder',
+              folderUid: 'uid',
+              folderTitle: 'Folder',
+              folderUrl: '/dashboards/f/uid/folder',
             },
             },
             {
             {
               id: 500,
               id: 500,
@@ -363,7 +358,7 @@ describe('ManageDashboards', () => {
           },
           },
           {
           {
             id: 0,
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: true }],
             items: [{ id: 3, checked: true }],
             checked: false,
             checked: false,
           },
           },
@@ -391,7 +386,7 @@ describe('ManageDashboards', () => {
           },
           },
           {
           {
             id: 0,
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: false }],
             items: [{ id: 3, checked: false }],
             checked: false,
             checked: false,
           },
           },
@@ -420,7 +415,7 @@ describe('ManageDashboards', () => {
           },
           },
           {
           {
             id: 0,
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: true }],
             items: [{ id: 3, checked: true }],
             checked: false,
             checked: false,
           },
           },
@@ -455,7 +450,7 @@ describe('ManageDashboards', () => {
           },
           },
           {
           {
             id: 0,
             id: 0,
-            title: 'Root',
+            title: 'General',
             items: [{ id: 3, checked: false }],
             items: [{ id: 3, checked: false }],
             checked: false,
             checked: false,
           },
           },
@@ -483,22 +478,22 @@ describe('ManageDashboards', () => {
       ctrl.sections = [
       ctrl.sections = [
         {
         {
           id: 1,
           id: 1,
+          uid: 'folder',
           title: 'folder',
           title: 'folder',
-          items: [{ id: 2, checked: true, slug: 'folder-dash' }],
+          items: [{ id: 2, checked: true, uid: 'folder-dash' }],
           checked: true,
           checked: true,
-          slug: 'folder',
         },
         },
         {
         {
           id: 3,
           id: 3,
           title: 'folder-2',
           title: 'folder-2',
-          items: [{ id: 3, checked: true, slug: 'folder-2-dash' }],
+          items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
           checked: false,
           checked: false,
-          slug: 'folder-2',
+          uid: 'folder-2',
         },
         },
         {
         {
           id: 0,
           id: 0,
-          title: 'Root',
-          items: [{ id: 3, checked: true, slug: 'root-dash' }],
+          title: 'General',
+          items: [{ id: 3, checked: true, uid: 'root-dash' }],
           checked: true,
           checked: true,
         },
         },
       ];
       ];
@@ -535,14 +530,14 @@ describe('ManageDashboards', () => {
         {
         {
           id: 1,
           id: 1,
           title: 'folder',
           title: 'folder',
-          items: [{ id: 2, checked: true, slug: 'dash' }],
+          items: [{ id: 2, checked: true, uid: 'dash' }],
           checked: false,
           checked: false,
-          slug: 'folder',
+          uid: 'folder',
         },
         },
         {
         {
           id: 0,
           id: 0,
-          title: 'Root',
-          items: [{ id: 3, checked: true, slug: 'dash-2' }],
+          title: 'General',
+          items: [{ id: 3, checked: true, uid: 'dash-2' }],
           checked: false,
           checked: false,
         },
         },
       ];
       ];

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

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

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

@@ -190,7 +190,9 @@ describe('SearchSrv', () => {
             title: 'dash in folder1 1',
             title: 'dash in folder1 1',
             type: 'dash-db',
             type: 'dash-db',
             folderId: 1,
             folderId: 1,
+            folderUid: 'uid',
             folderTitle: 'folder1',
             folderTitle: 'folder1',
+            folderUrl: '/dashboards/f/uid/folder1',
           },
           },
         ])
         ])
       );
       );
@@ -206,6 +208,11 @@ describe('SearchSrv', () => {
 
 
     it('should group results by folder', () => {
     it('should group results by folder', () => {
       expect(results).toHaveLength(2);
       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 => {
     return this.backendSrv.createDashboardFolder(this.title).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       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.titleTouched = true;
 
 
     this.validationSrv
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.title)
+      .validateNewFolderName(this.title)
       .then(() => {
       .then(() => {
         this.hasValidationError = false;
         this.hasValidationError = false;
       })
       })

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

@@ -93,7 +93,7 @@ export class DashboardImportCtrl {
     this.nameExists = false;
     this.nameExists = false;
 
 
     this.validationSrv
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.dash.title)
+      .validateNewDashboardName(0, this.dash.title)
       .then(() => {
       .then(() => {
         this.hasNameValidationError = false;
         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;
     var promise;
 
 
     if (type === 'script') {
     if (type === 'script') {
       promise = this._loadScriptedDashboard(slug);
       promise = this._loadScriptedDashboard(slug);
     } else if (type === 'snapshot') {
     } 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);
         return this._dashboardLoadFailed('Snapshot not found', true);
       });
       });
     } else {
     } else {
       promise = this.backendSrv
       promise = this.backendSrv
-        .getDashboard(this.$routeParams.type, this.$routeParams.slug)
+        .getDashboardByUid(uid)
         .then(result => {
         .then(result => {
           if (result.meta.isFolder) {
           if (result.meta.isFolder) {
             this.$rootScope.appEvent('alert-error', ['Dashboard not found']);
             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 {
 export class DashboardModel {
   id: any;
   id: any;
+  uid: any;
   title: any;
   title: any;
   autoUpdate: any;
   autoUpdate: any;
   description: any;
   description: any;
@@ -56,6 +57,7 @@ export class DashboardModel {
 
 
     this.events = new Emitter();
     this.events = new Emitter();
     this.id = data.id || null;
     this.id = data.id || null;
+    this.uid = data.uid || null;
     this.revision = data.revision;
     this.revision = data.revision;
     this.title = data.title || 'No Title';
     this.title = data.title || 'No Title';
     this.autoUpdate = data.autoUpdate;
     this.autoUpdate = data.autoUpdate;
@@ -279,6 +281,40 @@ export class DashboardModel {
     this.events.emit('repeats-processed');
     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) {
   getPanelRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
     // if first clone return source
     // if first clone return source
     if (valueIndex === 0) {
     if (valueIndex === 0) {
@@ -569,7 +605,7 @@ export class DashboardModel {
 
 
     if (row.collapsed) {
     if (row.collapsed) {
       row.collapsed = false;
       row.collapsed = false;
-      let hasRepeat = false;
+      let hasRepeat = _.some(row.panels, p => p.repeat);
 
 
       if (row.panels.length > 0) {
       if (row.panels.length > 0) {
         // Use first panel to figure out if it was moved or pushed
         // 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
           // update insert post and y max
           insertPos += 1;
           insertPos += 1;
           yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
           yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
-
-          if (panel.repeat) {
-            hasRepeat = true;
-          }
         }
         }
 
 
         const pushDownAmount = yMax - row.gridPos.y;
         const pushDownAmount = yMax - row.gridPos.y;
@@ -606,7 +638,7 @@ export class DashboardModel {
         row.panels = [];
         row.panels = [];
 
 
         if (hasRepeat) {
         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) {
   postSave(clone, data) {
     this.dash.version = data.version;
     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);
     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 { FolderPageLoader } from './folder_page_loader';
+import locationUtil from 'app/core/utils/location_util';
 
 
 export class FolderDashboardsCtrl {
 export class FolderDashboardsCtrl {
   navModel: any;
   navModel: any;
   folderId: number;
   folderId: number;
-  folderSlug: string;
+  uid: string;
 
 
   /** @ngInject */
   /** @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 {
 export class FolderPageLoader {
-  constructor(private backendSrv, private $routeParams) {}
+  constructor(private backendSrv) {}
 
 
-  load(ctrl, folderId, activeChildId) {
+  load(ctrl, uid, activeChildId) {
     ctrl.navModel = {
     ctrl.navModel = {
       main: {
       main: {
         icon: 'fa fa-folder-open',
         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 folderTitle = result.dashboard.title;
+      const folderUrl = result.meta.url;
       ctrl.navModel.main.text = folderTitle;
       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');
       const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       dashTab.url = folderUrl;
       dashTab.url = folderUrl;
 
 
@@ -57,8 +58,4 @@ export class FolderPageLoader {
       return result;
       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 {
 export class FolderPermissionsCtrl {
   navModel: any;
   navModel: any;
   folderId: number;
   folderId: number;
+  uid: string;
   dashboard: any;
   dashboard: any;
   meta: any;
   meta: any;
 
 
   /** @ngInject */
   /** @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;
   enterFolderCreation: any;
   exitFolderCreation: any;
   exitFolderCreation: any;
   enableCreateNew: boolean;
   enableCreateNew: boolean;
-  rootName = 'Root';
+  rootName = 'General';
   folder: any;
   folder: any;
   createNewFolder: boolean;
   createNewFolder: boolean;
   newFolderName: string;
   newFolderName: string;
@@ -33,10 +33,13 @@ export class FolderPickerCtrl {
     return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
     return this.backendSrv.get('api/dashboards/folders', { query: query }).then(result => {
       if (
       if (
         query === '' ||
         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 });
         result.unshift({ title: this.rootName, id: 0 });
       }
       }
@@ -64,7 +67,7 @@ export class FolderPickerCtrl {
     this.newFolderNameTouched = true;
     this.newFolderNameTouched = true;
 
 
     this.validationSrv
     this.validationSrv
-      .validateNewDashboardOrFolderName(this.newFolderName)
+      .validateNewFolderName(this.newFolderName)
       .then(() => {
       .then(() => {
         this.hasValidationError = false;
         this.hasValidationError = false;
       })
       })

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

@@ -5,6 +5,7 @@ export class FolderSettingsCtrl {
   folderPageLoader: FolderPageLoader;
   folderPageLoader: FolderPageLoader;
   navModel: any;
   navModel: any;
   folderId: number;
   folderId: number;
+  uid: string;
   canSave = false;
   canSave = false;
   dashboard: any;
   dashboard: any;
   meta: any;
   meta: any;
@@ -13,14 +14,18 @@ export class FolderSettingsCtrl {
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private backendSrv, navModelSrv, private $routeParams, private $location) {
   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;
         this.title = this.dashboard.title;
       });
       });
     }
     }
@@ -36,11 +41,10 @@ export class FolderSettingsCtrl {
     this.dashboard.title = this.title.trim();
     this.dashboard.title = this.title.trim();
 
 
     return this.backendSrv
     return this.backendSrv
-      .saveDashboard(this.dashboard, { overwrite: false })
+      .updateDashboardFolder(this.dashboard, { overwrite: false })
       .then(result => {
       .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');
         appEvents.emit('dashboard-saved');
@@ -65,7 +69,7 @@ export class FolderSettingsCtrl {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       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`]);
           appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
           this.$location.url('dashboards');
           this.$location.url('dashboards');
         });
         });
@@ -84,7 +88,7 @@ export class FolderSettingsCtrl {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         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>
 <page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
 
 
 <div class="page-container page-body">
 <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>
 </div>

+ 1 - 0
public/app/features/dashboard/save_as_modal.ts

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

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

@@ -96,13 +96,14 @@
 </div>
 </div>
 
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
 <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"
     dashboardId="ctrl.dashboard.id"
     backendSrv="ctrl.backendSrv"
     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>
 
 
 <div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">
 <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;
   canSave: boolean;
   canDelete: boolean;
   canDelete: boolean;
   sections: any[];
   sections: any[];
+  hasUnsavedFolderChange: boolean;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
   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.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
     this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
     this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
+    this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope);
   }
   }
 
 
   buildSectionList() {
   buildSectionList() {
@@ -135,6 +137,10 @@ export class SettingsCtrl {
     this.dashboardSrv.saveDashboard();
     this.dashboardSrv.saveDashboard();
   }
   }
 
 
+  onPostSave() {
+    this.hasUnsavedFolderChange = false;
+  }
+
   hideSettings() {
   hideSettings() {
     var urlParams = this.$location.search();
     var urlParams = this.$location.search();
     delete urlParams.editview;
     delete urlParams.editview;
@@ -186,7 +192,7 @@ export class SettingsCtrl {
   }
   }
 
 
   deleteDashboardConfirmed() {
   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']);
       appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
       this.$location.url('/');
       this.$location.url('/');
     });
     });
@@ -195,7 +201,15 @@ export class SettingsCtrl {
   onFolderChange(folder) {
   onFolderChange(folder) {
     this.dashboard.meta.folderId = folder.id;
     this.dashboard.meta.folderId = folder.id;
     this.dashboard.meta.folderTitle = folder.title;
     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);
       $scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
 
 
       var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
       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.fullscreen;
       delete params.edit;
       delete params.edit;
       soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
       soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
@@ -84,6 +85,7 @@ export class ShareModalCtrl {
         config.appSubUrl + '/dashboard-solo/',
         config.appSubUrl + '/dashboard-solo/',
         config.appSubUrl + '/render/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 += '&width=1000';
       $scope.imageUrl += '&height=500';
       $scope.imageUrl += '&height=500';
       $scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format('Z'));
       $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 = {
     validationSrv = {
-      validateNewDashboardOrFolderName: jest.fn().mockReturnValue(Promise.resolve()),
+      validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
     };
     };
 
 
     ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});
     ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});

+ 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' },
       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() {
     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.panel = { id: 22 };
 
 
       ctx.scope.init();
       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';
       var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
       expect(ctx.scope.imageUrl).to.contain(base + params);
       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';
 import coreModule from 'app/core/core_module';
 
 
+const hitTypes = {
+  FOLDER: 'dash-folder',
+  DASHBOARD: 'dash-db',
+};
+
 export class ValidationSrv {
 export class ValidationSrv {
-  rootName = 'root';
+  rootName = 'general';
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private $q, private backendSrv) {}
   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();
     name = (name || '').trim();
+    const nameLowerCased = name.toLowerCase();
 
 
     if (name.length === 0) {
     if (name.length === 0) {
       return this.$q.reject({
       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({
       return this.$q.reject({
         type: 'EXISTING',
         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();
     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({
           deferred.reject({
             type: 'EXISTING',
             type: 'EXISTING',
-            message: 'A folder or dashboard with the same name already exists',
+            message: existingErrorMessage,
           });
           });
           break;
           break;
         }
         }

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

@@ -1,19 +1,33 @@
 import angular from 'angular';
 import angular from 'angular';
+import locationUtil from 'app/core/utils/location_util';
+import appEvents from 'app/core/app_events';
 
 
 export class SoloPanelCtrl {
 export class SoloPanelCtrl {
   /** @ngInject */
   /** @ngInject */
-  constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv) {
+  constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
     var panelId;
     var panelId;
 
 
     $scope.init = function() {
     $scope.init = function() {
       contextSrv.sidemenu = false;
       contextSrv.sidemenu = false;
+      appEvents.emit('toggle-sidemenu');
 
 
       var params = $location.search();
       var params = $location.search();
       panelId = parseInt(params.panelId);
       panelId = parseInt(params.panelId);
 
 
       $scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
       $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;
         result.meta.soloMode = true;
         $scope.initDashboard(result, $scope);
         $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__body">
           <div class="alert-rule-item__header">
           <div class="alert-rule-item__header">
             <p class="alert-rule-item__name">
             <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}}
                 {{alert.name}}
               </a>
               </a>
             </p>
             </p>

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

@@ -4,7 +4,7 @@
       {{group.header}}
       {{group.header}}
     </h6>
     </h6>
     <div class="dashlist-item" ng-repeat="dash in group.list">
     <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">
         <span class="dashlist-title">
           {{dash.title}}
           {{dash.title}}
         </span>
         </span>

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

@@ -1,14 +1,15 @@
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
+import locationUtil from 'app/core/utils/location_util';
 
 
 export class LoadDashboardCtrl {
 export class LoadDashboardCtrl {
   /** @ngInject */
   /** @ngInject */
-  constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location) {
+  constructor($scope, $routeParams, dashboardLoaderSrv, backendSrv, $location, $browser) {
     $scope.appEvent('dashboard-fetch-start');
     $scope.appEvent('dashboard-fetch-start');
 
 
-    if (!$routeParams.slug) {
+    if (!$routeParams.uid && !$routeParams.slug) {
       backendSrv.get('/api/dashboards/home').then(function(homeDash) {
       backendSrv.get('/api/dashboards/home').then(function(homeDash) {
         if (homeDash.redirectUri) {
         if (homeDash.redirectUri) {
-          $location.path('dashboard/' + homeDash.redirectUri);
+          $location.path(homeDash.redirectUri);
         } else {
         } else {
           var meta = homeDash.meta;
           var meta = homeDash.meta;
           meta.canSave = meta.canShare = meta.canStar = false;
           meta.canSave = meta.canShare = meta.canStar = false;
@@ -18,7 +19,26 @@ export class LoadDashboardCtrl {
       return;
       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) {
       if ($routeParams.keepRows) {
         result.meta.keepRows = true;
         result.meta.keepRows = true;
       }
       }

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

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

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

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

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

@@ -13,7 +13,7 @@ export const AlertRule = types
     stateClass: types.string,
     stateClass: types.string,
     stateAge: types.string,
     stateAge: types.string,
     info: types.optional(types.string, ''),
     info: types.optional(types.string, ''),
-    dashboardUri: types.string,
+    url: types.string,
     canEdit: types.boolean,
     canEdit: types.boolean,
   })
   })
   .views(self => ({
   .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', {
 export const Folder = types.model('Folder', {
   id: types.identifier(types.number),
   id: types.identifier(types.number),
-  slug: types.string,
   title: types.string,
   title: types.string,
+  url: types.string,
   canSave: types.boolean,
   canSave: types.boolean,
   hasChanged: types.boolean,
   hasChanged: types.boolean,
 });
 });
@@ -13,13 +13,13 @@ export const FolderStore = types
     folder: types.maybe(Folder),
     folder: types.maybe(Folder),
   })
   })
   .actions(self => ({
   .actions(self => ({
-    load: flow(function* load(slug: string) {
+    load: flow(function* load(uid: string) {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboard('db', slug);
+      const res = yield backendSrv.getDashboardByUid(uid);
       self.folder = Folder.create({
       self.folder = Folder.create({
         id: res.dashboard.id,
         id: res.dashboard.id,
         title: res.dashboard.title,
         title: res.dashboard.title,
-        slug: res.meta.slug,
+        url: res.meta.url,
         canSave: res.meta.canSave,
         canSave: res.meta.canSave,
         hasChanged: false,
         hasChanged: false,
       });
       });
@@ -35,14 +35,15 @@ export const FolderStore = types
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
       dashboard.title = self.folder.title.trim();
       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() {
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
       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', () => {
 describe('NavStore', () => {
   const folderId = 1;
   const folderId = 1;
   const folderTitle = 'Folder Name';
   const folderTitle = 'Folder Name';
-  const folderSlug = 'folder-name';
+  const folderUrl = '/dashboards/f/uid/folder-name';
   const canAdmin = true;
   const canAdmin = true;
 
 
   const folder = {
   const folder = {
     id: folderId,
     id: folderId,
-    slug: folderSlug,
+    url: folderUrl,
     title: folderTitle,
     title: folderTitle,
     canAdmin: canAdmin,
     canAdmin: canAdmin,
   };
   };
@@ -33,9 +33,9 @@ describe('NavStore', () => {
 
 
   it('Should set correct urls for each tab', () => {
   it('Should set correct urls for each tab', () => {
     expect(store.main.children.length).toBe(3);
     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', () => {
   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) {
     initFolderNav(folder: any, activeChildId: string) {
-      const folderUrl = createFolderUrl(folder.id, folder.slug);
-
       let main = {
       let main = {
         icon: 'fa fa-folder-open',
         icon: 'fa fa-folder-open',
         id: 'manage-folder',
         id: 'manage-folder',
@@ -56,21 +54,21 @@ export const NavStore = types
             icon: 'fa fa-fw fa-th-large',
             icon: 'fa fa-fw fa-th-large',
             id: 'manage-folder-dashboards',
             id: 'manage-folder-dashboards',
             text: 'Dashboards',
             text: 'Dashboards',
-            url: folderUrl,
+            url: folder.url,
           },
           },
           {
           {
             active: activeChildId === 'manage-folder-permissions',
             active: activeChildId === 'manage-folder-permissions',
             icon: 'fa fa-fw fa-lock',
             icon: 'fa fa-fw fa-lock',
             id: 'manage-folder-permissions',
             id: 'manage-folder-permissions',
             text: 'Permissions',
             text: 'Permissions',
-            url: folderUrl + '/permissions',
+            url: `${folder.url}/permissions`,
           },
           },
           {
           {
             active: activeChildId === 'manage-folder-settings',
             active: activeChildId === 'manage-folder-settings',
             icon: 'fa fa-fw fa-cog',
             icon: 'fa fa-fw fa-cog',
             id: 'manage-folder-settings',
             id: 'manage-folder-settings',
             text: 'Settings',
             text: 'Settings',
-            url: folderUrl + '/settings',
+            url: `${folder.url}/settings`,
           },
           },
         ],
         ],
       };
       };
@@ -118,7 +116,3 @@ export const NavStore = types
       self.main = NavItem.create(main);
       self.main = NavItem.create(main);
     },
     },
   }));
   }));
-
-function createFolderUrl(folderId: number, slug: string) {
-  return `dashboards/folder/${folderId}/${slug}`;
-}

Неке датотеке нису приказане због велике количине промена