Kaynağa Gözat

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

Sven Klemm 7 yıl önce
ebeveyn
işleme
6c6be9cfc0
73 değiştirilmiş dosya ile 2158 ekleme ve 588 silme
  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. 
 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
-  - React migration foundation (core components) 
-  - Graphite 1.1 Tags Support
+  - Backend plugins? (alert notifiers, auth)
   
 ### Long term (4 - 8 months)
 
-- Backend plugins to support more Auth options, Alerting data sources & notifications
 - Alerting improvements (silence, per series tracking, etc)
-- Dashboard as configuration and other automation / provisioning improvements
 - Progress on React migration
 - Change visualization (panel type) on the fly. 
 - 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
 snapshot_remove_expired = true
 
-# remove snapshots after 90 days
-snapshot_TTL_days = 90
-
 #################################### Dashboards ##################
 
 [dashboards]

+ 0 - 3
conf/sample.ini

@@ -175,9 +175,6 @@ log_queries =
 # remove expired snapshot
 ;snapshot_remove_expired = true
 
-# remove snapshots after 90 days
-;snapshot_TTL_days = 90
-
 #################################### Dashboards History ##################
 [dashboards]
 # 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 /api/datasources/:name`
+`GET /api/datasources/name/:name`
 
 **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
 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
 
-### remove snapshots after 90 days
-Time to live for snapshots.
-
 ## [external_image_storage]
 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",
     "rxjs": "^5.4.3",
     "tether": "^1.4.0",
-    "tether-drop": "https://github.com/torkelo/drop",
+    "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "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.Get("/api/snapshot/shared-options/", GetSharingOptions)
 	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
 	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)
 
+		// 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
 		apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
 			dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
@@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() {
 				dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
 				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 {
 		return nil, ApiError(404, "Dashboard not found", err)
 	}
+
 	return query.Result, nil
 }
 
