Bläddra i källkod

Merge remote-tracking branch 'upstream/master' into postgres-query-builder

Sven Klemm 7 år sedan
förälder
incheckning
6c6be9cfc0
73 ändrade filer med 2158 tillägg och 588 borttagningar
  1. 15 12
      ROADMAP.md
  2. 0 3
      conf/defaults.ini
  3. 0 3
      conf/sample.ini
  4. 25 0
      docker/blocks/prometheus2/docker-compose.yaml
  5. 1 1
      docs/sources/http_api/data_source.md
  6. 1 4
      docs/sources/installation/configuration.md
  7. 1 1
      package.json
  8. 22 4
      pkg/api/api.go
  9. 9 4
      pkg/api/dashboard.go
  10. 4 4
      pkg/api/dashboard_permission.go
  11. 16 16
      pkg/api/dashboard_permission_test.go
  12. 35 7
      pkg/api/dashboard_snapshot.go
  13. 97 0
      pkg/api/dashboard_snapshot_test.go
  14. 6 2
      pkg/api/dashboard_test.go
  15. 25 0
      pkg/api/dtos/folder.go
  16. 147 0
      pkg/api/folder.go
  17. 103 0
      pkg/api/folder_permission.go
  18. 125 0
      pkg/api/folder_permission_test.go
  19. 254 0
      pkg/api/folder_test.go
  20. 4 1
      pkg/models/dashboard_acl.go
  21. 7 4
      pkg/models/dashboard_snapshot.go
  22. 1 0
      pkg/models/dashboard_version.go
  23. 3 7
      pkg/models/dashboards.go
  24. 91 0
      pkg/models/folders.go
  25. 12 2
      pkg/services/cleanup/cleanup.go
  26. 16 11
      pkg/services/dashboards/dashboard_service.go
  27. 2 51
      pkg/services/dashboards/dashboard_service_test.go
  28. 245 0
      pkg/services/dashboards/folder_service.go
  29. 191 0
      pkg/services/dashboards/folder_service_test.go
  30. 49 0
      pkg/services/guardian/guardian.go
  31. 16 4
      pkg/services/sqlstore/dashboard.go
  32. 23 75
      pkg/services/sqlstore/dashboard_service_integration_test.go
  33. 26 12
      pkg/services/sqlstore/dashboard_snapshot.go
  34. 136 2
      pkg/services/sqlstore/dashboard_snapshot_test.go
  35. 25 0
      pkg/services/sqlstore/dashboard_test.go
  36. 1 3
      pkg/services/sqlstore/dashboard_version.go
  37. 0 2
      pkg/setting/setting.go
  38. 1 1
      public/app/containers/ManageDashboards/FolderPermissions.tsx
  39. 7 10
      public/app/containers/ManageDashboards/FolderSettings.jest.tsx
  40. 17 14
      public/app/containers/ManageDashboards/FolderSettings.tsx
  41. 2 2
      public/app/core/components/Permissions/AddPermissions.jest.tsx
  42. 0 4
      public/app/core/components/help/help.ts
  43. 5 40
      public/app/core/components/manage_dashboards/manage_dashboards.ts
  44. 27 37
      public/app/core/services/backend_srv.ts
  45. 5 26
      public/app/core/services/keybindingSrv.ts
  46. 21 0
      public/app/core/utils/kbn.ts
  47. 2 2
      public/app/features/dashboard/create_folder_ctrl.ts
  48. 39 0
      public/app/features/dashboard/dashboard_ctrl.ts
  49. 28 0
      public/app/features/dashboard/dashboard_model.ts
  50. 1 1
      public/app/features/dashboard/dashboard_srv.ts
  51. 1 1
      public/app/features/dashboard/folder_dashboards_ctrl.ts
  52. 6 6
      public/app/features/dashboard/folder_page_loader.ts
  53. 3 3
      public/app/features/dashboard/folder_picker/folder_picker.ts
  54. 10 18
      public/app/features/dashboard/folder_settings_ctrl.ts
  55. 12 1
      public/app/features/dashboard/save_as_modal.ts
  56. 1 1
      public/app/features/dashboard/specs/dashboard_migration.jest.ts
  57. 4 25
      public/app/features/panel/panel_ctrl.ts
  58. 1 1
      public/app/features/panel/solo_panel_ctrl.ts
  59. 1 0
      public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html
  60. 9 4
      public/app/plugins/panel/heatmap/color_legend.ts
  61. 1 2
      public/app/plugins/panel/heatmap/rendering.ts
  62. 3 0
      public/app/plugins/panel/heatmap/specs/renderer_specs.ts
  63. 1 1
      public/app/routes/dashboard_loaders.ts
  64. 14 12
      public/app/stores/FolderStore/FolderStore.ts
  65. 2 2
      public/app/stores/PermissionsStore/PermissionsStore.jest.ts
  66. 2 2
      public/app/stores/PermissionsStore/PermissionsStore.ts
  67. 93 129
      public/sass/base/_fonts.scss
  68. 6 4
      public/sass/components/_panel_heatmap.scss
  69. 1 1
      public/sass/components/_scrollbar.scss
  70. 1 0
      public/test/mocks/common.ts
  71. 78 0
      tests/api/folder.test.ts
  72. 17 1
      tests/api/setup.ts
  73. 2 2
      yarn.lock

+ 15 - 12
ROADMAP.md

@@ -1,25 +1,28 @@
-# Roadmap (2017-10-31)
+# Roadmap (2018-02-22)
 
 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 This roadmap is a tentative plan for the core development team. Things change constantly as PRs come in and priorities change. 
 But it will give you an idea of our current vision and plan. 
 But it will give you an idea of our current vision and plan. 
 
 
-### Short term (1-4 months)
+### Short term (1-2 months)
 
 
- - Release Grafana v5
-  - Teams
-  - Dashboard folders
-  - Dashboard & folder permissions (assigned to users or groups)
-  - New Dashboard layout engine
-  - New sidemenu & nav UX
+- v5.1
+  - Crossplatform builds & build speed improvements
+  - Enterprise LDAP
+  - New template interpolation syntax
+  - Provisioning workflow
+  - First login registration view
+  - IFQL Initial support
+  
+### Mid term (2-4 months)
+
+- v5.2
+  - Azure monitor backend rewrite
   - Elasticsearch alerting
   - Elasticsearch alerting
-  - React migration foundation (core components) 
-  - Graphite 1.1 Tags Support
+  - Backend plugins? (alert notifiers, auth)
   
   
 ### Long term (4 - 8 months)
 ### Long term (4 - 8 months)
 
 
-- Backend plugins to support more Auth options, Alerting data sources & notifications
 - Alerting improvements (silence, per series tracking, etc)
 - Alerting improvements (silence, per series tracking, etc)
-- Dashboard as configuration and other automation / provisioning improvements
 - Progress on React migration
 - Progress on React migration
 - Change visualization (panel type) on the fly. 
 - Change visualization (panel type) on the fly. 
 - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) 
 - Multi stat panel (vertical version of singlestat with bars/graph mode with big number etc) 

+ 0 - 3
conf/defaults.ini

@@ -187,9 +187,6 @@ external_snapshot_name = Publish to snapshot.raintank.io
 # remove expired snapshot
 # remove expired snapshot
 snapshot_remove_expired = true
 snapshot_remove_expired = true
 
 
-# remove snapshots after 90 days
-snapshot_TTL_days = 90
-
 #################################### Dashboards ##################
 #################################### Dashboards ##################
 
 
 [dashboards]
 [dashboards]

+ 0 - 3
conf/sample.ini

@@ -175,9 +175,6 @@ log_queries =
 # remove expired snapshot
 # remove expired snapshot
 ;snapshot_remove_expired = true
 ;snapshot_remove_expired = true
 
 
-# remove snapshots after 90 days
-;snapshot_TTL_days = 90
-
 #################################### Dashboards History ##################
 #################################### Dashboards History ##################
 [dashboards]
 [dashboards]
 # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1
 # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1

+ 25 - 0
docker/blocks/prometheus2/docker-compose.yaml

@@ -0,0 +1,25 @@
+  prometheus:
+    build: blocks/prometheus2
+    network_mode: host
+    ports:
+      - "9090:9090"
+
+  node_exporter:
+    image: prom/node-exporter
+    network_mode: host
+    ports:
+      - "9100:9100"
+
+  fake-prometheus-data:
+    image: grafana/fake-data-gen
+    network_mode: host
+    ports:
+      - "9091:9091"
+    environment:
+      FD_DATASOURCE: prom
+
+  alertmanager:
+    image: quay.io/prometheus/alertmanager
+    network_mode: host
+    ports:
+      - "9093:9093"

+ 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`
+`GET /api/datasources/name/:name`
 
 
 **Example Request**:
 **Example Request**:
 
 

+ 1 - 4
docs/sources/installation/configuration.md

@@ -795,12 +795,9 @@ Set root url to a Grafana instance where you want to publish external snapshots
 ### external_snapshot_name
 ### external_snapshot_name
 Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io`
 Set name for external snapshot button. Defaults to `Publish to snapshot.raintank.io`
 
 
-### remove expired snapshot
+### snapshot_remove_expired
 Enabled to automatically remove expired snapshots
 Enabled to automatically remove expired snapshots
 
 
-### remove snapshots after 90 days
-Time to live for snapshots.
-
 ## [external_image_storage]
 ## [external_image_storage]
 These options control how images should be made public so they can be shared on services like slack.
 These options control how images should be made public so they can be shared on services like slack.
 
 

+ 1 - 1
package.json

@@ -165,7 +165,7 @@
     "rst2html": "github:thoward/rst2html#990cb89",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",
     "rxjs": "^5.4.3",
     "tether": "^1.4.0",
     "tether": "^1.4.0",
-    "tether-drop": "https://github.com/torkelo/drop",
+    "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tinycolor2": "^1.4.1"
     "tinycolor2": "^1.4.1"
   }
   }
 }
 }

+ 22 - 4
pkg/api/api.go

@@ -106,7 +106,7 @@ func (hs *HttpServer) registerRoutes() {
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
 	r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
 	r.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	r.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	r.Get("/api/snapshots/:key", GetDashboardSnapshot)
 	r.Get("/api/snapshots/:key", GetDashboardSnapshot)
-	r.Get("/api/snapshots-delete/:key", reqEditorRole, DeleteDashboardSnapshot)
+	r.Get("/api/snapshots-delete/:key", reqEditorRole, wrap(DeleteDashboardSnapshot))
 
 
 	// api renew session based on remember cookie
 	// api renew session based on remember cookie
 	r.Get("/api/login/ping", quota("session"), LoginApiPing)
 	r.Get("/api/login/ping", quota("session"), LoginApiPing)
@@ -246,6 +246,24 @@ func (hs *HttpServer) registerRoutes() {
 		apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
 		apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
 
 
+		// Folders
+		apiRoute.Group("/folders", func(folderRoute RouteRegister) {
+			folderRoute.Get("/", wrap(GetFolders))
+			folderRoute.Get("/id/:id", wrap(GetFolderById))
+			folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
+
+			folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) {
+				folderUidRoute.Get("/", wrap(GetFolderByUid))
+				folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
+				folderUidRoute.Delete("/", wrap(DeleteFolder))
+
+				folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) {
+					folderPermissionRoute.Get("/", wrap(GetFolderPermissionList))
+					folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions))
+				})
+			})
+		})
+
 		// Dashboard
 		// Dashboard
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
 			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
@@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() {
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
 				dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
 
 
-				dashIdRoute.Group("/acl", func(aclRoute RouteRegister) {
-					aclRoute.Get("/", wrap(GetDashboardAclList))
-					aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl))
+				dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) {
+					dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList))
+					dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions))
 				})
 				})
 			})
 			})
 		})
 		})

+ 9 - 4
pkg/api/dashboard.go

@@ -137,6 +137,7 @@ func getDashboardHelper(orgId int64, slug string, id int64, uid string) (*m.Dash
 	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)
 	}
 	}
+
 	return query.Result, nil
 	return query.Result, nil
 }
 }
 
 
@@ -166,8 +167,10 @@ func DeleteDashboard(c *middleware.Context) Response {
 		return ApiError(500, "Failed to delete dashboard", err)
 		return ApiError(500, "Failed to delete dashboard", err)
 	}
 	}
 
 
-	var resp = map[string]interface{}{"title": dash.Title}
-	return Json(200, resp)
+	return Json(200, util.DynMap{
+		"title":   dash.Title,
+		"message": fmt.Sprintf("Dashboard %s deleted", dash.Title),
+	})
 }
 }
 
 
 func DeleteDashboardByUid(c *middleware.Context) Response {
 func DeleteDashboardByUid(c *middleware.Context) Response {
@@ -186,8 +189,10 @@ func DeleteDashboardByUid(c *middleware.Context) Response {
 		return ApiError(500, "Failed to delete dashboard", err)
 		return ApiError(500, "Failed to delete dashboard", err)
 	}
 	}
 
 
-	var resp = map[string]interface{}{"title": dash.Title}
-	return Json(200, resp)
+	return Json(200, util.DynMap{
+		"title":   dash.Title,
+		"message": fmt.Sprintf("Dashboard %s deleted", dash.Title),
+	})
 }
 }
 
 
 func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {
 func PostDashboard(c *middleware.Context, cmd m.SaveDashboardCommand) Response {

+ 4 - 4
pkg/api/dashboard_acl.go → pkg/api/dashboard_permission.go

@@ -10,7 +10,7 @@ import (
 	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/services/guardian"
 )
 )
 
 
-func GetDashboardAclList(c *middleware.Context) Response {
+func GetDashboardPermissionList(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
@@ -26,7 +26,7 @@ func GetDashboardAclList(c *middleware.Context) Response {
 
 
 	acl, err := guardian.GetAcl()
 	acl, err := guardian.GetAcl()
 	if err != nil {
 	if err != nil {
-		return ApiError(500, "Failed to get dashboard acl", err)
+		return ApiError(500, "Failed to get dashboard permissions", err)
 	}
 	}
 
 
 	for _, perm := range acl {
 	for _, perm := range acl {
@@ -38,7 +38,7 @@ func GetDashboardAclList(c *middleware.Context) Response {
 	return Json(200, acl)
 	return Json(200, acl)
 }
 }
 
 
-func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
+func UpdateDashboardPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 	dashId := c.ParamsInt64(":dashboardId")
 
 
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
@@ -82,5 +82,5 @@ func UpdateDashboardAcl(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCom
 		return ApiError(500, "Failed to create permission", err)
 		return ApiError(500, "Failed to create permission", err)
 	}
 	}
 
 
-	return ApiSuccess("Dashboard acl updated")
+	return ApiSuccess("Dashboard permissions updated")
 }
 }

+ 16 - 16
pkg/api/dashboard_acl_test.go → pkg/api/dashboard_permission_test.go

@@ -12,8 +12,8 @@ import (
 	. "github.com/smartystreets/goconvey/convey"
 	. "github.com/smartystreets/goconvey/convey"
 )
 )
 
 
-func TestDashboardAclApiEndpoint(t *testing.T) {
-	Convey("Given a dashboard acl", t, func() {
+func TestDashboardPermissionApiEndpoint(t *testing.T) {
+	Convey("Given a dashboard with permissions", t, func() {
 		mockResult := []*m.DashboardAclInfoDTO{
 		mockResult := []*m.DashboardAclInfoDTO{
 			{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
 			{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
 			{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
 			{OrgId: 1, DashboardId: 1, UserId: 3, Permission: m.PERMISSION_EDIT},
@@ -54,9 +54,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		// 4. user is an org editor AND has no permissions for the dashboard
 		// 4. user is an org editor AND has no permissions for the dashboard
 
 
 		Convey("When user is org admin", func() {
 		Convey("When user is org admin", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
 				Convey("Should be able to access ACL", func() {
 				Convey("Should be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 200)
 					So(sc.resp.Code, ShouldEqual, 200)
@@ -69,9 +69,9 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 				})
 				})
 			})
 			})
 
 
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/2/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
 				getDashboardNotFoundError = m.ErrDashboardNotFound
 				getDashboardNotFoundError = m.ErrDashboardNotFound
-				sc.handlerFunc = GetDashboardAclList
+				sc.handlerFunc = GetDashboardPermissionList
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 				Convey("Should not be able to access ACL", func() {
 				Convey("Should not be able to access ACL", func() {
@@ -86,7 +86,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 					},
 					},
 				}
 				}
 
 
-				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) {
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_ADMIN, cmd, func(sc *scenarioContext) {
 					getDashboardNotFoundError = m.ErrDashboardNotFound
 					getDashboardNotFoundError = m.ErrDashboardNotFound
 					CallPostAcl(sc)
 					CallPostAcl(sc)
 					So(sc.resp.Code, ShouldEqual, 404)
 					So(sc.resp.Code, ShouldEqual, 404)
@@ -95,11 +95,11 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		})
 		})
 
 
 		Convey("When user is org editor and has admin permission in the ACL", func() {
 		Convey("When user is org editor and has admin permission in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 
 				Convey("Should be able to access ACL", func() {
 				Convey("Should be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 200)
 					So(sc.resp.Code, ShouldEqual, 200)
@@ -113,7 +113,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 					},
 					},
 				}
 				}
 
 
-				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
 					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 
 					CallPostAcl(sc)
 					CallPostAcl(sc)
@@ -129,7 +129,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 					},
 					},
 				}
 				}
 
 
-				postAclScenario("When calling POST on", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
+				postAclScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) {
 					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 					mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN})
 
 
 					CallPostAcl(sc)
 					CallPostAcl(sc)
@@ -140,12 +140,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		})
 		})
 
 
 		Convey("When user is org viewer and has edit permission in the ACL", func() {
 		Convey("When user is org viewer and has edit permission in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardId/permissions", m.ROLE_VIEWER, func(sc *scenarioContext) {
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 				mockResult = append(mockResult, &m.DashboardAclInfoDTO{OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT})
 
 
 				// Getting the permissions is an Admin permission
 				// Getting the permissions is an Admin permission
 				Convey("Should not be able to get list of permissions from ACL", func() {
 				Convey("Should not be able to get list of permissions from ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 403)
 					So(sc.resp.Code, ShouldEqual, 403)
@@ -154,10 +154,10 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
 		})
 		})
 
 
 		Convey("When user is org editor and not in the ACL", func() {
 		Convey("When user is org editor and not in the ACL", func() {
-			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) {
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:dashboardsId/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
 
 
 				Convey("Should not be able to access ACL", func() {
 				Convey("Should not be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 
 					So(sc.resp.Code, ShouldEqual, 403)
 					So(sc.resp.Code, ShouldEqual, 403)
@@ -204,7 +204,7 @@ func postAclScenario(desc string, url string, routePattern string, role m.RoleTy
 			sc.context.OrgId = TestOrgID
 			sc.context.OrgId = TestOrgID
 			sc.context.OrgRole = role
 			sc.context.OrgRole = role
 
 
-			return UpdateDashboardAcl(c, cmd)
+			return UpdateDashboardPermissions(c, cmd)
 		})
 		})
 
 
 		sc.m.Post(routePattern, sc.defaultHandler)
 		sc.m.Post(routePattern, sc.defaultHandler)

+ 35 - 7
pkg/api/dashboard_snapshot.go

@@ -8,6 +8,7 @@ import (
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/metrics"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	m "github.com/grafana/grafana/pkg/models"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/setting"
 	"github.com/grafana/grafana/pkg/util"
 	"github.com/grafana/grafana/pkg/util"
 )
 )
@@ -56,6 +57,7 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho
 	})
 	})
 }
 }
 
 
