Merge branch 'master' into docs_v5.0

This commit is contained in:
Marcus Efraimsson 2018-02-22 15:55:44 +01:00
commit 6751bbc17f
47 changed files with 1650 additions and 348 deletions

View File

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

View File

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

View File

@ -246,6 +246,24 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id/*", reqSignedIn, hs.ProxyDataSourceRequest)
apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest) apiRoute.Any("/datasources/proxy/:id", reqSignedIn, hs.ProxyDataSourceRequest)
// Folders
apiRoute.Group("/folders", func(folderRoute RouteRegister) {
folderRoute.Get("/", wrap(GetFolders))
folderRoute.Get("/id/:id", wrap(GetFolderById))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute RouteRegister) {
folderUidRoute.Get("/", wrap(GetFolderByUid))
folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder))
folderUidRoute.Delete("/", wrap(DeleteFolder))
folderUidRoute.Group("/permissions", func(folderPermissionRoute RouteRegister) {
folderPermissionRoute.Get("/", wrap(GetFolderPermissionList))
folderPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateFolderPermissions))
})
})
})
// Dashboard // Dashboard
apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) { apiRoute.Group("/dashboards", func(dashboardRoute RouteRegister) {
dashboardRoute.Get("/uid/:uid", wrap(GetDashboard)) dashboardRoute.Get("/uid/:uid", wrap(GetDashboard))
@ -266,9 +284,9 @@ func (hs *HttpServer) registerRoutes() {
dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion)) dashIdRoute.Get("/versions/:id", wrap(GetDashboardVersion))
dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion)) dashIdRoute.Post("/restore", bind(dtos.RestoreDashboardVersionCommand{}), wrap(RestoreDashboardVersion))
dashIdRoute.Group("/acl", func(aclRoute RouteRegister) { dashIdRoute.Group("/permissions", func(dashboardPermissionRoute RouteRegister) {
aclRoute.Get("/", wrap(GetDashboardAclList)) dashboardPermissionRoute.Get("/", wrap(GetDashboardPermissionList))
aclRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardAcl)) dashboardPermissionRoute.Post("/", bind(dtos.UpdateDashboardAclCommand{}), wrap(UpdateDashboardPermissions))
}) })
}) })
}) })

View File

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

View File

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

View File

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

View File

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

25
pkg/api/dtos/folder.go Normal file
View File

@ -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
pkg/api/folder.go Normal file
View File

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

View File

@ -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")
}

View File

@ -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
pkg/api/folder_test.go Normal file
View File

@ -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
}
}

View File

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

View File

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

91
pkg/models/folders.go Normal file
View File

@ -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
}

View File

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

View File

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

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,8 +78,8 @@ export class ManageDashboardsCtrl {
return; return;
} }
return this.backendSrv.getDashboardByUid(this.folderUid).then(dash => { return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
this.canSave = dash.meta.canSave; this.canSave = folder.canSave;
}); });
}); });
} }
@ -173,48 +173,13 @@ export class ManageDashboardsCtrl {
icon: 'fa-trash', icon: 'fa-trash',
yesText: 'Delete', yesText: 'Delete',
onConfirm: () => { onConfirm: () => {
const foldersAndDashboards = data.folders.concat(data.dashboards); this.deleteFoldersAndDashboards(data.folders, data.dashboards);
this.deleteFoldersAndDashboards(foldersAndDashboards);
}, },
}); });
} }
private deleteFoldersAndDashboards(uids) { private deleteFoldersAndDashboards(folderUids, dashboardUids) {
this.backendSrv.deleteDashboards(uids).then(result => { this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
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]);
}
this.refreshList(); this.refreshList();
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

78
tests/api/folder.test.ts Normal file
View File

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

View File

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