@@ -166,8 +167,10 @@ func DeleteDashboard(c *middleware.Context) Response {
 		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 {
@@ -186,8 +189,10 @@ func DeleteDashboardByUid(c *middleware.Context) Response {
 		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 {

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

@@ -10,7 +10,7 @@ import (
 	"github.com/grafana/grafana/pkg/services/guardian"
 )
 
-func GetDashboardAclList(c *middleware.Context) Response {
+func GetDashboardPermissionList(c *middleware.Context) Response {
 	dashId := c.ParamsInt64(":dashboardId")
 
 	_, rsp := getDashboardHelper(c.OrgId, "", dashId, "")
@@ -26,7 +26,7 @@ func GetDashboardAclList(c *middleware.Context) Response {
 
 	acl, err := guardian.GetAcl()
 	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 {
@@ -38,7 +38,7 @@ func GetDashboardAclList(c *middleware.Context) Response {
 	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")
 
 	_, 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 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"
 )
 
-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{
 			{OrgId: 1, DashboardId: 1, UserId: 2, Permission: m.PERMISSION_VIEW},
 			{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
 
 		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() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 					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
-				sc.handlerFunc = GetDashboardAclList
+				sc.handlerFunc = GetDashboardPermissionList
 				sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 				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
 					CallPostAcl(sc)
 					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() {
-			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})
 
 				Convey("Should be able to access ACL", func() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 					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})
 
 					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})
 
 					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() {
-			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})
 
 				// Getting the permissions is an Admin permission
 				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()
 
 					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() {
-			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() {
-					sc.handlerFunc = GetDashboardAclList
+					sc.handlerFunc = GetDashboardPermissionList
 					sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
 
 					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.OrgRole = role
 
-			return UpdateDashboardAcl(c, cmd)
+			return UpdateDashboardPermissions(c, cmd)
 		})
 
 		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/middleware"
 	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/util"
 )
@@ -56,6 +57,7 @@ func CreateDashboardSnapshot(c *middleware.Context, cmd m.CreateDashboardSnapsho
 	})
 }
 
+// GET /api/snapshots/:key
 func GetDashboardSnapshot(c *middleware.Context) {
 	key := c.Params(":key")
 	query := &m.GetDashboardSnapshotQuery{Key: key}
@@ -90,18 +92,43 @@ func GetDashboardSnapshot(c *middleware.Context) {
 	c.JSON(200, dto)
 }
 
-func DeleteDashboardSnapshot(c *middleware.Context) {
+// GET /api/snapshots-delete/:key
+func DeleteDashboardSnapshot(c *middleware.Context) Response {
 	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}
 
 	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 {
 	query := c.Query("query")
 	limit := c.QueryInt("limit")
@@ -111,9 +138,10 @@ func SearchDashboardSnapshots(c *middleware.Context) Response {
 	}
 
 	searchQuery := m.GetDashboardSnapshotsQuery{
-		Name:  query,
-		Limit: limit,
-		OrgId: c.OrgId,
+		Name:         query,
+		Limit:        limit,
+		OrgId:        c.OrgId,
+		SignedInUser: c.SignedInUser,
 	}
 
 	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 {
-	sc.handlerFunc = GetDashboard
-	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+	CallGetDashboard(sc)
 
 	So(sc.resp.Code, ShouldEqual, 200)
 
@@ -758,6 +757,11 @@ func GetDashboardShouldReturn200(sc *scenarioContext) dtos.DashboardFullWithMeta
 	return dash
 }
 
+func CallGetDashboard(sc *scenarioContext) {
+	sc.handlerFunc = GetDashboard
+	sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+}
+
 func CallGetDashboardVersion(sc *scenarioContext) {
 	bus.AddHandler("test", func(query *m.GetDashboardVersionQuery) error {
 		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 (
 	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.")
+	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
@@ -45,7 +47,8 @@ type DashboardAcl struct {
 
 type DashboardAclInfoDTO struct {
 	OrgId       int64 `json:"-"`
-	DashboardId int64 `json:"dashboardId"`
+	DashboardId int64 `json:"dashboardId,omitempty"`
+	FolderId    int64 `json:"folderId,omitempty"`
 
 	Created time.Time `json:"created"`
 	Updated time.Time `json:"updated"`

+ 7 - 4
pkg/models/dashboard_snapshot.go

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

+ 1 - 0
pkg/models/dashboard_version.go

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

+ 3 - 7
pkg/models/dashboards.go

@@ -14,7 +14,7 @@ import (
 // Typed errors
 var (
 	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")
 	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")
@@ -112,9 +112,9 @@ func NewDashboard(title string) *Dashboard {
 // NewDashboardFolder creates a new dashboard folder
 func NewDashboardFolder(title string) *Dashboard {
 	folder := NewDashboard(title)
+	folder.IsFolder = true
 	folder.Data.Set("schemaVersion", 16)
-	folder.Data.Set("editable", true)
-	folder.Data.Set("hideControls", true)
+	folder.Data.Set("version", 0)
 	folder.IsFolder = true
 	return folder
 }
@@ -166,10 +166,6 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
 		userId = -1
 	}
 
-	if dash.Data.Get("version").MustInt(0) == 0 {
-		dash.CreatedBy = userId
-	}
-
 	dash.UpdatedBy = userId
 	dash.OrgId = cmd.OrgId
 	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() {
-	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() {
-	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() {

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

@@ -41,7 +41,10 @@ type SaveDashboardDTO struct {
 	Dashboard *models.Dashboard
 }
 
-type dashboardServiceImpl struct{}
+type dashboardServiceImpl struct {
+	orgId int64
+	user  *models.SignedInUser
+}
 
 func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) {
 	cmd := &models.GetProvisionedDashboardDataQuery{Name: name}
@@ -53,7 +56,7 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardData(name string) ([]*mod
 	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.Title = strings.TrimSpace(dash.Title)
@@ -78,13 +81,15 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO)
 		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{
@@ -142,7 +147,7 @@ func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO,
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 		return nil, err
 	}
@@ -172,7 +177,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 		UserId:  0,
 		OrgRole: models.ROLE_ADMIN,
 	}
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, false)
 	if err != nil {
 		return nil, err
 	}
@@ -191,7 +196,7 @@ func (dr *dashboardServiceImpl) SaveFolderForProvisionedDashboards(dto *SaveDash
 }
 
 func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Dashboard, error) {
-	cmd, err := dr.buildSaveDashboardCommand(dto)
+	cmd, err := dr.buildSaveDashboardCommand(dto, true)
 	if err != nil {
 		return nil, err
 	}

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

@@ -17,7 +17,7 @@ func TestDashboardService(t *testing.T) {
 		service := dashboardServiceImpl{}
 
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(&fakeDashboardGuardian{canSave: true})
+		guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true})
 
 		Convey("Save dashboard validation", func() {
 			dto := &SaveDashboardDTO{}
@@ -72,7 +72,7 @@ func TestDashboardService(t *testing.T) {
 					dto.Dashboard.SetUid(tc.Uid)
 					dto.User = &models.SignedInUser{}
 
-					_, err := service.buildSaveDashboardCommand(dto)
+					_, err := service.buildSaveDashboardCommand(dto, true)
 					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
 	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 {
 	dash := cmd.GetDashboardModel()
 
+	userId := cmd.UserId
+
+	if userId == 0 {
+		userId = -1
+	}
+
 	if dash.Id > 0 {
 		var existing m.Dashboard
 		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 {
 		dash.SetVersion(1)
+		dash.Created = time.Now()
+		dash.CreatedBy = userId
+		dash.Updated = time.Now()
+		dash.UpdatedBy = userId
 		metrics.M_Api_Dashboard_Insert.Inc()
 		affectedRows, err = sess.Insert(dash)
 	} else {
-		v := dash.Version
-		v++
-		dash.SetVersion(v)
+		dash.SetVersion(dash.Version + 1)
 
 		if !cmd.UpdatedAt.IsZero() {
 			dash.Updated = cmd.UpdatedAt
+		} else {
+			dash.Updated = time.Now()
 		}
 
+		dash.UpdatedBy = userId
+
 		affectedRows, err = sess.MustCols("folder_id").ID(dash.Id).Update(dash)
 	}
 
@@ -514,7 +526,7 @@ func getExistingDashboardByIdOrUidForUpdate(sess *DBSession, cmd *m.ValidateDash
 		}
 
 		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, 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, 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, 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, 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() {
 							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 {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 
 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() {
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,
@@ -861,15 +809,15 @@ func dashboardGuardianScenario(desc string, mock *mockDashboardGuarder, fn scena
 }
 
 type dashboardPermissionScenarioContext struct {
-	dashboardGuardianMock *mockDashboardGuarder
+	dashboardGuardianMock *guardian.FakeDashboardGuardian
 }
 
 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() {
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 		sc := &dashboardPermissionScenarioContext{
 			dashboardGuardianMock: mock,
@@ -884,8 +832,8 @@ func dashboardPermissionScenario(desc string, mock *mockDashboardGuarder, fn das
 }
 
 func permissionScenario(desc string, canSave bool, fn dashboardPermissionScenarioFunc) {
-	mock := &mockDashboardGuarder{
-		canSave: canSave,
+	mock := &guardian.FakeDashboardGuardian{
+		CanSaveValue: canSave,
 	}
 	dashboardPermissionScenario(desc, mock, fn)
 }
@@ -902,10 +850,10 @@ func callSaveWithError(cmd models.SaveDashboardCommand) error {
 	return err
 }
 
-func dashboardServiceScenario(desc string, mock *mockDashboardGuarder, fn scenarioFunc) {
+func dashboardServiceScenario(desc string, mock *guardian.FakeDashboardGuardian, fn scenarioFunc) {
 	Convey(desc, func() {
 		origNewDashboardGuardian := guardian.New
-		mockDashboardGuardian(mock)
+		guardian.MockDashboardGuardian(mock)
 
 		sc := &scenarioContext{
 			dashboardGuardianMock: mock,

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

@@ -16,20 +16,23 @@ func init() {
 	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 {
 	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
 	})
 }
@@ -72,7 +75,7 @@ func DeleteDashboardSnapshot(cmd *m.DeleteDashboardSnapshotCommand) 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)
 
 	if err != nil {
@@ -85,6 +88,8 @@ func GetDashboardSnapshot(query *m.GetDashboardSnapshotQuery) error {
 	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 {
 	var snapshots = make(m.DashboardSnapshotsList, 0)
 
@@ -95,7 +100,16 @@ func SearchDashboardSnapshots(query *m.GetDashboardSnapshotsQuery) error {
 		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)
 	query.Result = snapshots
 	return err

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

@@ -2,11 +2,14 @@ package sqlstore
 
 import (
 	"testing"
+	"time"
 
+	"github.com/go-xorm/xorm"
 	. "github.com/smartystreets/goconvey/convey"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
 	m "github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
 )
 
 func TestDashboardSnapshotDBAccess(t *testing.T) {
@@ -14,17 +17,19 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 	Convey("Testing DashboardSnapshot data access", t, func() {
 		InitTestDB(t)
 
-		Convey("Given saved snaphot", func() {
+		Convey("Given saved snapshot", func() {
 			cmd := m.CreateDashboardSnapshotCommand{
 				Key: "hej",
 				Dashboard: simplejson.NewFromAny(map[string]interface{}{
 					"hello": "mupp",
 				}),
+				UserId: 1000,
+				OrgId:  1,
 			}
 			err := CreateDashboardSnapshot(&cmd)
 			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"}
 				err = GetDashboardSnapshot(&query)
 				So(err, ShouldBeNil)
@@ -33,6 +38,135 @@ func TestDashboardSnapshotDBAccess(t *testing.T) {
 				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 (
 	"fmt"
 	"testing"
+	"time"
 
 	"github.com/go-xorm/xorm"
 	"github.com/grafana/grafana/pkg/components/simplejson"
@@ -124,6 +125,24 @@ func TestDashboardDataAccess(t *testing.T) {
 				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() {
 				cmd := m.SaveDashboardCommand{
 					OrgId: 1,
@@ -134,6 +153,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					Overwrite: true,
 					FolderId:  2,
+					UserId:    100,
 				}
 
 				err := SaveDashboard(&cmd)
@@ -149,6 +169,7 @@ func TestDashboardDataAccess(t *testing.T) {
 					}),
 					FolderId:  0,
 					Overwrite: true,
+					UserId:    100,
 				}
 
 				err = SaveDashboard(&cmd)
@@ -162,6 +183,10 @@ func TestDashboardDataAccess(t *testing.T) {
 				err = GetDashboard(&query)
 				So(err, ShouldBeNil)
 				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() {

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

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

+ 0 - 2
pkg/setting/setting.go

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

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

@@ -26,7 +26,7 @@ export class FolderPermissions extends Component<IContainerProps, any> {
   loadStore() {
     const { nav, folder, view } = this.props;
     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');
     });
   }

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

@@ -9,17 +9,14 @@ describe('FolderSettings', () => {
   let page;
 
   beforeAll(() => {
-    backendSrv.getDashboardByUid.mockReturnValue(
+    backendSrv.getFolderByUid.mockReturnValue(
       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
 export class FolderSettings extends React.Component<IContainerProps, any> {
   formSnapshot: any;
-  dashboard: any;
 
   constructor(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 => {
       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');
     });
@@ -51,7 +48,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     const { nav, folder, view } = this.props;
 
     folder
-      .saveFolder(this.dashboard, { overwrite: false })
+      .saveFolder({ overwrite: false })
       .then(newUrl => {
         view.updatePathAndQuery(newUrl, {}, {});
 
@@ -61,7 +58,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       .then(() => {
         return nav.initFolderNav(toJS(folder.folder), 'manage-folder-settings');
       })
-      .catch(this.handleSaveFolderError);
+      .catch(this.handleSaveFolderError.bind(this));
   }
 
   delete(evt) {
@@ -79,7 +76,7 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
       icon: 'fa-trash',
       yesText: 'Delete',
       onConfirm: () => {
-        return this.props.folder.deleteFolder().then(() => {
+        return folder.deleteFolder().then(() => {
           appEvents.emit('alert-success', ['Folder Deleted', `${title} has been deleted`]);
           view.updatePathAndQuery('dashboards', '', '');
         });
@@ -91,6 +88,8 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
     if (err.data && err.data.status === 'version-mismatch') {
       err.isHandled = true;
 
+      const { nav, folder, view } = this.props;
+
       appEvents.emit('confirm-modal', {
         title: 'Conflict',
         text: 'Someone else has updated this folder.',
@@ -98,16 +97,20 @@ export class FolderSettings extends React.Component<IContainerProps, any> {
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         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() {

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

@@ -53,7 +53,7 @@ describe('AddPermissions', () => {
       wrapper.find('form').simulate('submit', { preventDefault() {} });
 
       expect(backendSrv.post.mock.calls.length).toBe(1);
-      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+      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() {} });
 
       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', 'r'], description: 'Remove Panel' },
       ],
-      'Focused Row': [
-        { keys: ['r', 'c'], description: 'Collapse Row' },
-        { keys: ['r', 'r'], description: 'Remove Row' },
-      ],
       '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 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',
       yesText: 'Delete',
       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();
     });
   }

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

@@ -221,14 +221,18 @@ export class BackendSrv {
     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) {
     return this.get(`/api/dashboards/uid/${uid}`);
   }
 
+  getFolderByUid(uid: string) {
+    return this.get(`/api/folders/${uid}`);
+  }
+
   saveDashboard(dash, 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 || {};
 
-    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,
-      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 = [];
 
-    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, []);

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

@@ -171,8 +171,9 @@ export class KeybindingSrv {
     // delete panel
     this.bind('p r', () => {
       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;
       }
     });
@@ -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
     this.bind('d shift+c', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = true;
-      }
+      dashboard.collapseRows();
     });
 
     // expand all rows
     this.bind('d shift+e', () => {
-      for (let row of dashboard.rows) {
-        row.collapse = false;
-      }
+      dashboard.expandRows();
     });
 
     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.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
 kbn.valueFormats.ops = kbn.formatBuilders.simpleCountUnit('ops');
 kbn.valueFormats.rps = kbn.formatBuilders.simpleCountUnit('rps');
@@ -878,6 +887,18 @@ kbn.getUnitFormats = function() {
         { 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',
       submenu: [

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

@@ -18,9 +18,9 @@ export class CreateFolderCtrl {
       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']);
-      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 { PanelContainer } from './dashgrid/PanelContainer';
 import { DashboardModel } from './dashboard_model';
+import { PanelModel } from './panel_model';
 
 export class DashboardCtrl implements PanelContainer {
   dashboard: DashboardModel;
@@ -130,9 +131,47 @@ export class DashboardCtrl implements PanelContainer {
     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) {
     this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.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);
   }
 }

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

@@ -524,6 +524,34 @@ export class DashboardModel {
     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) {
     this.meta.focusPanelId = id;
   }

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

@@ -92,7 +92,7 @@ export class DashboardSrv {
 
   save(clone, 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
       .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);
 
       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()) {
           $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;
 
       const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
       dashTab.url = folderUrl;
 
-      if (result.meta.canAdmin) {
+      if (folder.canAdmin) {
         const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
         permTab.url = folderUrl + '/permissions';
 
@@ -55,7 +55,7 @@ export class FolderPageLoader {
         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();
     }
 
-    return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => {
+    return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => {
       appEvents.emit('alert-success', ['Folder Created', 'OK']);
 
       this.closeCreateFolder();
       this.folder = {
-        text: result.dashboard.title,
-        value: result.dashboard.id,
+        text: result.title,
+        value: result.id,
       };
       this.onFolderChange(this.folder);
     });

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

@@ -7,8 +7,7 @@ export class FolderSettingsCtrl {
   folderId: number;
   uid: string;
   canSave = false;
-  dashboard: any;
-  meta: any;
+  folder: any;
   title: string;
   hasChanged: boolean;
 
@@ -23,10 +22,9 @@ export class FolderSettingsCtrl {
           $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;
     }
 
-    this.dashboard.title = this.title.trim();
+    this.folder.title = this.title.trim();
 
     return this.backendSrv
-      .updateDashboardFolder(this.dashboard, { overwrite: false })
+      .updateFolder(this.folder)
       .then(result => {
         if (result.url !== this.$location.path()) {
           this.$location.url(result.url + '/settings');
@@ -54,7 +52,7 @@ export class FolderSettingsCtrl {
   }
 
   titleChanged() {
-    this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase();
+    this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase();
   }
 
   delete(evt) {
@@ -69,8 +67,8 @@ export class FolderSettingsCtrl {
       icon: 'fa-trash',
       yesText: 'Delete',
       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');
         });
       },
@@ -88,15 +86,9 @@ export class FolderSettingsCtrl {
         yesText: 'Save & Overwrite',
         icon: 'fa-warning',
         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">
         <folder-picker initial-folder-id="ctrl.folderId"
                        on-change="ctrl.onFolderChange($folder)"
+                       enter-folder-creation="ctrl.onEnterFolderCreation()"
+                       exit-folder-creation="ctrl.onExitFolderCreation()"
                        enable-create-new="true"
                        label-class="width-7">
         </folder-picker>
@@ -29,7 +31,7 @@ const template = `
 		</div>
 
 		<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>
 		</div>
 	</form>
@@ -40,6 +42,7 @@ export class SaveDashboardAsModalCtrl {
   clone: any;
   folderId: any;
   dismiss: () => void;
+  isValidFolderSelection = true;
 
   /** @ngInject */
   constructor(private dashboardSrv) {
@@ -79,6 +82,14 @@ export class SaveDashboardAsModalCtrl {
   onFolderChange(folder) {
     this.folderId = folder.id;
   }
+
+  onEnterFolderCreation() {
+    this.isValidFolderSelection = false;
+  }
+
+  onExitFolderCreation() {
+    this.isValidFolderSelection = true;
+  }
 }
 
 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() {
       model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
-      model.rows[0].panels[0] = { };
+      model.rows[0].panels[0] = {};
 
       let dashboard = new DashboardModel(model);
       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() {

+ 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 (!($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) {
             const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
             $location.path(url).replace();

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

@@ -101,6 +101,7 @@
 												get-options="getSizeOptions()"
 												on-change="onChangeInternal()"
 												label-mode="true"
+                        allow-custom="true"
 												css-class="width-12">
 			</gf-form-dropdown>
 		</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');
 
+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.
  */
 module.directive('colorLegend', function() {
   return {
     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) {
       let ctrl = scope.ctrl;
       let panel = scope.ctrl.panel;
@@ -50,7 +55,7 @@ module.directive('colorLegend', function() {
 module.directive('heatmapLegend', function() {
   return {
     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) {
       let ctrl = scope.ctrl;
       let panel = scope.ctrl.panel;
@@ -163,10 +168,10 @@ function drawLegendValues(elem, colorScale, rangeFrom, rangeTo, maxValue, minVal
   let xAxis = d3
     .axisBottom(legendValueScale)
     .tickValues(ticks)
-    .tickSize(2);
+    .tickSize(LEGEND_TICK_SIZE);
 
   let colorRect = legendElem.find(':first-child');
-  let posY = getSvgElemHeight(legendElem) + 2;
+  let posY = getSvgElemHeight(legendElem) + LEGEND_VALUE_MARGIN;
   let posX = getSvgElemX(colorRect);
 
   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 -= 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');
 

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

@@ -51,6 +51,9 @@ describe('grafanaHeatmap', function() {
                   colorScheme: 'interpolateOranges',
                   fillBackground: false,
                 },
+                legend: {
+                  show: false,
+                },
                 xBucketSize: 1000,
                 xBucketNumber: null,
                 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 (!($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) {
           $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', {
   id: types.identifier(types.number),
+  uid: types.string,
   title: types.string,
   url: types.string,
   canSave: types.boolean,
-  uid: types.string,
   hasChanged: types.boolean,
+  version: types.number,
 });
 
 export const FolderStore = types
@@ -21,15 +22,15 @@ export const FolderStore = types
       }
 
       const backendSrv = getEnv(self).backendSrv;
-      const res = yield backendSrv.getDashboardByUid(uid);
-
+      const res = yield backendSrv.getFolderByUid(uid);
       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,
+        version: res.version,
       });
 
       return res;
@@ -40,12 +41,13 @@ export const FolderStore = types
       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;
-      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.version = res.version;
 
       return `${self.folder.url}/settings`;
     }),
@@ -53,6 +55,6 @@ export const FolderStore = types
     deleteFolder: flow(function* deleteFolder() {
       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].permissionName).toBe('Edit');
     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', () => {
@@ -54,7 +54,7 @@ describe('PermissionsStore', () => {
 
     expect(store.items.length).toBe(2);
     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', () => {

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

@@ -110,7 +110,7 @@ export const PermissionsStore = types
         self.dashboardId = dashboardId;
         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);
         self.items = items;
         self.originalItems = items;
@@ -210,7 +210,7 @@ const updateItems = self => {
 
   let res;
   try {
-    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/acl`, {
+    res = backendSrv.post(`/api/dashboards/id/${self.dashboardId}/permissions`, {
       items: updated,
     });
   } 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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: normal;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* cyrillic */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* greek-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* greek */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* vietnamese */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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;
 }
 /* latin-ext */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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 */
 @font-face {
-  font-family: "Roboto";
+  font-family: 'Roboto';
   font-style: italic;
   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 {
   // position: relative;
   cursor: crosshair;
@@ -10,7 +12,7 @@
     text {
       fill: $text-color;
       color: $text-color;
-      font-size: $font-size-sm;
+      font-size: $font-size-heatmap-tick;
     }
 
     line {
@@ -56,12 +58,12 @@
 .heatmap-legend-wrapper {
   @include clearfix();
   margin: 0 $spacer;
-  padding-top: 10px;
+  padding-top: 4px;
 
   svg {
     width: 100%;
     max-width: 300px;
-    height: 33px;
+    height: 18px;
     float: left;
     white-space: nowrap;
     padding-left: 10px;
@@ -75,7 +77,7 @@
     text {
       fill: $text-color;
       color: $text-color;
-      font-size: $font-size-sm;
+      font-size: $font-size-heatmap-tick;
     }
 
     line {

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

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

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

@@ -2,6 +2,7 @@ export const backendSrv = {
   get: jest.fn(),
   getDashboard: jest.fn(),
   getDashboardByUid: jest.fn(),
+  getFolderByUid: 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;
 }
 
+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) {
   const org = await getOrg(state.orgName);
 
@@ -99,9 +111,13 @@ export async function ensureState(state) {
     await setUsingOrg(user, org);
   }
 
-  for (let dashboard of state.dashboards) {
+  for (let dashboard of state.dashboards || []) {
     await createDashboard(state.admin, dashboard);
   }
 
+  for (let folder of state.folders || []) {
+    await createFolder(state.admin, folder);
+  }
+
   return state;
 }

+ 2 - 2
yarn.lock

@@ -9773,9 +9773,9 @@ test-exclude@^4.1.1:
     read-pkg-up "^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"
-  resolved "https://github.com/torkelo/drop#fc83ca88db0076fbf6359cbe1743a9ef0f1ee6e1"
+  resolved "https://github.com/torkelo/drop/tarball/master#6a3eb15b882b416f06e1e7ae04c7e57d08418020"
   dependencies:
     tether "^1.1.0"