+// GET /api/snapshots/:key
 func GetDashboardSnapshot(c *middleware.Context) {
 func GetDashboardSnapshot(c *middleware.Context) {
 	key := c.Params(":key")
 	key := c.Params(":key")
 	query := &m.GetDashboardSnapshotQuery{Key: key}
 	query := &m.GetDashboardSnapshotQuery{Key: key}
@@ -90,18 +92,43 @@ func GetDashboardSnapshot(c *middleware.Context) {
 	c.JSON(200, dto)
 	c.JSON(200, dto)
 }
 }
 
 
-func DeleteDashboardSnapshot(c *middleware.Context) {
+// GET /api/snapshots-delete/:key
+func DeleteDashboardSnapshot(c *middleware.Context) Response {
 	key := c.Params(":key")
 	key := c.Params(":key")
+
+	query := &m.GetDashboardSnapshotQuery{DeleteKey: key}
+
+	err := bus.Dispatch(query)
+	if err != nil {
+		return ApiError(500, "Failed to get dashboard snapshot", err)
+	}
+
+	if query.Result == nil {
+		return ApiError(404, "Failed to get dashboard snapshot", nil)
+	}
+	dashboard := query.Result.Dashboard
+	dashboardId := dashboard.Get("id").MustInt64()
+
+	guardian := guardian.New(dashboardId, c.OrgId, c.SignedInUser)
+	canEdit, err := guardian.CanEdit()
+	if err != nil {
+		return ApiError(500, "Error while checking permissions for snapshot", err)
+	}
+
+	if !canEdit && query.Result.UserId != c.SignedInUser.UserId {
+		return ApiError(403, "Access denied to this snapshot", nil)
+	}
+
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key}
 	cmd := &m.DeleteDashboardSnapshotCommand{DeleteKey: key}
 
 
 	if err := bus.Dispatch(cmd); err != nil {
 	if err := bus.Dispatch(cmd); err != nil {
-		c.JsonApiErr(500, "Failed to delete dashboard snapshot", err)
-		return
+		return ApiError(500, "Failed to delete dashboard snapshot", err)
 	}
 	}
 
 
-	c.JSON(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."})
+	return Json(200, util.DynMap{"message": "Snapshot deleted. It might take an hour before it's cleared from a CDN cache."})
 }
 }
 
 
+// GET /api/dashboard/snapshots
 func SearchDashboardSnapshots(c *middleware.Context) Response {
 func SearchDashboardSnapshots(c *middleware.Context) Response {
 	query := c.Query("query")
 	query := c.Query("query")
 	limit := c.QueryInt("limit")
 	limit := c.QueryInt("limit")
@@ -111,9 +138,10 @@ func SearchDashboardSnapshots(c *middleware.Context) Response {
 	}
 	}
 
 
 	searchQuery := m.GetDashboardSnapshotsQuery{
 	searchQuery := m.GetDashboardSnapshotsQuery{
-		Name:  query,
-		Limit: limit,
-		OrgId: c.OrgId,
+		Name:         query,
+		Limit:        limit,
+		OrgId:        c.OrgId,
+		SignedInUser: c.SignedInUser,
 	}
 	}
 
 
 	err := bus.Dispatch(&searchQuery)
 	err := bus.Dispatch(&searchQuery)

+ 97 - 0
pkg/api/dashboard_snapshot_test.go

@@ -0,0 +1,97 @@
+package api
+
+import (
+	"testing"
+	"time"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/components/simplejson"
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDashboardSnapshotApiEndpoint(t *testing.T) {
+	Convey("Given a single snapshot", t, func() {
+		jsonModel, _ := simplejson.NewJson([]byte(`{"id":100}`))
+
+		mockSnapshotResult := &m.DashboardSnapshot{
+			Id:        1,
+			Dashboard: jsonModel,
+			Expires:   time.Now().Add(time.Duration(1000) * time.Second),
+			UserId:    999999,
+		}
+
+		bus.AddHandler("test", func(query *m.GetDashboardSnapshotQuery) error {
+			query.Result = mockSnapshotResult
+			return nil
+		})
+
+		bus.AddHandler("test", func(cmd *m.DeleteDashboardSnapshotCommand) error {
+			return nil
+		})
+
+		viewerRole := m.ROLE_VIEWER
+		editorRole := m.ROLE_EDITOR
+		aclMockResp := []*m.DashboardAclInfoDTO{}
+		bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error {
+			query.Result = aclMockResp
+			return nil
+		})
+
+		teamResp := []*m.Team{}
+		bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error {
+			query.Result = teamResp
+			return nil
+		})
+
+		Convey("When user has editor role and is not in the ACL", func() {
+			Convey("Should not be able to delete snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 403)
+				})
+			})
+		})
+
+		Convey("When user is editor and dashboard has default ACL", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{
+				{Role: &viewerRole, Permission: m.PERMISSION_VIEW},
+				{Role: &editorRole, Permission: m.PERMISSION_EDIT},
+			}
+
+			Convey("Should be able to delete a snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+					So(err, ShouldBeNil)
+
+					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+				})
+			})
+		})
+
+		Convey("When user is editor and is the creator of the snapshot", func() {
+			aclMockResp = []*m.DashboardAclInfoDTO{}
+			mockSnapshotResult.UserId = TestUserID
+
+			Convey("Should be able to delete a snapshot", func() {
+				loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots-delete/12345", "/api/snapshots-delete/:key", m.ROLE_EDITOR, func(sc *scenarioContext) {
+					sc.handlerFunc = DeleteDashboardSnapshot
+					sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec()
+
+					So(sc.resp.Code, ShouldEqual, 200)
+					respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes())
+					So(err, ShouldBeNil)
+
+					So(respJSON.Get("message").MustString(), ShouldStartWith, "Snapshot deleted")
+				})
+			})
+		})
+	})
+}

+ 6 - 2
pkg/api/dashboard_test.go

@@ -746,8 +746,7 @@ func TestDashboardApiEndpoint(t *testing.T) {
 }
 }
 
 
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
 func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta {
-	sc.handlerFunc = GetDashboard
-	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+	CallGetDashboard(sc)
 
 
 	So(sc.resp.Code, ShouldEqual, 200)
 	So(sc.resp.Code, ShouldEqual, 200)
 
 
@@ -758,6 +757,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta
 	return dash
 	return dash
 }
 }
 
 
+func CallGetDashboard(sc *scenarioContext) {
+	sc.handlerFunc = GetDashboard
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
 func CallGetDashboardVersion(sc *scenarioContext) {
 func CallGetDashboardVersion(sc *scenarioContext) {
 	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 		query.Result = &m.DashboardVersion{}
 		query.Result = &m.DashboardVersion{}

+ 25 - 0
pkg/api/dtos/folder.go

@@ -0,0 +1,25 @@
+package dtos
+
+import "time"
+
+type Folder struct {
+	Id        int64     `json:"id"`
+	Uid       string    `json:"uid"`
+	Title     string    `json:"title"`
+	Url       string    `json:"url"`
+	HasAcl    bool      `json:"hasAcl"`
+	CanSave   bool      `json:"canSave"`
+	CanEdit   bool      `json:"canEdit"`
+	CanAdmin  bool      `json:"canAdmin"`
+	CreatedBy string    `json:"createdBy"`
+	Created   time.Time `json:"created"`
+	UpdatedBy string    `json:"updatedBy"`
+	Updated   time.Time `json:"updated"`
+	Version   int       `json:"version"`
+}
+
+type FolderSearchHit struct {
+	Id    int64  `json:"id"`
+	Uid   string `json:"uid"`
+	Title string `json:"title"`
+}

+ 147 - 0
pkg/api/folder.go

@@ -0,0 +1,147 @@
+package api
+
+import (
+	"fmt"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+	"github.com/grafana/grafana/pkg/util"
+)
+
+func GetFolders(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folders, err := s.GetFolders(c.QueryInt("limit"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	result := make([]dtos.FolderSearchHit, 0)
+
+	for _, f := range folders {
+		result = append(result, dtos.FolderSearchHit{
+			Id:    f.Id,
+			Uid:   f.Uid,
+			Title: f.Title,
+		})
+	}
+
+	return Json(200, result)
+}
+
+func GetFolderByUid(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, folder))
+}
+
+func GetFolderById(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderById(c.ParamsInt64(":id"))
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, folder))
+}
+
+func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	err := s.CreateFolder(&cmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, cmd.Result))
+}
+
+func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	err := s.UpdateFolder(c.Params(":uid"), &cmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser)
+	return Json(200, toFolderDto(g, cmd.Result))
+}
+
+func DeleteFolder(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	f, err := s.DeleteFolder(c.Params(":uid"))
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	return Json(200, util.DynMap{
+		"title":   f.Title,
+		"message": fmt.Sprintf("Folder %s deleted", f.Title),
+	})
+}
+
+func toFolderDto(g guardian.DashboardGuardian, folder *m.Folder) dtos.Folder {
+	canEdit, _ := g.CanEdit()
+	canSave, _ := g.CanSave()
+	canAdmin, _ := g.CanAdmin()
+
+	// Finding creator and last updater of the folder
+	updater, creator := "Anonymous", "Anonymous"
+	if folder.CreatedBy > 0 {
+		creator = getUserLogin(folder.CreatedBy)
+	}
+	if folder.UpdatedBy > 0 {
+		updater = getUserLogin(folder.UpdatedBy)
+	}
+
+	return dtos.Folder{
+		Id:        folder.Id,
+		Uid:       folder.Uid,
+		Title:     folder.Title,
+		Url:       folder.Url,
+		HasAcl:    folder.HasAcl,
+		CanSave:   canSave,
+		CanEdit:   canEdit,
+		CanAdmin:  canAdmin,
+		CreatedBy: creator,
+		Created:   folder.Created,
+		UpdatedBy: updater,
+		Updated:   folder.Updated,
+		Version:   folder.Version,
+	}
+}
+
+func toFolderError(err error) Response {
+	if err == m.ErrFolderTitleEmpty ||
+		err == m.ErrFolderSameNameExists ||
+		err == m.ErrFolderWithSameUIDExists ||
+		err == m.ErrDashboardTypeMismatch ||
+		err == m.ErrDashboardInvalidUid ||
+		err == m.ErrDashboardUidToLong {
+		return ApiError(400, err.Error(), nil)
+	}
+
+	if err == m.ErrFolderAccessDenied {
+		return ApiError(403, "Access denied", err)
+	}
+
+	if err == m.ErrFolderNotFound {
+		return Json(404, util.DynMap{"status": "not-found", "message": m.ErrFolderNotFound.Error()})
+	}
+
+	if err == m.ErrFolderVersionMismatch {
+		return Json(412, util.DynMap{"status": "version-mismatch", "message": m.ErrFolderVersionMismatch.Error()})
+	}
+
+	return ApiError(500, "Folder API error", err)
+}

+ 103 - 0
pkg/api/folder_permission.go

@@ -0,0 +1,103 @@
+package api
+
+import (
+	"time"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+)
+
+func GetFolderPermissionList(c *middleware.Context) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+
+	if canAdmin, err := guardian.CanAdmin(); err != nil || !canAdmin {
+		return toFolderError(m.ErrFolderAccessDenied)
+	}
+
+	acl, err := guardian.GetAcl()
+	if err != nil {
+		return ApiError(500, "Failed to get folder permissions", err)
+	}
+
+	for _, perm := range acl {
+		perm.FolderId = folder.Id
+		perm.DashboardId = 0
+
+		if perm.Slug != "" {
+			perm.Url = m.GetDashboardFolderUrl(perm.IsFolder, perm.Uid, perm.Slug)
+		}
+	}
+
+	return Json(200, acl)
+}
+
+func UpdateFolderPermissions(c *middleware.Context, apiCmd dtos.UpdateDashboardAclCommand) Response {
+	s := dashboards.NewFolderService(c.OrgId, c.SignedInUser)
+	folder, err := s.GetFolderByUid(c.Params(":uid"))
+
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	guardian := guardian.New(folder.Id, c.OrgId, c.SignedInUser)
+	canAdmin, err := guardian.CanAdmin()
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	if !canAdmin {
+		return toFolderError(m.ErrFolderAccessDenied)
+	}
+
+	cmd := m.UpdateDashboardAclCommand{}
+	cmd.DashboardId = folder.Id
+
+	for _, item := range apiCmd.Items {
+		cmd.Items = append(cmd.Items, &m.DashboardAcl{
+			OrgId:       c.OrgId,
+			DashboardId: folder.Id,
+			UserId:      item.UserId,
+			TeamId:      item.TeamId,
+			Role:        item.Role,
+			Permission:  item.Permission,
+			Created:     time.Now(),
+			Updated:     time.Now(),
+		})
+	}
+
+	if okToUpdate, err := guardian.CheckPermissionBeforeUpdate(m.PERMISSION_ADMIN, cmd.Items); err != nil || !okToUpdate {
+		if err != nil {
+			return ApiError(500, "Error while checking folder permissions", err)
+		}
+
+		return ApiError(403, "Cannot remove own admin permission for a folder", nil)
+	}
+
+	if err := bus.Dispatch(&cmd); err != nil {
+		if err == m.ErrDashboardAclInfoMissing {
+			err = m.ErrFolderAclInfoMissing
+		}
+		if err == m.ErrDashboardPermissionDashboardEmpty {
+			err = m.ErrFolderPermissionFolderEmpty
+		}
+
+		if err == m.ErrFolderAclInfoMissing || err == m.ErrFolderPermissionFolderEmpty {
+			return ApiError(409, err.Error(), err)
+		}
+
+		return ApiError(500, "Failed to create permission", err)
+	}
+
+	return ApiSuccess("Folder permissions updated")
+}

+ 125 - 0
pkg/api/folder_permission_test.go

