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

This commit is contained in:
Sven Klemm 2018-02-26 07:50:49 +01:00
commit 6c6be9cfc0
73 changed files with 2160 additions and 590 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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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