@@ -0,0 +1,125 @@
+package api
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+	"github.com/grafana/grafana/pkg/services/guardian"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFolderPermissionApiEndpoint(t *testing.T) {
+	Convey("Folder permissions test", t, func() {
+		Convey("Given user has no admin permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: false})
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
+				callGetFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 403)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+
+		Convey("Given user has admin permissions and permissions to update", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanAdminValue: true, CheckPermissionBeforeUpdateValue: true})
+
+			mock := &fakeFolderService{
+				GetFolderByUidResult: &m.Folder{
+					Id:    1,
+					Uid:   "uid",
+					Title: "Folder",
+				},
+			}
+
+			origNewFolderService := dashboards.NewFolderService
+			mockFolderService(mock)
+
+			loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", m.ROLE_ADMIN, func(sc *scenarioContext) {
+				callGetFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			cmd := dtos.UpdateDashboardAclCommand{
+				Items: []dtos.DashboardAclUpdateItem{
+					{UserId: 1000, Permission: m.PERMISSION_ADMIN},
+				},
+			}
+
+			updateFolderPermissionScenario("When calling POST on", "/api/folders/uid/permissions", "/api/folders/:uid/permissions", cmd, func(sc *scenarioContext) {
+				callUpdateFolderPermissions(sc)
+				So(sc.resp.Code, ShouldEqual, 200)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+				dashboards.NewFolderService = origNewFolderService
+			})
+		})
+	})
+}
+
+func callGetFolderPermissions(sc *scenarioContext) {
+	sc.handlerFunc = GetFolderPermissionList
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func callUpdateFolderPermissions(sc *scenarioContext) {
+	bus.AddHandler("test", func(cmd *m.UpdateDashboardAclCommand) error {
+		return nil
+	})
+
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func updateFolderPermissionScenario(desc string, url string, routePattern string, cmd dtos.UpdateDashboardAclCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.OrgId = TestOrgID
+			sc.context.UserId = TestUserID
+
+			return UpdateFolderPermissions(c, cmd)
+		})
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		fn(sc)
+	})
+}

+ 254 - 0
pkg/api/folder_test.go

@@ -0,0 +1,254 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/middleware"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/dashboards"
+
+	m "github.com/grafana/grafana/pkg/models"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFoldersApiEndpoint(t *testing.T) {
+	Convey("Create/update folder response tests", t, func() {
+		Convey("Given a correct request for creating a folder", func() {
+			cmd := m.CreateFolderCommand{
+				Uid:   "uid",
+				Title: "Folder",
+			}
+
+			mock := &fakeFolderService{
+				CreateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder"},
+			}
+
+			createFolderScenario("When calling POST on", "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) {
+				callCreateFolder(sc)
+
+				Convey("It should return correct response data", func() {
+					folder := dtos.Folder{}
+					err := json.NewDecoder(sc.resp.Body).Decode(&folder)
+					So(err, ShouldBeNil)
+					So(folder.Id, ShouldEqual, 1)
+					So(folder.Uid, ShouldEqual, "uid")
+					So(folder.Title, ShouldEqual, "Folder")
+				})
+			})
+		})
+
+		Convey("Given incorrect requests for creating a folder", func() {
+			testCases := []struct {
+				Error              error
+				ExpectedStatusCode int
+			}{
+				{Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403},
+				{Error: m.ErrFolderNotFound, ExpectedStatusCode: 404},
+				{Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412},
+				{Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500},
+			}
+
+			cmd := m.CreateFolderCommand{
+				Uid:   "uid",
+				Title: "Folder",
+			}
+
+			for _, tc := range testCases {
+				mock := &fakeFolderService{
+					CreateFolderError: tc.Error,
+				}
+
+				createFolderScenario(fmt.Sprintf("Expect '%s' error when calling POST on", tc.Error.Error()), "/api/folders", "/api/folders", mock, cmd, func(sc *scenarioContext) {
+					callCreateFolder(sc)
+					if sc.resp.Code != tc.ExpectedStatusCode {
+						t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code)
+					}
+				})
+			}
+		})
+
+		Convey("Given a correct request for updating a folder", func() {
+			cmd := m.UpdateFolderCommand{
+				Title: "Folder upd",
+			}
+
+			mock := &fakeFolderService{
+				UpdateFolderResult: &m.Folder{Id: 1, Uid: "uid", Title: "Folder upd"},
+			}
+
+			updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) {
+				callUpdateFolder(sc)
+
+				Convey("It should return correct response data", func() {
+					folder := dtos.Folder{}
+					err := json.NewDecoder(sc.resp.Body).Decode(&folder)
+					So(err, ShouldBeNil)
+					So(folder.Id, ShouldEqual, 1)
+					So(folder.Uid, ShouldEqual, "uid")
+					So(folder.Title, ShouldEqual, "Folder upd")
+				})
+			})
+		})
+
+		Convey("Given incorrect requests for updating a folder", func() {
+			testCases := []struct {
+				Error              error
+				ExpectedStatusCode int
+			}{
+				{Error: m.ErrFolderWithSameUIDExists, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderTitleEmpty, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderSameNameExists, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardInvalidUid, ExpectedStatusCode: 400},
+				{Error: m.ErrDashboardUidToLong, ExpectedStatusCode: 400},
+				{Error: m.ErrFolderAccessDenied, ExpectedStatusCode: 403},
+				{Error: m.ErrFolderNotFound, ExpectedStatusCode: 404},
+				{Error: m.ErrFolderVersionMismatch, ExpectedStatusCode: 412},
+				{Error: m.ErrFolderFailedGenerateUniqueUid, ExpectedStatusCode: 500},
+			}
+
+			cmd := m.UpdateFolderCommand{
+				Title: "Folder upd",
+			}
+
+			for _, tc := range testCases {
+				mock := &fakeFolderService{
+					UpdateFolderError: tc.Error,
+				}
+
+				updateFolderScenario(fmt.Sprintf("Expect '%s' error when calling PUT on", tc.Error.Error()), "/api/folders/uid", "/api/folders/:uid", mock, cmd, func(sc *scenarioContext) {
+					callUpdateFolder(sc)
+					if sc.resp.Code != tc.ExpectedStatusCode {
+						t.Errorf("For error '%s' expected status code %d, actual %d", tc.Error, tc.ExpectedStatusCode, sc.resp.Code)
+					}
+				})
+			}
+		})
+	})
+}
+
+func callGetFolderByUid(sc *scenarioContext) {
+	sc.handlerFunc = GetFolderByUid
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
+func callDeleteFolder(sc *scenarioContext) {
+	sc.handlerFunc = DeleteFolder
+	sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()
+}
+
+func callCreateFolder(sc *scenarioContext) {
+	sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+}
+
+func createFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.CreateFolderCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
+
+			return CreateFolder(c, cmd)
+		})
+
+		origNewFolderService := dashboards.NewFolderService
+		mockFolderService(mock)
+
+		sc.m.Post(routePattern, sc.defaultHandler)
+
+		defer func() {
+			dashboards.NewFolderService = origNewFolderService
+		}()
+
+		fn(sc)
+	})
+}
+
+func callUpdateFolder(sc *scenarioContext) {
+	sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
+}
+
+func updateFolderScenario(desc string, url string, routePattern string, mock *fakeFolderService, cmd m.UpdateFolderCommand, fn scenarioFunc) {
+	Convey(desc+" "+url, func() {
+		defer bus.ClearBusHandlers()
+
+		sc := setupScenarioContext(url)
+		sc.defaultHandler = wrap(func(c *middleware.Context) Response {
+			sc.context = c
+			sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID}
+
+			return UpdateFolder(c, cmd)
+		})
+
+		origNewFolderService := dashboards.NewFolderService
+		mockFolderService(mock)
+
+		sc.m.Put(routePattern, sc.defaultHandler)
+
+		defer func() {
+			dashboards.NewFolderService = origNewFolderService
+		}()
+
+		fn(sc)
+	})
+}
+
+type fakeFolderService struct {
+	GetFoldersResult     []*models.Folder
+	GetFoldersError      error
+	GetFolderByUidResult *models.Folder
+	GetFolderByUidError  error
+	GetFolderByIdResult  *models.Folder
+	GetFolderByIdError   error
+	CreateFolderResult   *models.Folder
+	CreateFolderError    error
+	UpdateFolderResult   *models.Folder
+	UpdateFolderError    error
+	DeleteFolderResult   *models.Folder
+	DeleteFolderError    error
+	DeletedFolderUids    []string
+}
+
+func (s *fakeFolderService) GetFolders(limit int) ([]*models.Folder, error) {
+	return s.GetFoldersResult, s.GetFoldersError
+}
+
+func (s *fakeFolderService) GetFolderById(id int64) (*models.Folder, error) {
+	return s.GetFolderByIdResult, s.GetFolderByIdError
+}
+
+func (s *fakeFolderService) GetFolderByUid(uid string) (*models.Folder, error) {
+	return s.GetFolderByUidResult, s.GetFolderByUidError
+}
+
+func (s *fakeFolderService) CreateFolder(cmd *models.CreateFolderCommand) error {
+	cmd.Result = s.CreateFolderResult
+	return s.CreateFolderError
+}
+
+func (s *fakeFolderService) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error {
+	cmd.Result = s.UpdateFolderResult
+	return s.UpdateFolderError
+}
+
+func (s *fakeFolderService) DeleteFolder(uid string) (*models.Folder, error) {
+	s.DeletedFolderUids = append(s.DeletedFolderUids, uid)
+	return s.DeleteFolderResult, s.DeleteFolderError
+}
+
+func mockFolderService(mock *fakeFolderService) {
+	dashboards.NewFolderService = func(orgId int64, user *models.SignedInUser) dashboards.FolderService {
+		return mock
+	}
+}

+ 4 - 1
pkg/models/dashboard_acl.go

@@ -26,6 +26,8 @@ func (p PermissionType) String() string {
 var (
 var (
 	ErrDashboardAclInfoMissing           = errors.New("User id and team id cannot both be empty for a dashboard permission.")
 	ErrDashboardAclInfoMissing           = errors.New("User id and team id cannot both be empty for a dashboard permission.")
 	ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.")
 	ErrDashboardPermissionDashboardEmpty = errors.New("Dashboard Id must be greater than zero for a dashboard permission.")
+	ErrFolderAclInfoMissing              = errors.New("User id and team id cannot both be empty for a folder permission.")
+	ErrFolderPermissionFolderEmpty       = errors.New("Folder Id must be greater than zero for a folder permission.")
 )
 )
 
 
 // Dashboard ACL model
 // Dashboard ACL model
@@ -45,7 +47,8 @@ type DashboardAcl struct {
 
 
 type DashboardAclInfoDTO struct {
 type DashboardAclInfoDTO struct {
 	OrgId       int64 `json:"-"`
 	OrgId       int64 `json:"-"`
-	DashboardId int64 `json:"dashboardId"`
+	DashboardId int64 `json:"dashboardId,omitempty"`
+	FolderId    int64 `json:"folderId,omitempty"`
 
 
 	Created time.Time `json:"created"`
 	Created time.Time `json:"created"`
 	Updated time.Time `json:"updated"`
 	Updated time.Time `json:"updated"`

+ 7 - 4
pkg/models/dashboard_snapshot.go

@@ -64,10 +64,12 @@ type DeleteDashboardSnapshotCommand struct {
 }
 }
 
 
 type DeleteExpiredSnapshotsCommand struct {
 type DeleteExpiredSnapshotsCommand struct {
+	DeletedRows int64
 }
 }
 
 
 type GetDashboardSnapshotQuery struct {
 type GetDashboardSnapshotQuery struct {
-	Key string
+	Key       string
+	DeleteKey string
 
 
 	Result *DashboardSnapshot
 	Result *DashboardSnapshot
 }
 }
@@ -76,9 +78,10 @@ type DashboardSnapshots []*DashboardSnapshot
 type DashboardSnapshotsList []*DashboardSnapshotDTO
 type DashboardSnapshotsList []*DashboardSnapshotDTO
 
 
 type GetDashboardSnapshotsQuery struct {
 type GetDashboardSnapshotsQuery struct {
-	Name  string
-	Limit int
-	OrgId int64
+	Name         string
+	Limit        int
+	OrgId        int64
+	SignedInUser *SignedInUser
 
 
 	Result DashboardSnapshotsList
 	Result DashboardSnapshotsList
 }
 }

+ 1 - 0
pkg/models/dashboard_version.go

@@ -75,4 +75,5 @@ type GetDashboardVersionsQuery struct {
 //
 //
 
 
 type DeleteExpiredVersionsCommand struct {
 type DeleteExpiredVersionsCommand struct {
+	DeletedRows int64
 }
 }

+ 3 - 7
pkg/models/dashboards.go

@@ -14,7 +14,7 @@ import (
 // Typed errors
 // Typed errors
 var (
 var (
 	ErrDashboardNotFound                      = errors.New("Dashboard not found")
 	ErrDashboardNotFound                      = errors.New("Dashboard not found")
-	ErrFolderNotFound                         = errors.New("Folder not found")
+	ErrDashboardFolderNotFound                = errors.New("Folder not found")
 	ErrDashboardSnapshotNotFound              = errors.New("Dashboard snapshot not found")
 	ErrDashboardSnapshotNotFound              = errors.New("Dashboard snapshot not found")
 	ErrDashboardWithSameUIDExists             = errors.New("A dashboard with the same uid already exists")
 	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")
 	ErrDashboardWithSameNameInFolderExists    = errors.New("A dashboard with the same name in the folder already exists")
@@ -112,9 +112,9 @@ func NewDashboard(title string) *Dashboard {
 // NewDashboardFolder creates a new dashboard folder
 // NewDashboardFolder creates a new dashboard folder
 func NewDashboardFolder(title string) *Dashboard {
 func NewDashboardFolder(title string) *Dashboard {
 	folder := NewDashboard(title)
 	folder := NewDashboard(title)
+	folder.IsFolder = true
 	folder.Data.Set("schemaVersion", 16)
 	folder.Data.Set("schemaVersion", 16)
-	folder.Data.Set("editable", true)
-	folder.Data.Set("hideControls", true)
+	folder.Data.Set("version", 0)
 	folder.IsFolder = true
 	folder.IsFolder = true
 	return folder
 	return folder
 }
 }
@@ -166,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 		userId = -1
 		userId = -1
 	}
 	}
 
 
-	if dash.Data.Get("version").MustInt(0) == 0 {
-		dash.CreatedBy = userId
-	}
-
 	dash.UpdatedBy = userId
 	dash.UpdatedBy = userId
 	dash.OrgId = cmd.OrgId
 	dash.OrgId = cmd.OrgId
 	dash.PluginId = cmd.PluginId
 	dash.PluginId = cmd.PluginId

+ 91 - 0
pkg/models/folders.go

@@ -0,0 +1,91 @@
+package models
+
+import (
+	"errors"
+	"strings"
+	"time"
+)
+
+// Typed errors
+var (
+	ErrFolderNotFound                = errors.New("Folder not found")
+	ErrFolderVersionMismatch         = errors.New("The folder has been changed by someone else")
+	ErrFolderTitleEmpty              = errors.New("Folder title cannot be empty")
+	ErrFolderWithSameUIDExists       = errors.New("A folder/dashboard with the same uid already exists")
+	ErrFolderSameNameExists          = errors.New("A folder or dashboard in the general folder with the same name already exists")
+	ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id")
+	ErrFolderAccessDenied            = errors.New("Access denied to folder")
+)
+
+type Folder struct {
+	Id      int64
+	Uid     string
+	Title   string
+	Url     string
+	Version int
+
+	Created time.Time
+	Updated time.Time
+
+	UpdatedBy int64
+	CreatedBy int64
+	HasAcl    bool
+}
+
+// GetDashboardModel turns the command into the savable model
+func (cmd *CreateFolderCommand) GetDashboardModel(orgId int64, userId int64) *Dashboard {
+	dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title))
+	dashFolder.OrgId = orgId
+	dashFolder.SetUid(strings.TrimSpace(cmd.Uid))
+
+	if userId == 0 {
+		userId = -1
+	}
+
+	dashFolder.CreatedBy = userId
+	dashFolder.UpdatedBy = userId
+	dashFolder.UpdateSlug()
+
+	return dashFolder
+}
+
+// UpdateDashboardModel updates an existing model from command into model for update
+func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard, orgId int64, userId int64) {
+	dashFolder.OrgId = orgId
+	dashFolder.Title = strings.TrimSpace(cmd.Title)
+	dashFolder.Data.Set("title", dashFolder.Title)
+
+	if cmd.Uid != "" {
+		dashFolder.SetUid(cmd.Uid)
+	}
+
+	dashFolder.SetVersion(cmd.Version)
+	dashFolder.IsFolder = true
+
+	if userId == 0 {
+		userId = -1
+	}
+
+	dashFolder.UpdatedBy = userId
+	dashFolder.UpdateSlug()
+}
+
+//
+// COMMANDS
+//
+
+type CreateFolderCommand struct {
+	Uid   string `json:"uid"`
+	Title string `json:"title"`
+
+	Result *Folder
+}
+
+type UpdateFolderCommand struct {
+	Uid       string `json:"uid"`
+	Title     string `json:"title"`
+	Version   int    `json:"version"`
+	Overwrite bool   `json:"overwrite"`
+
+	Result *Folder
+}

+ 12 - 2
pkg/services/cleanup/cleanup.go

@@ -83,11 +83,21 @@ func (service *CleanUpService) cleanUpTmpFiles() {
 }
 }
 
 
 func (service *CleanUpService) deleteExpiredSnapshots() {
 func (service *CleanUpService) deleteExpiredSnapshots() {
-	bus.Dispatch(&m.DeleteExpiredSnapshotsCommand{})
+	cmd := m.DeleteExpiredSnapshotsCommand{}
+	if err := bus.Dispatch(&cmd); err != nil {
+		service.log.Error("Failed to delete expired snapshots", "error", err.Error())
+	} else {
+		service.log.Debug("Deleted expired snapshots", "rows affected", cmd.DeletedRows)
+	}
 }
 }
 
 
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
-	bus.Dispatch(&m.DeleteExpiredVersionsCommand{})
+	cmd := m.DeleteExpiredVersionsCommand{}
+	if err := bus.Dispatch(&cmd); err != nil {
+		service.log.Error("Failed to delete expired dashboard versions", "error", err.Error())
+	} else {
+		service.log.Debug("Deleted old/expired dashboard versions", "rows affected", cmd.DeletedRows)
+	}
 }
 }
 
 
 func (service *CleanUpService) deleteOldLoginAttempts() {
 func (service *CleanUpService) deleteOldLoginAttempts() {

+ 16 - 11
pkg/services/dashboards/dashboard_service.go

@@ -41,7 +41,10 @@ type SaveDashboardDTO struct {
 	Dashboard *models.Dashboard
 	Dashboard *models.Dashboard
 }
 }
 
 
-type dashboardServiceImpl struct{}
+type dashboardServiceImpl struct {
+	orgId int64
+	user  *models.SignedInUser
+}
 
 
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
 	cmd := &models.GetProvisionedDashboardDataQuery{Name: name}
 	cmd := &models.GetProvisionedDashboardDataQuery{Name: name}
@@ -53,7 +56,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
 	return cmd.Result, nil
 	return cmd.Result, nil
 }
 }
 
 
-func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO) (*models.SaveDashboardCommand, error) {
+func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, validateAlerts bool) (*models.SaveDashboardCommand, error) {
 	dash := dto.Dashboard
 	dash := dto.Dashboard
 
 
 	dash.Title = strings.TrimSpace(dash.Title)
 	dash.Title = strings.TrimSpace(dash.Title)
@@ -78,13 +81,15 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO)
 		return nil, models.ErrDashboardUidToLong
 		return nil, models.ErrDashboardUidToLong
 	}
 	}
 
 
-	validateAlertsCmd := models.ValidateDashboardAlertsCommand{
-		OrgId:     dto.OrgId,
-		Dashboard: dash,
-	}
+	if validateAlerts {
+		validateAlertsCmd := models.ValidateDashboardAlertsCommand{
+			OrgId:     dto.OrgId,
+			Dashboard: dash,
+		}
 
 
-	if err := bus.Dispatch(&validateAlertsCmd); err != nil {
-		return nil, models.ErrDashboardContainsInvalidAlertData
+		if err := bus.Dispatch(&validateAlertsCmd); err != nil {
+			return nil, models.ErrDashboardContainsInvalidAlertData
+		}
 	}
 	}
 
 
 	validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{
 	validateBeforeSaveCmd := models.ValidateDashboardBeforeSaveCommand{
@@ -142,7 +147,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 		UserId:  0,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 		OrgRole: models.ROLE_ADMIN,
 	}
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -172,7 +177,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 		UserId:  0,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 		OrgRole: models.ROLE_ADMIN,
 	}
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -191,7 +196,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 }
 }
 
 
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}

+ 2 - 51
pkg/services/dashboards/dashboard_service_test.go

@@ -17,7 +17,7 @@ func TestDashboardService(t *testing.T) {
 		service := dashboardServiceImpl{}
 		service := dashboardServiceImpl{}
 
 
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(&fakeDashboardGuardian{canSave: true})
+		guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
 
 
 		Convey("Save dashboard validation", func() {
 		Convey("Save dashboard validation", func() {
 			dto := &SaveDashboardDTO{}
 			dto := &SaveDashboardDTO{}
@@ -72,7 +72,7 @@ func TestDashboardService(t *testing.T) {
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.User = &models.SignedInUser{}
 					dto.User = &models.SignedInUser{}
 
 
-					_, err := service.buildSaveDashboardCommand(dto)
+					_, err := service.buildSaveDashboardCommand(dto, true)
 					So(err, ShouldEqual, tc.Error)
 					So(err, ShouldEqual, tc.Error)
 				}
 				}
 			})
 			})
@@ -93,52 +93,3 @@ func TestDashboardService(t *testing.T) {
 		})
 		})
 	})
 	})
 }
 }
-
-func mockDashboardGuardian(mock *fakeDashboardGuardian) {
-	guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian {
-		mock.orgId = orgId
-		mock.dashId = dashId
-		mock.user = user
-		return mock
-	}
-}
-
-type fakeDashboardGuardian struct {
-	dashId                      int64
-	orgId                       int64
-	user                        *models.SignedInUser
-	canSave                     bool
-	canEdit                     bool
-	canView                     bool
-	canAdmin                    bool
-	hasPermission               bool
-	checkPermissionBeforeUpdate bool
-}
-
-func (g *fakeDashboardGuardian) CanSave() (bool, error) {
-	return g.canSave, nil
-}
-
-func (g *fakeDashboardGuardian) CanEdit() (bool, error) {
-	return g.canEdit, nil
-}
-
-func (g *fakeDashboardGuardian) CanView() (bool, error) {
-	return g.canView, nil
-}
-
-func (g *fakeDashboardGuardian) CanAdmin() (bool, error) {
-	return g.canAdmin, nil
-}
-
-func (g *fakeDashboardGuardian) HasPermission(permission models.PermissionType) (bool, error) {
-	return g.hasPermission, nil
-}
-
-func (g *fakeDashboardGuardian) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) {
-	return g.checkPermissionBeforeUpdate, nil
-}
-
-func (g *fakeDashboardGuardian) GetAcl() ([]*models.DashboardAclInfoDTO, error) {
-	return nil, nil
-}

+ 245 - 0
pkg/services/dashboards/folder_service.go

@@ -0,0 +1,245 @@
+package dashboards
+
+import (
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/guardian"
+	"github.com/grafana/grafana/pkg/services/search"
+)
+
+// FolderService service for operating on folders
+type FolderService interface {
+	GetFolders(limit int) ([]*models.Folder, error)
+	GetFolderById(id int64) (*models.Folder, error)
+	GetFolderByUid(uid string) (*models.Folder, error)
+	CreateFolder(cmd *models.CreateFolderCommand) error
+	UpdateFolder(uid string, cmd *models.UpdateFolderCommand) error
+	DeleteFolder(uid string) (*models.Folder, error)
+}
+
+// NewFolderService factory for creating a new folder service
+var NewFolderService = func(orgId int64, user *models.SignedInUser) FolderService {
+	return &dashboardServiceImpl{
+		orgId: orgId,
+		user:  user,
+	}
+}
+
+func (dr *dashboardServiceImpl) GetFolders(limit int) ([]*models.Folder, error) {
+	if limit == 0 {
+		limit = 1000
+	}
+
+	searchQuery := search.Query{
+		SignedInUser: dr.user,
+		DashboardIds: make([]int64, 0),
+		FolderIds:    make([]int64, 0),
+		Limit:        limit,
+		OrgId:        dr.orgId,
+		Type:         "dash-folder",
+		Permission:   models.PERMISSION_VIEW,
+	}
+
+	if err := bus.Dispatch(&searchQuery); err != nil {
+		return nil, err
+	}
+
+	folders := make([]*models.Folder, 0)
+
+	for _, hit := range searchQuery.Result {
+		folders = append(folders, &models.Folder{
+			Id:    hit.Id,
+			Uid:   hit.Uid,
+			Title: hit.Title,
+		})
+	}
+
+	return folders, nil
+}
+
+func (dr *dashboardServiceImpl) GetFolderById(id int64) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Id: id}
+	dashFolder, err := getFolder(query)
+
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	g := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canView, err := g.CanView(); err != nil || !canView {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func (dr *dashboardServiceImpl) GetFolderByUid(uid string) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+	dashFolder, err := getFolder(query)
+
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	g := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canView, err := g.CanView(); err != nil || !canView {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) error {
+	dashFolder := cmd.GetDashboardModel(dr.orgId, dr.user.UserId)
+
+	dto := &SaveDashboardDTO{
+		Dashboard: dashFolder,
+		OrgId:     dr.orgId,
+		User:      dr.user,
+	}
+
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	err = bus.Dispatch(saveDashboardCmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id}
+	dashFolder, err = getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.Result = dashToFolder(dashFolder)
+
+	return nil
+}
+
+func (dr *dashboardServiceImpl) UpdateFolder(existingUid string, cmd *models.UpdateFolderCommand) error {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: existingUid}
+	dashFolder, err := getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.UpdateDashboardModel(dashFolder, dr.orgId, dr.user.UserId)
+
+	dto := &SaveDashboardDTO{
+		Dashboard: dashFolder,
+		OrgId:     dr.orgId,
+		User:      dr.user,
+		Overwrite: cmd.Overwrite,
+	}
+
+	saveDashboardCmd, err := dr.buildSaveDashboardCommand(dto, false)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	err = bus.Dispatch(saveDashboardCmd)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	query = models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id}
+	dashFolder, err = getFolder(query)
+	if err != nil {
+		return toFolderError(err)
+	}
+
+	cmd.Result = dashToFolder(dashFolder)
+
+	return nil
+}
+
+func (dr *dashboardServiceImpl) DeleteFolder(uid string) (*models.Folder, error) {
+	query := models.GetDashboardQuery{OrgId: dr.orgId, Uid: uid}
+	dashFolder, err := getFolder(query)
+	if err != nil {
+		return nil, toFolderError(err)
+	}
+
+	guardian := guardian.New(dashFolder.Id, dr.orgId, dr.user)
+	if canSave, err := guardian.CanSave(); err != nil || !canSave {
+		if err != nil {
+			return nil, toFolderError(err)
+		}
+		return nil, models.ErrFolderAccessDenied
+	}
+
+	deleteCmd := models.DeleteDashboardCommand{OrgId: dr.orgId, Id: dashFolder.Id}
+	if err := bus.Dispatch(&deleteCmd); err != nil {
+		return nil, toFolderError(err)
+	}
+
+	return dashToFolder(dashFolder), nil
+}
+
+func getFolder(query models.GetDashboardQuery) (*models.Dashboard, error) {
+	if err := bus.Dispatch(&query); err != nil {
+		return nil, toFolderError(err)
+	}
+
+	if !query.Result.IsFolder {
+		return nil, models.ErrFolderNotFound
+	}
+
+	return query.Result, nil
+}
+
+func dashToFolder(dash *models.Dashboard) *models.Folder {
+	return &models.Folder{
+		Id:        dash.Id,
+		Uid:       dash.Uid,
+		Title:     dash.Title,
+		HasAcl:    dash.HasAcl,
+		Url:       dash.GetUrl(),
+		Version:   dash.Version,
+		Created:   dash.Created,
+		CreatedBy: dash.CreatedBy,
+		Updated:   dash.Updated,
+		UpdatedBy: dash.UpdatedBy,
+	}
+}
+
+func toFolderError(err error) error {
+	if err == models.ErrDashboardTitleEmpty {
+		return models.ErrFolderTitleEmpty
+	}
+
+	if err == models.ErrDashboardUpdateAccessDenied {
+		return models.ErrFolderAccessDenied
+	}
+
+	if err == models.ErrDashboardWithSameNameInFolderExists {
+		return models.ErrFolderSameNameExists
+	}
+
+	if err == models.ErrDashboardWithSameUIDExists {
+		return models.ErrFolderWithSameUIDExists
+	}
+
+	if err == models.ErrDashboardVersionMismatch {
+		return models.ErrFolderVersionMismatch
+	}
+
+	if err == models.ErrDashboardNotFound {
+		return models.ErrFolderNotFound
+	}
+
+	if err == models.ErrDashboardFailedGenerateUniqueUid {
+		err = models.ErrFolderFailedGenerateUniqueUid
+	}
+
+	return err
+}

+ 191 - 0
pkg/services/dashboards/folder_service_test.go

@@ -0,0 +1,191 @@
+package dashboards
+
+import (
+	"testing"
+
+	"github.com/grafana/grafana/pkg/bus"
+	"github.com/grafana/grafana/pkg/models"
+
+	"github.com/grafana/grafana/pkg/services/guardian"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestFolderService(t *testing.T) {
+	Convey("Folder service tests", t, func() {
+		service := dashboardServiceImpl{
+			orgId: 1,
+			user:  &models.SignedInUser{UserId: 1},
+		}
+
+		Convey("Given user has no permissions", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{})
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = models.NewDashboardFolder("Folder")
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+				return models.ErrDashboardUpdateAccessDenied
+			})
+
+			Convey("When get folder by id should return access denied error", func() {
+				_, err := service.GetFolderById(1)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When get folder by uid should return access denied error", func() {
+				_, err := service.GetFolderByUid("uid")
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When creating folder should return access denied error", func() {
+				err := service.CreateFolder(&models.CreateFolderCommand{
+					Title: "Folder",
+				})
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When updating folder should return access denied error", func() {
+				err := service.UpdateFolder("uid", &models.UpdateFolderCommand{
+					Uid:   "uid",
+					Title: "Folder",
+				})
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Convey("When deleting folder by uid should return access denied error", func() {
+				_, err := service.DeleteFolder("uid")
+				So(err, ShouldNotBeNil)
+				So(err, ShouldEqual, models.ErrFolderAccessDenied)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Given user has permission to save", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
+
+			dash := models.NewDashboardFolder("Folder")
+			dash.Id = 1
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = dash
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error {
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.SaveDashboardCommand) error {
+				cmd.Result = dash
+				return nil
+			})
+
+			bus.AddHandler("test", func(cmd *models.DeleteDashboardCommand) error {
+				return nil
+			})
+
+			Convey("When creating folder should not return access denied error", func() {
+				err := service.CreateFolder(&models.CreateFolderCommand{
+					Title: "Folder",
+				})
+				So(err, ShouldBeNil)
+			})
+
+			Convey("When updating folder should not return access denied error", func() {
+				err := service.UpdateFolder("uid", &models.UpdateFolderCommand{
+					Uid:   "uid",
+					Title: "Folder",
+				})
+				So(err, ShouldBeNil)
+			})
+
+			Convey("When deleting folder by uid should not return access denied error", func() {
+				_, err := service.DeleteFolder("uid")
+				So(err, ShouldBeNil)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Given user has permission to view", func() {
+			origNewGuardian := guardian.New
+			guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true})
+
+			dashFolder := models.NewDashboardFolder("Folder")
+			dashFolder.Id = 1
+			dashFolder.Uid = "uid-abc"
+
+			bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
+				query.Result = dashFolder
+				return nil
+			})
+
+			Convey("When get folder by id should return folder", func() {
+				f, _ := service.GetFolderById(1)
+				So(f.Id, ShouldEqual, dashFolder.Id)
+				So(f.Uid, ShouldEqual, dashFolder.Uid)
+				So(f.Title, ShouldEqual, dashFolder.Title)
+			})
+
+			Convey("When get folder by uid should return folder", func() {
+				f, _ := service.GetFolderByUid("uid")
+				So(f.Id, ShouldEqual, dashFolder.Id)
+				So(f.Uid, ShouldEqual, dashFolder.Uid)
+				So(f.Title, ShouldEqual, dashFolder.Title)
+			})
+
+			Reset(func() {
+				guardian.New = origNewGuardian
+			})
+		})
+
+		Convey("Should map errors correct", func() {
+			testCases := []struct {
+				ActualError   error
+				ExpectedError error
+			}{
+				{ActualError: models.ErrDashboardTitleEmpty, ExpectedError: models.ErrFolderTitleEmpty},
+				{ActualError: models.ErrDashboardUpdateAccessDenied, ExpectedError: models.ErrFolderAccessDenied},
+				{ActualError: models.ErrDashboardWithSameNameInFolderExists, ExpectedError: models.ErrFolderSameNameExists},
+				{ActualError: models.ErrDashboardWithSameUIDExists, ExpectedError: models.ErrFolderWithSameUIDExists},
+				{ActualError: models.ErrDashboardVersionMismatch, ExpectedError: models.ErrFolderVersionMismatch},
+				{ActualError: models.ErrDashboardNotFound, ExpectedError: models.ErrFolderNotFound},
+				{ActualError: models.ErrDashboardFailedGenerateUniqueUid, ExpectedError: models.ErrFolderFailedGenerateUniqueUid},
+				{ActualError: models.ErrDashboardInvalidUid, ExpectedError: models.ErrDashboardInvalidUid},
+			}
+
+			for _, tc := range testCases {
+				actualError := toFolderError(tc.ActualError)
+				if actualError != tc.ExpectedError {
+					t.Errorf("For error '%s' expected error '%s', actual '%s'", tc.ActualError, tc.ExpectedError, actualError)
+				}
+			}
+		})
+	})
+}

+ 49 - 0
pkg/services/guardian/guardian.go

@@ -158,3 +158,52 @@ func (g *dashboardGuardianImpl) getTeams() ([]*m.Team, error) {
 	g.groups = query.Result
 	g.groups = query.Result
 	return query.Result, err
 	return query.Result, err
 }
 }
+
+type FakeDashboardGuardian struct {
+	DashId                           int64
+	OrgId                            int64
+	User                             *m.SignedInUser
+	CanSaveValue                     bool
+	CanEditValue                     bool
+	CanViewValue                     bool
+	CanAdminValue                    bool
+	HasPermissionValue               bool
+	CheckPermissionBeforeUpdateValue bool
+}
+
+func (g *FakeDashboardGuardian) CanSave() (bool, error) {
+	return g.CanSaveValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanEdit() (bool, error) {
+	return g.CanEditValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanView() (bool, error) {
+	return g.CanViewValue, nil
+}
+
+func (g *FakeDashboardGuardian) CanAdmin() (bool, error) {
+	return g.CanAdminValue, nil
+}
+
+func (g *FakeDashboardGuardian) HasPermission(permission m.PermissionType) (bool, error) {
+	return g.HasPermissionValue, nil
+}
+
+func (g *FakeDashboardGuardian) CheckPermissionBeforeUpdate(permission m.PermissionType, updatePermissions []*m.DashboardAcl) (bool, error) {
+	return g.CheckPermissionBeforeUpdateValue, nil
+}
+
+func (g *FakeDashboardGuardian) GetAcl() ([]*m.DashboardAclInfoDTO, error) {
+	return nil, nil
+}
+
+func MockDashboardGuardian(mock *FakeDashboardGuardian) {
+	New = func(dashId int64, orgId int64, user *m.SignedInUser) DashboardGuardian {
+		mock.OrgId = orgId
+		mock.DashId = dashId
+		mock.User = user
+		return mock
+	}
+}

+ 16 - 4
pkg/services/sqlstore/dashboard.go

@@ -37,6 +37,12 @@ func SaveDashboard(cmd *m.SaveDashboardCommand) error {
 func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 	dash := cmd.GetDashboardModel()
 	dash := cmd.GetDashboardModel()
 
 
+	userId := cmd.UserId
+
+	if userId == 0 {
+		userId = -1
+	}
+
 	if dash.Id > 0 {
 	if dash.Id > 0 {
 		var existing m.Dashboard
 		var existing m.Dashboard
 		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)
@@ -76,17 +82,23 @@ func saveDashboard(sess *DBSession, cmd *m.SaveDashboardCommand) error {
 
 
 	if dash.Id == 0 {
 	if dash.Id == 0 {
 		dash.SetVersion(1)
 		dash.SetVersion(1)
+		dash.Created = time.Now()
+		dash.CreatedBy = userId
+		dash.Updated = time.Now()
+		dash.UpdatedBy = userId
 		metrics.M_Api_Dashboard_Insert.Inc()
 		metrics.M_Api_Dashboard_Insert.Inc()
 		affectedRows, err = sess.Insert(dash)
 		affectedRows, err = sess.Insert(dash)
 	} else {
 	} else {
-		v := dash.Version
-		v++
-		dash.SetVersion(v)
+		dash.SetVersion(dash.Version + 1)
 
 
 		if !cmd.UpdatedAt.IsZero() {
 		if !cmd.UpdatedAt.IsZero() {
 			dash.Updated = cmd.UpdatedAt
 			dash.Updated = cmd.UpdatedAt
+		} else {
+			dash.Updated = time.Now()
 		}
 		}
 
 
+		dash.UpdatedBy = userId
+
 		affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
 		affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
 	}
 	}
 
 
@@ -514,7 +526,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash
 		}
 		}
 
 
 		if !folderExists {
 		if !folderExists {
-			return m.ErrFolderNotFound
+			return m.ErrDashboardFolderNotFound
 		}
 		}
 	}
 	}
 
 

+ 23 - 75
pkg/services/sqlstore/dashboard_service_integration_test.go

@@ -142,9 +142,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, 0)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, 0)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -165,9 +165,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, otherSavedFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, otherSavedFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -189,9 +189,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInGeneralFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInGeneralFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 
 
@@ -213,9 +213,9 @@ func TestIntegratedDashboardService(t *testing.T) {
 						So(err, ShouldNotBeNil)
 						So(err, ShouldNotBeNil)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 						So(err, ShouldEqual, models.ErrDashboardUpdateAccessDenied)
 
 
-						So(sc.dashboardGuardianMock.dashId, ShouldEqual, savedDashInFolder.Id)
-						So(sc.dashboardGuardianMock.orgId, ShouldEqual, cmd.OrgId)
-						So(sc.dashboardGuardianMock.user.UserId, ShouldEqual, cmd.UserId)
+						So(sc.dashboardGuardianMock.DashId, ShouldEqual, savedDashInFolder.Id)
+						So(sc.dashboardGuardianMock.OrgId, ShouldEqual, cmd.OrgId)
+						So(sc.dashboardGuardianMock.User.UserId, ShouldEqual, cmd.UserId)
 					})
 					})
 				})
 				})
 			})
 			})
@@ -363,7 +363,7 @@ func TestIntegratedDashboardService(t *testing.T) {
 
 
 						Convey("It should result in folder not found error", func() {
 						Convey("It should result in folder not found error", func() {
 							So(err, ShouldNotBeNil)
 							So(err, ShouldNotBeNil)
-							So(err, ShouldEqual, models.ErrFolderNotFound)
+							So(err, ShouldEqual, models.ErrDashboardFolderNotFound)
 						})
 						})
 					})
 					})
 
 
@@ -785,68 +785,16 @@ func TestIntegratedDashboardService(t *testing.T) {
 	})
 	})
 }
 }
 
 
-func mockDashboardGuardian(mock *mockDashboardGuarder) {
-	guardian.New = func(dashId int64, orgId int64, user *models.SignedInUser) guardian.DashboardGuardian {
-		mock.orgId = orgId
-		mock.dashId = dashId
-		mock.user = user
-		return mock
-	}
-}
-
-type mockDashboardGuarder struct {
-	dashId                      int64
-	orgId                       int64
-	user                        *models.SignedInUser
-	canSave                     bool
-	canSaveCallCounter          int
-	canEdit                     bool
-	canView                     bool
-	canAdmin                    bool
-	hasPermission               bool
-	checkPermissionBeforeRemove bool
-	checkPermissionBeforeUpdate bool
-}
-
-func (g *mockDashboardGuarder) CanSave() (bool, error) {
-	g.canSaveCallCounter++
-	return g.canSave, nil
-}
-
-func (g *mockDashboardGuarder) CanEdit() (bool, error) {
-	return g.canEdit, nil
-}
-
-func (g *mockDashboardGuarder) CanView() (bool, error) {
-	return g.canView, nil
-}
-
-func (g *mockDashboardGuarder) CanAdmin() (bool, error) {
-	return g.canAdmin, nil
-}
-
-func (g *mockDashboardGuarder) HasPermission(permission models.PermissionType) (bool, error) {
-	return g.hasPermission, nil
-}
-
-func (g *mockDashboardGuarder) CheckPermissionBeforeUpdate(permission models.PermissionType, updatePermissions []*models.DashboardAcl) (bool, error) {
-	return g.checkPermissionBeforeUpdate, nil
-}
-
-func (g *mockDashboardGuarder) GetAcl() ([]*models.DashboardAclInfoDTO, error) {
-	return nil, nil
-}
-
 type scenarioContext struct {
 type scenarioContext struct {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 }
 
 
 type scenarioFunc func(c *scenarioContext)
 type scenarioFunc func(c *scenarioContext)
 
 
-func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) {
+func dashboardGuardianScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &scenarioContext{
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,
@@ -861,15 +809,15 @@ func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scena
 }
 }
 
 
 type dashboardPermissionScenarioContext struct {
 type dashboardPermissionScenarioContext struct {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 }
 
 
 type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext)
 type dashboardPermissionScenarioFunc func(sc *dashboardPermissionScenarioContext)
 
 
-func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn dashboardPermissionScenarioFunc) {
+func dashboardPermissionScenario(desc string, mock *guardian.FakeDashboardGuardian, fn dashboardPermissionScenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &dashboardPermissionScenarioContext{
 		sc := &dashboardPermissionScenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,
@@ -884,8 +832,8 @@ func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn das
 }
 }
 
 
 func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) {
 func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) {
-	mock := &mockDashboardGuarder{
-		canSave: canSave,
+	mock := &guardian.FakeDashboardGuardian{
+		CanSaveValue: canSave,
 	}
 	}
 	dashboardPermissionScenario(desc, mock, fn)
 	dashboardPermissionScenario(desc, mock, fn)
 }
 }
@@ -902,10 +850,10 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error {
 	return err
 	return err
 }
 }
 
 
-func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) {
+func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
 	Convey(desc, func() {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 
 		sc := &scenarioContext{
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,
 			dashboardGuardianMock: mock,

+ 26 - 12
pkg/services/sqlstore/dashboard_snapshot.go

@@ -16,20 +16,23 @@ func init() {
 	bus.AddHandler("sql", DeleteExpiredSnapshots)
 	bus.AddHandler("sql", DeleteExpiredSnapshots)
 }
 }
 
 
+// DeleteExpiredSnapshots removes snapshots with old expiry dates.
+// SnapShotRemoveExpired is deprecated and should be removed in the future.
+// Snapshot expiry is decided by the user when they share the snapshot.
 func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error {
 func DeleteExpiredSnapshots(cmd *m.DeleteExpiredSnapshotsCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		var expiredCount int64 = 0
-
-		if setting.SnapShotRemoveExpired {
-			deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?"
-			expiredResponse, err := x.Exec(deleteExpiredSql, time.Now)
-			if err != nil {
-				return err
-			}
-			expiredCount, _ = expiredResponse.RowsAffected()
+		if !setting.SnapShotRemoveExpired {
+			sqlog.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.")
+			return nil
 		}
 		}
 
 
-		sqlog.Debug("Deleted old/expired snaphots", "expired", expiredCount)
+		deleteExpiredSql := "DELETE FROM dashboard_snapshot WHERE expires < ?"
+		expiredResponse, err := sess.Exec(deleteExpiredSql, time.Now())
+		if err != nil {
+			return err
+		}
+		cmd.DeletedRows, _ = expiredResponse.RowsAffected()
+
 		return nil
 		return nil
 	})
 	})
 }
 }
@@ -72,7 +75,7 @@ func DeleteDashboardSnapshot(cmd *m.DeleteDashboardSnapshotCommand) error {
 }
 }
 
 
 func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
 func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
-	snapshot := m.DashboardSnapshot{Key: query.Key}
+	snapshot := m.DashboardSnapshot{Key: query.Key, DeleteKey: query.DeleteKey}
 	has, err := x.Get(&snapshot)
 	has, err := x.Get(&snapshot)
 
 
 	if err != nil {
 	if err != nil {
@@ -85,6 +88,8 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
 	return nil
 	return nil
 }
 }
 
 
+// SearchDashboardSnapshots returns a list of all snapshots for admins
+// for other roles, it returns snapshots created by the user
 func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 	var snapshots = make(m.DashboardSnapshotsList, 0)
 	var snapshots = make(m.DashboardSnapshotsList, 0)
 
 
@@ -95,7 +100,16 @@ func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 		sess.Where("name LIKE ?", query.Name)
 		sess.Where("name LIKE ?", query.Name)
 	}
 	}
 
 
-	sess.Where("org_id = ?", query.OrgId)
+	// admins can see all snapshots, everyone else can only see their own snapshots
+	if query.SignedInUser.OrgRole == m.ROLE_ADMIN {
+		sess.Where("org_id = ?", query.OrgId)
+	} else if !query.SignedInUser.IsAnonymous {
+		sess.Where("org_id = ? AND user_id = ?", query.OrgId, query.SignedInUser.UserId)
+	} else {
+		query.Result = snapshots
+		return nil
+	}
+
 	err := sess.Find(&snapshots)
 	err := sess.Find(&snapshots)
 	query.Result = snapshots
 	query.Result = snapshots
 	return err
 	return err

+ 136 - 2
pkg/services/sqlstore/dashboard_snapshot_test.go

@@ -2,11 +2,14 @@ package sqlstore
 
 
 import (
 import (
 	"testing"
 	"testing"
+	"time"
 
 
+	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 	. "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/setting"
 )
 )
 
 
 func TestDashboardSnapshotDBAccess(t *testing.T) {
 func TestDashboardSnapshotDBAccess(t *testing.T) {
@@ -14,17 +17,19 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 	Convey("Testing DashboardSnapshot data access", t, func() {
 	Convey("Testing DashboardSnapshot data access", t, func() {
 		InitTestDB(t)
 		InitTestDB(t)
 
 
-		Convey("Given saved snaphot", func() {
+		Convey("Given saved snapshot", func() {
 			cmd := m.CreateDashboardSnapshotCommand{
 			cmd := m.CreateDashboardSnapshotCommand{
 				Key: "hej",
 				Key: "hej",
 				Dashboard: simplejson.NewFromAny(map[string]interface{}{
 				Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					"hello": "mupp",
 					"hello": "mupp",
 				}),
 				}),
+				UserId: 1000,
+				OrgId:  1,
 			}
 			}
 			err := CreateDashboardSnapshot(&cmd)
 			err := CreateDashboardSnapshot(&cmd)
 			So(err, ShouldBeNil)
 			So(err, ShouldBeNil)
 
 
-			Convey("Should be able to get snaphot by key", func() {
+			Convey("Should be able to get snapshot by key", func() {
 				query := m.GetDashboardSnapshotQuery{Key: "hej"}
 				query := m.GetDashboardSnapshotQuery{Key: "hej"}
 				err = GetDashboardSnapshot(&query)
 				err = GetDashboardSnapshot(&query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
@@ -33,6 +38,135 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 				So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp")
 				So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp")
 			})
 			})
 
 
+			Convey("And the user has the admin role", func() {
+				Convey("Should return all the snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+				})
+			})
+
+			Convey("And the user has the editor role and has created a snapshot", func() {
+				Convey("Should return all the snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 1000},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 1)
+				})
+			})
+
+			Convey("And the user has the editor role and has not created any snapshot", func() {
+				Convey("Should not return any snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, UserId: 2},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 0)
+				})
+			})
+
+			Convey("And the user is anonymous", func() {
+				cmd := m.CreateDashboardSnapshotCommand{
+					Key:       "strangesnapshotwithuserid0",
+					DeleteKey: "adeletekey",
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"hello": "mupp",
+					}),
+					UserId: 0,
+					OrgId:  1,
+				}
+				err := CreateDashboardSnapshot(&cmd)
+				So(err, ShouldBeNil)
+
+				Convey("Should not return any snapshots", func() {
+					query := m.GetDashboardSnapshotsQuery{
+						OrgId:        1,
+						SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_EDITOR, IsAnonymous: true, UserId: 0},
+					}
+					err := SearchDashboardSnapshots(&query)
+					So(err, ShouldBeNil)
+
+					So(query.Result, ShouldNotBeNil)
+					So(len(query.Result), ShouldEqual, 0)
+				})
+			})
 		})
 		})
 	})
 	})
 }
 }
+
+func TestDeleteExpiredSnapshots(t *testing.T) {
+	Convey("Testing dashboard snapshots clean up", t, func() {
+		x := InitTestDB(t)
+
+		setting.SnapShotRemoveExpired = true
+
+		notExpiredsnapshot := createTestSnapshot(x, "key1", 1000)
+		createTestSnapshot(x, "key2", -1000)
+		createTestSnapshot(x, "key3", -1000)
+
+		Convey("Clean up old dashboard snapshots", func() {
+			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+			So(err, ShouldBeNil)
+
+			query := m.GetDashboardSnapshotsQuery{
+				OrgId:        1,
+				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+			}
+			err = SearchDashboardSnapshots(&query)
+			So(err, ShouldBeNil)
+
+			So(len(query.Result), ShouldEqual, 1)
+			So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key)
+		})
+
+		Convey("Don't delete anything if there are no expired snapshots", func() {
+			err := DeleteExpiredSnapshots(&m.DeleteExpiredSnapshotsCommand{})
+			So(err, ShouldBeNil)
+
+			query := m.GetDashboardSnapshotsQuery{
+				OrgId:        1,
+				SignedInUser: &m.SignedInUser{OrgRole: m.ROLE_ADMIN},
+			}
+			SearchDashboardSnapshots(&query)
+
+			So(len(query.Result), ShouldEqual, 1)
+		})
+	})
+}
+
+func createTestSnapshot(x *xorm.Engine, key string, expires int64) *m.DashboardSnapshot {
+	cmd := m.CreateDashboardSnapshotCommand{
+		Key:       key,
+		DeleteKey: "delete" + key,
+		Dashboard: simplejson.NewFromAny(map[string]interface{}{
+			"hello": "mupp",
+		}),
+		UserId:  1000,
+		OrgId:   1,
+		Expires: expires,
+	}
+	err := CreateDashboardSnapshot(&cmd)
+	So(err, ShouldBeNil)
+
+	// Set expiry date manually - to be able to create expired snapshots
+	expireDate := time.Now().Add(time.Second * time.Duration(expires))
+	_, err = x.Exec("update dashboard_snapshot set expires = ? where "+dialect.Quote("key")+" = ?", expireDate, key)
+	So(err, ShouldBeNil)
+
+	return cmd.Result
+}

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

@@ -3,6 +3,7 @@ package sqlstore
 import (
 import (
 	"fmt"
 	"fmt"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/go-xorm/xorm"
 	"github.com/go-xorm/xorm"
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -124,6 +125,24 @@ func TestDashboardDataAccess(t *testing.T) {
 				generateNewUid = util.GenerateShortUid
 				generateNewUid = util.GenerateShortUid
 			})
 			})
 
 
+			Convey("Should be able to create dashboard", func() {
+				cmd := m.SaveDashboardCommand{
+					OrgId: 1,
+					Dashboard: simplejson.NewFromAny(map[string]interface{}{
+						"title": "folderId",
+						"tags":  []interface{}{},
+					}),
+					UserId: 100,
+				}
+
+				err := SaveDashboard(&cmd)
+				So(err, ShouldBeNil)
+				So(cmd.Result.CreatedBy, ShouldEqual, 100)
+				So(cmd.Result.Created.IsZero(), ShouldBeFalse)
+				So(cmd.Result.UpdatedBy, ShouldEqual, 100)
+				So(cmd.Result.Updated.IsZero(), ShouldBeFalse)
+			})
+
 			Convey("Should be able to update dashboard by id and remove folderId", func() {
 			Convey("Should be able to update dashboard by id and remove folderId", func() {
 				cmd := m.SaveDashboardCommand{
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
 					OrgId: 1,
@@ -134,6 +153,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					}),
 					Overwrite: true,
 					Overwrite: true,
 					FolderId:  2,
 					FolderId:  2,
+					UserId:    100,
 				}
 				}
 
 
 				err := SaveDashboard(&cmd)
 				err := SaveDashboard(&cmd)
@@ -149,6 +169,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					}),
 					FolderId:  0,
 					FolderId:  0,
 					Overwrite: true,
 					Overwrite: true,
+					UserId:    100,
 				}
 				}
 
 
 				err = SaveDashboard(&cmd)
 				err = SaveDashboard(&cmd)
@@ -162,6 +183,10 @@ func TestDashboardDataAccess(t *testing.T) {
 				err = GetDashboard(&query)
 				err = GetDashboard(&query)
 				So(err, ShouldBeNil)
 				So(err, ShouldBeNil)
 				So(query.Result.FolderId, ShouldEqual, 0)
 				So(query.Result.FolderId, ShouldEqual, 0)
+				So(query.Result.CreatedBy, ShouldEqual, savedDash.CreatedBy)
+				So(query.Result.Created, ShouldEqual, savedDash.Created.Truncate(time.Second))
+				So(query.Result.UpdatedBy, ShouldEqual, 100)
+				So(query.Result.Updated.IsZero(), ShouldBeFalse)
 			})
 			})
 
 
 			Convey("Should be able to delete a dashboard folder and its children", func() {
 			Convey("Should be able to delete a dashboard folder and its children", func() {

+ 1 - 3
pkg/services/sqlstore/dashboard_version.go

@@ -69,7 +69,6 @@ func GetDashboardVersions(query *m.GetDashboardVersionsQuery) error {
 
 
 func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-		expiredCount := int64(0)
 		versions := []DashboardVersionExp{}
 		versions := []DashboardVersionExp{}
 		versionsToKeep := setting.DashboardVersionsToKeep
 		versionsToKeep := setting.DashboardVersionsToKeep
 
 
@@ -98,8 +97,7 @@ func DeleteExpiredVersions(cmd *m.DeleteExpiredVersionsCommand) error {
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
-			expiredCount, _ = expiredResponse.RowsAffected()
-			sqlog.Debug("Deleted old/expired dashboard versions", "expired", expiredCount)
+			cmd.DeletedRows, _ = expiredResponse.RowsAffected()
 		}
 		}
 
 
 		return nil
 		return nil

+ 0 - 2
pkg/setting/setting.go

@@ -88,7 +88,6 @@ var (
 	ExternalSnapshotUrl   string
 	ExternalSnapshotUrl   string
 	ExternalSnapshotName  string
 	ExternalSnapshotName  string
 	ExternalEnabled       bool
 	ExternalEnabled       bool
-	SnapShotTTLDays       int
 	SnapShotRemoveExpired bool
 	SnapShotRemoveExpired bool
 
 
 	// Dashboard history
 	// Dashboard history
@@ -523,7 +522,6 @@ func NewConfigContext(args *CommandLineArgs) error {
 	ExternalSnapshotName = snapshots.Key("external_snapshot_name").String()
 	ExternalSnapshotName = snapshots.Key("external_snapshot_name").String()
 	ExternalEnabled = snapshots.Key("external_enabled").MustBool(true)
 	ExternalEnabled = snapshots.Key("external_enabled").MustBool(true)
 	SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true)
 	SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true)
-	SnapShotTTLDays = snapshots.Key("snapshot_TTL_days").MustInt(90)
 
 
 	// read dashboard settings
 	// read dashboard settings
 	dashboards := Cfg.Section("dashboards")
 	dashboards := Cfg.Section("dashboards")

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

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

+ 7 - 10
public/app/containers/ManageDashboards/FolderSettings.jest.tsx

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

+ 17 - 14
public/app/containers/ManageDashboards/FolderSettings.tsx

@@ -10,7 +10,6 @@ import appEvents from 'app/core/app_events';
 @observer
 @observer
 export class FolderSettings extends React.Component<IContainerProps, any> {
 export class FolderSettings extends React.Component<IContainerProps, any> {
   formSnapshot: any;
   formSnapshot: any;
-  dashboard: any;
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -22,9 +21,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
 
 
     return folder.load(view.routeParams.get('uid') 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;
-
-      view.updatePathAndQuery(`${res.meta.url}/settings`, {}, {});
+      view.updatePathAndQuery(`${res.url}/settings`, {}, {});
 
 
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
     });
     });
@@ -51,7 +48,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     const { nav, folder, view } = this.props;
     const { nav, folder, view } = this.props;
 
 
     folder
     folder
-      .saveFolder(this.dashboard, { overwrite: false })
+      .saveFolder({ overwrite: false })
       .then(newUrl => {
       .then(newUrl => {
         view.updatePathAndQuery(newUrl, {}, {});
         view.updatePathAndQuery(newUrl, {}, {});
 
 
@@ -61,7 +58,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       .then(() => {
       .then(() => {
         return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
         return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       })
       })
-      .catch(this.handleSaveFolderError);
+      .catch(this.handleSaveFolderError.bind(this));
   }
   }
 
 
   delete(evt) {
   delete(evt) {
@@ -79,7 +76,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        return this.props.folder.deleteFolder().then(() => {
+        return folder.deleteFolder().then(() => {
           appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
           appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
           view.updatePathAndQuery('dashboards', '', '');
           view.updatePathAndQuery('dashboards', '', '');
         });
         });
@@ -91,6 +88,8 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     if (err.data && err.data.status === 'version-mismatch') {
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
       err.isHandled = true;
 
 
+      const { nav, folder, view } = this.props;
+
       appEvents.emit('confirm-modal', {
       appEvents.emit('confirm-modal', {
         title: 'Conflict',
         title: 'Conflict',
         text: 'Someone else has updated this folder.',
         text: 'Someone else has updated this folder.',
@@ -98,16 +97,20 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.props.folder.saveFolder(this.dashboard, { overwrite: true });
+          folder
+            .saveFolder({ overwrite: true })
+            .then(newUrl => {
+              view.updatePathAndQuery(newUrl, {}, {});
+
+              appEvents.emit('dashboard-saved');
+              appEvents.emit('alert-success', ['Folder saved']);
+            })
+            .then(() => {
+              return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
+            });
         },
         },
       });
       });
     }
     }
-
-    if (err.data && err.data.status === 'name-exists') {
-      err.isHandled = true;
-
-      appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
-    }
   }
   }
 
 
   render() {
   render() {

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

@@ -53,7 +53,7 @@ describe('AddPermissions', () => {
       wrapper.find('form').simulate('submit', { preventDefault() {} });
       wrapper.find('form').simulate('submit', { preventDefault() {} });
 
 
       expect(backendSrv.post.mock.calls.length).toBe(1);
       expect(backendSrv.post.mock.calls.length).toBe(1);
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
     });
     });
   });
   });
 
 
@@ -80,7 +80,7 @@ describe('AddPermissions', () => {
       wrapper.find('form').simulate('submit', { preventDefault() {} });
       wrapper.find('form').simulate('submit', { preventDefault() {} });
 
 
       expect(backendSrv.post.mock.calls.length).toBe(1);
       expect(backendSrv.post.mock.calls.length).toBe(1);
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
     });
     });
   });
   });
 
 

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

@@ -33,10 +33,6 @@ export class HelpCtrl {
         { keys: ['p', 's'], description: 'Open Panel Share Modal' },
         { keys: ['p', 's'], description: 'Open Panel Share Modal' },
         { keys: ['p', 'r'], description: 'Remove Panel' },
         { keys: ['p', 'r'], description: 'Remove Panel' },
       ],
       ],
-      'Focused Row': [
-        { keys: ['r', 'c'], description: 'Collapse Row' },
-        { keys: ['r', 'r'], description: 'Remove Row' },
-      ],
       'Time Range': [
       'Time Range': [
         { keys: ['t', 'z'], description: 'Zoom out time range' },
         { keys: ['t', 'z'], description: 'Zoom out time range' },
         {
         {

+ 5 - 40
public/app/core/components/manage_dashboards/manage_dashboards.ts

@@ -78,8 +78,8 @@ export class ManageDashboardsCtrl {
           return;
           return;
         }
         }
 
 
-        return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => {
-          this.canSave = dash.meta.canSave;
+        return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
+          this.canSave = folder.canSave;
         });
         });
       });
       });
   }
   }
@@ -173,48 +173,13 @@ export class ManageDashboardsCtrl {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        const foldersAndDashboards = data.folders.concat(data.dashboards);
-        this.deleteFoldersAndDashboards(foldersAndDashboards);
+        this.deleteFoldersAndDashboards(data.folders, data.dashboards);
       },
       },
     });
     });
   }
   }
 
 
-  private deleteFoldersAndDashboards(uids) {
-    this.backendSrv.deleteDashboards(uids).then(result => {
-      const folders = _.filter(result, dash => dash.meta.isFolder);
-      const folderCount = folders.length;
-      const dashboards = _.filter(result, dash => !dash.meta.isFolder);
-      const dashCount = dashboards.length;
-
-      if (result.length > 0) {
-        let header;
-        let msg;
-
-        if (folderCount > 0 && dashCount > 0) {
-          header = `Folder${folderCount === 1 ? '' : 's'} And Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
-          msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} `;
-          msg += `and ${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
-        } else if (folderCount > 0) {
-          header = `Folder${folderCount === 1 ? '' : 's'} Deleted`;
-
-          if (folderCount === 1) {
-            msg = `${folders[0].dashboard.title} has been deleted`;
-          } else {
-            msg = `${folderCount} folder${folderCount === 1 ? '' : 's'} has been deleted`;
-          }
-        } else if (dashCount > 0) {
-          header = `Dashboard${dashCount === 1 ? '' : 's'} Deleted`;
-
-          if (dashCount === 1) {
-            msg = `${dashboards[0].dashboard.title} has been deleted`;
-          } else {
-            msg = `${dashCount} dashboard${dashCount === 1 ? '' : 's'} has been deleted`;
-          }
-        }
-
-        appEvents.emit('alert-success', [header, msg]);
-      }
-
+  private deleteFoldersAndDashboards(folderUids, dashboardUids) {
+    this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
       this.refreshList();
       this.refreshList();
     });
     });
   }
   }

+ 27 - 37
public/app/core/services/backend_srv.ts

@@ -221,14 +221,18 @@ export class BackendSrv {
     return this.get('/api/search', query);
     return this.get('/api/search', query);
   }
   }
 
 
-  getDashboard(type, slug) {
-    return this.get('/api/dashboards/' + type + '/' + slug);
+  getDashboardBySlug(slug) {
+    return this.get(`/api/dashboards/db/${slug}`);
   }
   }
 
 
   getDashboardByUid(uid: string) {
   getDashboardByUid(uid: string) {
     return this.get(`/api/dashboards/uid/${uid}`);
     return this.get(`/api/dashboards/uid/${uid}`);
   }
   }
 
 
+  getFolderByUid(uid: string) {
+    return this.get(`/api/folders/${uid}`);
+  }
+
   saveDashboard(dash, options) {
   saveDashboard(dash, options) {
     options = options || {};
     options = options || {};
 
 
@@ -240,55 +244,41 @@ export class BackendSrv {
     });
     });
   }
   }
 
 
-  createDashboardFolder(name) {
-    const dash = {
-      schemaVersion: 16,
-      title: name.trim(),
-      editable: true,
-      panels: [],
-    };
-
-    return this.post('/api/dashboards/db/', {
-      dashboard: dash,
-      isFolder: true,
-      overwrite: false,
-    }).then(res => {
-      return this.getDashboard('db', res.slug);
-    });
+  createFolder(payload: any) {
+    return this.post('/api/folders', payload);
   }
   }
 
 
-  saveFolder(dash, options) {
+  updateFolder(folder, options) {
     options = options || {};
     options = options || {};
 
 
-    return this.post('/api/dashboards/db/', {
-      dashboard: dash,
-      isFolder: true,
+    return this.put(`/api/folders/${folder.uid}`, {
+      title: folder.title,
+      version: folder.version,
       overwrite: options.overwrite === true,
       overwrite: options.overwrite === true,
-      message: options.message || '',
     });
     });
   }
   }
 
 
-  deleteDashboard(uid) {
-    let deferred = this.$q.defer();
+  deleteFolder(uid: string, showSuccessAlert) {
+    return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true });
+  }
 
 
-    this.getDashboardByUid(uid).then(fullDash => {
-      this.delete(`/api/dashboards/uid/${uid}`)
-        .then(() => {
-          deferred.resolve(fullDash);
-        })
-        .catch(err => {
-          deferred.reject(err);
-        });
+  deleteDashboard(uid, showSuccessAlert) {
+    return this.request({
+      method: 'DELETE',
+      url: `/api/dashboards/uid/${uid}`,
+      showSuccessAlert: showSuccessAlert === true,
     });
     });
-
-    return deferred.promise;
   }
   }
 
 
-  deleteDashboards(dashboardUids) {
+  deleteFoldersAndDashboards(folderUids, dashboardUids) {
     const tasks = [];
     const tasks = [];
 
 
-    for (let uid of dashboardUids) {
-      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, uid));
+    for (let folderUid of folderUids) {
+      tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true));
+    }
+
+    for (let dashboardUid of dashboardUids) {
+      tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true));
     }
     }
 
 
     return this.executeInOrder(tasks, []);
     return this.executeInOrder(tasks, []);

+ 5 - 26
public/app/core/services/keybindingSrv.ts

@@ -171,8 +171,9 @@ export class KeybindingSrv {
     // delete panel
     // delete panel
     this.bind('p r', () => {
     this.bind('p r', () => {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
       if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        panelInfo.row.removePanel(panelInfo.panel);
+        this.$rootScope.appEvent('panel-remove', {
+          panelId: dashboard.meta.focusPanelId,
+        });
         dashboard.meta.focusPanelId = 0;
         dashboard.meta.focusPanelId = 0;
       }
       }
     });
     });
@@ -192,36 +193,14 @@ export class KeybindingSrv {
       }
       }
     });
     });
 
 
-    // delete row
-    this.bind('r r', () => {
-      if (dashboard.meta.focusPanelId && dashboard.meta.canEdit) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        dashboard.removeRow(panelInfo.row);
-        dashboard.meta.focusPanelId = 0;
-      }
-    });
-
-    // collapse row
-    this.bind('r c', () => {
-      if (dashboard.meta.focusPanelId) {
-        var panelInfo = dashboard.getPanelInfoById(dashboard.meta.focusPanelId);
-        panelInfo.row.toggleCollapse();
-        dashboard.meta.focusPanelId = 0;
-      }
-    });
-
     // collapse all rows
     // collapse all rows
     this.bind('d shift+c', () => {
     this.bind('d shift+c', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = true;
-      }
+      dashboard.collapseRows();
     });
     });
 
 
     // expand all rows
     // expand all rows
     this.bind('d shift+e', () => {
     this.bind('d shift+e', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = false;
-      }
+      dashboard.expandRows();
     });
     });
 
 
     this.bind('d n', e => {
     this.bind('d n', e => {

+ 21 - 0
public/app/core/utils/kbn.ts

@@ -463,6 +463,15 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
 kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
 kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
 kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
 
 
+// Hash Rate
+kbn.valueFormats.Hs     = kbn.formatBuilders.decimalSIPrefix('H/s');
+kbn.valueFormats.KHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
+kbn.valueFormats.MHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 2);
+kbn.valueFormats.GHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 3);
+kbn.valueFormats.THs    = kbn.formatBuilders.decimalSIPrefix('H/s', 4);
+kbn.valueFormats.PHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 5);
+kbn.valueFormats.EHs    = kbn.formatBuilders.decimalSIPrefix('H/s', 6);
+
 // Throughput
 // Throughput
 kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
 kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
 kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
 kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
@@ -878,6 +887,18 @@ kbn.getUnitFormats = function() {
         { text: 'gigabits/sec', value: 'Gbits' },
         { text: 'gigabits/sec', value: 'Gbits' },
       ],
       ],
     },
     },
+    {
+      text: 'hash rate',
+      submenu: [
+        {text: 'hashes/sec', value: 'Hs'},
+        {text: 'kilohashes/sec',    value: 'KHs'},
+        {text: 'megahashes/sec',   value: 'MHs'},
+        {text: 'gigahashes/sec', value: 'GHs'},
+        {text: 'terahashes/sec',    value: 'THs'},
+        {text: 'petahashes/sec', value: 'PHs'},
+        {text: 'exahashes/sec',    value: 'EHs'},
+      ],
+    },
     {
     {
       text: 'throughput',
       text: 'throughput',
       submenu: [
       submenu: [

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

@@ -18,9 +18,9 @@ export class CreateFolderCtrl {
       return;
       return;
     }
     }
 
 
-    return this.backendSrv.createDashboardFolder(this.title).then(result => {
+    return this.backendSrv.createFolder({ title: this.title }).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
-      this.$location.url(locationUtil.stripBaseFromUrl(result.meta.url));
+      this.$location.url(locationUtil.stripBaseFromUrl(result.url));
     });
     });
   }
   }
 
 

+ 39 - 0
public/app/features/dashboard/dashboard_ctrl.ts

@@ -3,6 +3,7 @@ import config from 'app/core/config';
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import { PanelContainer } from './dashgrid/PanelContainer';
 import { PanelContainer } from './dashgrid/PanelContainer';
 import { DashboardModel } from './dashboard_model';
 import { DashboardModel } from './dashboard_model';
+import { PanelModel } from './panel_model';
 
 
 export class DashboardCtrl implements PanelContainer {
 export class DashboardCtrl implements PanelContainer {
   dashboard: DashboardModel;
   dashboard: DashboardModel;
@@ -130,9 +131,47 @@ export class DashboardCtrl implements PanelContainer {
     return this;
     return this;
   }
   }
 
 
+  onRemovingPanel(evt, options) {
+    options = options || {};
+    if (!options.panelId) {
+      return;
+    }
+
+    var panelInfo = this.dashboard.getPanelInfoById(options.panelId);
+    this.removePanel(panelInfo.panel, true);
+  }
+
+  removePanel(panel: PanelModel, ask: boolean) {
+    // confirm deletion
+    if (ask !== false) {
+      var text2, confirmText;
+
+      if (panel.alert) {
+        text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
+        confirmText = 'YES';
+      }
+
+      this.$scope.appEvent('confirm-modal', {
+        title: 'Remove Panel',
+        text: 'Are you sure you want to remove this panel?',
+        text2: text2,
+        icon: 'fa-trash',
+        confirmText: confirmText,
+        yesText: 'Remove',
+        onConfirm: () => {
+          this.removePanel(panel, false);
+        },
+      });
+      return;
+    }
+
+    this.dashboard.removePanel(panel);
+  }
+
   init(dashboard) {
   init(dashboard) {
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
     this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
     this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
+    this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
     this.setupDashboard(dashboard);
     this.setupDashboard(dashboard);
   }
   }
 }
 }

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

@@ -524,6 +524,34 @@ export class DashboardModel {
     this.removePanel(row);
     this.removePanel(row);
   }
   }
 
 
+  expandRows() {
+    for (let i = 0; i < this.panels.length; i++) {
+      var panel = this.panels[i];
+
+      if (panel.type !== 'row') {
+        continue;
+      }
+
+      if (panel.collapsed) {
+        this.toggleRow(panel);
+      }
+    }
+  }
+
+  collapseRows() {
+    for (let i = 0; i < this.panels.length; i++) {
+      var panel = this.panels[i];
+
+      if (panel.type !== 'row') {
+        continue;
+      }
+
+      if (!panel.collapsed) {
+        this.toggleRow(panel);
+      }
+    }
+  }
+
   setPanelFocus(id) {
   setPanelFocus(id) {
     this.meta.focusPanelId = id;
     this.meta.focusPanelId = id;
   }
   }

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

@@ -92,7 +92,7 @@ export class DashboardSrv {
 
 
   save(clone, options) {
   save(clone, options) {
     options = options || {};
     options = options || {};
-    options.folderId = options.folderId || this.dash.meta.folderId || clone.folderId;
+    options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
 
 
     return this.backendSrv
     return this.backendSrv
       .saveDashboard(clone, options)
       .saveDashboard(clone, options)

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

@@ -14,7 +14,7 @@ export class FolderDashboardsCtrl {
       const loader = new FolderPageLoader(this.backendSrv);
       const loader = new FolderPageLoader(this.backendSrv);
 
 
       loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
       loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
-        const url = locationUtil.stripBaseFromUrl(folder.meta.url);
+        const url = locationUtil.stripBaseFromUrl(folder.url);
 
 
         if (url !== $location.path()) {
         if (url !== $location.path()) {
           $location.path(url).replace();
           $location.path(url).replace();

+ 6 - 6
public/app/features/dashboard/folder_page_loader.ts

@@ -36,16 +36,16 @@ export class FolderPageLoader {
       },
       },
     };
     };
 
 
-    return this.backendSrv.getDashboardByUid(uid).then(result => {
-      ctrl.folderId = result.dashboard.id;
-      const folderTitle = result.dashboard.title;
-      const folderUrl = result.meta.url;
+    return this.backendSrv.getFolderByUid(uid).then(folder => {
+      ctrl.folderId = folder.id;
+      const folderTitle = folder.title;
+      const folderUrl = folder.url;
       ctrl.navModel.main.text = folderTitle;
       ctrl.navModel.main.text = folderTitle;
 
 
       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;
 
 
-      if (result.meta.canAdmin) {
+      if (folder.canAdmin) {
         const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
         const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
         permTab.url = folderUrl + '/permissions';
         permTab.url = folderUrl + '/permissions';
 
 
@@ -55,7 +55,7 @@ export class FolderPageLoader {
         ctrl.navModel.main.children = [dashTab];
         ctrl.navModel.main.children = [dashTab];
       }
       }
 
 
-      return result;
+      return folder;
     });
     });
   }
   }
 }
 }

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

@@ -89,13 +89,13 @@ export class FolderPickerCtrl {
       evt.preventDefault();
       evt.preventDefault();
     }
     }
 
 
-    return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => {
+    return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
 
 
       this.closeCreateFolder();
       this.closeCreateFolder();
       this.folder = {
       this.folder = {
-        text: result.dashboard.title,
-        value: result.dashboard.id,
+        text: result.title,
+        value: result.id,
       };
       };
       this.onFolderChange(this.folder);
       this.onFolderChange(this.folder);
     });
     });

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

@@ -7,8 +7,7 @@ export class FolderSettingsCtrl {
   folderId: number;
   folderId: number;
   uid: string;
   uid: string;
   canSave = false;
   canSave = false;
-  dashboard: any;
-  meta: any;
+  folder: any;
   title: string;
   title: string;
   hasChanged: boolean;
   hasChanged: boolean;
 
 
@@ -23,10 +22,9 @@ export class FolderSettingsCtrl {
           $location.path(`${folder.meta.url}/settings`).replace();
           $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.folder = folder;
+        this.canSave = this.folder.canSave;
+        this.title = this.folder.title;
       });
       });
     }
     }
   }
   }
@@ -38,10 +36,10 @@ export class FolderSettingsCtrl {
       return;
       return;
     }
     }
 
 
-    this.dashboard.title = this.title.trim();
+    this.folder.title = this.title.trim();
 
 
     return this.backendSrv
     return this.backendSrv
-      .updateDashboardFolder(this.dashboard, { overwrite: false })
+      .updateFolder(this.folder)
       .then(result => {
       .then(result => {
         if (result.url !== this.$location.path()) {
         if (result.url !== this.$location.path()) {
           this.$location.url(result.url + '/settings');
           this.$location.url(result.url + '/settings');
@@ -54,7 +52,7 @@ export class FolderSettingsCtrl {
   }
   }
 
 
   titleChanged() {
   titleChanged() {
-    this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase();
+    this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase();
   }
   }
 
 
   delete(evt) {
   delete(evt) {
@@ -69,8 +67,8 @@ export class FolderSettingsCtrl {
       icon: 'fa-trash',
       icon: 'fa-trash',
       yesText: 'Delete',
       yesText: 'Delete',
       onConfirm: () => {
       onConfirm: () => {
-        return this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
-          appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
+        return this.backendSrv.deleteFolder(this.uid).then(() => {
+          appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]);
           this.$location.url('dashboards');
           this.$location.url('dashboards');
         });
         });
       },
       },
@@ -88,15 +86,9 @@ export class FolderSettingsCtrl {
         yesText: 'Save & Overwrite',
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         icon: 'fa-warning',
         onConfirm: () => {
         onConfirm: () => {
-          this.backendSrv.updateDashboardFolder(this.dashboard, { overwrite: true });
+          this.backendSrv.updateFolder(this.folder, { overwrite: true });
         },
         },
       });
       });
     }
     }
-
-    if (err.data && err.data.status === 'name-exists') {
-      err.isHandled = true;
-
-      appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
-    }
   }
   }
 }
 }

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

@@ -22,6 +22,8 @@ const template = `
       <div class="gf-form">
       <div class="gf-form">
         <folder-picker initial-folder-id="ctrl.folderId"
         <folder-picker initial-folder-id="ctrl.folderId"
                        on-change="ctrl.onFolderChange($folder)"
                        on-change="ctrl.onFolderChange($folder)"
+                       enter-folder-creation="ctrl.onEnterFolderCreation()"
+                       exit-folder-creation="ctrl.onExitFolderCreation()"
                        enable-create-new="true"
                        enable-create-new="true"
                        label-class="width-7">
                        label-class="width-7">
         </folder-picker>
         </folder-picker>
@@ -29,7 +31,7 @@ const template = `
 		</div>
 		</div>
 
 
 		<div class="gf-form-button-row text-center">
 		<div class="gf-form-button-row text-center">
-			<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
+			<button type="submit" class="btn btn-success" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 		</div>
 		</div>
 	</form>
 	</form>
@@ -40,6 +42,7 @@ export class SaveDashboardAsModalCtrl {
   clone: any;
   clone: any;
   folderId: any;
   folderId: any;
   dismiss: () => void;
   dismiss: () => void;
+  isValidFolderSelection = true;
 
 
   /** @ngInject */
   /** @ngInject */
   constructor(private dashboardSrv) {
   constructor(private dashboardSrv) {
@@ -79,6 +82,14 @@ export class SaveDashboardAsModalCtrl {
   onFolderChange(folder) {
   onFolderChange(folder) {
     this.folderId = folder.id;
     this.folderId = folder.id;
   }
   }
+
+  onEnterFolderCreation() {
+    this.isValidFolderSelection = false;
+  }
+
+  onExitFolderCreation() {
+    this.isValidFolderSelection = true;
+  }
 }
 }
 
 
 export function saveDashboardAsDirective() {
 export function saveDashboardAsDirective() {

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

@@ -374,7 +374,7 @@ describe('DashboardModel', function() {
 
 
     it('should assign id', function() {
     it('should assign id', function() {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
-      model.rows[0].panels[0] = { };
+      model.rows[0].panels[0] = {};
 
 
       let dashboard = new DashboardModel(model);
       let dashboard = new DashboardModel(model);
       expect(dashboard.panels[0].id).toBe(1);
       expect(dashboard.panels[0].id).toBe(1);

+ 4 - 25
public/app/features/panel/panel_ctrl.ts

@@ -241,31 +241,10 @@ export class PanelCtrl {
     });
     });
   }
   }
 
 
-  removePanel(ask: boolean) {
-    // confirm deletion
-    if (ask !== false) {
-      var text2, confirmText;
-
-      if (this.panel.alert) {
-        text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
-        confirmText = 'YES';
-      }
-
-      appEvents.emit('confirm-modal', {
-        title: 'Remove Panel',
-        text: 'Are you sure you want to remove this panel?',
-        text2: text2,
-        icon: 'fa-trash',
-        confirmText: confirmText,
-        yesText: 'Remove',
-        onConfirm: () => {
-          this.removePanel(false);
-        },
-      });
-      return;
-    }
-
-    this.dashboard.removePanel(this.panel);
+  removePanel() {
+    this.publishAppEvent('panel-remove', {
+      panelId: this.panel.id,
+    });
   }
   }
 
 
   editPanelJson() {
   editPanelJson() {

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

@@ -18,7 +18,7 @@ export class SoloPanelCtrl {
 
 
       // if no uid, redirect to new route based on slug
       // if no uid, redirect to new route based on slug
       if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
       if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
-        backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
+        backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
           if (res) {
           if (res) {
             const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
             const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
             $location.path(url).replace();
             $location.path(url).replace();

+ 1 - 0
public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html

@@ -101,6 +101,7 @@
 												get-options="getSizeOptions()"
 												get-options="getSizeOptions()"
 												on-change="onChangeInternal()"
 												on-change="onChangeInternal()"
 												label-mode="true"
 												label-mode="true"
+                        allow-custom="true"
 												css-class="width-12">
 												css-class="width-12">
 			</gf-form-dropdown>
 			</gf-form-dropdown>
 		</div>
 		</div>

+ 9 - 4
public/app/plugins/panel/heatmap/color_legend.ts

@@ -8,13 +8,18 @@ import { getColorScale, getOpacityScale } from './color_scale';
 
 
 let module = angular.module('grafana.directives');
 let module = angular.module('grafana.directives');
 
 
+const LEGEND_HEIGHT_PX = 6;
+const LEGEND_WIDTH_PX = 100;
+const LEGEND_TICK_SIZE = 0;
+const LEGEND_VALUE_MARGIN = 0;
+
 /**
 /**
  * Color legend for heatmap editor.
  * Color legend for heatmap editor.
  */
  */
 module.directive('colorLegend', function() {
 module.directive('colorLegend', function() {
   return {
   return {
     restrict: 'E',
     restrict: 'E',
-    template: '<div class="heatmap-color-legend"><svg width="16.8rem" height="24px"></svg></div>',
+    template: '<div class="heatmap-color-legend"><svg width="16.5rem" height="24px"></svg></div>',
     link: function(scope, elem, attrs) {
     link: function(scope, elem, attrs) {
       let ctrl = scope.ctrl;
       let ctrl = scope.ctrl;
       let panel = scope.ctrl.panel;
       let panel = scope.ctrl.panel;
@@ -50,7 +55,7 @@ module.directive('colorLegend', function() {
 module.directive('heatmapLegend', function() {
 module.directive('heatmapLegend', function() {
   return {
   return {
     restrict: 'E',
     restrict: 'E',
-    template: '<div class="heatmap-color-legend"><svg width="100px" height="14px"></svg></div>',
+    template: `<div class="heatmap-color-legend"><svg width="${LEGEND_WIDTH_PX}px" height="${LEGEND_HEIGHT_PX}px"></svg></div>`,
     link: function(scope, elem, attrs) {
     link: function(scope, elem, attrs) {
       let ctrl = scope.ctrl;
       let ctrl = scope.ctrl;
       let panel = scope.ctrl.panel;
       let panel = scope.ctrl.panel;
@@ -163,10 +168,10 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal
   let xAxis = d3
   let xAxis = d3
     .axisBottom(legendValueScale)
     .axisBottom(legendValueScale)
     .tickValues(ticks)
     .tickValues(ticks)
-    .tickSize(2);
+    .tickSize(LEGEND_TICK_SIZE);
 
 
   let colorRect = legendElem.find(':first-child');
   let colorRect = legendElem.find(':first-child');
-  let posY = getSvgElemHeight(legendElem) + 2;
+  let posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN;
   let posX = getSvgElemX(colorRect);
   let posX = getSvgElemX(colorRect);
 
 
   d3
   d3

+ 1 - 2
public/app/plugins/panel/heatmap/rendering.ts

@@ -66,8 +66,7 @@ export default function link(scope, elem, attrs, ctrl) {
         height = parseInt(height.replace('px', ''), 10);
         height = parseInt(height.replace('px', ''), 10);
       }
       }
 
 
-      height -= 5; // padding
-      height -= panel.title ? 24 : 9; // subtract panel title bar
+      height -= panel.legend.show ? 28 : 11; // bottom padding and space for legend
 
 
       $heatmap.css('height', height + 'px');
       $heatmap.css('height', height + 'px');
 
 

+ 3 - 0
public/app/plugins/panel/heatmap/specs/renderer_specs.ts

@@ -51,6 +51,9 @@ describe('grafanaHeatmap', function() {
                   colorScheme: 'interpolateOranges',
                   colorScheme: 'interpolateOranges',
                   fillBackground: false,
                   fillBackground: false,
                 },
                 },
+                legend: {
+                  show: false,
+                },
                 xBucketSize: 1000,
                 xBucketSize: 1000,
                 xBucketNumber: null,
                 xBucketNumber: null,
                 yBucketSize: 1,
                 yBucketSize: 1,

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

@@ -21,7 +21,7 @@ export class LoadDashboardCtrl {
 
 
     // if no uid, redirect to new route based on slug
     // if no uid, redirect to new route based on slug
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
     if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
-      backendSrv.get(`/api/dashboards/db/${$routeParams.slug}`).then(res => {
+      backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
         if (res) {
         if (res) {
           $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
           $location.path(locationUtil.stripBaseFromUrl(res.meta.url)).replace();
         }
         }

+ 14 - 12
public/app/stores/FolderStore/FolderStore.ts

@@ -2,11 +2,12 @@ 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),
+  uid: types.string,
   title: types.string,
   title: types.string,
   url: types.string,
   url: types.string,
   canSave: types.boolean,
   canSave: types.boolean,
-  uid: types.string,
   hasChanged: types.boolean,
   hasChanged: types.boolean,
+  version: types.number,
 });
 });
 
 
 export const FolderStore = types
 export const FolderStore = types
@@ -21,15 +22,15 @@ export const FolderStore = types
       }
       }
 
 
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboardByUid(uid);
-
+      const res = yield backendSrv.getFolderByUid(uid);
       self.folder = Folder.create({
       self.folder = Folder.create({
-        id: res.dashboard.id,
-        title: res.dashboard.title,
-        url: res.meta.url,
-        uid: res.dashboard.uid,
-        canSave: res.meta.canSave,
+        id: res.id,
+        uid: res.uid,
+        title: res.title,
+        url: res.url,
+        canSave: res.canSave,
         hasChanged: false,
         hasChanged: false,
+        version: res.version,
       });
       });
 
 
       return res;
       return res;
@@ -40,12 +41,13 @@ export const FolderStore = types
       self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
       self.folder.hasChanged = originalTitle.toLowerCase() !== title.trim().toLowerCase() && title.trim().length > 0;
     },
     },
 
 
-    saveFolder: flow(function* saveFolder(dashboard: any, options: any) {
+    saveFolder: flow(function* saveFolder(options: any) {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
-      dashboard.title = self.folder.title.trim();
+      self.folder.title = self.folder.title.trim();
 
 
-      const res = yield backendSrv.saveFolder(dashboard, options);
+      const res = yield backendSrv.updateFolder(self.folder, options);
       self.folder.url = res.url;
       self.folder.url = res.url;
+      self.folder.version = res.version;
 
 
       return `${self.folder.url}/settings`;
       return `${self.folder.url}/settings`;
     }),
     }),
@@ -53,6 +55,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
     deleteFolder: flow(function* deleteFolder() {
       const backendSrv = getEnv(self).backendSrv;
       const backendSrv = getEnv(self).backendSrv;
 
 
-      return backendSrv.deleteDashboard(self.folder.uid);
+      return backendSrv.deleteFolder(self.folder.uid);
     }),
     }),
   }));
   }));

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

@@ -44,7 +44,7 @@ describe('PermissionsStore', () => {
     expect(store.items[0].permission).toBe(2);
     expect(store.items[0].permission).toBe(2);
     expect(store.items[0].permissionName).toBe('Edit');
     expect(store.items[0].permissionName).toBe('Edit');
     expect(backendSrv.post.mock.calls.length).toBe(1);
     expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
   });
   });
 
 
   it('should save removed permissions automatically', () => {
   it('should save removed permissions automatically', () => {
@@ -54,7 +54,7 @@ describe('PermissionsStore', () => {
 
 
     expect(store.items.length).toBe(2);
     expect(store.items.length).toBe(2);
     expect(backendSrv.post.mock.calls.length).toBe(1);
     expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
   });
   });
 
 
   describe('when duplicate team permissions are added', () => {
   describe('when duplicate team permissions are added', () => {

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

@@ -110,7 +110,7 @@ export const PermissionsStore = types
         self.dashboardId = dashboardId;
         self.dashboardId = dashboardId;
         self.items.clear();
         self.items.clear();
 
 
-        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
+        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/permissions`);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
         self.items = items;
         self.items = items;
         self.originalItems = items;
         self.originalItems = items;
@@ -210,7 +210,7 @@ const updateItems = self => {
 
 
   let res;
   let res;
   try {
   try {
-    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, {
+    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
       items: updated,
       items: updated,
     });
     });
   } catch (error) {
   } catch (error) {

+ 93 - 129
public/sass/base/_fonts.scss

@@ -1,290 +1,254 @@
-@import "font_awesome";
-@import "grafana_icons";
+@import 'font_awesome';
+@import 'grafana_icons';
 
 
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/ek4gzZ-GeXAPcSbHtCeQI_esZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/-2n2p-_Y08sg57CNWQfKNvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/u0TOpm082MNkS5K0Q4rhqvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/NdF9MtnOpLzo-noMoG0miPesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto'), local('Roboto-Regular'),
+    url(../fonts/roboto/Fcx7Wwv8OzT71A3E1XOAjvesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto"), local("Roboto-Regular"),
-    url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto'), local('Roboto-Regular'), url(../fonts/roboto/CWB0XYA8bzo0kSThX0UTuA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/ZLqKeelYbATG60EpZBSDyxJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oHi30kwQWvpCWqAhzHcCSBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/rGvHdJnr2l75qb0YND9NyBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mx9Uck6uB63VIKFYnEMXrRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/mbmhprMH69Zi6eEPBYVFhRJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/oOeFwZNlrTefzLYmlVV1UBJtnKITppOI_IvcXXDNrsc.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   font-style: normal;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium"), local("Roboto-Medium"),
-    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+    url(../fonts/roboto/RxZJdnzeo3R5zSexge8UUVtXRa8TVwTICgirnJhmVJw.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/WxrXJa0C3KdtC7lMafG4dRTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/OpXUqTo0UgQQhGj_SFdLWBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/1hZf02POANh32k2VkgEoUBTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/cDKhRaXnQTOVbaoxwdOr9xTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/K23cxWVTrIFD6DJsEVi07RTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vSzulfKSK0LLjjfeaxcREhTbgVql8nDJpwnrE27mub0.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 400;
   font-weight: 400;
-  src: local("Roboto Italic"), local("Roboto-Italic"),
-    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Italic'), local('Roboto-Italic'),
+    url(../fonts/roboto/vPcynSL0qHq_6dX7lKVByfesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }
 /* cyrillic-ext */
 /* cyrillic-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TTOQ_MqJVwkKsUn0wKzc2I.woff2) format('woff2');
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
   unicode-range: U+0460-052f, U+20b4, U+2de0-2dff, U+A640-A69F;
 }
 }
 /* cyrillic */
 /* cyrillic */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0TUj_cnvWIuuBMVgbX098Mw.woff2) format('woff2');
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
   unicode-range: U+0400-045f, U+0490-0491, U+04b0-04b1, U+2116;
 }
 }
 /* greek-ext */
 /* greek-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0UbcKLIaa1LC45dFaAfauRA.woff2) format('woff2');
   unicode-range: U+1f00-1fff;
   unicode-range: U+1f00-1fff;
 }
 }
 /* greek */
 /* greek */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Wo_sUJ8uO4YLWRInS22T3Y.woff2) format('woff2');
   unicode-range: U+0370-03ff;
   unicode-range: U+0370-03ff;
 }
 }
 /* vietnamese */
 /* vietnamese */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2)
-      format("woff2");
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0b6up8jxqWt8HVA3mDhkV_0.woff2) format('woff2');
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
   unicode-range: U+0102-0103, U+1ea0-1ef9, U+20ab;
 }
 }
 /* latin-ext */
 /* latin-ext */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2)
-      format("woff2");
-  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f,
-    U+A720-A7FF;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0SYE0-AqJ3nfInTTiDXDjU4.woff2) format('woff2');
+  unicode-range: U+0100-024f, U+1-1eff, U+20a0-20ab, U+20ad-20cf, U+2c60-2c7f, U+A720-A7FF;
 }
 }
 /* latin */
 /* latin */
 @font-face {
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   font-style: italic;
   font-weight: 500;
   font-weight: 500;
-  src: local("Roboto Medium Italic"), local("Roboto-MediumItalic"),
-    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2)
-      format("woff2");
-  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc,
-    U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
+  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
+    url(../fonts/roboto/OLffGBTaF0XFOW1gnuHF0Y4P5ICox8Kq3LLUNMylGO4.woff2) format('woff2');
+  unicode-range: U+0000-00ff, U+0131, U+0152-0153, U+02c6, U+02da, U+02dc, U+2000-206f, U+2074, U+20ac, U+2212, U+2215;
 }
 }

+ 6 - 4
public/sass/components/_panel_heatmap.scss

@@ -1,3 +1,5 @@
+$font-size-heatmap-tick: 11px;
+
 .heatmap-canvas-wrapper {
 .heatmap-canvas-wrapper {
   // position: relative;
   // position: relative;
   cursor: crosshair;
   cursor: crosshair;
@@ -10,7 +12,7 @@
     text {
     text {
       fill: $text-color;
       fill: $text-color;
       color: $text-color;
       color: $text-color;
-      font-size: $font-size-sm;
+      font-size: $font-size-heatmap-tick;
     }
     }
 
 
     line {
     line {
@@ -56,12 +58,12 @@
 .heatmap-legend-wrapper {
 .heatmap-legend-wrapper {
   @include clearfix();
   @include clearfix();
   margin: 0 $spacer;
   margin: 0 $spacer;
-  padding-top: 10px;
+  padding-top: 4px;
 
 
   svg {
   svg {
     width: 100%;
     width: 100%;
     max-width: 300px;
     max-width: 300px;
-    height: 33px;
+    height: 18px;
     float: left;
     float: left;
     white-space: nowrap;
     white-space: nowrap;
     padding-left: 10px;
     padding-left: 10px;
@@ -75,7 +77,7 @@
     text {
     text {
       fill: $text-color;
       fill: $text-color;
       color: $text-color;
       color: $text-color;
-      font-size: $font-size-sm;
+      font-size: $font-size-heatmap-tick;
     }
     }
 
 
     line {
     line {

+ 1 - 1
public/sass/components/_scrollbar.scss

@@ -75,7 +75,7 @@
   border-radius: 6px;
   border-radius: 6px;
   width: 6px;
   width: 6px;
   /* there must be 'right' for ps__thumb-y */
   /* there must be 'right' for ps__thumb-y */
-  right: 2px;
+  right: 0px;
   /* please don't change 'position' */
   /* please don't change 'position' */
   position: absolute;
   position: absolute;
 }
 }

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

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

+ 78 - 0
tests/api/folder.test.ts

@@ -0,0 +1,78 @@
+import client from './client';
+import * as setup from './setup';
+
+describe('/api/folders', () => {
+  let state: any = {};
+
+  beforeAll(async () => {
+    state = await setup.ensureState({
+      orgName: 'api-test-org',
+      users: [
+        { user: setup.admin, role: 'Admin' },
+        { user: setup.editor, role: 'Editor' },
+        { user: setup.viewer, role: 'Viewer' },
+      ],
+      admin: setup.admin,
+      folders: [
+        {
+          title: 'Folder 1',
+          uid: 'f-01',
+        },
+        {
+          title: 'Folder 2',
+          uid: 'f-02',
+        },
+        {
+          title: 'Folder 3',
+          uid: 'f-03',
+        },
+      ],
+    });
+  });
+
+  describe('With admin user', () => {
+    it('can delete folder', async () => {
+      let rsp = await client.callAs(setup.admin).delete(`/api/folders/f-01`);
+      expect(rsp.data.title).toBe('Folder 1');
+    });
+
+    it('can update folder', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-02`, {
+        uid: 'f-02',
+        title: 'Folder 2 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.title).toBe('Folder 2 upd');
+    });
+
+    it('can update folder uid', async () => {
+      let rsp = await client.callAs(setup.admin).put(`/api/folders/f-03`, {
+        uid: 'f-03-upd',
+        title: 'Folder 3 upd',
+        overwrite: true,
+      });
+      expect(rsp.data.uid).toBe('f-03-upd');
+      expect(rsp.data.title).toBe('Folder 3 upd');
+    });
+  });
+
+  describe('With viewer user', () => {
+    it('Cannot delete folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).delete(`/api/folders/f-02`);
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+
+    it('Cannot update folder', async () => {
+      let rsp = await setup.expectError(() => {
+        return client.callAs(setup.viewer).put(`/api/folders/f-02`, {
+          uid: 'f-02',
+          title: 'Folder 2 upd',
+          overwrite: true,
+        });
+      });
+      expect(rsp.response.status).toBe(403);
+    });
+  });
+});

+ 17 - 1
tests/api/setup.ts

@@ -90,6 +90,18 @@ export async function createDashboard(user, dashboard) {
   return dashboard;
   return dashboard;
 }
 }
 
 
+export async function createFolder(user, folder) {
+  const rsp = await client.callAs(user).post(`/api/folders`, {
+    uid: folder.uid,
+    title: folder.title,
+    overwrite: true,
+  });
+  folder.id = rsp.id;
+  folder.url = rsp.url;
+
+  return folder;
+}
+
 export async function ensureState(state) {
 export async function ensureState(state) {
   const org = await getOrg(state.orgName);
   const org = await getOrg(state.orgName);
 
 
@@ -99,9 +111,13 @@ export async function ensureState(state) {
     await setUsingOrg(user, org);
     await setUsingOrg(user, org);
   }
   }
 
 
-  for (let dashboard of state.dashboards) {
+  for (let dashboard of state.dashboards || []) {
     await createDashboard(state.admin, dashboard);
     await createDashboard(state.admin, dashboard);
   }
   }
 
 
+  for (let folder of state.folders || []) {
+    await createFolder(state.admin, folder);
+  }
+
   return state;
   return state;
 }
 }

+ 2 - 2
yarn.lock

@@ -9773,9 +9773,9 @@ test-exclude@^4.1.1:
     read-pkg-up "^1.0.1"
     read-pkg-up "^1.0.1"
     require-main-filename "^1.0.1"
     require-main-filename "^1.0.1"
 
 
-"tether-drop@https://github.com/torkelo/drop":
+"tether-drop@https://github.com/torkelo/drop/tarball/master":
   version "1.5.0"
   version "1.5.0"
-  resolved "https://github.com/torkelo/drop#fc83ca88db0076fbf6359cbe1743a9ef0f1ee6e1"
+  resolved "https://github.com/torkelo/drop/tarball/master#6a3eb15b882b416f06e1e7ae04c7e57d08418020"
   dependencies:
   dependencies:
     tether "^1.1.0"
     tether "^1.1.0"